Compare commits

...

14 commits

Author SHA1 Message Date
jlightner
70910d516e fix: detect CIFS/NFS via /proc/mounts before opening DB
Instead of trying WAL mode and recovering after failure, proactively
detect network filesystems by parsing /proc/mounts and skip WAL
entirely. This avoids the stale WAL/SHM files that made recovery
impossible on CIFS mounts.
2026-04-01 05:53:40 +00:00
jlightner
6682438163 fix: probe WAL with test write + close/reopen fallback for CIFS
The PRAGMA journal_mode=WAL returns 'wal' even on CIFS mounts where
WAL shared-memory actually fails. Now we do a concrete test write after
setting WAL mode; if it fails, we close the connection, remove stale
WAL/SHM files, reopen in DELETE mode.
2026-04-01 05:13:06 +00:00
jlightner
3205c101c3 fix: graceful WAL mode fallback for CIFS/network filesystems
When the data directory is on a CIFS/SMB mount (or other filesystem
lacking mmap shared-memory support), SQLite WAL mode fails with
'locking protocol' or 'readonly database' errors. The init_db function
now detects this and falls back to DELETE journal mode automatically.
2026-04-01 05:04:45 +00:00
jlightner
23143b4e11 Merge fix/archive-org-audio-detection: correct audio-only detection for archive.org 2026-04-01 04:21:27 +00:00
jlightner
d518304331 fix: detect video from URL extension when yt-dlp extract_flat strips codec info
archive.org and other direct-file hosts return metadata without vcodec
when using extract_flat mode. The UI was incorrectly labeling these as
'Audio Only'. Now we check the URL path extension and yt-dlp's reported
ext against known video containers as a fallback before marking a source
as audio-only.

Fixes incorrect audio-only detection for archive.org video URLs.
2026-04-01 04:21:19 +00:00
xpltd
44e24e9393 README: add Docker image location + pull/run instructions
- Added ghcr.io/xpltdco/media-rip:latest prominently in Quickstart
- Added curl one-liner to grab docker-compose.yml
- Added docker run alternative for users who don't want compose
- Updated features: 9 built-in themes (was 3)
2026-03-22 17:15:21 -05:00
xpltd
4870157dbd Mobile header full-width + live theme preview on save
Header: remove max-width constraint on mobile so header background
spans the full viewport width.

Theme: updateAdminConfig now applies the new theme immediately if
the user's current mode matches the changed side (e.g. changing
dark theme while in dark mode updates live, without page reload
or dark→light→dark toggle).
2026-03-22 17:10:59 -05:00
xpltd
9cfa9818f9 Fix paste broken by isAnalyzing + UI polish batch
Critical fix:
- Input field no longer disabled during URL analysis — the race condition
  fix (isAnalyzing=true on paste) was disabling the input mid-paste,
  causing the browser to drop the pasted text. Input now only disabled
  during submission.

UI polish:
- All action row elements standardized to 42px height
- Mobile toggle pills wider (min-width: 42px, matches gear icon)
- URL clear button (floating X) in the input field
- Footer visible in mobile view (padding above bottom nav)
- FormatPicker mobile: ellipsis on codec text, wrapped layout at narrow widths
2026-03-22 17:01:35 -05:00
xpltd
f72b649acf Mobile queue badge + fix paste-then-download race condition
Mobile:
- Queue tab shows badge with active job count (queued/downloading)
- Badge hidden when user is already on Queue tab
- Styled as accent-colored pill with count (caps at 9+)

Paste race fix:
- Set isAnalyzing=true immediately on paste event, not after 50ms timeout
- Prevents Download button from being briefly clickable between paste
  and analysis start
- Handles edge case where URL is cleared before timeout fires
2026-03-22 16:37:03 -05:00
xpltd
1b5f24f796 Fix theme: load config before theme init, prevent flash on navigation
Theme init was running before config loaded, so admin theme settings
were ignored (config was null). Now: init once immediately (from cookie
or fallback), load config, init again with admin defaults. Theme
persists correctly across all routes including /admin.
2026-03-22 16:09:43 -05:00
xpltd
02c5e7bc1f 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
2026-03-22 15:58:49 -05:00
xpltd
6804301825 Fix CI: test clients need X-Requested-With for API access guard 2026-03-22 01:39:01 -05:00
xpltd
43b5ba3f72 Fix lint: remove unused save_settings import in revoke_api_key 2026-03-22 01:26:18 -05:00
xpltd
b0d2781980 Flip API key logic: no key = browser-only, add confirmation gates
- No API key configured: external API access blocked, browser-only
- API key generated: external access enabled with that key
- Added 'Sure?' confirmation on Regenerate and Revoke buttons (3s timeout)
- Updated hint text to reflect security-first default
2026-03-22 01:16:19 -05:00
22 changed files with 640 additions and 311 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.
- **Session isolation** — Each browser gets its own download queue. No cross-talk.
- **Playlist support** — Collapsible parent/child jobs with per-video status tracking.
- **Three built-in themes** — Cyberpunk (default), Dark, Light. Switch in the header.
- **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.
- **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.
- **Cookie auth** — Upload cookies.txt per session for paywalled/private content.
@ -21,8 +21,32 @@ A self-hostable yt-dlp web frontend. Paste a URL, pick quality, download — wit
## Quickstart
The Docker image is published to GitHub Container Registry:
```
ghcr.io/xpltdco/media-rip:latest
```
Pull and run with Docker Compose (recommended):
```bash
docker compose up
# Download the compose file
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.

View file

@ -91,6 +91,9 @@ class UIConfig(BaseModel):
default_theme: str = "dark"
welcome_message: str = "Paste any video or audio URL. We rip it, you download it. No accounts, no tracking."
theme_dark: str = "cyberpunk" # Which dark theme to use
theme_light: str = "light" # Which light theme to use
theme_default_mode: str = "dark" # Start in "dark" or "light" mode
class AdminConfig(BaseModel):

View file

@ -1,8 +1,10 @@
"""SQLite database layer with WAL mode and async CRUD operations.
"""SQLite database layer with async CRUD operations.
Uses aiosqlite for async access. ``init_db`` sets critical PRAGMAs
(busy_timeout, WAL, synchronous) *before* creating any tables so that
concurrent download workers never hit ``SQLITE_BUSY``.
(busy_timeout, journal_mode, synchronous) *before* creating any tables so
that concurrent download workers never hit ``SQLITE_BUSY``. WAL mode is
preferred on local filesystems; DELETE mode is used automatically when a
network filesystem (CIFS, NFS) is detected.
"""
from __future__ import annotations
@ -90,19 +92,31 @@ async def init_db(db_path: str) -> aiosqlite.Connection:
PRAGMA order matters:
1. ``busy_timeout`` prevents immediate ``SQLITE_BUSY`` on lock contention
2. ``journal_mode=WAL`` enables concurrent readers + single writer
3. ``synchronous=NORMAL`` safe durability level for WAL mode
2. ``journal_mode`` WAL for local filesystems, DELETE for network mounts
(CIFS/NFS lack the shared-memory primitives WAL requires)
3. ``synchronous=NORMAL`` safe durability level
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.row_factory = aiosqlite.Row
# --- PRAGMAs (before any DDL) ---
await db.execute("PRAGMA busy_timeout = 5000")
result = await db.execute("PRAGMA journal_mode = WAL")
row = await result.fetchone()
journal_mode = row[0] if row else "unknown"
if use_wal:
journal_mode = await _try_journal_mode(db, "wal")
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)
await db.execute("PRAGMA synchronous = NORMAL")
@ -115,6 +129,54 @@ async def init_db(db_path: str) -> aiosqlite.Connection:
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
# ---------------------------------------------------------------------------

View file

@ -295,6 +295,9 @@ async def get_settings(
"admin_username": config.admin.username,
"purge_enabled": config.purge.enabled,
"purge_max_age_minutes": config.purge.max_age_minutes,
"theme_dark": config.ui.theme_dark,
"theme_light": config.ui.theme_light,
"theme_default_mode": config.ui.theme_default_mode,
}
@ -418,6 +421,31 @@ async def update_settings(
to_persist["purge_max_age_minutes"] = val
updated.append("purge_max_age_minutes")
# Theme settings
valid_dark = {"cyberpunk", "dark", "midnight", "hacker", "neon"}
valid_light = {"light", "paper", "arctic", "solarized"}
if "theme_dark" in body:
val = body["theme_dark"]
if val in valid_dark:
config.ui.theme_dark = val
to_persist["theme_dark"] = val
updated.append("theme_dark")
if "theme_light" in body:
val = body["theme_light"]
if val in valid_light:
config.ui.theme_light = val
to_persist["theme_light"] = val
updated.append("theme_light")
if "theme_default_mode" in body:
val = body["theme_default_mode"]
if val in ("dark", "light"):
config.ui.theme_default_mode = val
to_persist["theme_default_mode"] = val
updated.append("theme_default_mode")
# --- Persist to DB ---
if to_persist:
await save_settings(db, to_persist)
@ -520,7 +548,7 @@ async def revoke_api_key(
config = request.app.state.config
config.server.api_key = ""
from app.services.settings import save_settings, delete_setting
from app.services.settings import delete_setting
db = request.app.state.db
await delete_setting(db, "api_key")

View file

@ -27,37 +27,32 @@ router = APIRouter(tags=["downloads"])
def _check_api_access(request: Request) -> None:
"""Verify the caller is a browser user or has a valid API key.
When no API key is configured, all requests are allowed (open access).
When an API key is set:
- Requests with a valid X-API-Key header pass.
- Requests from the web UI pass (have a Referer from the same origin
or an X-Requested-With header set by the frontend).
- All other requests are rejected with 403.
Browser users (X-Requested-With: XMLHttpRequest) always pass.
Non-browser callers must provide a valid X-API-Key header.
If no API key is configured, non-browser requests are blocked entirely.
"""
# Browser users always pass
if request.headers.get("x-requested-with") == "XMLHttpRequest":
return
config = request.app.state.config
api_key = config.server.api_key
if not api_key:
return # No key configured — open access
# No key configured — block non-browser access
raise_api_key_required("API access is disabled. Generate an API key in the admin panel, then provide it via X-API-Key header.")
# Check API key header
provided_key = request.headers.get("x-api-key", "")
if provided_key and secrets.compare_digest(provided_key, api_key):
return
# Check browser origin — frontend sends X-Requested-With: XMLHttpRequest
if request.headers.get("x-requested-with") == "XMLHttpRequest":
return
raise_api_key_required()
def raise_api_key_required():
def raise_api_key_required(detail: str = "Invalid or missing API key. Provide X-API-Key header."):
from fastapi import HTTPException
raise HTTPException(
status_code=403,
detail="API key required. Provide X-API-Key header or use the web UI.",
)
raise HTTPException(status_code=403, detail=detail)
@router.post("/downloads", response_model=Job, status_code=201)

View file

@ -22,6 +22,9 @@ async def public_config(request: Request) -> dict:
return {
"session_mode": config.session.mode,
"default_theme": config.ui.default_theme,
"theme_dark": config.ui.theme_dark,
"theme_light": config.ui.theme_light,
"theme_default_mode": config.ui.theme_default_mode,
"welcome_message": config.ui.welcome_message,
"purge_enabled": config.purge.enabled,
"max_concurrent_downloads": config.downloads.max_concurrent,

View file

@ -561,6 +561,31 @@ class DownloadService:
url_lower = url.lower()
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
def _get_auth_hint(url: str) -> str | None:
"""Return a user-facing hint for sites that commonly need auth."""
@ -645,13 +670,27 @@ class DownloadService:
"url": e.get("url") or e.get("webpage_url", ""),
"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 = {
"type": "playlist",
"title": info.get("title", "Playlist"),
"count": len(entries),
"entries": entries,
"is_audio_only": domain_audio,
"default_ext": self._guess_ext_from_url(url, domain_audio),
"is_audio_only": playlist_audio,
"default_ext": self._guess_ext_from_url(url, playlist_audio),
}
if unavailable_count > 0:
result["unavailable_count"] = unavailable_count
@ -659,6 +698,11 @@ class DownloadService:
else:
# Single video/track
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
# Detect likely file extension
ext = info.get("ext")

View file

@ -35,6 +35,9 @@ ADMIN_WRITABLE_KEYS = {
"purge_enabled",
"purge_max_age_minutes",
"api_key",
"theme_dark",
"theme_light",
"theme_default_mode",
}
@ -113,6 +116,12 @@ def apply_persisted_to_config(config, settings: dict) -> None:
config.purge.privacy_retention_minutes = settings["privacy_retention_minutes"]
if "api_key" in settings:
config.server.api_key = settings["api_key"]
if "theme_dark" in settings:
config.ui.theme_dark = settings["theme_dark"]
if "theme_light" in settings:
config.ui.theme_light = settings["theme_light"]
if "theme_default_mode" in settings:
config.ui.theme_default_mode = settings["theme_default_mode"]
logger.info("Applied %d persisted settings to config", len(settings))

View file

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

View file

@ -185,8 +185,16 @@ async def test_session_isolation(client, tmp_path):
transport = ASGITransport(app=test_app)
async with AsyncClient(transport=transport, base_url="http://test") as client_a:
async with AsyncClient(transport=transport, base_url="http://test") as client_b:
async with AsyncClient(
transport=transport,
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(
"/api/downloads",
json={"url": "https://example.com/a"},

View file

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

View file

@ -71,6 +71,9 @@ export interface FormatInfo {
export interface PublicConfig {
session_mode: string
default_theme: string
theme_dark: string
theme_light: string
theme_default_mode: string
welcome_message: string
purge_enabled: boolean
max_concurrent_downloads: number

View file

@ -3,6 +3,7 @@ import { computed, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useAdminStore } from '@/stores/admin'
import { useConfigStore } from '@/stores/config'
import { useThemeStore } from '@/stores/theme'
import { api } from '@/api/client'
import AdminLogin from './AdminLogin.vue'
import AdminSetup from './AdminSetup.vue'
@ -35,6 +36,11 @@ const adminUsername = ref('admin')
const purgeEnabled = ref(true)
const purgeMaxAgeMinutes = ref(1440)
// Theme settings
const themeDark = ref('cyberpunk')
const themeLight = ref('light')
const themeDefaultMode = ref('dark')
// Change password state
const currentPassword = ref('')
const newPassword = ref('')
@ -47,6 +53,10 @@ const passwordError = ref<string | null>(null)
const apiKey = ref<string | null>(null)
const showApiKey = ref(false)
const apiKeyCopied = ref(false)
const regenConfirming = ref(false)
const revokeConfirming = ref(false)
let regenConfirmTimer: ReturnType<typeof setTimeout> | null = null
let revokeConfirmTimer: ReturnType<typeof setTimeout> | null = null
const canChangePassword = computed(() =>
currentPassword.value.length > 0 &&
@ -85,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
@ -107,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)
}
@ -193,6 +212,7 @@ async function generateApiKey() {
async function regenerateApiKey() {
await generateApiKey()
regenConfirming.value = false
}
async function revokeApiKey() {
@ -203,6 +223,27 @@ async function revokeApiKey() {
showApiKey.value = false
}
} catch { /* ignore */ }
revokeConfirming.value = false
}
function handleRegenClick() {
if (regenConfirming.value) {
regenerateApiKey()
return
}
regenConfirming.value = true
if (regenConfirmTimer) clearTimeout(regenConfirmTimer)
regenConfirmTimer = setTimeout(() => { regenConfirming.value = false }, 3000)
}
function handleRevokeClick() {
if (revokeConfirming.value) {
revokeApiKey()
return
}
revokeConfirming.value = true
if (revokeConfirmTimer) clearTimeout(revokeConfirmTimer)
revokeConfirmTimer = setTimeout(() => { revokeConfirming.value = false }, 3000)
}
function copyApiKey() {
@ -394,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>
@ -618,8 +692,8 @@ function formatFilesize(bytes: number | null): string {
<div class="settings-field">
<label>API Key</label>
<p class="field-hint">
When set, external API access requires this key via <code>X-API-Key</code> header.
Browser users are not affected. Without a key, the download API is open to anyone who can reach the server.
Generate a key to enable external API access (e.g. scripts, automation).
Without a key, downloads can only be submitted through the web UI.
</p>
<div v-if="apiKey" class="api-key-display">
<div class="api-key-value">
@ -633,13 +707,25 @@ function formatFilesize(bytes: number | null): string {
</button>
</div>
<div class="api-key-actions">
<button class="btn-regen" @click="regenerateApiKey">Regenerate</button>
<button class="btn-revoke" @click="revokeApiKey">Revoke</button>
<button
class="btn-regen"
:class="{ 'btn-confirm': regenConfirming }"
@click="handleRegenClick"
>
{{ regenConfirming ? 'Sure?' : 'Regenerate' }}
</button>
<button
class="btn-revoke"
:class="{ 'btn-confirm': revokeConfirming }"
@click="handleRevokeClick"
>
{{ revokeConfirming ? 'Sure?' : 'Revoke' }}
</button>
</div>
<span v-if="apiKeyCopied" class="save-confirm"> Copied</span>
</div>
<div v-else class="api-key-empty">
<p class="field-hint" style="margin-bottom: var(--space-sm);">No API key set download API is open.</p>
<p class="field-hint" style="margin-bottom: var(--space-sm);">No API key set external API access is disabled.</p>
<button class="btn-save" @click="generateApiKey">Generate API Key</button>
</div>
</div>
@ -884,6 +970,25 @@ h3 {
margin-top: var(--space-sm);
}
.theme-settings {
display: flex;
flex-direction: column;
gap: var(--space-sm);
margin-top: var(--space-sm);
}
.theme-setting-row {
display: flex;
align-items: center;
gap: var(--space-md);
}
.theme-setting-label {
min-width: 100px;
font-weight: 500;
color: var(--color-text);
}
.format-default-row {
display: flex;
align-items: center;

View file

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

View file

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

View file

@ -1,13 +1,26 @@
<script setup lang="ts">
import { ref, computed } from 'vue'
import { useThemeStore } from '@/stores/theme'
import { useDownloadsStore } from '@/stores/downloads'
import WireframeBackground from './WireframeBackground.vue'
const themeStore = useThemeStore()
const downloadsStore = useDownloadsStore()
const showWireframe = computed(() => themeStore.currentTheme === 'cyberpunk')
type MobileTab = 'submit' | 'queue'
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>
<template>
@ -41,7 +54,10 @@ const activeTab = ref<MobileTab>('submit')
:class="{ active: activeTab === 'queue' }"
@click="activeTab = 'queue'"
>
<span class="nav-icon-wrap">
<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>
</button>
</nav>
@ -136,5 +152,26 @@ const activeTab = ref<MobileTab>('submit')
text-transform: uppercase;
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>

View file

@ -1,29 +1,17 @@
<script setup lang="ts">
import { ref } from 'vue'
import { useThemeStore } from '@/stores/theme'
const theme = useThemeStore()
const showPicker = ref(false)
function selectTheme(id: string) {
theme.setTheme(id)
showPicker.value = false
}
function closePicker() {
showPicker.value = false
}
</script>
<template>
<div class="theme-picker-wrapper" @mouseleave="closePicker">
<button
class="theme-toggle-btn"
:title="'Theme: ' + (theme.currentMeta?.name || theme.currentTheme)"
@click="showPicker = !showPicker"
aria-label="Theme picker"
class="dark-mode-toggle"
:title="theme.isDark ? 'Switch to light mode' : 'Switch to dark mode'"
@click="theme.toggleDarkMode()"
aria-label="Toggle dark/light mode"
>
<!-- Sun icon (dark mode active) -->
<!-- Sun icon (shown in dark mode click to go light) -->
<svg v-if="theme.isDark" xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="12" r="5"/>
<line x1="12" y1="1" x2="12" y2="3"/>
@ -35,54 +23,15 @@ function closePicker() {
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/>
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/>
</svg>
<!-- Moon icon (light mode active) -->
<!-- Moon icon (shown in light mode click to go dark) -->
<svg v-else xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"/>
</svg>
</button>
<Transition name="fade">
<div v-if="showPicker" class="theme-dropdown">
<div class="theme-group">
<div class="theme-group-label">Dark</div>
<button
v-for="t in theme.darkThemes"
:key="t.id"
class="theme-option"
:class="{ active: theme.currentTheme === t.id }"
@click="selectTheme(t.id)"
:title="t.description"
>
<span class="theme-name">{{ t.name }}</span>
<span v-if="theme.currentTheme === t.id" class="theme-check"></span>
</button>
</div>
<div class="theme-divider"></div>
<div class="theme-group">
<div class="theme-group-label">Light</div>
<button
v-for="t in theme.lightThemes"
:key="t.id"
class="theme-option"
:class="{ active: theme.currentTheme === t.id }"
@click="selectTheme(t.id)"
:title="t.description"
>
<span class="theme-name">{{ t.name }}</span>
<span v-if="theme.currentTheme === t.id" class="theme-check"></span>
</button>
</div>
</div>
</Transition>
</div>
</template>
<style scoped>
.theme-picker-wrapper {
position: relative;
}
.theme-toggle-btn {
.dark-mode-toggle {
display: flex;
align-items: center;
justify-content: center;
@ -97,76 +46,7 @@ function closePicker() {
padding: 0;
}
.theme-toggle-btn:hover {
.dark-mode-toggle:hover {
color: var(--color-accent);
}
.theme-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 4px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
box-shadow: var(--shadow-lg);
min-width: 180px;
z-index: 100;
overflow: hidden;
}
.theme-group-label {
padding: 8px 14px 4px;
font-size: var(--font-size-xs);
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
color: var(--color-text-muted);
}
.theme-divider {
height: 1px;
background: var(--color-border);
margin: 4px 0;
}
.theme-option {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
padding: 8px 14px;
background: transparent;
color: var(--color-text);
border: none;
cursor: pointer;
font-size: var(--font-size-sm);
font-family: var(--font-ui);
text-align: left;
transition: background var(--transition-fast);
}
.theme-option:hover {
background: var(--color-surface-hover);
}
.theme-option.active {
color: var(--color-accent);
}
.theme-check {
color: var(--color-accent);
font-weight: bold;
}
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.15s ease, transform 0.15s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
transform: translateY(-4px);
}
</style>

View file

@ -144,6 +144,7 @@ function selectFormat(id: string | null): void {
cursor: pointer;
min-height: var(--touch-min);
transition: background-color 0.15s ease;
gap: var(--space-sm);
}
.format-option:hover {
@ -157,6 +158,10 @@ function selectFormat(id: string | null): void {
.format-label {
font-size: var(--font-size-base);
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.format-hint {
@ -165,9 +170,31 @@ function selectFormat(id: string | null): void {
}
.format-codecs {
font-size: var(--font-size-sm);
font-size: var(--font-size-xs);
color: var(--color-text-muted);
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 {

View file

@ -184,10 +184,12 @@ function onFormatSelect(formatId: string | null): 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
setTimeout(async () => {
if (url.value.trim()) {
isAnalyzing.value = true
analyzeError.value = null
startAnalyzePhase()
try {
@ -204,6 +206,9 @@ function handlePaste(): void {
isAnalyzing.value = false
stopAnalyzePhase()
}
} else {
// URL was cleared before timeout cancel analysis state
isAnalyzing.value = false
}
}, 50)
}
@ -294,7 +299,8 @@ function formatTooltip(fmt: string): string {
<template>
<div class="url-input">
<!-- URL field -->
<!-- URL field with clear button -->
<div class="url-field-wrap">
<input
v-model="url"
type="url"
@ -302,8 +308,18 @@ function formatTooltip(fmt: string): string {
class="url-field"
@paste="handlePaste"
@keydown.enter="submitDownload"
:disabled="isAnalyzing || store.isSubmitting"
:disabled="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 -->
<div class="action-row">
@ -451,15 +467,46 @@ function formatTooltip(fmt: string): string {
width: 100%;
}
.url-field-wrap {
position: relative;
width: 100%;
}
.url-field {
width: 100%;
font-size: var(--font-size-base);
padding-right: 40px; /* room for clear button */
}
/* Action row: gear | toggle | download */
.action-row {
.url-clear {
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 {
display: flex;
align-items: stretch;
gap: var(--space-sm);
}
@ -467,8 +514,8 @@ function formatTooltip(fmt: string): string {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
width: 42px;
min-height: 42px;
padding: 0;
flex-shrink: 0;
background: var(--color-surface);
@ -508,7 +555,7 @@ function formatTooltip(fmt: string): string {
border: none;
border-radius: 0;
font-size: var(--font-size-sm);
min-height: 38px;
min-height: 42px;
transition: all 0.15s ease;
}
@ -535,7 +582,7 @@ function formatTooltip(fmt: string): string {
white-space: nowrap;
padding: var(--space-sm) var(--space-lg);
font-weight: 600;
min-height: 38px;
min-height: 42px;
background: var(--color-accent);
color: var(--color-bg);
}
@ -827,6 +874,8 @@ button:disabled {
.toggle-pill {
padding: var(--space-xs) var(--space-sm);
min-width: 42px;
justify-content: center;
}
}

View file

@ -1,15 +1,17 @@
/**
* Theme Pinia store manages theme selection and application.
*
* Built-in themes: cyberpunk (default), dark, light
* Custom themes: loaded via /api/themes manifest at runtime
* Admin sets: which dark theme, which light theme, and default mode (dark/light).
* Visitors: see the admin default, can toggle dark/light via header icon.
* Visitor preference stored in cookie (session-scoped persistence).
*
* Persistence: localStorage key 'mrip-theme'
* Application: sets data-theme attribute on <html> element
* Built-in themes: 5 dark + 4 light = 9 total.
* Custom themes: loaded via /api/themes manifest at runtime.
*/
import { ref, computed } from 'vue'
import { defineStore } from 'pinia'
import { useConfigStore } from './config'
export interface ThemeMeta {
id: string
@ -20,8 +22,7 @@ export interface ThemeMeta {
variant: 'dark' | 'light'
}
const STORAGE_KEY = 'mrip-theme'
const DEFAULT_THEME = 'cyberpunk'
const COOKIE_KEY = 'mrip-mode'
const BUILTIN_THEMES: ThemeMeta[] = [
// Dark themes
@ -38,76 +39,100 @@ 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 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 {
currentTheme.value = lightTheme
_apply(lightTheme)
}
}
/**
* Load custom themes from backend manifest.
*/
@ -124,22 +149,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 +167,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 +191,32 @@ export const useThemeStore = defineStore('theme', () => {
document.documentElement.setAttribute('data-theme', themeId)
}
function _getCookie(name: string): string | null {
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))
return match ? decodeURIComponent(match[1]) : null
}
function _setCookie(name: string, value: string, days: number): void {
const expires = new Date(Date.now() + days * 86400000).toUTCString()
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`
}
return {
currentTheme,
currentMode,
customThemes,
allThemes,
darkThemes,
lightThemes,
currentMeta,
isDark,
adminDarkTheme,
adminLightTheme,
adminDefaultMode,
init,
setTheme,
toggleDarkMode,
updateAdminConfig,
loadCustomThemes,
}
})

View file

@ -28,6 +28,9 @@ describe('config store', () => {
const mockConfig = {
session_mode: 'isolated',
default_theme: 'dark',
theme_dark: 'cyberpunk',
theme_light: 'light',
theme_default_mode: 'dark',
welcome_message: 'Test welcome',
purge_enabled: false,
max_concurrent_downloads: 3,

View file

@ -1,21 +1,9 @@
import { describe, it, expect, beforeEach, vi } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
import { useThemeStore } from '@/stores/theme'
import { useConfigStore } from '@/stores/config'
// Mock localStorage
const localStorageMock = (() => {
let store: Record<string, string> = {}
return {
getItem: vi.fn((key: string) => store[key] || null),
setItem: vi.fn((key: string, value: string) => { store[key] = value }),
removeItem: vi.fn((key: string) => { delete store[key] }),
clear: vi.fn(() => { store = {} }),
}
})()
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock })
// Mock document.documentElement.setAttribute
// Mock document
const setAttributeMock = vi.fn()
Object.defineProperty(globalThis, 'document', {
value: {
@ -25,44 +13,47 @@ Object.defineProperty(globalThis, 'document', {
getElementById: vi.fn(() => null),
createElement: vi.fn(() => ({ id: '', textContent: '' })),
head: { appendChild: vi.fn() },
cookie: '',
},
})
describe('theme store', () => {
beforeEach(() => {
setActivePinia(createPinia())
localStorageMock.clear()
setAttributeMock.mockClear()
document.cookie = ''
})
it('initializes with cyberpunk as default', () => {
it('initializes with cyberpunk as default (dark mode)', () => {
const store = useThemeStore()
store.init()
expect(store.currentTheme).toBe('cyberpunk')
expect(store.currentMode).toBe('dark')
expect(store.isDark).toBe(true)
expect(setAttributeMock).toHaveBeenCalledWith('data-theme', 'cyberpunk')
})
it('restores saved theme from localStorage', () => {
localStorageMock.setItem('mrip-theme', 'dark')
it('uses admin config for defaults when available', () => {
const configStore = useConfigStore()
configStore.config = {
theme_dark: 'neon',
theme_light: 'paper',
theme_default_mode: 'light',
} as any
const store = useThemeStore()
store.init()
expect(store.currentTheme).toBe('dark')
expect(setAttributeMock).toHaveBeenCalledWith('data-theme', 'dark')
expect(store.currentTheme).toBe('paper')
expect(store.currentMode).toBe('light')
expect(store.isDark).toBe(false)
})
it('falls back to cyberpunk for invalid saved theme', () => {
localStorageMock.setItem('mrip-theme', 'nonexistent')
const store = useThemeStore()
store.init()
expect(store.currentTheme).toBe('cyberpunk')
})
it('setTheme updates state, localStorage, and DOM', () => {
it('setTheme updates state and applies to DOM', () => {
const store = useThemeStore()
store.init()
store.setTheme('light')
expect(store.currentTheme).toBe('light')
expect(localStorageMock.setItem).toHaveBeenCalledWith('mrip-theme', 'light')
expect(store.currentMode).toBe('light')
expect(setAttributeMock).toHaveBeenCalledWith('data-theme', 'light')
})
@ -87,6 +78,12 @@ describe('theme store', () => {
expect(store.allThemes.every(t => t.builtin)).toBe(true)
})
it('darkThemes has 5, lightThemes has 4', () => {
const store = useThemeStore()
expect(store.darkThemes).toHaveLength(5)
expect(store.lightThemes).toHaveLength(4)
})
it('currentMeta returns metadata for active theme', () => {
const store = useThemeStore()
store.init()
@ -94,48 +91,50 @@ describe('theme store', () => {
expect(store.currentMeta?.name).toBe('Cyberpunk')
})
it('isDark is true for cyberpunk and dark themes', () => {
it('isDark reflects current mode', () => {
const store = useThemeStore()
store.init()
expect(store.isDark).toBe(true)
store.setTheme('dark')
expect(store.isDark).toBe(true)
})
it('isDark is false for light theme', () => {
const store = useThemeStore()
store.init()
store.setTheme('light')
expect(store.isDark).toBe(false)
store.setTheme('hacker')
expect(store.isDark).toBe(true)
})
it('toggleDarkMode switches from dark to light', () => {
it('toggleDarkMode switches between admin dark and light themes', () => {
const store = useThemeStore()
store.init() // starts on cyberpunk (dark)
store.init() // cyberpunk (dark)
store.toggleDarkMode()
expect(store.currentTheme).toBe('light')
expect(store.isDark).toBe(false)
})
it('toggleDarkMode switches from light back to last dark theme', () => {
const store = useThemeStore()
store.init()
// Start on cyberpunk, toggle to light, toggle back
store.toggleDarkMode()
expect(store.currentTheme).toBe('light')
store.toggleDarkMode()
expect(store.currentTheme).toBe('cyberpunk')
expect(store.isDark).toBe(true)
})
it('toggleDarkMode remembers dark theme when starting from dark', () => {
it('toggleDarkMode uses admin-configured themes', () => {
const configStore = useConfigStore()
configStore.config = {
theme_dark: 'neon',
theme_light: 'arctic',
theme_default_mode: 'dark',
} as any
const store = useThemeStore()
store.init()
store.setTheme('dark') // switch to the "dark" theme (not cyberpunk)
expect(store.currentTheme).toBe('neon')
store.toggleDarkMode()
expect(store.currentTheme).toBe('light')
expect(store.currentTheme).toBe('arctic')
store.toggleDarkMode()
expect(store.currentTheme).toBe('dark') // returns to dark, not cyberpunk
expect(store.currentTheme).toBe('neon')
})
it('updateAdminConfig changes the theme pair', () => {
const store = useThemeStore()
store.init()
store.updateAdminConfig('midnight', 'solarized', 'light')
expect(store.adminDarkTheme).toBe('midnight')
expect(store.adminLightTheme).toBe('solarized')
expect(store.adminDefaultMode).toBe('light')
})
})