mirror of
https://github.com/xpltdco/media-rip.git
synced 2026-04-03 02:53:58 -06:00
Compare commits
No commits in common. "master" and "v1.2.0" have entirely different histories.
22 changed files with 310 additions and 639 deletions
28
README.md
28
README.md
|
|
@ -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.
|
||||
- **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.
|
||||
- **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,32 +21,8 @@ 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
|
||||
# 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
|
||||
docker compose up
|
||||
```
|
||||
|
||||
Open [http://localhost:8080](http://localhost:8080) and paste a URL. On first run, you'll set an admin password.
|
||||
|
|
|
|||
|
|
@ -91,9 +91,6 @@ 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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
(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.
|
||||
(busy_timeout, WAL, synchronous) *before* creating any tables so that
|
||||
concurrent download workers never hit ``SQLITE_BUSY``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -92,31 +90,19 @@ 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 for local filesystems, DELETE for network mounts
|
||||
(CIFS/NFS lack the shared-memory primitives WAL requires)
|
||||
3. ``synchronous=NORMAL`` — safe durability level
|
||||
2. ``journal_mode=WAL`` — enables concurrent readers + single writer
|
||||
3. ``synchronous=NORMAL`` — safe durability level for WAL mode
|
||||
|
||||
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")
|
||||
|
||||
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")
|
||||
|
||||
result = await db.execute("PRAGMA journal_mode = WAL")
|
||||
row = await result.fetchone()
|
||||
journal_mode = row[0] if row else "unknown"
|
||||
logger.info("journal_mode set to %s", journal_mode)
|
||||
|
||||
await db.execute("PRAGMA synchronous = NORMAL")
|
||||
|
|
@ -129,54 +115,6 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -295,9 +295,6 @@ 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,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -421,31 +418,6 @@ 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)
|
||||
|
|
@ -548,7 +520,7 @@ async def revoke_api_key(
|
|||
config = request.app.state.config
|
||||
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
|
||||
await delete_setting(db, "api_key")
|
||||
|
||||
|
|
|
|||
|
|
@ -27,32 +27,37 @@ router = APIRouter(tags=["downloads"])
|
|||
def _check_api_access(request: Request) -> None:
|
||||
"""Verify the caller is a browser user or has a valid API key.
|
||||
|
||||
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.
|
||||
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 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:
|
||||
# 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.")
|
||||
return # No key configured — open access
|
||||
|
||||
# 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(detail: str = "Invalid or missing API key. Provide X-API-Key header."):
|
||||
def raise_api_key_required():
|
||||
from fastapi import HTTPException
|
||||
raise HTTPException(status_code=403, detail=detail)
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="API key required. Provide X-API-Key header or use the web UI.",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/downloads", response_model=Job, status_code=201)
|
||||
|
|
|
|||
|
|
@ -22,9 +22,6 @@ 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,
|
||||
|
|
|
|||
|
|
@ -561,31 +561,6 @@ 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."""
|
||||
|
|
@ -670,27 +645,13 @@ 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": playlist_audio,
|
||||
"default_ext": self._guess_ext_from_url(url, playlist_audio),
|
||||
"is_audio_only": domain_audio,
|
||||
"default_ext": self._guess_ext_from_url(url, domain_audio),
|
||||
}
|
||||
if unavailable_count > 0:
|
||||
result["unavailable_count"] = unavailable_count
|
||||
|
|
@ -698,11 +659,6 @@ 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")
|
||||
|
|
|
|||
|
|
@ -35,9 +35,6 @@ ADMIN_WRITABLE_KEYS = {
|
|||
"purge_enabled",
|
||||
"purge_max_age_minutes",
|
||||
"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"]
|
||||
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))
|
||||
|
||||
|
|
|
|||
|
|
@ -109,11 +109,7 @@ 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",
|
||||
headers={"X-Requested-With": "XMLHttpRequest"},
|
||||
) as ac:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as ac:
|
||||
yield ac
|
||||
|
||||
# Teardown
|
||||
|
|
|
|||
|
|
@ -185,16 +185,8 @@ async def test_session_isolation(client, tmp_path):
|
|||
|
||||
transport = ASGITransport(app=test_app)
|
||||
|
||||
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:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client_a:
|
||||
async with AsyncClient(transport=transport, base_url="http://test") as client_b:
|
||||
await client_a.post(
|
||||
"/api/downloads",
|
||||
json={"url": "https://example.com/a"},
|
||||
|
|
|
|||
|
|
@ -13,11 +13,8 @@ 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()
|
||||
|
|
|
|||
|
|
@ -71,9 +71,6 @@ export interface FormatInfo {
|
|||
export interface PublicConfig {
|
||||
session_mode: string
|
||||
default_theme: string
|
||||
theme_dark: string
|
||||
theme_light: string
|
||||
theme_default_mode: string
|
||||
welcome_message: string
|
||||
purge_enabled: boolean
|
||||
max_concurrent_downloads: number
|
||||
|
|
|
|||
|
|
@ -3,7 +3,6 @@ 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'
|
||||
|
|
@ -36,11 +35,6 @@ 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('')
|
||||
|
|
@ -53,10 +47,6 @@ 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 &&
|
||||
|
|
@ -95,9 +85,6 @@ 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
|
||||
|
|
@ -120,15 +107,9 @@ 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)
|
||||
}
|
||||
|
|
@ -212,7 +193,6 @@ async function generateApiKey() {
|
|||
|
||||
async function regenerateApiKey() {
|
||||
await generateApiKey()
|
||||
regenConfirming.value = false
|
||||
}
|
||||
|
||||
async function revokeApiKey() {
|
||||
|
|
@ -223,27 +203,6 @@ 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() {
|
||||
|
|
@ -435,39 +394,6 @@ 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>
|
||||
|
|
@ -692,8 +618,8 @@ function formatFilesize(bytes: number | null): string {
|
|||
<div class="settings-field">
|
||||
<label>API Key</label>
|
||||
<p class="field-hint">
|
||||
Generate a key to enable external API access (e.g. scripts, automation).
|
||||
Without a key, downloads can only be submitted through the web UI.
|
||||
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.
|
||||
</p>
|
||||
<div v-if="apiKey" class="api-key-display">
|
||||
<div class="api-key-value">
|
||||
|
|
@ -707,25 +633,13 @@ function formatFilesize(bytes: number | null): string {
|
|||
</button>
|
||||
</div>
|
||||
<div class="api-key-actions">
|
||||
<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>
|
||||
<button class="btn-regen" @click="regenerateApiKey">Regenerate</button>
|
||||
<button class="btn-revoke" @click="revokeApiKey">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 — external API access is disabled.</p>
|
||||
<p class="field-hint" style="margin-bottom: var(--space-sm);">No API key set — download API is open.</p>
|
||||
<button class="btn-save" @click="generateApiKey">Generate API Key</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -970,25 +884,6 @@ 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;
|
||||
|
|
|
|||
|
|
@ -40,12 +40,6 @@ 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;
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ 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);
|
||||
|
|
@ -45,12 +44,6 @@ 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;
|
||||
|
|
|
|||
|
|
@ -1,26 +1,13 @@
|
|||
<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>
|
||||
|
|
@ -54,10 +41,7 @@ const queueBadge = computed(() => {
|
|||
: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>
|
||||
|
|
@ -152,26 +136,5 @@ const queueBadge = computed(() => {
|
|||
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>
|
||||
|
|
|
|||
|
|
@ -1,17 +1,29 @@
|
|||
<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="dark-mode-toggle"
|
||||
:title="theme.isDark ? 'Switch to light mode' : 'Switch to dark mode'"
|
||||
@click="theme.toggleDarkMode()"
|
||||
aria-label="Toggle dark/light mode"
|
||||
class="theme-toggle-btn"
|
||||
:title="'Theme: ' + (theme.currentMeta?.name || theme.currentTheme)"
|
||||
@click="showPicker = !showPicker"
|
||||
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">
|
||||
<circle cx="12" cy="12" r="5"/>
|
||||
<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="18.36" y1="5.64" x2="19.78" y2="4.22"/>
|
||||
</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">
|
||||
<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>
|
||||
.dark-mode-toggle {
|
||||
.theme-picker-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.theme-toggle-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
|
@ -46,7 +97,76 @@ const theme = useThemeStore()
|
|||
padding: 0;
|
||||
}
|
||||
|
||||
.dark-mode-toggle:hover {
|
||||
.theme-toggle-btn: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>
|
||||
|
|
|
|||
|
|
@ -144,7 +144,6 @@ 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 {
|
||||
|
|
@ -158,10 +157,6 @@ 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 {
|
||||
|
|
@ -170,31 +165,9 @@ function selectFormat(id: string | null): void {
|
|||
}
|
||||
|
||||
.format-codecs {
|
||||
font-size: var(--font-size-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -184,12 +184,10 @@ 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 {
|
||||
|
|
@ -206,9 +204,6 @@ function handlePaste(): void {
|
|||
isAnalyzing.value = false
|
||||
stopAnalyzePhase()
|
||||
}
|
||||
} else {
|
||||
// URL was cleared before timeout — cancel analysis state
|
||||
isAnalyzing.value = false
|
||||
}
|
||||
}, 50)
|
||||
}
|
||||
|
|
@ -299,8 +294,7 @@ function formatTooltip(fmt: string): string {
|
|||
|
||||
<template>
|
||||
<div class="url-input">
|
||||
<!-- URL field with clear button -->
|
||||
<div class="url-field-wrap">
|
||||
<!-- URL field -->
|
||||
<input
|
||||
v-model="url"
|
||||
type="url"
|
||||
|
|
@ -308,18 +302,8 @@ function formatTooltip(fmt: string): string {
|
|||
class="url-field"
|
||||
@paste="handlePaste"
|
||||
@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 -->
|
||||
<div class="action-row">
|
||||
|
|
@ -467,46 +451,15 @@ 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 */
|
||||
}
|
||||
|
||||
.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: gear | toggle | download */
|
||||
.action-row {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
|
|
@ -514,8 +467,8 @@ function formatTooltip(fmt: string): string {
|
|||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 42px;
|
||||
min-height: 42px;
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
padding: 0;
|
||||
flex-shrink: 0;
|
||||
background: var(--color-surface);
|
||||
|
|
@ -555,7 +508,7 @@ function formatTooltip(fmt: string): string {
|
|||
border: none;
|
||||
border-radius: 0;
|
||||
font-size: var(--font-size-sm);
|
||||
min-height: 42px;
|
||||
min-height: 38px;
|
||||
transition: all 0.15s ease;
|
||||
}
|
||||
|
||||
|
|
@ -582,7 +535,7 @@ function formatTooltip(fmt: string): string {
|
|||
white-space: nowrap;
|
||||
padding: var(--space-sm) var(--space-lg);
|
||||
font-weight: 600;
|
||||
min-height: 42px;
|
||||
min-height: 38px;
|
||||
background: var(--color-accent);
|
||||
color: var(--color-bg);
|
||||
}
|
||||
|
|
@ -874,8 +827,6 @@ button:disabled {
|
|||
|
||||
.toggle-pill {
|
||||
padding: var(--space-xs) var(--space-sm);
|
||||
min-width: 42px;
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,17 +1,15 @@
|
|||
/**
|
||||
* Theme Pinia store — manages theme selection and application.
|
||||
*
|
||||
* 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).
|
||||
* Built-in themes: cyberpunk (default), dark, light
|
||||
* Custom themes: loaded via /api/themes manifest at runtime
|
||||
*
|
||||
* Built-in themes: 5 dark + 4 light = 9 total.
|
||||
* Custom themes: loaded via /api/themes manifest at runtime.
|
||||
* Persistence: localStorage key 'mrip-theme'
|
||||
* Application: sets data-theme attribute on <html> element
|
||||
*/
|
||||
|
||||
import { ref, computed } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
import { useConfigStore } from './config'
|
||||
|
||||
export interface ThemeMeta {
|
||||
id: string
|
||||
|
|
@ -22,7 +20,8 @@ export interface ThemeMeta {
|
|||
variant: 'dark' | 'light'
|
||||
}
|
||||
|
||||
const COOKIE_KEY = 'mrip-mode'
|
||||
const STORAGE_KEY = 'mrip-theme'
|
||||
const DEFAULT_THEME = 'cyberpunk'
|
||||
|
||||
const BUILTIN_THEMES: ThemeMeta[] = [
|
||||
// Dark themes
|
||||
|
|
@ -39,98 +38,74 @@ const BUILTIN_THEMES: ThemeMeta[] = [
|
|||
]
|
||||
|
||||
export const useThemeStore = defineStore('theme', () => {
|
||||
const currentTheme = ref('cyberpunk')
|
||||
const currentMode = ref<'dark' | 'light'>('dark')
|
||||
const currentTheme = ref(DEFAULT_THEME)
|
||||
const customThemes = ref<ThemeMeta[]>([])
|
||||
const customThemeCSS = ref<Map<string, string>>(new Map())
|
||||
|
||||
// Admin-configured theme pair (loaded from public config)
|
||||
const adminDarkTheme = ref('cyberpunk')
|
||||
const adminLightTheme = ref('light')
|
||||
const adminDefaultMode = ref<'dark' | 'light'>('dark')
|
||||
/** 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
|
||||
})
|
||||
|
||||
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[]>(() => [
|
||||
...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 — reads admin config, then checks for visitor cookie override.
|
||||
* Initialize the theme store — reads from localStorage and applies.
|
||||
*/
|
||||
function init(): void {
|
||||
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
|
||||
const saved = localStorage.getItem(STORAGE_KEY)
|
||||
if (saved && BUILTIN_THEMES.some(t => t.id === saved)) {
|
||||
currentTheme.value = saved
|
||||
} else {
|
||||
currentMode.value = 'dark'
|
||||
currentTheme.value = adminDarkTheme.value
|
||||
currentTheme.value = DEFAULT_THEME
|
||||
}
|
||||
_setCookie(COOKIE_KEY, currentMode.value, 365)
|
||||
_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 {
|
||||
const found = allThemes.value.find(t => t.id === themeId)
|
||||
if (!found) return
|
||||
|
||||
currentTheme.value = 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)
|
||||
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 {
|
||||
currentTheme.value = lightTheme
|
||||
_apply(lightTheme)
|
||||
localStorage.setItem(STORAGE_KEY + '-light', themeId)
|
||||
}
|
||||
_apply(themeId)
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -149,16 +124,22 @@ export const useThemeStore = defineStore('theme', () => {
|
|||
author: t.author,
|
||||
description: t.description,
|
||||
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
|
||||
if (!BUILTIN_THEMES.some(t => t.id === currentTheme.value)) {
|
||||
await _loadCustomCSS(currentTheme.value)
|
||||
}
|
||||
}
|
||||
} 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)!)
|
||||
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 { /* */ }
|
||||
} catch {
|
||||
// Failed to load custom CSS
|
||||
}
|
||||
}
|
||||
|
||||
function _injectCustomCSS(themeId: string, css: string): void {
|
||||
|
|
@ -191,32 +176,17 @@ export const useThemeStore = defineStore('theme', () => {
|
|||
document.documentElement.setAttribute('data-theme', themeId)
|
||||
}
|
||||
|
||||
function _getCookie(name: string): string | null {
|
||||
const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`))
|
||||
return match ? decodeURIComponent(match[1]) : null
|
||||
}
|
||||
|
||||
function _setCookie(name: string, value: string, days: number): void {
|
||||
const expires = new Date(Date.now() + days * 86400000).toUTCString()
|
||||
document.cookie = `${name}=${encodeURIComponent(value)}; expires=${expires}; path=/; SameSite=Lax`
|
||||
}
|
||||
|
||||
return {
|
||||
currentTheme,
|
||||
currentMode,
|
||||
customThemes,
|
||||
allThemes,
|
||||
darkThemes,
|
||||
lightThemes,
|
||||
currentMeta,
|
||||
isDark,
|
||||
adminDarkTheme,
|
||||
adminLightTheme,
|
||||
adminDefaultMode,
|
||||
init,
|
||||
setTheme,
|
||||
toggleDarkMode,
|
||||
updateAdminConfig,
|
||||
loadCustomThemes,
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -28,9 +28,6 @@ describe('config store', () => {
|
|||
const mockConfig = {
|
||||
session_mode: 'isolated',
|
||||
default_theme: 'dark',
|
||||
theme_dark: 'cyberpunk',
|
||||
theme_light: 'light',
|
||||
theme_default_mode: 'dark',
|
||||
welcome_message: 'Test welcome',
|
||||
purge_enabled: false,
|
||||
max_concurrent_downloads: 3,
|
||||
|
|
|
|||
|
|
@ -1,9 +1,21 @@
|
|||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { setActivePinia, createPinia } from 'pinia'
|
||||
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()
|
||||
Object.defineProperty(globalThis, 'document', {
|
||||
value: {
|
||||
|
|
@ -13,47 +25,44 @@ 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 (dark mode)', () => {
|
||||
it('initializes with cyberpunk as default', () => {
|
||||
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('uses admin config for defaults when available', () => {
|
||||
const configStore = useConfigStore()
|
||||
configStore.config = {
|
||||
theme_dark: 'neon',
|
||||
theme_light: 'paper',
|
||||
theme_default_mode: 'light',
|
||||
} as any
|
||||
|
||||
it('restores saved theme from localStorage', () => {
|
||||
localStorageMock.setItem('mrip-theme', 'dark')
|
||||
const store = useThemeStore()
|
||||
store.init()
|
||||
expect(store.currentTheme).toBe('paper')
|
||||
expect(store.currentMode).toBe('light')
|
||||
expect(store.isDark).toBe(false)
|
||||
expect(store.currentTheme).toBe('dark')
|
||||
expect(setAttributeMock).toHaveBeenCalledWith('data-theme', 'dark')
|
||||
})
|
||||
|
||||
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()
|
||||
store.init()
|
||||
store.setTheme('light')
|
||||
expect(store.currentTheme).toBe('light')
|
||||
expect(store.currentMode).toBe('light')
|
||||
expect(localStorageMock.setItem).toHaveBeenCalledWith('mrip-theme', 'light')
|
||||
expect(setAttributeMock).toHaveBeenCalledWith('data-theme', 'light')
|
||||
})
|
||||
|
||||
|
|
@ -78,12 +87,6 @@ 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()
|
||||
|
|
@ -91,50 +94,48 @@ describe('theme store', () => {
|
|||
expect(store.currentMeta?.name).toBe('Cyberpunk')
|
||||
})
|
||||
|
||||
it('isDark reflects current mode', () => {
|
||||
it('isDark is true for cyberpunk and dark themes', () => {
|
||||
const store = useThemeStore()
|
||||
store.init()
|
||||
expect(store.isDark).toBe(true)
|
||||
store.setTheme('light')
|
||||
expect(store.isDark).toBe(false)
|
||||
store.setTheme('hacker')
|
||||
|
||||
store.setTheme('dark')
|
||||
expect(store.isDark).toBe(true)
|
||||
})
|
||||
|
||||
it('toggleDarkMode switches between admin dark and light themes', () => {
|
||||
it('isDark is false for light theme', () => {
|
||||
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()
|
||||
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 uses admin-configured themes', () => {
|
||||
const configStore = useConfigStore()
|
||||
configStore.config = {
|
||||
theme_dark: 'neon',
|
||||
theme_light: 'arctic',
|
||||
theme_default_mode: 'dark',
|
||||
} as any
|
||||
|
||||
it('toggleDarkMode remembers dark theme when starting from dark', () => {
|
||||
const store = useThemeStore()
|
||||
store.init()
|
||||
expect(store.currentTheme).toBe('neon')
|
||||
store.setTheme('dark') // switch to the "dark" theme (not cyberpunk)
|
||||
store.toggleDarkMode()
|
||||
expect(store.currentTheme).toBe('arctic')
|
||||
expect(store.currentTheme).toBe('light')
|
||||
store.toggleDarkMode()
|
||||
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')
|
||||
expect(store.currentTheme).toBe('dark') // returns to dark, not cyberpunk
|
||||
})
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue