Compare commits

..

No commits in common. "master" and "v1.2.1" have entirely different histories.

21 changed files with 289 additions and 585 deletions

View file

@ -11,7 +11,7 @@ A self-hostable yt-dlp web frontend. Paste a URL, pick quality, download — wit
- **Real-time progress** — Server-Sent Events stream download progress to the browser instantly. - **Real-time progress** — Server-Sent Events stream download progress to the browser instantly.
- **Session isolation** — Each browser gets its own download queue. No cross-talk. - **Session isolation** — Each browser gets its own download queue. No cross-talk.
- **Playlist support** — Collapsible parent/child jobs with per-video status tracking. - **Playlist support** — Collapsible parent/child jobs with per-video status tracking.
- **9 built-in themes** — 5 dark (Cyberpunk, Dark, Midnight, Hacker, Neon) + 4 light (Light, Paper, Arctic, Solarized). Admin picks the pair, visitors toggle dark/light. - **Three built-in themes** — Cyberpunk (default), Dark, Light. Switch in the header.
- **Custom themes** — Drop a CSS file into `/themes` volume. No rebuild needed. - **Custom themes** — Drop a CSS file into `/themes` volume. No rebuild needed.
- **Admin panel** — Session management, storage info, manual purge, error logs. Protected by bcrypt auth. - **Admin panel** — Session management, storage info, manual purge, error logs. Protected by bcrypt auth.
- **Cookie auth** — Upload cookies.txt per session for paywalled/private content. - **Cookie auth** — Upload cookies.txt per session for paywalled/private content.
@ -21,32 +21,8 @@ A self-hostable yt-dlp web frontend. Paste a URL, pick quality, download — wit
## Quickstart ## Quickstart
The Docker image is published to GitHub Container Registry:
```
ghcr.io/xpltdco/media-rip:latest
```
Pull and run with Docker Compose (recommended):
```bash ```bash
# Download the compose file docker compose up
curl -O https://raw.githubusercontent.com/xpltdco/media-rip/master/docker-compose.yml
# Start the container
docker compose up -d
```
Or pull and run directly:
```bash
docker run -d \
--name mediarip \
-p 8080:8000 \
-v ./downloads:/downloads \
-v mediarip-data:/data \
--restart unless-stopped \
ghcr.io/xpltdco/media-rip:latest
``` ```
Open [http://localhost:8080](http://localhost:8080) and paste a URL. On first run, you'll set an admin password. Open [http://localhost:8080](http://localhost:8080) and paste a URL. On first run, you'll set an admin password.

View file

@ -91,9 +91,6 @@ 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):

View file

@ -1,10 +1,8 @@
"""SQLite database layer with async CRUD operations. """SQLite database layer with WAL mode and async CRUD operations.
Uses aiosqlite for async access. ``init_db`` sets critical PRAGMAs Uses aiosqlite for async access. ``init_db`` sets critical PRAGMAs
(busy_timeout, journal_mode, synchronous) *before* creating any tables so (busy_timeout, WAL, synchronous) *before* creating any tables so that
that concurrent download workers never hit ``SQLITE_BUSY``. WAL mode is concurrent download workers never hit ``SQLITE_BUSY``.
preferred on local filesystems; DELETE mode is used automatically when a
network filesystem (CIFS, NFS) is detected.
""" """
from __future__ import annotations from __future__ import annotations
@ -92,31 +90,19 @@ async def init_db(db_path: str) -> aiosqlite.Connection:
PRAGMA order matters: PRAGMA order matters:
1. ``busy_timeout`` prevents immediate ``SQLITE_BUSY`` on lock contention 1. ``busy_timeout`` prevents immediate ``SQLITE_BUSY`` on lock contention
2. ``journal_mode`` WAL for local filesystems, DELETE for network mounts 2. ``journal_mode=WAL`` enables concurrent readers + single writer
(CIFS/NFS lack the shared-memory primitives WAL requires) 3. ``synchronous=NORMAL`` safe durability level for WAL mode
3. ``synchronous=NORMAL`` safe durability level
Returns the ready-to-use connection. Returns the ready-to-use connection.
""" """
# Detect network filesystem *before* opening the DB so we never attempt
# WAL on CIFS/NFS (which creates broken SHM files that persist).
use_wal = not _is_network_filesystem(db_path)
db = await aiosqlite.connect(db_path) db = await aiosqlite.connect(db_path)
db.row_factory = aiosqlite.Row db.row_factory = aiosqlite.Row
# --- PRAGMAs (before any DDL) --- # --- PRAGMAs (before any DDL) ---
await db.execute("PRAGMA busy_timeout = 5000") await db.execute("PRAGMA busy_timeout = 5000")
result = await db.execute("PRAGMA journal_mode = WAL")
if use_wal: row = await result.fetchone()
journal_mode = await _try_journal_mode(db, "wal") journal_mode = row[0] if row else "unknown"
else:
logger.info(
"Network filesystem detected for %s — using DELETE journal mode",
db_path,
)
journal_mode = await _try_journal_mode(db, "delete")
logger.info("journal_mode set to %s", journal_mode) logger.info("journal_mode set to %s", journal_mode)
await db.execute("PRAGMA synchronous = NORMAL") await db.execute("PRAGMA synchronous = NORMAL")
@ -129,54 +115,6 @@ async def init_db(db_path: str) -> aiosqlite.Connection:
return db return db
def _is_network_filesystem(db_path: str) -> bool:
"""Return True if *db_path* resides on a network filesystem (CIFS, NFS, etc.).
Parses ``/proc/mounts`` (Linux) to find the filesystem type of the
longest-prefix mount matching the database directory. Returns False
on non-Linux hosts or if detection fails.
"""
import os
network_fs_types = {"cifs", "nfs", "nfs4", "smb", "smbfs", "9p", "fuse.sshfs"}
try:
db_dir = os.path.dirname(os.path.abspath(db_path))
with open("/proc/mounts", "r") as f:
mounts = f.readlines()
best_match = ""
best_fstype = ""
for line in mounts:
parts = line.split()
if len(parts) < 3:
continue
mountpoint, fstype = parts[1], parts[2]
if db_dir.startswith(mountpoint) and len(mountpoint) > len(best_match):
best_match = mountpoint
best_fstype = fstype
is_net = best_fstype in network_fs_types
if is_net:
logger.info(
"Detected %s filesystem at %s for database %s",
best_fstype, best_match, db_path,
)
return is_net
except Exception:
return False
async def _try_journal_mode(
db: aiosqlite.Connection, mode: str,
) -> str:
"""Try setting *mode* and return the actual journal mode string."""
try:
result = await db.execute(f"PRAGMA journal_mode = {mode}")
row = await result.fetchone()
return (row[0] if row else "unknown").lower()
except Exception as exc:
logger.warning("PRAGMA journal_mode=%s failed: %s", mode, exc)
return "error"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# CRUD helpers # CRUD helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -295,9 +295,6 @@ 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,
} }
@ -421,31 +418,6 @@ 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)
@ -548,7 +520,7 @@ async def revoke_api_key(
config = request.app.state.config config = request.app.state.config
config.server.api_key = "" config.server.api_key = ""
from app.services.settings import delete_setting from app.services.settings import save_settings, delete_setting
db = request.app.state.db db = request.app.state.db
await delete_setting(db, "api_key") await delete_setting(db, "api_key")

View file

@ -22,9 +22,6 @@ 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,

View file

@ -561,31 +561,6 @@ class DownloadService:
url_lower = url.lower() url_lower = url.lower()
return any(domain in url_lower for domain in audio_domains) return any(domain in url_lower for domain in audio_domains)
@staticmethod
def _url_or_ext_implies_video(url: str, ext: str | None) -> bool:
"""Return True if the URL path or reported extension is a known video container.
This acts as a fallback when yt-dlp's extract_flat mode strips codec
metadata (common for archive.org, direct-file URLs, etc.), which would
otherwise cause the UI to wrongly label the source as "audio only".
"""
video_extensions = {
"mp4", "mkv", "webm", "avi", "mov", "flv", "wmv", "mpg",
"mpeg", "m4v", "ts", "3gp", "ogv",
}
# Check the extension reported by yt-dlp
if ext and ext.lower() in video_extensions:
return True
# Check the URL path for a video file extension
from urllib.parse import urlparse
path = urlparse(url).path.lower()
# Strip any trailing slashes / query residue
path = path.rstrip("/")
for vext in video_extensions:
if path.endswith(f".{vext}"):
return True
return False
@staticmethod @staticmethod
def _get_auth_hint(url: str) -> str | None: def _get_auth_hint(url: str) -> str | None:
"""Return a user-facing hint for sites that commonly need auth.""" """Return a user-facing hint for sites that commonly need auth."""
@ -670,27 +645,13 @@ class DownloadService:
"url": e.get("url") or e.get("webpage_url", ""), "url": e.get("url") or e.get("webpage_url", ""),
"duration": e.get("duration"), "duration": e.get("duration"),
}) })
# Domain-based detection may miss video playlists on generic
# hosting sites (e.g. archive.org). If any entry URL looks like
# a video file, override domain_audio for the whole playlist.
playlist_audio = domain_audio
if playlist_audio:
for e_check in entries:
entry_url = e_check.get("url", "")
if self._url_or_ext_implies_video(entry_url, None):
playlist_audio = False
break
if not playlist_audio and not domain_audio:
# Also check the top-level URL itself
if self._url_or_ext_implies_video(url, info.get("ext")):
playlist_audio = False
result = { result = {
"type": "playlist", "type": "playlist",
"title": info.get("title", "Playlist"), "title": info.get("title", "Playlist"),
"count": len(entries), "count": len(entries),
"entries": entries, "entries": entries,
"is_audio_only": playlist_audio, "is_audio_only": domain_audio,
"default_ext": self._guess_ext_from_url(url, playlist_audio), "default_ext": self._guess_ext_from_url(url, domain_audio),
} }
if unavailable_count > 0: if unavailable_count > 0:
result["unavailable_count"] = unavailable_count result["unavailable_count"] = unavailable_count
@ -698,11 +659,6 @@ class DownloadService:
else: else:
# Single video/track # Single video/track
has_video = bool(info.get("vcodec") and info["vcodec"] != "none") has_video = bool(info.get("vcodec") and info["vcodec"] != "none")
# extract_flat mode often strips codec info, so also check the
# URL extension and the reported ext — if either is a known video
# container we should NOT mark it as audio-only.
if not has_video:
has_video = self._url_or_ext_implies_video(url, info.get("ext"))
is_audio_only = domain_audio or not has_video is_audio_only = domain_audio or not has_video
# Detect likely file extension # Detect likely file extension
ext = info.get("ext") ext = info.get("ext")

View file

@ -35,9 +35,6 @@ 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",
} }
@ -116,12 +113,6 @@ 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))

View file

@ -109,11 +109,7 @@ async def client(tmp_path: Path):
test_app.state.start_time = datetime.now(timezone.utc) test_app.state.start_time = datetime.now(timezone.utc)
transport = ASGITransport(app=test_app) transport = ASGITransport(app=test_app)
async with AsyncClient( async with AsyncClient(transport=transport, base_url="http://test") as ac:
transport=transport,
base_url="http://test",
headers={"X-Requested-With": "XMLHttpRequest"},
) as ac:
yield ac yield ac
# Teardown # Teardown

View file

@ -185,16 +185,8 @@ async def test_session_isolation(client, tmp_path):
transport = ASGITransport(app=test_app) transport = ASGITransport(app=test_app)
async with AsyncClient( async with AsyncClient(transport=transport, base_url="http://test") as client_a:
transport=transport, async with AsyncClient(transport=transport, base_url="http://test") as client_b:
base_url="http://test",
headers={"X-Requested-With": "XMLHttpRequest"},
) as client_a:
async with AsyncClient(
transport=transport,
base_url="http://test",
headers={"X-Requested-With": "XMLHttpRequest"},
) as client_b:
await client_a.post( await client_a.post(
"/api/downloads", "/api/downloads",
json={"url": "https://example.com/a"}, json={"url": "https://example.com/a"},

View file

@ -13,11 +13,8 @@ const themeStore = useThemeStore()
const { connect } = useSSE() const { connect } = useSSE()
onMounted(async () => { onMounted(async () => {
// Apply theme from cookie immediately to prevent flash-of-wrong-theme
themeStore.init() themeStore.init()
// Then load server config and re-apply with admin defaults
await configStore.loadConfig() await configStore.loadConfig()
themeStore.init()
await themeStore.loadCustomThemes() await themeStore.loadCustomThemes()
await downloadsStore.fetchJobs() await downloadsStore.fetchJobs()
connect() connect()

View file

@ -71,9 +71,6 @@ 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

View file

@ -3,7 +3,6 @@ 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'
@ -36,11 +35,6 @@ 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('')
@ -95,9 +89,6 @@ 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
@ -120,15 +111,9 @@ 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)
} }
@ -435,39 +420,6 @@ 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>
@ -970,25 +922,6 @@ 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;

View file

@ -40,12 +40,6 @@ onMounted(async () => {
opacity: 0.7; opacity: 0.7;
} }
@media (max-width: 767px) {
.app-footer {
padding-bottom: calc(var(--mobile-nav-height) + var(--space-md));
}
}
.sep { .sep {
margin: 0 var(--space-sm); margin: 0 var(--space-sm);
opacity: 0.5; opacity: 0.5;

View file

@ -27,7 +27,6 @@ function goHome(): void {
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
width: 100%;
height: var(--header-height); height: var(--header-height);
background: var(--color-surface); background: var(--color-surface);
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
@ -45,12 +44,6 @@ function goHome(): void {
align-items: center; align-items: center;
} }
@media (max-width: 767px) {
.header-content {
max-width: none;
}
}
.header-title { .header-title {
font-size: var(--font-size-xl); font-size: var(--font-size-xl);
font-weight: 700; font-weight: 700;

View file

@ -1,26 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue' import { ref, computed } from 'vue'
import { useThemeStore } from '@/stores/theme' import { useThemeStore } from '@/stores/theme'
import { useDownloadsStore } from '@/stores/downloads'
import WireframeBackground from './WireframeBackground.vue' import WireframeBackground from './WireframeBackground.vue'
const themeStore = useThemeStore() const themeStore = useThemeStore()
const downloadsStore = useDownloadsStore()
const showWireframe = computed(() => themeStore.currentTheme === 'cyberpunk') const showWireframe = computed(() => themeStore.currentTheme === 'cyberpunk')
type MobileTab = 'submit' | 'queue' type MobileTab = 'submit' | 'queue'
const activeTab = ref<MobileTab>('submit') const activeTab = ref<MobileTab>('submit')
/** Number of active (non-terminal) jobs — shown as badge on Queue tab */
const queueBadge = computed(() => {
let count = 0
for (const job of downloadsStore.jobs.values()) {
if (job.status === 'queued' || job.status === 'downloading' || job.status === 'extracting') {
count++
}
}
return count
})
</script> </script>
<template> <template>
@ -54,10 +41,7 @@ const queueBadge = computed(() => {
:class="{ active: activeTab === 'queue' }" :class="{ active: activeTab === 'queue' }"
@click="activeTab = 'queue'" @click="activeTab = 'queue'"
> >
<span class="nav-icon-wrap">
<span class="nav-icon"></span> <span class="nav-icon"></span>
<span v-if="queueBadge > 0 && activeTab !== 'queue'" class="nav-badge">{{ queueBadge > 9 ? '9+' : queueBadge }}</span>
</span>
<span class="nav-label">Queue</span> <span class="nav-label">Queue</span>
</button> </button>
</nav> </nav>
@ -152,26 +136,5 @@ const queueBadge = computed(() => {
text-transform: uppercase; text-transform: uppercase;
letter-spacing: 0.05em; letter-spacing: 0.05em;
} }
.nav-icon-wrap {
position: relative;
display: inline-flex;
}
.nav-badge {
position: absolute;
top: -6px;
right: -10px;
background: var(--color-accent);
color: var(--color-bg);
font-size: 0.6rem;
font-weight: 700;
min-width: 16px;
height: 16px;
line-height: 16px;
text-align: center;
border-radius: var(--radius-full);
padding: 0 3px;
}
} }
</style> </style>

View file

@ -1,17 +1,29 @@
<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="dark-mode-toggle" class="theme-toggle-btn"
:title="theme.isDark ? 'Switch to light mode' : 'Switch to dark mode'" :title="'Theme: ' + (theme.currentMeta?.name || theme.currentTheme)"
@click="theme.toggleDarkMode()" @click="showPicker = !showPicker"
aria-label="Toggle dark/light mode" aria-label="Theme picker"
> >
<!-- Sun icon (shown in dark mode click to go light) --> <!-- 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"> <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"/>
@ -23,15 +35,54 @@ const theme = useThemeStore()
<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 (shown in light mode click to go dark) --> <!-- 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"> <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>
.dark-mode-toggle { .theme-picker-wrapper {
position: relative;
}
.theme-toggle-btn {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -46,7 +97,76 @@ const theme = useThemeStore()
padding: 0; padding: 0;
} }
.dark-mode-toggle:hover { .theme-toggle-btn: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>

View file

@ -144,7 +144,6 @@ function selectFormat(id: string | null): void {
cursor: pointer; cursor: pointer;
min-height: var(--touch-min); min-height: var(--touch-min);
transition: background-color 0.15s ease; transition: background-color 0.15s ease;
gap: var(--space-sm);
} }
.format-option:hover { .format-option:hover {
@ -158,10 +157,6 @@ function selectFormat(id: string | null): void {
.format-label { .format-label {
font-size: var(--font-size-base); font-size: var(--font-size-base);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
} }
.format-hint { .format-hint {
@ -170,31 +165,9 @@ function selectFormat(id: string | null): void {
} }
.format-codecs { .format-codecs {
font-size: var(--font-size-xs); font-size: var(--font-size-sm);
color: var(--color-text-muted); color: var(--color-text-muted);
font-family: var(--font-mono); font-family: var(--font-mono);
white-space: nowrap;
flex-shrink: 0;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
}
@media (max-width: 540px) {
.format-option {
flex-wrap: wrap;
gap: 2px;
}
.format-label {
flex: 1 1 100%;
font-size: var(--font-size-sm);
}
.format-codecs {
font-size: 0.6875rem;
max-width: none;
}
} }
.format-empty { .format-empty {

View file

@ -184,12 +184,10 @@ function onFormatSelect(formatId: string | null): void {
} }
function handlePaste(): void { function handlePaste(): void {
// Immediately signal that analysis is starting prevents the Download
// button from being briefly clickable between paste and analysis.
isAnalyzing.value = true
// Auto-extract on paste unified loading state // Auto-extract on paste unified loading state
setTimeout(async () => { setTimeout(async () => {
if (url.value.trim()) { if (url.value.trim()) {
isAnalyzing.value = true
analyzeError.value = null analyzeError.value = null
startAnalyzePhase() startAnalyzePhase()
try { try {
@ -206,9 +204,6 @@ function handlePaste(): void {
isAnalyzing.value = false isAnalyzing.value = false
stopAnalyzePhase() stopAnalyzePhase()
} }
} else {
// URL was cleared before timeout cancel analysis state
isAnalyzing.value = false
} }
}, 50) }, 50)
} }
@ -299,8 +294,7 @@ function formatTooltip(fmt: string): string {
<template> <template>
<div class="url-input"> <div class="url-input">
<!-- URL field with clear button --> <!-- URL field -->
<div class="url-field-wrap">
<input <input
v-model="url" v-model="url"
type="url" type="url"
@ -308,18 +302,8 @@ function formatTooltip(fmt: string): string {
class="url-field" class="url-field"
@paste="handlePaste" @paste="handlePaste"
@keydown.enter="submitDownload" @keydown.enter="submitDownload"
:disabled="store.isSubmitting" :disabled="isAnalyzing || store.isSubmitting"
/> />
<button
v-if="url.trim()"
class="url-clear"
@click="url = ''"
title="Clear URL"
aria-label="Clear URL"
>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
</button>
</div>
<!-- Action row: gear, media toggle, download button --> <!-- Action row: gear, media toggle, download button -->
<div class="action-row"> <div class="action-row">
@ -467,46 +451,15 @@ function formatTooltip(fmt: string): string {
width: 100%; width: 100%;
} }
.url-field-wrap {
position: relative;
width: 100%;
}
.url-field { .url-field {
width: 100%; width: 100%;
font-size: var(--font-size-base); font-size: var(--font-size-base);
padding-right: 40px; /* room for clear button */
} }
.url-clear { /* Action row: gear | toggle | download */
position: absolute;
right: 8px;
top: 50%;
transform: translateY(-50%);
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
padding: 0;
background: transparent;
color: var(--color-text-muted);
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
opacity: 0.6;
transition: opacity 0.15s ease, color 0.15s ease;
}
.url-clear:hover {
opacity: 1;
color: var(--color-text);
}
/* Action row: gear | toggle | download — all 42px height */
.action-row { .action-row {
display: flex; display: flex;
align-items: stretch; align-items: center;
gap: var(--space-sm); gap: var(--space-sm);
} }
@ -514,8 +467,8 @@ function formatTooltip(fmt: string): string {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: 42px; width: 38px;
min-height: 42px; height: 38px;
padding: 0; padding: 0;
flex-shrink: 0; flex-shrink: 0;
background: var(--color-surface); background: var(--color-surface);
@ -555,7 +508,7 @@ function formatTooltip(fmt: string): string {
border: none; border: none;
border-radius: 0; border-radius: 0;
font-size: var(--font-size-sm); font-size: var(--font-size-sm);
min-height: 42px; min-height: 38px;
transition: all 0.15s ease; transition: all 0.15s ease;
} }
@ -582,7 +535,7 @@ function formatTooltip(fmt: string): string {
white-space: nowrap; white-space: nowrap;
padding: var(--space-sm) var(--space-lg); padding: var(--space-sm) var(--space-lg);
font-weight: 600; font-weight: 600;
min-height: 42px; min-height: 38px;
background: var(--color-accent); background: var(--color-accent);
color: var(--color-bg); color: var(--color-bg);
} }
@ -874,8 +827,6 @@ button:disabled {
.toggle-pill { .toggle-pill {
padding: var(--space-xs) var(--space-sm); padding: var(--space-xs) var(--space-sm);
min-width: 42px;
justify-content: center;
} }
} }

View file

@ -1,17 +1,15 @@
/** /**
* Theme Pinia store manages theme selection and application. * Theme Pinia store manages theme selection and application.
* *
* Admin sets: which dark theme, which light theme, and default mode (dark/light). * Built-in themes: cyberpunk (default), dark, light
* Visitors: see the admin default, can toggle dark/light via header icon. * Custom themes: loaded via /api/themes manifest at runtime
* Visitor preference stored in cookie (session-scoped persistence).
* *
* Built-in themes: 5 dark + 4 light = 9 total. * Persistence: localStorage key 'mrip-theme'
* Custom themes: loaded via /api/themes manifest at runtime. * Application: sets data-theme attribute on <html> element
*/ */
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
@ -22,7 +20,8 @@ export interface ThemeMeta {
variant: 'dark' | 'light' variant: 'dark' | 'light'
} }
const COOKIE_KEY = 'mrip-mode' const STORAGE_KEY = 'mrip-theme'
const DEFAULT_THEME = 'cyberpunk'
const BUILTIN_THEMES: ThemeMeta[] = [ const BUILTIN_THEMES: ThemeMeta[] = [
// Dark themes // Dark themes
@ -39,98 +38,74 @@ const BUILTIN_THEMES: ThemeMeta[] = [
] ]
export const useThemeStore = defineStore('theme', () => { export const useThemeStore = defineStore('theme', () => {
const currentTheme = ref('cyberpunk') const currentTheme = ref(DEFAULT_THEME)
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())
// Admin-configured theme pair (loaded from public config) /** Whether the current theme is a dark variant. */
const adminDarkTheme = ref('cyberpunk') const isDark = computed(() => {
const adminLightTheme = ref('light') const meta = allThemes.value.find(t => t.id === currentTheme.value)
const adminDefaultMode = ref<'dark' | 'light'>('dark') return meta ? meta.variant === 'dark' : true
})
const isDark = computed(() => currentMode.value === 'dark') const darkThemes = computed(() => allThemes.value.filter(t => t.variant === '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 reads admin config, then checks for visitor cookie override. * Initialize the theme store reads from localStorage and applies.
*/ */
function init(): void { function init(): void {
const configStore = useConfigStore() const saved = localStorage.getItem(STORAGE_KEY)
const cfg = configStore.config if (saved && BUILTIN_THEMES.some(t => t.id === saved)) {
currentTheme.value = saved
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 { } else {
currentMode.value = 'dark' currentTheme.value = DEFAULT_THEME
currentTheme.value = adminDarkTheme.value
} }
_setCookie(COOKIE_KEY, currentMode.value, 365)
_apply(currentTheme.value) _apply(currentTheme.value)
} }
/** /**
* Set a specific theme by ID used by admin preview. * 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.
*/ */
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
currentMode.value = found.variant localStorage.setItem(STORAGE_KEY, themeId)
_setCookie(COOKIE_KEY, currentMode.value, 365) // Remember the last dark theme for toggle
_apply(themeId) const meta = allThemes.value.find(t => t.id === themeId)
} if (meta?.variant === 'dark') {
localStorage.setItem(STORAGE_KEY + '-dark', themeId)
/**
* Update admin theme config and apply if current mode matches.
* Called after saving settings gives live preview without page reload.
*/
function updateAdminConfig(darkTheme: string, lightTheme: string, defaultMode: 'dark' | 'light'): void {
adminDarkTheme.value = darkTheme
adminLightTheme.value = lightTheme
adminDefaultMode.value = defaultMode
// Apply the new theme if user's current mode matches the changed side
if (currentMode.value === 'dark') {
currentTheme.value = darkTheme
_apply(darkTheme)
} else { } else {
currentTheme.value = lightTheme localStorage.setItem(STORAGE_KEY + '-light', themeId)
_apply(lightTheme)
} }
_apply(themeId)
} }
/** /**
@ -149,16 +124,22 @@ 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', variant: t.variant || 'dark', // default custom themes to 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 // Custom themes unavailable — use built-ins only
} }
} }
@ -167,13 +148,17 @@ 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 {
@ -191,32 +176,17 @@ 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,
} }
}) })

View file

@ -28,9 +28,6 @@ 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,

View file

@ -1,9 +1,21 @@
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 document // 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
const setAttributeMock = vi.fn() const setAttributeMock = vi.fn()
Object.defineProperty(globalThis, 'document', { Object.defineProperty(globalThis, 'document', {
value: { value: {
@ -13,47 +25,44 @@ 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 (dark mode)', () => { it('initializes with cyberpunk as default', () => {
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('uses admin config for defaults when available', () => { it('restores saved theme from localStorage', () => {
const configStore = useConfigStore() localStorageMock.setItem('mrip-theme', 'dark')
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('paper') expect(store.currentTheme).toBe('dark')
expect(store.currentMode).toBe('light') expect(setAttributeMock).toHaveBeenCalledWith('data-theme', 'dark')
expect(store.isDark).toBe(false)
}) })
it('setTheme updates state and applies to DOM', () => { 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', () => {
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(store.currentMode).toBe('light') expect(localStorageMock.setItem).toHaveBeenCalledWith('mrip-theme', 'light')
expect(setAttributeMock).toHaveBeenCalledWith('data-theme', 'light') expect(setAttributeMock).toHaveBeenCalledWith('data-theme', 'light')
}) })
@ -78,12 +87,6 @@ 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()
@ -91,50 +94,48 @@ describe('theme store', () => {
expect(store.currentMeta?.name).toBe('Cyberpunk') expect(store.currentMeta?.name).toBe('Cyberpunk')
}) })
it('isDark reflects current mode', () => { it('isDark is true for cyberpunk and dark themes', () => {
const store = useThemeStore() const store = useThemeStore()
store.init() store.init()
expect(store.isDark).toBe(true) expect(store.isDark).toBe(true)
store.setTheme('light')
expect(store.isDark).toBe(false) store.setTheme('dark')
store.setTheme('hacker')
expect(store.isDark).toBe(true) expect(store.isDark).toBe(true)
}) })
it('toggleDarkMode switches between admin dark and light themes', () => { it('isDark is false for light theme', () => {
const store = useThemeStore() const store = useThemeStore()
store.init() // cyberpunk (dark) store.init()
store.setTheme('light')
expect(store.isDark).toBe(false)
})
it('toggleDarkMode switches from dark to light', () => {
const store = useThemeStore()
store.init() // starts on cyberpunk (dark)
store.toggleDarkMode() 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 uses admin-configured themes', () => { it('toggleDarkMode remembers dark theme when starting from dark', () => {
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()
expect(store.currentTheme).toBe('neon') store.setTheme('dark') // switch to the "dark" theme (not cyberpunk)
store.toggleDarkMode() store.toggleDarkMode()
expect(store.currentTheme).toBe('arctic') expect(store.currentTheme).toBe('light')
store.toggleDarkMode() store.toggleDarkMode()
expect(store.currentTheme).toBe('neon') expect(store.currentTheme).toBe('dark') // returns to dark, not cyberpunk
})
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')
}) })
}) })