Admin-controlled themes with visitor dark/light toggle

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
This commit is contained in:
xpltd 2026-03-22 15:58:49 -05:00
parent 6804301825
commit 02c5e7bc1f
10 changed files with 270 additions and 255 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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 {
></textarea>
</div>
<div class="settings-field">
<label>Theme</label>
<p class="field-hint">Set the default appearance for visitors. Users can toggle dark/light mode via the header icon.</p>
<div class="theme-settings">
<div class="theme-setting-row">
<span class="theme-setting-label">Default Mode</span>
<select v-model="themeDefaultMode" class="settings-select">
<option value="dark">Dark</option>
<option value="light">Light</option>
</select>
</div>
<div class="theme-setting-row">
<span class="theme-setting-label">Dark Theme</span>
<select v-model="themeDark" class="settings-select">
<option value="cyberpunk">Cyberpunk</option>
<option value="dark">Dark</option>
<option value="midnight">Midnight</option>
<option value="hacker">Hacker</option>
<option value="neon">Neon</option>
</select>
</div>
<div class="theme-setting-row">
<span class="theme-setting-label">Light Theme</span>
<select v-model="themeLight" class="settings-select">
<option value="light">Light</option>
<option value="paper">Paper</option>
<option value="arctic">Arctic</option>
<option value="solarized">Solarized</option>
</select>
</div>
</div>
</div>
<div class="settings-field">
<label>Default Output Formats</label>
<p class="field-hint">When "Auto" is selected, files are converted to these formats instead of the native container.</p>
@ -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;

View file

@ -1,29 +1,17 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useThemeStore } from '@/stores/theme'
const theme = useThemeStore()
const showPicker = ref(false)
function selectTheme(id: string) {
theme.setTheme(id)
showPicker.value = false
}
function closePicker() {
showPicker.value = false
}
</script>
<template>
<div class="theme-picker-wrapper" @mouseleave="closePicker">
<button
class="theme-toggle-btn"
:title="'Theme: ' + (theme.currentMeta?.name || theme.currentTheme)"
@click="showPicker = !showPicker"
aria-label="Theme picker"
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 (dark mode active) -->
<!-- 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"/>
@ -35,54 +23,15 @@ function closePicker() {
<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 (light mode active) -->
<!-- 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>
<Transition name="fade">
<div v-if="showPicker" class="theme-dropdown">
<div class="theme-group">
<div class="theme-group-label">Dark</div>
<button
v-for="t in theme.darkThemes"
:key="t.id"
class="theme-option"
:class="{ active: theme.currentTheme === t.id }"
@click="selectTheme(t.id)"
:title="t.description"
>
<span class="theme-name">{{ t.name }}</span>
<span v-if="theme.currentTheme === t.id" class="theme-check"></span>
</button>
</div>
<div class="theme-divider"></div>
<div class="theme-group">
<div class="theme-group-label">Light</div>
<button
v-for="t in theme.lightThemes"
:key="t.id"
class="theme-option"
:class="{ active: theme.currentTheme === t.id }"
@click="selectTheme(t.id)"
:title="t.description"
>
<span class="theme-name">{{ t.name }}</span>
<span v-if="theme.currentTheme === t.id" class="theme-check"></span>
</button>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.theme-picker-wrapper {
position: relative;
}
.theme-toggle-btn {
.dark-mode-toggle {
display: flex;
align-items: center;
justify-content: center;
@ -97,76 +46,7 @@ function closePicker() {
padding: 0;
}
.theme-toggle-btn:hover {
.dark-mode-toggle:hover {
color: var(--color-accent);
}
.theme-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
min-width: 180px;
z-index: 100;
overflow: hidden;
}
.theme-group-label {
padding: 8px 14px 4px;
font-size: var(--font-size-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.theme-divider {
height: 1px;
background: var(--color-border);
margin: 4px 0;
}
.theme-option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 14px;
background: transparent;
color: var(--color-text);
border: none;
cursor: pointer;
font-size: var(--font-size-sm);
font-family: var(--font-ui);
text-align: left;
transition: background var(--transition-fast);
}
.theme-option:hover {
background: var(--color-surface-hover);
}
.theme-option.active {
color: var(--color-accent);
}
.theme-check {
color: var(--color-accent);
font-weight: bold;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>

View file

@ -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 <html> 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<ThemeMeta[]>([])
const customThemeCSS = ref<Map<string, string>>(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<ThemeMeta[]>(() => [
...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<ThemeMeta | undefined>(() =>
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,
}
})

View file

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

View file

@ -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<string, string> = {}
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')
})
})