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:
xpltd 2026-03-18 21:16:24 -05:00
parent 6780290f07
commit ccd863f57d
14 changed files with 249 additions and 74 deletions

View file

@ -73,6 +73,7 @@ class UIConfig(BaseModel):
"""UI preferences.""" """UI preferences."""
default_theme: str = "dark" 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): class AdminConfig(BaseModel):

View file

@ -23,6 +23,7 @@ async def public_config(request: Request) -> dict:
return { return {
"session_mode": config.session.mode, "session_mode": config.session.mode,
"default_theme": config.ui.default_theme, "default_theme": config.ui.default_theme,
"welcome_message": config.ui.welcome_message,
"purge_enabled": config.purge.enabled, "purge_enabled": config.purge.enabled,
"max_concurrent_downloads": config.downloads.max_concurrent, "max_concurrent_downloads": config.downloads.max_concurrent,
} }

View file

@ -4,10 +4,11 @@ import { useSSE } from '@/composables/useSSE'
import { useConfigStore } from '@/stores/config' import { useConfigStore } from '@/stores/config'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import AppHeader from '@/components/AppHeader.vue' import AppHeader from '@/components/AppHeader.vue'
import AppFooter from '@/components/AppFooter.vue'
const configStore = useConfigStore() const configStore = useConfigStore()
const themeStore = useThemeStore() const themeStore = useThemeStore()
const { connectionStatus, connect } = useSSE() const { connect } = useSSE()
onMounted(async () => { onMounted(async () => {
themeStore.init() themeStore.init()
@ -18,34 +19,7 @@ onMounted(async () => {
</script> </script>
<template> <template>
<AppHeader :connection-status="connectionStatus" /> <AppHeader />
<nav class="app-nav">
<router-link to="/">Downloads</router-link>
<router-link to="/admin">Admin</router-link>
</nav>
<router-view /> <router-view />
<AppFooter />
</template> </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>

View file

@ -67,6 +67,7 @@ export interface FormatInfo {
export interface PublicConfig { export interface PublicConfig {
session_mode: string session_mode: string
default_theme: string default_theme: string
welcome_message: string
purge_enabled: boolean purge_enabled: boolean
max_concurrent_downloads: number max_concurrent_downloads: number
} }

View 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>

View file

@ -1,17 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ConnectionStatus } from '@/composables/useSSE' import DarkModeToggle from '@/components/DarkModeToggle.vue'
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)',
}
</script> </script>
<template> <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> <span class="title-media">media</span><span class="title-dot">.</span><span class="title-rip">rip</span><span class="title-parens">()</span>
</h1> </h1>
<div class="header-right"> <div class="header-right">
<ThemePicker /> <DarkModeToggle />
<div class="header-status" :title="`SSE: ${connectionStatus}`">
<span
class="status-dot"
:style="{ backgroundColor: statusColor[connectionStatus] }"
></span>
</div>
</div> </div>
</div> </div>
</header> </header>
@ -72,17 +54,4 @@ const statusColor: Record<ConnectionStatus, string> = {
align-items: center; align-items: center;
gap: var(--space-md); 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> </style>

View file

@ -1,10 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import type { ConnectionStatus } from '@/composables/useSSE'
const props = defineProps<{
connectionStatus: ConnectionStatus
}>()
type MobileTab = 'submit' | 'queue' type MobileTab = 'submit' | 'queue'
const activeTab = ref<MobileTab>('submit') const activeTab = ref<MobileTab>('submit')

View 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>

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, ref } from 'vue'
import { useDownloadsStore } from '@/stores/downloads' import { useDownloadsStore } from '@/stores/downloads'
import ProgressBar from './ProgressBar.vue' import ProgressBar from './ProgressBar.vue'
import type { Job, JobStatus } from '@/api/types' import type { Job, JobStatus } from '@/api/types'
@ -43,11 +43,17 @@ const showProgress = computed(() =>
props.job.status === 'downloading' || props.job.status === 'extracting', props.job.status === 'downloading' || props.job.status === 'extracting',
) )
const cancelling = ref(false)
async function cancel(): Promise<void> { async function cancel(): Promise<void> {
if (cancelling.value) return
cancelling.value = true
try { try {
await store.cancelDownload(props.job.id) await store.cancelDownload(props.job.id)
} catch { } catch (err) {
// Error will show in UI via store console.error('[DownloadItem] Cancel failed:', err)
} finally {
cancelling.value = false
} }
} }
</script> </script>
@ -74,10 +80,12 @@ async function cancel(): Promise<void> {
<button <button
v-if="isActive" v-if="isActive"
class="btn-cancel" class="btn-cancel"
@click="cancel" :class="{ cancelling }"
:disabled="cancelling"
@click.stop="cancel"
title="Cancel download" title="Cancel download"
> >
{{ cancelling ? '…' : '✕' }}
</button> </button>
</div> </div>
</div> </div>

View file

@ -1,12 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
import AppLayout from '@/components/AppLayout.vue' import AppLayout from '@/components/AppLayout.vue'
import WelcomeMessage from '@/components/WelcomeMessage.vue'
import UrlInput from '@/components/UrlInput.vue' import UrlInput from '@/components/UrlInput.vue'
import DownloadQueue from '@/components/DownloadQueue.vue' import DownloadQueue from '@/components/DownloadQueue.vue'
</script> </script>
<template> <template>
<AppLayout connection-status="connected"> <AppLayout>
<template #url-input> <template #url-input>
<WelcomeMessage />
<UrlInput /> <UrlInput />
</template> </template>
<template #queue> <template #queue>

View 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>

View file

@ -33,6 +33,9 @@ export const useThemeStore = defineStore('theme', () => {
const customThemes = ref<ThemeMeta[]>([]) const customThemes = ref<ThemeMeta[]>([])
const customThemeCSS = ref<Map<string, string>>(new Map()) 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[]>(() => [ const allThemes = computed<ThemeMeta[]>(() => [
...BUILTIN_THEMES, ...BUILTIN_THEMES,
...customThemes.value, ...customThemes.value,
@ -55,6 +58,20 @@ export const useThemeStore = defineStore('theme', () => {
_apply(currentTheme.value) _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. * Switch to a theme by ID. Saves to localStorage and applies immediately.
*/ */
@ -64,6 +81,10 @@ export const useThemeStore = defineStore('theme', () => {
currentTheme.value = themeId currentTheme.value = themeId
localStorage.setItem(STORAGE_KEY, themeId) localStorage.setItem(STORAGE_KEY, themeId)
// Remember the last dark theme for toggle
if (themeId !== 'light') {
localStorage.setItem(STORAGE_KEY + '-dark', themeId)
}
_apply(themeId) _apply(themeId)
} }
@ -139,8 +160,10 @@ export const useThemeStore = defineStore('theme', () => {
customThemes, customThemes,
allThemes, allThemes,
currentMeta, currentMeta,
isDark,
init, init,
setTheme, setTheme,
toggleDarkMode,
loadCustomThemes, loadCustomThemes,
} }
}) })

View file

@ -28,6 +28,7 @@ describe('config store', () => {
const mockConfig = { const mockConfig = {
session_mode: 'isolated', session_mode: 'isolated',
default_theme: 'dark', default_theme: 'dark',
welcome_message: 'Test welcome',
purge_enabled: false, purge_enabled: false,
max_concurrent_downloads: 3, max_concurrent_downloads: 3,
} }

View file

@ -90,4 +90,49 @@ describe('theme store', () => {
expect(store.currentMeta?.id).toBe('cyberpunk') expect(store.currentMeta?.id).toBe('cyberpunk')
expect(store.currentMeta?.name).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
})
}) })