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."""
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):

View file

@ -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,
}

View file

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

View file

@ -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
}

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

View file

@ -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')

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

View file

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

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 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,
}
})

View file

@ -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,
}

View file

@ -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
})
})