From 02c5e7bc1f038520ff8b2f1eba474296080a42b1 Mon Sep 17 00:00:00 2001 From: xpltd Date: Sun, 22 Mar 2026 15:58:49 -0500 Subject: [PATCH] Admin-controlled themes with visitor dark/light toggle MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Admin Settings: - Theme section: pick Dark Theme, Light Theme, and Default Mode - 5 dark options (Cyberpunk/Dark/Midnight/Hacker/Neon) - 4 light options (Light/Paper/Arctic/Solarized) - Persisted in SQLite — survives container rebuilds - Served via /api/config/public so frontend loads admin defaults Visitor behavior: - Page loads with admin's chosen default (dark or light theme) - Sun/moon icon toggles between admin's dark and light pair - Preference stored in cookie — persists within browser session - No theme dropdown for visitors — admin controls the pair Header icon simplified back to clean dark/light toggle --- backend/app/core/config.py | 3 + backend/app/routers/admin.py | 28 ++++ backend/app/routers/system.py | 3 + backend/app/services/settings.py | 9 ++ frontend/src/api/types.ts | 3 + frontend/src/components/AdminPanel.vue | 67 ++++++++ frontend/src/components/DarkModeToggle.vue | 170 +++------------------ frontend/src/stores/theme.ts | 136 ++++++++++------- frontend/src/tests/stores/config.test.ts | 3 + frontend/src/tests/stores/theme.test.ts | 103 +++++++------ 10 files changed, 270 insertions(+), 255 deletions(-) diff --git a/backend/app/core/config.py b/backend/app/core/config.py index bb551aa..ff72e67 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -91,6 +91,9 @@ class UIConfig(BaseModel): default_theme: str = "dark" welcome_message: str = "Paste any video or audio URL. We rip it, you download it. No accounts, no tracking." + theme_dark: str = "cyberpunk" # Which dark theme to use + theme_light: str = "light" # Which light theme to use + theme_default_mode: str = "dark" # Start in "dark" or "light" mode class AdminConfig(BaseModel): diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 2868b1a..ea71bfc 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -295,6 +295,9 @@ async def get_settings( "admin_username": config.admin.username, "purge_enabled": config.purge.enabled, "purge_max_age_minutes": config.purge.max_age_minutes, + "theme_dark": config.ui.theme_dark, + "theme_light": config.ui.theme_light, + "theme_default_mode": config.ui.theme_default_mode, } @@ -418,6 +421,31 @@ async def update_settings( to_persist["purge_max_age_minutes"] = val updated.append("purge_max_age_minutes") + # Theme settings + valid_dark = {"cyberpunk", "dark", "midnight", "hacker", "neon"} + valid_light = {"light", "paper", "arctic", "solarized"} + + if "theme_dark" in body: + val = body["theme_dark"] + if val in valid_dark: + config.ui.theme_dark = val + to_persist["theme_dark"] = val + updated.append("theme_dark") + + if "theme_light" in body: + val = body["theme_light"] + if val in valid_light: + config.ui.theme_light = val + to_persist["theme_light"] = val + updated.append("theme_light") + + if "theme_default_mode" in body: + val = body["theme_default_mode"] + if val in ("dark", "light"): + config.ui.theme_default_mode = val + to_persist["theme_default_mode"] = val + updated.append("theme_default_mode") + # --- Persist to DB --- if to_persist: await save_settings(db, to_persist) diff --git a/backend/app/routers/system.py b/backend/app/routers/system.py index 4228289..26ef76c 100644 --- a/backend/app/routers/system.py +++ b/backend/app/routers/system.py @@ -22,6 +22,9 @@ async def public_config(request: Request) -> dict: return { "session_mode": config.session.mode, "default_theme": config.ui.default_theme, + "theme_dark": config.ui.theme_dark, + "theme_light": config.ui.theme_light, + "theme_default_mode": config.ui.theme_default_mode, "welcome_message": config.ui.welcome_message, "purge_enabled": config.purge.enabled, "max_concurrent_downloads": config.downloads.max_concurrent, diff --git a/backend/app/services/settings.py b/backend/app/services/settings.py index e9735b8..cd3491a 100644 --- a/backend/app/services/settings.py +++ b/backend/app/services/settings.py @@ -35,6 +35,9 @@ ADMIN_WRITABLE_KEYS = { "purge_enabled", "purge_max_age_minutes", "api_key", + "theme_dark", + "theme_light", + "theme_default_mode", } @@ -113,6 +116,12 @@ def apply_persisted_to_config(config, settings: dict) -> None: config.purge.privacy_retention_minutes = settings["privacy_retention_minutes"] if "api_key" in settings: config.server.api_key = settings["api_key"] + if "theme_dark" in settings: + config.ui.theme_dark = settings["theme_dark"] + if "theme_light" in settings: + config.ui.theme_light = settings["theme_light"] + if "theme_default_mode" in settings: + config.ui.theme_default_mode = settings["theme_default_mode"] logger.info("Applied %d persisted settings to config", len(settings)) diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index d90ba3c..746b4b2 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -71,6 +71,9 @@ export interface FormatInfo { export interface PublicConfig { session_mode: string default_theme: string + theme_dark: string + theme_light: string + theme_default_mode: string welcome_message: string purge_enabled: boolean max_concurrent_downloads: number diff --git a/frontend/src/components/AdminPanel.vue b/frontend/src/components/AdminPanel.vue index 181dcbf..333a0fa 100644 --- a/frontend/src/components/AdminPanel.vue +++ b/frontend/src/components/AdminPanel.vue @@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue' import { useRouter } from 'vue-router' import { useAdminStore } from '@/stores/admin' import { useConfigStore } from '@/stores/config' +import { useThemeStore } from '@/stores/theme' import { api } from '@/api/client' import AdminLogin from './AdminLogin.vue' import AdminSetup from './AdminSetup.vue' @@ -35,6 +36,11 @@ const adminUsername = ref('admin') const purgeEnabled = ref(true) const purgeMaxAgeMinutes = ref(1440) +// Theme settings +const themeDark = ref('cyberpunk') +const themeLight = ref('light') +const themeDefaultMode = ref('dark') + // Change password state const currentPassword = ref('') const newPassword = ref('') @@ -89,6 +95,9 @@ async function switchTab(tab: typeof activeTab.value) { adminUsername.value = data.admin_username ?? 'admin' purgeEnabled.value = data.purge_enabled ?? false purgeMaxAgeMinutes.value = data.purge_max_age_minutes ?? 1440 + themeDark.value = data.theme_dark ?? 'cyberpunk' + themeLight.value = data.theme_light ?? 'light' + themeDefaultMode.value = data.theme_default_mode ?? 'dark' } } catch { // Keep current values @@ -111,9 +120,15 @@ async function saveAllSettings() { admin_username: adminUsername.value, purge_enabled: purgeEnabled.value, purge_max_age_minutes: purgeMaxAgeMinutes.value, + theme_dark: themeDark.value, + theme_light: themeLight.value, + theme_default_mode: themeDefaultMode.value, }) if (ok) { await configStore.loadConfig() + // Update theme store with new admin config + const themeStore = useThemeStore() + themeStore.updateAdminConfig(themeDark.value, themeLight.value, themeDefaultMode.value as 'dark' | 'light') settingsSaved.value = true setTimeout(() => { settingsSaved.value = false }, 3000) } @@ -420,6 +435,39 @@ function formatFilesize(bytes: number | null): string { > +
+ +

Set the default appearance for visitors. Users can toggle dark/light mode via the header icon.

+
+
+ Default Mode + +
+
+ Dark Theme + +
+
+ Light Theme + +
+
+
+

When "Auto" is selected, files are converted to these formats instead of the native container.

@@ -922,6 +970,25 @@ h3 { margin-top: var(--space-sm); } +.theme-settings { + display: flex; + flex-direction: column; + gap: var(--space-sm); + margin-top: var(--space-sm); +} + +.theme-setting-row { + display: flex; + align-items: center; + gap: var(--space-md); +} + +.theme-setting-label { + min-width: 100px; + font-weight: 500; + color: var(--color-text); +} + .format-default-row { display: flex; align-items: center; diff --git a/frontend/src/components/DarkModeToggle.vue b/frontend/src/components/DarkModeToggle.vue index 81122d4..aaae9c9 100644 --- a/frontend/src/components/DarkModeToggle.vue +++ b/frontend/src/components/DarkModeToggle.vue @@ -1,88 +1,37 @@ diff --git a/frontend/src/stores/theme.ts b/frontend/src/stores/theme.ts index be5d454..887f3ef 100644 --- a/frontend/src/stores/theme.ts +++ b/frontend/src/stores/theme.ts @@ -1,15 +1,17 @@ /** * Theme Pinia store — manages theme selection and application. * - * Built-in themes: cyberpunk (default), dark, light - * Custom themes: loaded via /api/themes manifest at runtime + * Admin sets: which dark theme, which light theme, and default mode (dark/light). + * Visitors: see the admin default, can toggle dark/light via header icon. + * Visitor preference stored in cookie (session-scoped persistence). * - * Persistence: localStorage key 'mrip-theme' - * Application: sets data-theme attribute on element + * Built-in themes: 5 dark + 4 light = 9 total. + * Custom themes: loaded via /api/themes manifest at runtime. */ import { ref, computed } from 'vue' import { defineStore } from 'pinia' +import { useConfigStore } from './config' export interface ThemeMeta { id: string @@ -20,8 +22,7 @@ export interface ThemeMeta { variant: 'dark' | 'light' } -const STORAGE_KEY = 'mrip-theme' -const DEFAULT_THEME = 'cyberpunk' +const COOKIE_KEY = 'mrip-mode' const BUILTIN_THEMES: ThemeMeta[] = [ // Dark themes @@ -38,76 +39,90 @@ const BUILTIN_THEMES: ThemeMeta[] = [ ] export const useThemeStore = defineStore('theme', () => { - const currentTheme = ref(DEFAULT_THEME) + const currentTheme = ref('cyberpunk') + const currentMode = ref<'dark' | 'light'>('dark') const customThemes = ref([]) const customThemeCSS = ref>(new Map()) - /** Whether the current theme is a dark variant. */ - const isDark = computed(() => { - const meta = allThemes.value.find(t => t.id === currentTheme.value) - return meta ? meta.variant === 'dark' : true - }) + // Admin-configured theme pair (loaded from public config) + const adminDarkTheme = ref('cyberpunk') + const adminLightTheme = ref('light') + const adminDefaultMode = ref<'dark' | 'light'>('dark') - const darkThemes = computed(() => allThemes.value.filter(t => t.variant === 'dark')) - const lightThemes = computed(() => allThemes.value.filter(t => t.variant === 'light')) + const isDark = computed(() => currentMode.value === 'dark') const allThemes = computed(() => [ ...BUILTIN_THEMES, ...customThemes.value, ]) + const darkThemes = computed(() => allThemes.value.filter(t => t.variant === 'dark')) + const lightThemes = computed(() => allThemes.value.filter(t => t.variant === 'light')) + const currentMeta = computed(() => allThemes.value.find(t => t.id === currentTheme.value) ) /** - * Initialize the theme store — reads from localStorage and applies. + * Initialize — reads admin config, then checks for visitor cookie override. */ function init(): void { - const saved = localStorage.getItem(STORAGE_KEY) - if (saved && BUILTIN_THEMES.some(t => t.id === saved)) { - currentTheme.value = saved - } else { - currentTheme.value = DEFAULT_THEME + const configStore = useConfigStore() + const cfg = configStore.config + + if (cfg) { + adminDarkTheme.value = cfg.theme_dark || 'cyberpunk' + adminLightTheme.value = cfg.theme_light || 'light' + adminDefaultMode.value = (cfg.theme_default_mode as 'dark' | 'light') || 'dark' } + + // Check visitor cookie for mode override + const savedMode = _getCookie(COOKIE_KEY) as 'dark' | 'light' | null + currentMode.value = savedMode || adminDefaultMode.value + + // Apply the right theme for the current mode + const themeId = currentMode.value === 'dark' ? adminDarkTheme.value : adminLightTheme.value + currentTheme.value = themeId + _apply(themeId) + } + + /** + * Toggle between dark and light mode. Saves preference to cookie. + */ + function toggleDarkMode(): void { + if (isDark.value) { + currentMode.value = 'light' + currentTheme.value = adminLightTheme.value + } else { + currentMode.value = 'dark' + currentTheme.value = adminDarkTheme.value + } + _setCookie(COOKIE_KEY, currentMode.value, 365) _apply(currentTheme.value) } /** - * Toggle between the current theme's dark and light variant. - * Cyberpunk (default) ↔ Light. Dark ↔ Light. - */ - function toggleDarkMode(): void { - if (isDark.value) { - // Switch to last used light theme, or first available - const lastLight = localStorage.getItem(STORAGE_KEY + '-light') || 'light' - setTheme(lastLight) - } else { - // Return to the last dark theme, defaulting to cyberpunk - const lastDark = localStorage.getItem(STORAGE_KEY + '-dark') || DEFAULT_THEME - setTheme(lastDark) - } - } - - /** - * Switch to a theme by ID. Saves to localStorage and applies immediately. + * Set a specific theme by ID — used by admin preview. */ function setTheme(themeId: string): void { const found = allThemes.value.find(t => t.id === themeId) if (!found) return currentTheme.value = themeId - localStorage.setItem(STORAGE_KEY, themeId) - // Remember the last dark theme for toggle - const meta = allThemes.value.find(t => t.id === themeId) - if (meta?.variant === 'dark') { - localStorage.setItem(STORAGE_KEY + '-dark', themeId) - } else { - localStorage.setItem(STORAGE_KEY + '-light', themeId) - } + currentMode.value = found.variant + _setCookie(COOKIE_KEY, currentMode.value, 365) _apply(themeId) } + /** + * Update admin theme config (called after saving settings). + */ + function updateAdminConfig(darkTheme: string, lightTheme: string, defaultMode: 'dark' | 'light'): void { + adminDarkTheme.value = darkTheme + adminLightTheme.value = lightTheme + adminDefaultMode.value = defaultMode + } + /** * Load custom themes from backend manifest. */ @@ -124,22 +139,16 @@ export const useThemeStore = defineStore('theme', () => { author: t.author, description: t.description, builtin: false, - variant: t.variant || 'dark', // default custom themes to dark + variant: t.variant || 'dark', })) - // If saved theme is a custom theme, validate it still exists - const saved = localStorage.getItem(STORAGE_KEY) - if (saved && !allThemes.value.some(t => t.id === saved)) { - setTheme(DEFAULT_THEME) - } - // Apply custom theme CSS if current is custom if (!BUILTIN_THEMES.some(t => t.id === currentTheme.value)) { await _loadCustomCSS(currentTheme.value) } } } catch { - // Custom themes unavailable — use built-ins only + // Custom themes unavailable } } @@ -148,17 +157,13 @@ export const useThemeStore = defineStore('theme', () => { _injectCustomCSS(themeId, customThemeCSS.value.get(themeId)!) return } - try { const res = await fetch(`/api/themes/${themeId}/theme.css`) if (!res.ok) return - const css = await res.text() customThemeCSS.value.set(themeId, css) _injectCustomCSS(themeId, css) - } catch { - // Failed to load custom CSS - } + } catch { /* */ } } function _injectCustomCSS(themeId: string, css: string): void { @@ -176,17 +181,32 @@ export const useThemeStore = defineStore('theme', () => { document.documentElement.setAttribute('data-theme', themeId) } + function _getCookie(name: string): string | null { + const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)) + return match ? decodeURIComponent(match[1]) : null + } + + function _setCookie(name: string, value: string, days: number): void { + const expires = new Date(Date.now() + days * 86400000).toUTCString() + document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax` + } + return { currentTheme, + currentMode, customThemes, allThemes, darkThemes, lightThemes, currentMeta, isDark, + adminDarkTheme, + adminLightTheme, + adminDefaultMode, init, setTheme, toggleDarkMode, + updateAdminConfig, loadCustomThemes, } }) diff --git a/frontend/src/tests/stores/config.test.ts b/frontend/src/tests/stores/config.test.ts index e69bd3b..e6b6b0c 100644 --- a/frontend/src/tests/stores/config.test.ts +++ b/frontend/src/tests/stores/config.test.ts @@ -28,6 +28,9 @@ describe('config store', () => { const mockConfig = { session_mode: 'isolated', default_theme: 'dark', + theme_dark: 'cyberpunk', + theme_light: 'light', + theme_default_mode: 'dark', welcome_message: 'Test welcome', purge_enabled: false, max_concurrent_downloads: 3, diff --git a/frontend/src/tests/stores/theme.test.ts b/frontend/src/tests/stores/theme.test.ts index 5f7e5eb..039cb0a 100644 --- a/frontend/src/tests/stores/theme.test.ts +++ b/frontend/src/tests/stores/theme.test.ts @@ -1,21 +1,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest' import { setActivePinia, createPinia } from 'pinia' import { useThemeStore } from '@/stores/theme' +import { useConfigStore } from '@/stores/config' -// Mock localStorage -const localStorageMock = (() => { - let store: Record = {} - return { - getItem: vi.fn((key: string) => store[key] || null), - setItem: vi.fn((key: string, value: string) => { store[key] = value }), - removeItem: vi.fn((key: string) => { delete store[key] }), - clear: vi.fn(() => { store = {} }), - } -})() - -Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock }) - -// Mock document.documentElement.setAttribute +// Mock document const setAttributeMock = vi.fn() Object.defineProperty(globalThis, 'document', { value: { @@ -25,44 +13,47 @@ Object.defineProperty(globalThis, 'document', { getElementById: vi.fn(() => null), createElement: vi.fn(() => ({ id: '', textContent: '' })), head: { appendChild: vi.fn() }, + cookie: '', }, }) describe('theme store', () => { beforeEach(() => { setActivePinia(createPinia()) - localStorageMock.clear() setAttributeMock.mockClear() + document.cookie = '' }) - it('initializes with cyberpunk as default', () => { + it('initializes with cyberpunk as default (dark mode)', () => { const store = useThemeStore() store.init() expect(store.currentTheme).toBe('cyberpunk') + expect(store.currentMode).toBe('dark') + expect(store.isDark).toBe(true) expect(setAttributeMock).toHaveBeenCalledWith('data-theme', 'cyberpunk') }) - it('restores saved theme from localStorage', () => { - localStorageMock.setItem('mrip-theme', 'dark') + it('uses admin config for defaults when available', () => { + const configStore = useConfigStore() + configStore.config = { + theme_dark: 'neon', + theme_light: 'paper', + theme_default_mode: 'light', + } as any + const store = useThemeStore() store.init() - expect(store.currentTheme).toBe('dark') - expect(setAttributeMock).toHaveBeenCalledWith('data-theme', 'dark') + expect(store.currentTheme).toBe('paper') + expect(store.currentMode).toBe('light') + expect(store.isDark).toBe(false) }) - it('falls back to cyberpunk for invalid saved theme', () => { - localStorageMock.setItem('mrip-theme', 'nonexistent') - const store = useThemeStore() - store.init() - expect(store.currentTheme).toBe('cyberpunk') - }) - - it('setTheme updates state, localStorage, and DOM', () => { + it('setTheme updates state and applies to DOM', () => { const store = useThemeStore() store.init() store.setTheme('light') expect(store.currentTheme).toBe('light') - expect(localStorageMock.setItem).toHaveBeenCalledWith('mrip-theme', 'light') + expect(store.currentMode).toBe('light') expect(setAttributeMock).toHaveBeenCalledWith('data-theme', 'light') }) @@ -87,6 +78,12 @@ describe('theme store', () => { expect(store.allThemes.every(t => t.builtin)).toBe(true) }) + it('darkThemes has 5, lightThemes has 4', () => { + const store = useThemeStore() + expect(store.darkThemes).toHaveLength(5) + expect(store.lightThemes).toHaveLength(4) + }) + it('currentMeta returns metadata for active theme', () => { const store = useThemeStore() store.init() @@ -94,48 +91,50 @@ describe('theme store', () => { expect(store.currentMeta?.name).toBe('Cyberpunk') }) - it('isDark is true for cyberpunk and dark themes', () => { + it('isDark reflects current mode', () => { 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) + store.setTheme('hacker') + expect(store.isDark).toBe(true) }) - it('toggleDarkMode switches from dark to light', () => { + it('toggleDarkMode switches between admin dark and light themes', () => { const store = useThemeStore() - store.init() // starts on cyberpunk (dark) + store.init() // 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', () => { + it('toggleDarkMode uses admin-configured themes', () => { + const configStore = useConfigStore() + configStore.config = { + theme_dark: 'neon', + theme_light: 'arctic', + theme_default_mode: 'dark', + } as any + const store = useThemeStore() store.init() - store.setTheme('dark') // switch to the "dark" theme (not cyberpunk) + expect(store.currentTheme).toBe('neon') store.toggleDarkMode() - expect(store.currentTheme).toBe('light') + expect(store.currentTheme).toBe('arctic') store.toggleDarkMode() - expect(store.currentTheme).toBe('dark') // returns to dark, not cyberpunk + expect(store.currentTheme).toBe('neon') + }) + + it('updateAdminConfig changes the theme pair', () => { + const store = useThemeStore() + store.init() + store.updateAdminConfig('midnight', 'solarized', 'light') + expect(store.adminDarkTheme).toBe('midnight') + expect(store.adminLightTheme).toBe('solarized') + expect(store.adminDefaultMode).toBe('light') }) })