mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-03 02:53:58 -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"
|
default_theme: str = "dark"
|
||||||
welcome_message: str = "Paste any video or audio URL. We rip it, you download it. No accounts, no tracking."
|
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):
|
class AdminConfig(BaseModel):
|
||||||
|
|
|
||||||
|
|
@ -295,6 +295,9 @@ async def get_settings(
|
||||||
"admin_username": config.admin.username,
|
"admin_username": config.admin.username,
|
||||||
"purge_enabled": config.purge.enabled,
|
"purge_enabled": config.purge.enabled,
|
||||||
"purge_max_age_minutes": config.purge.max_age_minutes,
|
"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
|
to_persist["purge_max_age_minutes"] = val
|
||||||
updated.append("purge_max_age_minutes")
|
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 ---
|
# --- Persist to DB ---
|
||||||
if to_persist:
|
if to_persist:
|
||||||
await save_settings(db, to_persist)
|
await save_settings(db, to_persist)
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,9 @@ 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,
|
||||||
|
"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,
|
"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,
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,9 @@ ADMIN_WRITABLE_KEYS = {
|
||||||
"purge_enabled",
|
"purge_enabled",
|
||||||
"purge_max_age_minutes",
|
"purge_max_age_minutes",
|
||||||
"api_key",
|
"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"]
|
config.purge.privacy_retention_minutes = settings["privacy_retention_minutes"]
|
||||||
if "api_key" in settings:
|
if "api_key" in settings:
|
||||||
config.server.api_key = settings["api_key"]
|
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))
|
logger.info("Applied %d persisted settings to config", len(settings))
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,9 @@ export interface FormatInfo {
|
||||||
export interface PublicConfig {
|
export interface PublicConfig {
|
||||||
session_mode: string
|
session_mode: string
|
||||||
default_theme: string
|
default_theme: string
|
||||||
|
theme_dark: string
|
||||||
|
theme_light: string
|
||||||
|
theme_default_mode: string
|
||||||
welcome_message: string
|
welcome_message: string
|
||||||
purge_enabled: boolean
|
purge_enabled: boolean
|
||||||
max_concurrent_downloads: number
|
max_concurrent_downloads: number
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useAdminStore } from '@/stores/admin'
|
import { useAdminStore } from '@/stores/admin'
|
||||||
import { useConfigStore } from '@/stores/config'
|
import { useConfigStore } from '@/stores/config'
|
||||||
|
import { useThemeStore } from '@/stores/theme'
|
||||||
import { api } from '@/api/client'
|
import { api } from '@/api/client'
|
||||||
import AdminLogin from './AdminLogin.vue'
|
import AdminLogin from './AdminLogin.vue'
|
||||||
import AdminSetup from './AdminSetup.vue'
|
import AdminSetup from './AdminSetup.vue'
|
||||||
|
|
@ -35,6 +36,11 @@ const adminUsername = ref('admin')
|
||||||
const purgeEnabled = ref(true)
|
const purgeEnabled = ref(true)
|
||||||
const purgeMaxAgeMinutes = ref(1440)
|
const purgeMaxAgeMinutes = ref(1440)
|
||||||
|
|
||||||
|
// Theme settings
|
||||||
|
const themeDark = ref('cyberpunk')
|
||||||
|
const themeLight = ref('light')
|
||||||
|
const themeDefaultMode = ref('dark')
|
||||||
|
|
||||||
// Change password state
|
// Change password state
|
||||||
const currentPassword = ref('')
|
const currentPassword = ref('')
|
||||||
const newPassword = ref('')
|
const newPassword = ref('')
|
||||||
|
|
@ -89,6 +95,9 @@ async function switchTab(tab: typeof activeTab.value) {
|
||||||
adminUsername.value = data.admin_username ?? 'admin'
|
adminUsername.value = data.admin_username ?? 'admin'
|
||||||
purgeEnabled.value = data.purge_enabled ?? false
|
purgeEnabled.value = data.purge_enabled ?? false
|
||||||
purgeMaxAgeMinutes.value = data.purge_max_age_minutes ?? 1440
|
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 {
|
} catch {
|
||||||
// Keep current values
|
// Keep current values
|
||||||
|
|
@ -111,9 +120,15 @@ async function saveAllSettings() {
|
||||||
admin_username: adminUsername.value,
|
admin_username: adminUsername.value,
|
||||||
purge_enabled: purgeEnabled.value,
|
purge_enabled: purgeEnabled.value,
|
||||||
purge_max_age_minutes: purgeMaxAgeMinutes.value,
|
purge_max_age_minutes: purgeMaxAgeMinutes.value,
|
||||||
|
theme_dark: themeDark.value,
|
||||||
|
theme_light: themeLight.value,
|
||||||
|
theme_default_mode: themeDefaultMode.value,
|
||||||
})
|
})
|
||||||
if (ok) {
|
if (ok) {
|
||||||
await configStore.loadConfig()
|
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
|
settingsSaved.value = true
|
||||||
setTimeout(() => { settingsSaved.value = false }, 3000)
|
setTimeout(() => { settingsSaved.value = false }, 3000)
|
||||||
}
|
}
|
||||||
|
|
@ -420,6 +435,39 @@ function formatFilesize(bytes: number | null): string {
|
||||||
></textarea>
|
></textarea>
|
||||||
</div>
|
</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">
|
<div class="settings-field">
|
||||||
<label>Default Output Formats</label>
|
<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>
|
<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);
|
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 {
|
.format-default-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
|
||||||
|
|
@ -1,29 +1,17 @@
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref } from 'vue'
|
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
|
||||||
const theme = useThemeStore()
|
const theme = useThemeStore()
|
||||||
const showPicker = ref(false)
|
|
||||||
|
|
||||||
function selectTheme(id: string) {
|
|
||||||
theme.setTheme(id)
|
|
||||||
showPicker.value = false
|
|
||||||
}
|
|
||||||
|
|
||||||
function closePicker() {
|
|
||||||
showPicker.value = false
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div class="theme-picker-wrapper" @mouseleave="closePicker">
|
|
||||||
<button
|
<button
|
||||||
class="theme-toggle-btn"
|
class="dark-mode-toggle"
|
||||||
:title="'Theme: ' + (theme.currentMeta?.name || theme.currentTheme)"
|
:title="theme.isDark ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||||
@click="showPicker = !showPicker"
|
@click="theme.toggleDarkMode()"
|
||||||
aria-label="Theme picker"
|
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">
|
<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"/>
|
<circle cx="12" cy="12" r="5"/>
|
||||||
<line x1="12" y1="1" x2="12" y2="3"/>
|
<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="4.22" y1="19.78" x2="5.64" y2="18.36"/>
|
||||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||||
</svg>
|
</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">
|
<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"/>
|
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</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>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.theme-picker-wrapper {
|
.dark-mode-toggle {
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
.theme-toggle-btn {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
@ -97,76 +46,7 @@ function closePicker() {
|
||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.theme-toggle-btn:hover {
|
.dark-mode-toggle:hover {
|
||||||
color: var(--color-accent);
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -1,15 +1,17 @@
|
||||||
/**
|
/**
|
||||||
* Theme Pinia store — manages theme selection and application.
|
* Theme Pinia store — manages theme selection and application.
|
||||||
*
|
*
|
||||||
* Built-in themes: cyberpunk (default), dark, light
|
* Admin sets: which dark theme, which light theme, and default mode (dark/light).
|
||||||
* Custom themes: loaded via /api/themes manifest at runtime
|
* 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'
|
* Built-in themes: 5 dark + 4 light = 9 total.
|
||||||
* Application: sets data-theme attribute on <html> element
|
* Custom themes: loaded via /api/themes manifest at runtime.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { ref, computed } from 'vue'
|
import { ref, computed } from 'vue'
|
||||||
import { defineStore } from 'pinia'
|
import { defineStore } from 'pinia'
|
||||||
|
import { useConfigStore } from './config'
|
||||||
|
|
||||||
export interface ThemeMeta {
|
export interface ThemeMeta {
|
||||||
id: string
|
id: string
|
||||||
|
|
@ -20,8 +22,7 @@ export interface ThemeMeta {
|
||||||
variant: 'dark' | 'light'
|
variant: 'dark' | 'light'
|
||||||
}
|
}
|
||||||
|
|
||||||
const STORAGE_KEY = 'mrip-theme'
|
const COOKIE_KEY = 'mrip-mode'
|
||||||
const DEFAULT_THEME = 'cyberpunk'
|
|
||||||
|
|
||||||
const BUILTIN_THEMES: ThemeMeta[] = [
|
const BUILTIN_THEMES: ThemeMeta[] = [
|
||||||
// Dark themes
|
// Dark themes
|
||||||
|
|
@ -38,76 +39,90 @@ const BUILTIN_THEMES: ThemeMeta[] = [
|
||||||
]
|
]
|
||||||
|
|
||||||
export const useThemeStore = defineStore('theme', () => {
|
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 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. */
|
// Admin-configured theme pair (loaded from public config)
|
||||||
const isDark = computed(() => {
|
const adminDarkTheme = ref('cyberpunk')
|
||||||
const meta = allThemes.value.find(t => t.id === currentTheme.value)
|
const adminLightTheme = ref('light')
|
||||||
return meta ? meta.variant === 'dark' : true
|
const adminDefaultMode = ref<'dark' | 'light'>('dark')
|
||||||
})
|
|
||||||
|
|
||||||
const darkThemes = computed(() => allThemes.value.filter(t => t.variant === 'dark'))
|
const isDark = computed(() => currentMode.value === 'dark')
|
||||||
const lightThemes = computed(() => allThemes.value.filter(t => t.variant === 'light'))
|
|
||||||
|
|
||||||
const allThemes = computed<ThemeMeta[]>(() => [
|
const allThemes = computed<ThemeMeta[]>(() => [
|
||||||
...BUILTIN_THEMES,
|
...BUILTIN_THEMES,
|
||||||
...customThemes.value,
|
...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>(() =>
|
const currentMeta = computed<ThemeMeta | undefined>(() =>
|
||||||
allThemes.value.find(t => t.id === currentTheme.value)
|
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 {
|
function init(): void {
|
||||||
const saved = localStorage.getItem(STORAGE_KEY)
|
const configStore = useConfigStore()
|
||||||
if (saved && BUILTIN_THEMES.some(t => t.id === saved)) {
|
const cfg = configStore.config
|
||||||
currentTheme.value = saved
|
|
||||||
} else {
|
if (cfg) {
|
||||||
currentTheme.value = DEFAULT_THEME
|
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)
|
_apply(currentTheme.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Toggle between the current theme's dark and light variant.
|
* Set a specific theme by ID — used by admin preview.
|
||||||
* 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.
|
|
||||||
*/
|
*/
|
||||||
function setTheme(themeId: string): void {
|
function setTheme(themeId: string): void {
|
||||||
const found = allThemes.value.find(t => t.id === themeId)
|
const found = allThemes.value.find(t => t.id === themeId)
|
||||||
if (!found) return
|
if (!found) return
|
||||||
|
|
||||||
currentTheme.value = themeId
|
currentTheme.value = themeId
|
||||||
localStorage.setItem(STORAGE_KEY, themeId)
|
currentMode.value = found.variant
|
||||||
// Remember the last dark theme for toggle
|
_setCookie(COOKIE_KEY, currentMode.value, 365)
|
||||||
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)
|
|
||||||
}
|
|
||||||
_apply(themeId)
|
_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.
|
* Load custom themes from backend manifest.
|
||||||
*/
|
*/
|
||||||
|
|
@ -124,22 +139,16 @@ export const useThemeStore = defineStore('theme', () => {
|
||||||
author: t.author,
|
author: t.author,
|
||||||
description: t.description,
|
description: t.description,
|
||||||
builtin: false,
|
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
|
// Apply custom theme CSS if current is custom
|
||||||
if (!BUILTIN_THEMES.some(t => t.id === currentTheme.value)) {
|
if (!BUILTIN_THEMES.some(t => t.id === currentTheme.value)) {
|
||||||
await _loadCustomCSS(currentTheme.value)
|
await _loadCustomCSS(currentTheme.value)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch {
|
} 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)!)
|
_injectCustomCSS(themeId, customThemeCSS.value.get(themeId)!)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`/api/themes/${themeId}/theme.css`)
|
const res = await fetch(`/api/themes/${themeId}/theme.css`)
|
||||||
if (!res.ok) return
|
if (!res.ok) return
|
||||||
|
|
||||||
const css = await res.text()
|
const css = await res.text()
|
||||||
customThemeCSS.value.set(themeId, css)
|
customThemeCSS.value.set(themeId, css)
|
||||||
_injectCustomCSS(themeId, css)
|
_injectCustomCSS(themeId, css)
|
||||||
} catch {
|
} catch { /* */ }
|
||||||
// Failed to load custom CSS
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _injectCustomCSS(themeId: string, css: string): void {
|
function _injectCustomCSS(themeId: string, css: string): void {
|
||||||
|
|
@ -176,17 +181,32 @@ export const useThemeStore = defineStore('theme', () => {
|
||||||
document.documentElement.setAttribute('data-theme', themeId)
|
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 {
|
return {
|
||||||
currentTheme,
|
currentTheme,
|
||||||
|
currentMode,
|
||||||
customThemes,
|
customThemes,
|
||||||
allThemes,
|
allThemes,
|
||||||
darkThemes,
|
darkThemes,
|
||||||
lightThemes,
|
lightThemes,
|
||||||
currentMeta,
|
currentMeta,
|
||||||
isDark,
|
isDark,
|
||||||
|
adminDarkTheme,
|
||||||
|
adminLightTheme,
|
||||||
|
adminDefaultMode,
|
||||||
init,
|
init,
|
||||||
setTheme,
|
setTheme,
|
||||||
toggleDarkMode,
|
toggleDarkMode,
|
||||||
|
updateAdminConfig,
|
||||||
loadCustomThemes,
|
loadCustomThemes,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,9 @@ describe('config store', () => {
|
||||||
const mockConfig = {
|
const mockConfig = {
|
||||||
session_mode: 'isolated',
|
session_mode: 'isolated',
|
||||||
default_theme: 'dark',
|
default_theme: 'dark',
|
||||||
|
theme_dark: 'cyberpunk',
|
||||||
|
theme_light: 'light',
|
||||||
|
theme_default_mode: 'dark',
|
||||||
welcome_message: 'Test welcome',
|
welcome_message: 'Test welcome',
|
||||||
purge_enabled: false,
|
purge_enabled: false,
|
||||||
max_concurrent_downloads: 3,
|
max_concurrent_downloads: 3,
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,9 @@
|
||||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||||
import { setActivePinia, createPinia } from 'pinia'
|
import { setActivePinia, createPinia } from 'pinia'
|
||||||
import { useThemeStore } from '@/stores/theme'
|
import { useThemeStore } from '@/stores/theme'
|
||||||
|
import { useConfigStore } from '@/stores/config'
|
||||||
|
|
||||||
// Mock localStorage
|
// Mock document
|
||||||
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
|
|
||||||
const setAttributeMock = vi.fn()
|
const setAttributeMock = vi.fn()
|
||||||
Object.defineProperty(globalThis, 'document', {
|
Object.defineProperty(globalThis, 'document', {
|
||||||
value: {
|
value: {
|
||||||
|
|
@ -25,44 +13,47 @@ Object.defineProperty(globalThis, 'document', {
|
||||||
getElementById: vi.fn(() => null),
|
getElementById: vi.fn(() => null),
|
||||||
createElement: vi.fn(() => ({ id: '', textContent: '' })),
|
createElement: vi.fn(() => ({ id: '', textContent: '' })),
|
||||||
head: { appendChild: vi.fn() },
|
head: { appendChild: vi.fn() },
|
||||||
|
cookie: '',
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
describe('theme store', () => {
|
describe('theme store', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
setActivePinia(createPinia())
|
setActivePinia(createPinia())
|
||||||
localStorageMock.clear()
|
|
||||||
setAttributeMock.mockClear()
|
setAttributeMock.mockClear()
|
||||||
|
document.cookie = ''
|
||||||
})
|
})
|
||||||
|
|
||||||
it('initializes with cyberpunk as default', () => {
|
it('initializes with cyberpunk as default (dark mode)', () => {
|
||||||
const store = useThemeStore()
|
const store = useThemeStore()
|
||||||
store.init()
|
store.init()
|
||||||
expect(store.currentTheme).toBe('cyberpunk')
|
expect(store.currentTheme).toBe('cyberpunk')
|
||||||
|
expect(store.currentMode).toBe('dark')
|
||||||
|
expect(store.isDark).toBe(true)
|
||||||
expect(setAttributeMock).toHaveBeenCalledWith('data-theme', 'cyberpunk')
|
expect(setAttributeMock).toHaveBeenCalledWith('data-theme', 'cyberpunk')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('restores saved theme from localStorage', () => {
|
it('uses admin config for defaults when available', () => {
|
||||||
localStorageMock.setItem('mrip-theme', 'dark')
|
const configStore = useConfigStore()
|
||||||
|
configStore.config = {
|
||||||
|
theme_dark: 'neon',
|
||||||
|
theme_light: 'paper',
|
||||||
|
theme_default_mode: 'light',
|
||||||
|
} as any
|
||||||
|
|
||||||
const store = useThemeStore()
|
const store = useThemeStore()
|
||||||
store.init()
|
store.init()
|
||||||
expect(store.currentTheme).toBe('dark')
|
expect(store.currentTheme).toBe('paper')
|
||||||
expect(setAttributeMock).toHaveBeenCalledWith('data-theme', 'dark')
|
expect(store.currentMode).toBe('light')
|
||||||
|
expect(store.isDark).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('falls back to cyberpunk for invalid saved theme', () => {
|
it('setTheme updates state and applies to DOM', () => {
|
||||||
localStorageMock.setItem('mrip-theme', 'nonexistent')
|
|
||||||
const store = useThemeStore()
|
|
||||||
store.init()
|
|
||||||
expect(store.currentTheme).toBe('cyberpunk')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('setTheme updates state, localStorage, and DOM', () => {
|
|
||||||
const store = useThemeStore()
|
const store = useThemeStore()
|
||||||
store.init()
|
store.init()
|
||||||
store.setTheme('light')
|
store.setTheme('light')
|
||||||
expect(store.currentTheme).toBe('light')
|
expect(store.currentTheme).toBe('light')
|
||||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('mrip-theme', 'light')
|
expect(store.currentMode).toBe('light')
|
||||||
expect(setAttributeMock).toHaveBeenCalledWith('data-theme', 'light')
|
expect(setAttributeMock).toHaveBeenCalledWith('data-theme', 'light')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -87,6 +78,12 @@ describe('theme store', () => {
|
||||||
expect(store.allThemes.every(t => t.builtin)).toBe(true)
|
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', () => {
|
it('currentMeta returns metadata for active theme', () => {
|
||||||
const store = useThemeStore()
|
const store = useThemeStore()
|
||||||
store.init()
|
store.init()
|
||||||
|
|
@ -94,48 +91,50 @@ describe('theme store', () => {
|
||||||
expect(store.currentMeta?.name).toBe('Cyberpunk')
|
expect(store.currentMeta?.name).toBe('Cyberpunk')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('isDark is true for cyberpunk and dark themes', () => {
|
it('isDark reflects current mode', () => {
|
||||||
const store = useThemeStore()
|
const store = useThemeStore()
|
||||||
store.init()
|
store.init()
|
||||||
expect(store.isDark).toBe(true)
|
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')
|
store.setTheme('light')
|
||||||
expect(store.isDark).toBe(false)
|
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()
|
const store = useThemeStore()
|
||||||
store.init() // starts on cyberpunk (dark)
|
store.init() // cyberpunk (dark)
|
||||||
store.toggleDarkMode()
|
store.toggleDarkMode()
|
||||||
expect(store.currentTheme).toBe('light')
|
expect(store.currentTheme).toBe('light')
|
||||||
expect(store.isDark).toBe(false)
|
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()
|
store.toggleDarkMode()
|
||||||
expect(store.currentTheme).toBe('cyberpunk')
|
expect(store.currentTheme).toBe('cyberpunk')
|
||||||
expect(store.isDark).toBe(true)
|
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()
|
const store = useThemeStore()
|
||||||
store.init()
|
store.init()
|
||||||
store.setTheme('dark') // switch to the "dark" theme (not cyberpunk)
|
expect(store.currentTheme).toBe('neon')
|
||||||
store.toggleDarkMode()
|
store.toggleDarkMode()
|
||||||
expect(store.currentTheme).toBe('light')
|
expect(store.currentTheme).toBe('arctic')
|
||||||
store.toggleDarkMode()
|
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