mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-06-02 09:44:30 -06:00
GSD: M002/S01 complete — Bug fixes + header/footer rework
- Fix cancel download bug: add @click.stop, debounce with cancelling ref - Rework header: remove nav tabs, replace ThemePicker with DarkModeToggle - Add isDark computed + toggleDarkMode() to theme store - Add WelcomeMessage component above URL input, reads from public config - Add welcome_message to UIConfig and public config endpoint - Add AppFooter with app version, yt-dlp version, GitHub link - Remove SSE status dot from header - Remove connectionStatus prop from AppLayout - 5 new theme toggle tests (34 frontend tests total) - 179 backend tests still passing
This commit is contained in:
parent
6780290f07
commit
ccd863f57d
14 changed files with 249 additions and 74 deletions
|
|
@ -73,6 +73,7 @@ class UIConfig(BaseModel):
|
|||
"""UI preferences."""
|
||||
|
||||
default_theme: str = "dark"
|
||||
welcome_message: str = "Paste any video or audio URL. We rip it, you download it. No accounts, no tracking."
|
||||
|
||||
|
||||
class AdminConfig(BaseModel):
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ async def public_config(request: Request) -> dict:
|
|||
return {
|
||||
"session_mode": config.session.mode,
|
||||
"default_theme": config.ui.default_theme,
|
||||
"welcome_message": config.ui.welcome_message,
|
||||
"purge_enabled": config.purge.enabled,
|
||||
"max_concurrent_downloads": config.downloads.max_concurrent,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,11 @@ import { useSSE } from '@/composables/useSSE'
|
|||
import { useConfigStore } from '@/stores/config'
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
import AppHeader from '@/components/AppHeader.vue'
|
||||
import AppFooter from '@/components/AppFooter.vue'
|
||||
|
||||
const configStore = useConfigStore()
|
||||
const themeStore = useThemeStore()
|
||||
const { connectionStatus, connect } = useSSE()
|
||||
const { connect } = useSSE()
|
||||
|
||||
onMounted(async () => {
|
||||
themeStore.init()
|
||||
|
|
@ -18,34 +19,7 @@ onMounted(async () => {
|
|||
</script>
|
||||
|
||||
<template>
|
||||
<AppHeader :connection-status="connectionStatus" />
|
||||
<nav class="app-nav">
|
||||
<router-link to="/">Downloads</router-link>
|
||||
<router-link to="/admin">Admin</router-link>
|
||||
</nav>
|
||||
<AppHeader />
|
||||
<router-view />
|
||||
<AppFooter />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-nav {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
max-width: var(--content-max-width);
|
||||
margin: 0 auto;
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.app-nav a {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.app-nav a.router-link-active {
|
||||
color: var(--color-accent);
|
||||
border-bottom: 2px solid var(--color-accent);
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -67,6 +67,7 @@ export interface FormatInfo {
|
|||
export interface PublicConfig {
|
||||
session_mode: string
|
||||
default_theme: string
|
||||
welcome_message: string
|
||||
purge_enabled: boolean
|
||||
max_concurrent_downloads: number
|
||||
}
|
||||
|
|
|
|||
57
frontend/src/components/AppFooter.vue
Normal file
57
frontend/src/components/AppFooter.vue
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '@/api/client'
|
||||
|
||||
const appVersion = ref('')
|
||||
const ytDlpVersion = ref('')
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const health = await api.getHealth()
|
||||
appVersion.value = health.version
|
||||
ytDlpVersion.value = health.yt_dlp_version
|
||||
} catch {
|
||||
appVersion.value = '?.?.?'
|
||||
ytDlpVersion.value = 'unknown'
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<footer v-if="appVersion" class="app-footer">
|
||||
<span>media.rip() v{{ appVersion }}</span>
|
||||
<span class="sep">|</span>
|
||||
<span>yt-dlp {{ ytDlpVersion }}</span>
|
||||
<span class="sep">|</span>
|
||||
<a
|
||||
href="https://github.com/jlightner/media-rip"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>GitHub</a>
|
||||
</footer>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.app-footer {
|
||||
text-align: center;
|
||||
padding: var(--space-lg) var(--space-md);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-sm);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.sep {
|
||||
margin: 0 var(--space-sm);
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.app-footer a {
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
transition: color var(--transition-normal);
|
||||
}
|
||||
|
||||
.app-footer a:hover {
|
||||
color: var(--color-accent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,17 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import type { ConnectionStatus } from '@/composables/useSSE'
|
||||
import ThemePicker from '@/components/ThemePicker.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
connectionStatus: ConnectionStatus
|
||||
}>()
|
||||
|
||||
const statusColor: Record<ConnectionStatus, string> = {
|
||||
connected: 'var(--color-success)',
|
||||
connecting: 'var(--color-warning)',
|
||||
reconnecting: 'var(--color-warning)',
|
||||
disconnected: 'var(--color-error)',
|
||||
}
|
||||
import DarkModeToggle from '@/components/DarkModeToggle.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -21,13 +9,7 @@ const statusColor: Record<ConnectionStatus, string> = {
|
|||
<span class="title-media">media</span><span class="title-dot">.</span><span class="title-rip">rip</span><span class="title-parens">()</span>
|
||||
</h1>
|
||||
<div class="header-right">
|
||||
<ThemePicker />
|
||||
<div class="header-status" :title="`SSE: ${connectionStatus}`">
|
||||
<span
|
||||
class="status-dot"
|
||||
:style="{ backgroundColor: statusColor[connectionStatus] }"
|
||||
></span>
|
||||
</div>
|
||||
<DarkModeToggle />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
|
@ -72,17 +54,4 @@ const statusColor: Record<ConnectionStatus, string> = {
|
|||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.header-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
</style>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import type { ConnectionStatus } from '@/composables/useSSE'
|
||||
|
||||
const props = defineProps<{
|
||||
connectionStatus: ConnectionStatus
|
||||
}>()
|
||||
|
||||
type MobileTab = 'submit' | 'queue'
|
||||
const activeTab = ref<MobileTab>('submit')
|
||||
|
|
|
|||
54
frontend/src/components/DarkModeToggle.vue
Normal file
54
frontend/src/components/DarkModeToggle.vue
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
<script setup lang="ts">
|
||||
import { useThemeStore } from '@/stores/theme'
|
||||
|
||||
const theme = useThemeStore()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<button
|
||||
class="dark-mode-toggle"
|
||||
:title="theme.isDark ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
@click="theme.toggleDarkMode()"
|
||||
aria-label="Toggle dark/light mode"
|
||||
>
|
||||
<!-- Sun icon (shown in dark mode — click to go light) -->
|
||||
<svg v-if="theme.isDark" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<line x1="12" y1="1" x2="12" y2="3"/>
|
||||
<line x1="12" y1="21" x2="12" y2="23"/>
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/>
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/>
|
||||
<line x1="1" y1="12" x2="3" y2="12"/>
|
||||
<line x1="21" y1="12" x2="23" y2="12"/>
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</svg>
|
||||
<!-- Moon icon (shown in light mode — click to go dark) -->
|
||||
<svg v-else xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dark-mode-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
background: transparent;
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-normal);
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dark-mode-toggle:hover {
|
||||
color: var(--color-accent);
|
||||
border-color: var(--color-accent);
|
||||
background: color-mix(in srgb, var(--color-accent) 8%, transparent);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, ref } from 'vue'
|
||||
import { useDownloadsStore } from '@/stores/downloads'
|
||||
import ProgressBar from './ProgressBar.vue'
|
||||
import type { Job, JobStatus } from '@/api/types'
|
||||
|
|
@ -43,11 +43,17 @@ const showProgress = computed(() =>
|
|||
props.job.status === 'downloading' || props.job.status === 'extracting',
|
||||
)
|
||||
|
||||
const cancelling = ref(false)
|
||||
|
||||
async function cancel(): Promise<void> {
|
||||
if (cancelling.value) return
|
||||
cancelling.value = true
|
||||
try {
|
||||
await store.cancelDownload(props.job.id)
|
||||
} catch {
|
||||
// Error will show in UI via store
|
||||
} catch (err) {
|
||||
console.error('[DownloadItem] Cancel failed:', err)
|
||||
} finally {
|
||||
cancelling.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
|
@ -74,10 +80,12 @@ async function cancel(): Promise<void> {
|
|||
<button
|
||||
v-if="isActive"
|
||||
class="btn-cancel"
|
||||
@click="cancel"
|
||||
:class="{ cancelling }"
|
||||
:disabled="cancelling"
|
||||
@click.stop="cancel"
|
||||
title="Cancel download"
|
||||
>
|
||||
✕
|
||||
{{ cancelling ? '…' : '✕' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
<script setup lang="ts">
|
||||
import AppLayout from '@/components/AppLayout.vue'
|
||||
import WelcomeMessage from '@/components/WelcomeMessage.vue'
|
||||
import UrlInput from '@/components/UrlInput.vue'
|
||||
import DownloadQueue from '@/components/DownloadQueue.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppLayout connection-status="connected">
|
||||
<AppLayout>
|
||||
<template #url-input>
|
||||
<WelcomeMessage />
|
||||
<UrlInput />
|
||||
</template>
|
||||
<template #queue>
|
||||
|
|
|
|||
44
frontend/src/components/WelcomeMessage.vue
Normal file
44
frontend/src/components/WelcomeMessage.vue
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { api } from '@/api/client'
|
||||
|
||||
const message = ref('Paste any video or audio URL. We rip it, you download it. No accounts, no tracking.')
|
||||
const visible = ref(true)
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const config = await api.getPublicConfig()
|
||||
if (config.welcome_message !== undefined && config.welcome_message !== null) {
|
||||
if (config.welcome_message === '') {
|
||||
visible.value = false
|
||||
} else {
|
||||
message.value = config.welcome_message
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Use default message on error
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div v-if="visible" class="welcome-message">
|
||||
<p>{{ message }}</p>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.welcome-message {
|
||||
text-align: center;
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
color: var(--color-text-muted);
|
||||
font-size: var(--font-size-base);
|
||||
line-height: 1.6;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.welcome-message p {
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -33,6 +33,9 @@ export const useThemeStore = defineStore('theme', () => {
|
|||
const customThemes = ref<ThemeMeta[]>([])
|
||||
const customThemeCSS = ref<Map<string, string>>(new Map())
|
||||
|
||||
/** Whether the current theme is a dark variant (cyberpunk and dark are dark; light is light). */
|
||||
const isDark = computed(() => currentTheme.value !== 'light')
|
||||
|
||||
const allThemes = computed<ThemeMeta[]>(() => [
|
||||
...BUILTIN_THEMES,
|
||||
...customThemes.value,
|
||||
|
|
@ -55,6 +58,20 @@ export const useThemeStore = defineStore('theme', () => {
|
|||
_apply(currentTheme.value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Toggle between the current theme's dark and light variant.
|
||||
* Cyberpunk (default) ↔ Light. Dark ↔ Light.
|
||||
*/
|
||||
function toggleDarkMode(): void {
|
||||
if (isDark.value) {
|
||||
setTheme('light')
|
||||
} else {
|
||||
// Return to the last dark theme, defaulting to cyberpunk
|
||||
const lastDark = localStorage.getItem(STORAGE_KEY + '-dark') || DEFAULT_THEME
|
||||
setTheme(lastDark === 'light' ? DEFAULT_THEME : lastDark)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a theme by ID. Saves to localStorage and applies immediately.
|
||||
*/
|
||||
|
|
@ -64,6 +81,10 @@ export const useThemeStore = defineStore('theme', () => {
|
|||
|
||||
currentTheme.value = themeId
|
||||
localStorage.setItem(STORAGE_KEY, themeId)
|
||||
// Remember the last dark theme for toggle
|
||||
if (themeId !== 'light') {
|
||||
localStorage.setItem(STORAGE_KEY + '-dark', themeId)
|
||||
}
|
||||
_apply(themeId)
|
||||
}
|
||||
|
||||
|
|
@ -139,8 +160,10 @@ export const useThemeStore = defineStore('theme', () => {
|
|||
customThemes,
|
||||
allThemes,
|
||||
currentMeta,
|
||||
isDark,
|
||||
init,
|
||||
setTheme,
|
||||
toggleDarkMode,
|
||||
loadCustomThemes,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -28,6 +28,7 @@ describe('config store', () => {
|
|||
const mockConfig = {
|
||||
session_mode: 'isolated',
|
||||
default_theme: 'dark',
|
||||
welcome_message: 'Test welcome',
|
||||
purge_enabled: false,
|
||||
max_concurrent_downloads: 3,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -90,4 +90,49 @@ describe('theme store', () => {
|
|||
expect(store.currentMeta?.id).toBe('cyberpunk')
|
||||
expect(store.currentMeta?.name).toBe('Cyberpunk')
|
||||
})
|
||||
|
||||
it('isDark is true for cyberpunk and dark themes', () => {
|
||||
const store = useThemeStore()
|
||||
store.init()
|
||||
expect(store.isDark).toBe(true)
|
||||
|
||||
store.setTheme('dark')
|
||||
expect(store.isDark).toBe(true)
|
||||
})
|
||||
|
||||
it('isDark is false for light theme', () => {
|
||||
const store = useThemeStore()
|
||||
store.init()
|
||||
store.setTheme('light')
|
||||
expect(store.isDark).toBe(false)
|
||||
})
|
||||
|
||||
it('toggleDarkMode switches from dark to light', () => {
|
||||
const store = useThemeStore()
|
||||
store.init() // starts on cyberpunk (dark)
|
||||
store.toggleDarkMode()
|
||||
expect(store.currentTheme).toBe('light')
|
||||
expect(store.isDark).toBe(false)
|
||||
})
|
||||
|
||||
it('toggleDarkMode switches from light back to last dark theme', () => {
|
||||
const store = useThemeStore()
|
||||
store.init()
|
||||
// Start on cyberpunk, toggle to light, toggle back
|
||||
store.toggleDarkMode()
|
||||
expect(store.currentTheme).toBe('light')
|
||||
store.toggleDarkMode()
|
||||
expect(store.currentTheme).toBe('cyberpunk')
|
||||
expect(store.isDark).toBe(true)
|
||||
})
|
||||
|
||||
it('toggleDarkMode remembers dark theme when starting from dark', () => {
|
||||
const store = useThemeStore()
|
||||
store.init()
|
||||
store.setTheme('dark') // switch to the "dark" theme (not cyberpunk)
|
||||
store.toggleDarkMode()
|
||||
expect(store.currentTheme).toBe('light')
|
||||
store.toggleDarkMode()
|
||||
expect(store.currentTheme).toBe('dark') // returns to dark, not cyberpunk
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue