mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-02 18:43:59 -06:00
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:
parent
6804301825
commit
02c5e7bc1f
10 changed files with 270 additions and 255 deletions
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -1,88 +1,37 @@
|
|||
<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"
|
||||
>
|
||||
<!-- Sun icon (dark mode active) -->
|
||||
<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 (light mode active) -->
|
||||
<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>
|
||||
<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>
|
||||
.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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue