Fix: SplashScreen Default

This commit is contained in:
Youwes09
2026-03-27 15:37:02 -05:00
parent ac6b70fb32
commit 1f08b46919
8 changed files with 951 additions and 120 deletions
+115 -53
View File
@@ -11,21 +11,70 @@
import Layout from "./components/layout/Layout.svelte";
import Reader from "./components/reader/Reader.svelte";
import Settings from "./components/settings/Settings.svelte";
import ThemeEditor from "./components/settings/ThemeEditor.svelte";
import TitleBar from "./components/layout/TitleBar.svelte";
import Toaster from "./components/layout/Toaster.svelte";
import SplashScreen from "./components/layout/SplashScreen.svelte";
import MangaPreview from "./components/shared/MangaPreview.svelte";
const MAX_ATTEMPTS = 60;
let themeStyleEl: HTMLStyleElement | null = null;
$effect(() => {
const themeId = store.settings.theme ?? "dark";
const isCustom = themeId.startsWith("custom:");
if (!isCustom) {
themeStyleEl?.remove();
themeStyleEl = null;
document.documentElement.setAttribute("data-theme", themeId);
return;
}
const custom = store.settings.customThemes?.find(t => t.id === themeId);
if (!custom) {
themeStyleEl?.remove();
themeStyleEl = null;
document.documentElement.setAttribute("data-theme", "dark");
return;
}
const vars = Object.entries(custom.tokens)
.map(([k, v]) => ` --${k}: ${v};`)
.join("\n");
const css = `[data-theme="custom"] {\n${vars}\n}`;
if (!themeStyleEl) {
themeStyleEl = document.createElement("style");
themeStyleEl.id = "moku-custom-theme";
document.head.appendChild(themeStyleEl);
}
themeStyleEl.textContent = css;
document.documentElement.setAttribute("data-theme", "custom");
});
let themeEditorOpen = $state(false);
let themeEditorEditId = $state<string | null>(null);
function openThemeEditor(id?: string | null) {
themeEditorEditId = id ?? null;
themeEditorOpen = true;
}
function closeThemeEditor() {
themeEditorOpen = false;
themeEditorEditId = null;
}
const MAX_ATTEMPTS = 10;
const win = getCurrentWindow();
let serverProbeOk = $state(!store.settings.autoStartServer);
let appReady = $state(!store.settings.autoStartServer);
let failed = $state(false);
let notConfigured = $state(false);
let idle = $state(false);
let devSplash = $state(false);
let platformScale = $state(1);
let serverProbeOk = $state(false);
let appReady = $state(false);
let failed = $state(false);
let notConfigured = $state(false);
let idle = $state(false);
let devSplash = $state(false);
let platformScale = $state(1);
function applyZoom() {
const normalized = store.settings.uiScale * platformScale;
@@ -61,7 +110,7 @@
function resetIdle() {
if (idleTimer) clearTimeout(idleTimer);
if (idle) return; // don't re-arm while PIN screen is showing
if (idle) return;
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
if (ms === 0) return;
idleTimer = setTimeout(() => idle = true, ms);
@@ -77,15 +126,10 @@
});
$effect(() => {
// Re-runs whenever uiScale or platformScale changes.
store.settings.uiScale; platformScale;
applyZoom();
});
$effect(() => {
document.documentElement.setAttribute("data-theme", store.settings.theme ?? "dark");
});
$effect(() => {
if (!appReady) return;
const poll = () => gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
@@ -95,11 +139,6 @@
return () => clearInterval(pollInterval);
});
// ── Auto-update check (runs once after app is ready) ─────────────────────────
//
// Fetches the GitHub releases list via the Rust command and compares the latest
// tag against the installed version. On mismatch, shows a single non-blocking
// info toast. No modal, no blocking UI.
async function checkForUpdateSilently() {
try {
const [currentVersion, releases] = await Promise.all([
@@ -107,7 +146,6 @@
invoke<Array<{ tag_name: string; html_url: string }>>("list_releases"),
]);
// Filter out drafts / incomplete releases that have no tag_name
const valid = releases.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
if (!valid.length) return;
@@ -126,7 +164,6 @@
.sort((a, b) => compare(parse(a), parse(b)))[0]
.replace(/^v/, "");
// Only toast if latest is strictly newer than installed
const isNewer = compare(parse(latestTag), parse(currentVersion)) < 0;
if (isNewer) {
addToast({
@@ -136,23 +173,50 @@
duration: 8000,
});
}
} catch {
// Silently ignore — no network, private repo rate-limit, etc.
} catch {}
}
let cancelProbe = false;
function startProbe() {
cancelProbe = false;
failed = false;
let tries = 0;
async function probe() {
if (cancelProbe) return;
tries++;
try {
const rawUrl = store.settings.serverUrl;
const base = typeof rawUrl === "string" && rawUrl.trim()
? rawUrl.replace(/\/$/, "")
: "http://127.0.0.1:4567";
const s = store.settings;
const auth: Record<string, string> = s.serverAuthEnabled && s.serverAuthUser && s.serverAuthPass
? { Authorization: `Basic ${btoa(`${s.serverAuthUser.trim()}:${s.serverAuthPass.trim()}`)}` }
: {};
const res = await fetch(`${base}/api/graphql`, {
method: "POST",
headers: { "Content-Type": "application/json", ...auth },
body: JSON.stringify({ query: "{ __typename }" }),
signal: AbortSignal.timeout(2000),
});
if (res.ok && !cancelProbe) { serverProbeOk = true; return; }
} catch {}
if (tries >= MAX_ATTEMPTS && !cancelProbe) { failed = true; return; }
if (!cancelProbe) setTimeout(probe, 750);
}
setTimeout(probe, 800);
}
onMount(async () => {
document.addEventListener("contextmenu", e => e.preventDefault());
(window as any).__mokuShowSplash = () => devSplash = true;
// Fetch the platform scale factor then immediately re-apply zoom.
platformScale = await invoke<number>("get_platform_ui_scale").catch(() => 1);
applyZoom();
// ── Fullscreen state sync ─────────────────────────────────────────────────
// Seed the initial state, then keep it in sync on every resize event.
// onResized is the correct Tauri 2 API — it fires on fullscreen enter/exit,
// window snap, and manual resize. isFullscreen() is cheap (single IPC call).
store.isFullscreen = await win.isFullscreen();
const unlistenResize = await win.onResized(async () => {
store.isFullscreen = await win.isFullscreen();
@@ -168,30 +232,13 @@
});
}
if (!serverProbeOk) {
let cancelled = false, tries = 0;
async function probe() {
if (cancelled) return;
tries++;
try {
const res = await fetch(`${store.settings.serverUrl}/api/graphql`, {
method: "POST", headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: "{ __typename }" }),
signal: AbortSignal.timeout(2000),
});
if (res.ok && !cancelled) { serverProbeOk = true; return; }
} catch {}
if (tries >= MAX_ATTEMPTS && !cancelled) { failed = true; return; }
if (!cancelled) setTimeout(probe, 500);
}
setTimeout(probe, 800);
}
startProbe();
type P = { chapterId: number; mangaId: number; progress: number }[];
unlistenDownload = await listen<P>("download-progress", e => { setActiveDownloads(e.payload); });
return () => {
cancelled = true;
cancelProbe = true;
unlistenResize();
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
if (idleTimer) clearTimeout(idleTimer);
@@ -201,16 +248,24 @@
};
});
// Run the update check once, 5 seconds after the app finishes loading.
// The delay avoids adding to startup latency and ensures list_releases
// doesn't compete with the server probe.
$effect(() => {
if (!appReady) return;
const timer = setTimeout(checkForUpdateSilently, 5_000);
return () => clearTimeout(timer);
});
function handleRetry() { failed = false; notConfigured = false; serverProbeOk = false; }
function handleRetry() {
failed = false;
notConfigured = false;
serverProbeOk = false;
startProbe();
}
function handleBypass() {
cancelProbe = true;
serverProbeOk = true;
appReady = true;
}
</script>
{#if devSplash}
@@ -220,7 +275,8 @@
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed} {notConfigured}
showCards={store.settings.splashCards ?? true}
onReady={() => appReady = true}
onRetry={handleRetry} />
onRetry={handleRetry}
onBypass={handleBypass} />
{:else}
<div class="root">
{#if idle && !store.activeChapter}
@@ -231,7 +287,13 @@
<div class="content">
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
</div>
{#if store.settingsOpen}<Settings />{/if}
{#if store.settingsOpen}<Settings onOpenThemeEditor={openThemeEditor} />{/if}
{#if themeEditorOpen}
<ThemeEditor
bind:editingId={themeEditorEditId}
onClose={closeThemeEditor}
/>
{/if}
<MangaPreview />
<Toaster />
</div>
+22 -51
View File
@@ -13,11 +13,12 @@
showFps?: boolean;
onReady?: () => void;
onRetry?: () => void;
onBypass?: () => void;
onDismiss?: () => void;
}
let { mode = "loading", ringFull = false, failed = false, notConfigured = false,
showCards = true, showFps = false, onReady, onRetry, onDismiss }: Props = $props();
showCards = true, showFps = false, onReady, onRetry, onBypass, onDismiss }: Props = $props();
const lockEnabled = $derived(
store.settings.appLockEnabled && (store.settings.appLockPin?.length ?? 0) >= 4
@@ -26,7 +27,7 @@
let pinEntry = $state("");
let pinShake = $state(false);
let pinUnlocked = $state(false);
let pinVisible = $state(false); // delayed so the pin block fades in after the ring completes
let pinVisible = $state(false);
function submitPin() {
if (pinEntry === store.settings.appLockPin) {
@@ -49,12 +50,10 @@
}
}
function handleRetry() { onRetry?.(); }
function handleBypass() { onBypass?.(); }
const EXIT_MS = 320;
// Server typically takes 8-20s to boot. We animate the ring through three
// phases so it always feels like something is happening:
// 0 → 0.75 over ~12s (eased crawl while server starts)
// 0.75 → 0.92 over ~8s (slow down near the end, implying "almost there")
// jumps to 1.0 the moment the probe succeeds
const PHASE1_TARGET = 0.85;
const PHASE1_MS = 3000;
const PHASE2_TARGET = 0.95;
@@ -74,7 +73,6 @@
setTimeout(() => cb?.(), EXIT_MS);
}
// Animate ring progress with easing so it never stalls visually
let animFrame: number;
let animStart: number | null = null;
let animPhase = 1;
@@ -86,7 +84,6 @@
if (animPhase === 1) {
const t = Math.min(elapsed / PHASE1_MS, 1);
// ease-out cubic so it starts fast and slows down
const eased = 1 - Math.pow(1 - t, 3);
ringProg = 0.025 + eased * (PHASE1_TARGET - 0.025);
if (t >= 1) { animPhase = 2; animStart = ts; }
@@ -94,7 +91,6 @@
const t = Math.min(elapsed / PHASE2_MS, 1);
const eased = 1 - Math.pow(1 - t, 4);
ringProg = PHASE1_TARGET + eased * (PHASE2_TARGET - PHASE1_TARGET);
// Phase 2 never completes on its own — only ringFull triggers completion
}
animFrame = requestAnimationFrame(animateRing);
@@ -112,7 +108,6 @@
cancelAnimationFrame(animFrame);
ringProg = 1;
if (lockEnabled && !pinUnlocked) {
// Short pause after ring completes, then fade the PIN block in
setTimeout(() => { pinVisible = true; }, 400);
} else {
setTimeout(() => triggerExit(onReady), 650);
@@ -309,9 +304,6 @@
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
}
// Attach PIN keydown to the window so it fires regardless of which element has
// focus — the pin-block div is not natively focusable and would silently drop
// key events otherwise.
$effect(() => {
const needsPin =
(mode === "idle" && lockEnabled) ||
@@ -371,7 +363,6 @@
</div>
{:else}
<!-- Logo + ring — always present, ring fades out when pin takes over -->
<div style="position:relative;width:140px;height:140px;margin-bottom:20px;z-index:1">
{#if !failed && !notConfigured}
<svg width={ringSize} height={ringSize}
@@ -390,32 +381,23 @@
</div>
<p class="title-label">moku</p>
<!-- Bottom area: status text → fades out, pin dots → fades in. Same space, no DOM swap. -->
<div class="bottom-area" style="z-index:1">
<!-- Status / error — fades out once pin is visible -->
<div class="status-slot" class:status-slot-hide={lockEnabled && pinVisible}>
{#if notConfigured}
{#if failed || notConfigured}
<div class="error-box">
<p class="error-title">Server not configured</p>
<p class="error-body">Set the server path in Settings, then retry</p>
<div style="display:flex;gap:8px;margin-top:8px">
<button class="retry-btn" onclick={() => { store.settingsOpen = true; }}>Settings</button>
<button class="retry-btn" onclick={onRetry}>Retry</button>
<p class="error-label">
{failed ? "Could not reach server" : "Server not configured"}
</p>
<div class="error-actions">
<button class="err-btn" onclick={handleRetry}>Retry</button>
<button class="err-btn err-btn--primary" onclick={handleBypass}>Enter app</button>
</div>
</div>
{:else if failed}
<div class="error-box error-box--danger">
<p class="error-title" style="color:var(--color-error)">Could not reach Suwayomi</p>
<p class="error-body">Make sure tachidesk-server is on your PATH</p>
<button class="retry-btn" style="margin-top:8px" onclick={onRetry}>Retry</button>
</div>
{:else}
<p class="status-text">{ringFull ? "" : `Initializing server${dots}`}</p>
{/if}
</div>
<!-- PIN dots — fades in after ring completes, same position as status text -->
{#if lockEnabled}
<div class="pin-slot" class:pin-slot-visible={pinVisible}>
<div class="pin-dots" class:pin-shake={pinShake}>
@@ -426,7 +408,6 @@
<button class="pin-submit-btn" onclick={submitPin} tabindex="-1" aria-label="Submit PIN">Unlock</button>
</div>
{/if}
</div>
{/if}
</div>
@@ -442,38 +423,28 @@
.logo-breathe { animation: logoBreathe 4s ease-in-out infinite; }
.hint { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.22em; text-transform: uppercase; margin: 0; user-select: none; animation: hintFade 3.5s ease-in-out infinite; }
.title-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; letter-spacing: 0.26em; text-transform: uppercase; color: var(--text-secondary); margin: 0 0 8px; z-index: 1; user-select: none; }
.retry-btn { margin-top: 4px; padding: 5px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.08em; }
.retry-btn:hover { border-color: var(--border-strong); color: var(--text-secondary); }
.error-box { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 14px 20px; border-radius: var(--radius-lg); background: rgba(0,0,0,0.55); border: 1px solid rgba(255,255,255,0.12); max-width: 260px; text-align: center; backdrop-filter: blur(4px); }
.error-box--danger { border-color: rgba(220,50,50,0.5); }
.error-title { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.1em; margin: 0; }
.error-body { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.05em; margin: 0; line-height: 1.6; }
.error-box { display: flex; flex-direction: column; align-items: center; gap: 12px; padding: 16px 20px; border-radius: var(--radius-lg); background: var(--bg-surface); border: 1px solid var(--border-base); min-width: 200px; text-align: center; animation: errIn 0.25s cubic-bezier(0,0,0.2,1) both; }
@keyframes errIn { from { opacity:0; transform:translateY(4px) } to { opacity:1; transform:translateY(0) } }
.error-label { font-family: var(--font-ui); font-size: 11px; font-weight: 500; color: var(--text-muted); letter-spacing: 0.06em; margin: 0; }
.error-actions { display: flex; gap: 6px; }
.err-btn { padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-base); background: transparent; color: var(--text-muted); cursor: pointer; font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em; transition: border-color 0.15s, color 0.15s; }
.err-btn:hover { border-color: var(--border-strong); color: var(--text-secondary); }
.err-btn--primary { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.err-btn--primary:hover { border-color: var(--accent); color: var(--accent-bright); }
/* ── Loading → PIN unified bottom area ───────────────────────────────────── */
/* Fixed-height container so logo/title never move during the swap */
.bottom-area { display: flex; align-items: center; justify-content: center; height: 48px; position: relative; }
/* Status text slot */
.bottom-area { display: flex; align-items: center; justify-content: center; min-height: 48px; position: relative; }
.status-slot { display: flex; align-items: center; justify-content: center; transition: opacity 0.35s ease; position: absolute; }
.status-slot-hide { opacity: 0; pointer-events: none; }
.status-text { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: 0.12em; margin: 0; min-width: 160px; text-align: center; }
/* Ring fades out as PIN takes over */
.loading-ring { transition: opacity 0.5s ease; }
.ring-hide { opacity: 0; }
/* PIN dots slot — starts invisible, fades in */
.pin-slot { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: absolute; opacity: 0; transform: translateY(4px); transition: opacity 0.4s ease, transform 0.4s ease; pointer-events: none; }
.pin-slot-visible { opacity: 1; transform: translateY(0); pointer-events: auto; }
/* PIN dots shared between loading and idle modes */
.pin-block { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); position: relative; }
.pin-dots { display: flex; gap: 12px; align-items: center; }
.pin-dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid var(--border-strong); background: transparent; transition: background 0.12s, border-color 0.12s; }
.pin-dot-filled { background: var(--accent); border-color: var(--accent); }
@keyframes pinShake { 0%,100% { transform:translateX(0) } 20%,60% { transform:translateX(-6px) } 40%,80% { transform:translateX(6px) } }
.pin-shake { animation: pinShake 0.42s ease; }
/* Visually hidden submit button — tappable, invisible */
.pin-submit-btn { opacity: 0; width: 1px; height: 1px; overflow: hidden; padding: 0; border: none; background: none; cursor: pointer; pointer-events: auto; position: absolute; }
</style>
+6 -1
View File
@@ -127,6 +127,7 @@
const maxW = $derived(store.settings.maxPageWidth ?? 900);
const autoNext = $derived(store.settings.autoNextChapter ?? false);
const markOnNext = $derived(store.settings.markReadOnNext ?? true);
const overlayBars = $derived(store.settings.overlayBars ?? false);
const lastPage = $derived(store.pageUrls.length);
const displayChapter = $derived(
@@ -601,7 +602,7 @@
});
</script>
<div class="root" role="presentation" onmousemove={(e) => { if (e.clientY < 60 || window.innerHeight - e.clientY < 60) showUi(); }}>
<div class="root" class:overlay-bars={overlayBars} role="presentation" onmousemove={(e) => { if (e.clientY < 60 || window.innerHeight - e.clientY < 60) showUi(); }}>
<div class="topbar" class:hidden={!uiVisible}>
<button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
@@ -752,6 +753,10 @@
<style>
.root { position: fixed; inset: 0; background: #000; display: flex; flex-direction: column; z-index: var(--z-reader); transform: translateZ(0); will-change: transform; }
.overlay-bars { position: fixed; }
.overlay-bars .topbar { position: absolute; top: 0; left: 0; right: 0; z-index: 10; }
.overlay-bars .bottombar { position: absolute; bottom: 0; left: 0; right: 0; z-index: 10; }
.overlay-bars .viewer { height: 100%; }
.topbar { display: flex; align-items: center; gap: var(--sp-1); padding: 0 var(--sp-3); height: 40px; background: var(--bg-void); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; position: relative; z-index: 2; transition: opacity 0.25s ease; }
.topbar.hidden, .bottombar.hidden { opacity: 0; pointer-events: none; }
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
+127 -1
View File
@@ -7,13 +7,18 @@
import { open as openUrl } from "@tauri-apps/plugin-shell";
import { gql, thumbUrl } from "../../lib/client";
import { GET_DOWNLOADS_PATH, GET_TRACKERS, LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER, GET_TRACKER_RECORDS, GET_SERVER_SECURITY, SET_SERVER_AUTH, SET_SOCKS_PROXY, SET_FLARESOLVERR } from "../../lib/queries";
import { store, updateSettings, resetKeybinds, addFolder, removeFolder, renameFolder, toggleFolderTab, clearHistory, wipeAllData, setSettingsOpen } from "../../store/state.svelte";
import { store, updateSettings, resetKeybinds, addFolder, removeFolder, renameFolder, toggleFolderTab, clearHistory, wipeAllData, setSettingsOpen, deleteCustomTheme } from "../../store/state.svelte";
import { cache } from "../../lib/cache";
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "../../lib/keybinds";
import type { Settings, FitMode, Theme } from "../../store/state.svelte";
import type { Keybinds } from "../../lib/keybinds";
import type { Tracker } from "../../lib/types";
interface Props {
onOpenThemeEditor?: (id?: string | null) => void;
}
let { onOpenThemeEditor }: Props = $props();
type Tab = "general" | "appearance" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "tracking" | "security" | "about" | "devtools";
const TABS: { id: Tab; label: string; icon: any }[] = [
@@ -710,6 +715,67 @@
{#if active}<span class="theme-card-check"></span>{/if}
</button>
{/each}
<!-- Custom theme cards -->
{#each store.settings.customThemes ?? [] as custom}
{@const active = store.settings.theme === custom.id}
<div class="theme-card custom-theme-card" class:active>
<button
class="custom-theme-select"
onclick={() => updateSettings({ theme: custom.id })}
title="Apply {custom.name}"
>
<div class="theme-preview">
<div class="theme-preview-bg" style="background:{custom.tokens['bg-base']}">
<div class="theme-preview-sidebar" style="background:{custom.tokens['bg-surface']}"></div>
<div class="theme-preview-content">
<div class="theme-preview-accent" style="background:{custom.tokens['accent']}"></div>
<div class="theme-preview-text" style="background:{custom.tokens['text-primary']}55"></div>
<div class="theme-preview-text" style="background:{custom.tokens['text-primary']}33;width:60%"></div>
</div>
</div>
</div>
<div class="theme-card-info">
<span class="theme-card-label">{custom.name}</span>
<span class="theme-card-desc custom-badge">Custom</span>
</div>
</button>
<div class="custom-theme-actions">
<button
class="custom-theme-edit-btn"
onclick={() => onOpenThemeEditor?.(custom.id)}
title="Edit theme"
>
<Pencil size={10} />
</button>
<button
class="custom-theme-delete-btn"
onclick={() => {
if (confirm(`Delete theme "${custom.name}"?`)) deleteCustomTheme(custom.id);
}}
title="Delete theme"
>
<Trash size={10} />
</button>
</div>
{#if active}<span class="theme-card-check"></span>{/if}
</div>
{/each}
<!-- New Theme button -->
<button
class="theme-card new-theme-card"
onclick={() => onOpenThemeEditor?.(null)}
title="Create a custom theme"
>
<div class="new-theme-icon">
<Plus size={18} weight="light" />
</div>
<div class="theme-card-info">
<span class="theme-card-label">New Theme</span>
<span class="theme-card-desc">Create custom</span>
</div>
</button>
</div>
</div>
</div>
@@ -755,6 +821,10 @@
<div class="toggle-info"><span class="toggle-label">Page gap</span><span class="toggle-desc">Add spacing between pages in longstrip mode</span></div>
<button role="switch" aria-checked={store.settings.pageGap} aria-label="Page gap" class="toggle" class:on={store.settings.pageGap} onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}><span class="toggle-thumb"></span></button>
</label>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Overlay bars</span><span class="toggle-desc">Top and bottom bars float over the page instead of pushing it</span></div>
<button role="switch" aria-checked={store.settings.overlayBars ?? false} aria-label="Overlay bars" class="toggle" class:on={store.settings.overlayBars ?? false} onclick={() => updateSettings({ overlayBars: !(store.settings.overlayBars ?? false) })}><span class="toggle-thumb"></span></button>
</label>
</div>
<div class="section">
<p class="section-title">Fit &amp; Zoom</p>
@@ -1894,4 +1964,60 @@
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { transform: scale(0.97); opacity: 0 } to { transform: scale(1); opacity: 1 } }
/* ── Custom theme cards ─────────────────────────────────────────────── */
.custom-theme-card {
position: relative;
display: flex; flex-direction: column;
padding: 0; cursor: default;
}
.custom-theme-select {
flex: 1; text-align: left; cursor: pointer;
display: flex; flex-direction: column;
background: none; border: none; color: inherit;
font-family: inherit;
}
.custom-badge {
color: var(--accent-fg) !important;
}
.custom-theme-actions {
display: none;
position: absolute; top: 5px; left: 5px;
flex-direction: row; gap: 3px;
z-index: 1;
}
.custom-theme-card:hover .custom-theme-actions { display: flex; }
.custom-theme-edit-btn,
.custom-theme-delete-btn {
display: flex; align-items: center; justify-content: center;
width: 20px; height: 20px; border-radius: 3px;
font-size: 10px; cursor: pointer;
border: 1px solid var(--border-base);
background: var(--bg-overlay);
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
}
.custom-theme-edit-btn { color: var(--text-muted); }
.custom-theme-edit-btn:hover { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.custom-theme-delete-btn { color: var(--text-faint); }
.custom-theme-delete-btn:hover { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
/* ── New theme button ───────────────────────────────────────────────── */
.new-theme-card {
display: flex; flex-direction: column;
border-style: dashed !important;
border-color: var(--border-base) !important;
background: transparent !important;
transition: border-color var(--t-base) !important, background var(--t-base) !important;
}
.new-theme-card:hover {
border-color: var(--accent-dim) !important;
background: var(--accent-muted) !important;
}
.new-theme-icon {
height: 70px; display: flex; align-items: center; justify-content: center;
color: var(--text-faint);
transition: color var(--t-base);
}
.new-theme-card:hover .new-theme-icon { color: var(--accent-fg); }
</style>
+585
View File
@@ -0,0 +1,585 @@
<script lang="ts">
import { X, FloppyDisk, UploadSimple, DownloadSimple, ArrowLeft, Trash } from "phosphor-svelte";
import {
store, updateSettings, saveCustomTheme, deleteCustomTheme,
type CustomTheme, type ThemeTokens, DEFAULT_THEME_TOKENS,
} from "../../store/state.svelte";
interface Props {
editingId?: string | null;
onClose: () => void;
}
let { editingId = $bindable(null), onClose }: Props = $props();
// ── Token group definitions ───────────────────────────────────────────────
const TOKEN_GROUPS: { label: string; tokens: (keyof ThemeTokens)[] }[] = [
{
label: "Backgrounds",
tokens: ["bg-void", "bg-base", "bg-surface", "bg-raised", "bg-overlay", "bg-subtle"],
},
{
label: "Borders",
tokens: ["border-dim", "border-base", "border-strong", "border-focus"],
},
{
label: "Text",
tokens: ["text-primary", "text-secondary", "text-muted", "text-faint", "text-disabled"],
},
{
label: "Accent",
tokens: ["accent", "accent-dim", "accent-muted", "accent-fg", "accent-bright"],
},
{
label: "Semantic",
tokens: ["color-error", "color-error-bg", "color-success", "color-info", "color-info-bg"],
},
];
const TOKEN_LABELS: Record<keyof ThemeTokens, string> = {
"bg-void": "Void (deepest bg)",
"bg-base": "Base",
"bg-surface": "Surface",
"bg-raised": "Raised",
"bg-overlay": "Overlay",
"bg-subtle": "Subtle",
"border-dim": "Dim border",
"border-base": "Base border",
"border-strong": "Strong border",
"border-focus": "Focus ring",
"text-primary": "Primary text",
"text-secondary": "Secondary text",
"text-muted": "Muted text",
"text-faint": "Faint text",
"text-disabled": "Disabled text",
"accent": "Accent",
"accent-dim": "Accent dim",
"accent-muted": "Accent muted",
"accent-fg": "Accent foreground",
"accent-bright": "Accent bright",
"color-error": "Error",
"color-error-bg": "Error background",
"color-success": "Success",
"color-info": "Info",
"color-info-bg": "Info background",
};
// ── State ─────────────────────────────────────────────────────────────────
function loadInitial(): { name: string; tokens: ThemeTokens } {
if (editingId) {
const existing = store.settings.customThemes.find(t => t.id === editingId);
if (existing) return { name: existing.name, tokens: { ...existing.tokens } };
}
return { name: "My Theme", tokens: { ...DEFAULT_THEME_TOKENS } };
}
const initial = loadInitial();
let themeName: string = $state(initial.name);
let tokens: ThemeTokens = $state(initial.tokens);
let saveStatus: "idle" | "saved" = $state("idle");
let importError: string | null = $state(null);
// ── CSS vars helper ───────────────────────────────────────────────────────
function toCssVars(t: ThemeTokens): string {
return Object.entries(t).map(([k, v]) => `--${k}: ${v};`).join(" ");
}
// ── Actions ───────────────────────────────────────────────────────────────
function handleSave() {
const name = themeName.trim() || "Untitled Theme";
const id = editingId ?? `custom:${Math.random().toString(36).slice(2, 10)}`;
const theme: CustomTheme = { id, name, tokens: { ...tokens } };
saveCustomTheme(theme);
updateSettings({ theme: id });
editingId = id;
saveStatus = "saved";
setTimeout(() => (saveStatus = "idle"), 1800);
}
function handleDelete() {
if (!editingId) { onClose(); return; }
if (!confirm(`Delete theme "${themeName}"? This cannot be undone.`)) return;
deleteCustomTheme(editingId);
onClose();
}
function handleExport() {
const data: CustomTheme = {
id: editingId ?? "custom:export",
name: themeName.trim() || "Untitled Theme",
tokens: { ...tokens },
};
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${data.name.replace(/[^a-z0-9]/gi, "-").toLowerCase()}-theme.json`;
a.click();
URL.revokeObjectURL(url);
}
function handleImport() {
const inp = document.createElement("input");
inp.type = "file";
inp.accept = ".json";
inp.onchange = async () => {
const file = inp.files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
if (!data.tokens || typeof data.tokens !== "object") throw new Error("Invalid theme file — missing tokens");
if (typeof data.name === "string") themeName = data.name;
tokens = { ...DEFAULT_THEME_TOKENS, ...data.tokens };
importError = null;
} catch (e: any) {
importError = e.message ?? "Could not parse theme file";
setTimeout(() => (importError = null), 3000);
}
};
inp.click();
}
function resetToDefaults() {
tokens = { ...DEFAULT_THEME_TOKENS };
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
</script>
<svelte:window onkeydown={onKey} />
<!-- ── Main editor ────────────────────────────────────────────────────────────── -->
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="te-backdrop" onclick={onClose} role="presentation">
<div
class="te-shell"
role="dialog"
aria-label="Theme editor"
onclick={(e) => e.stopPropagation()}
>
<!-- ── Header ──────────────────────────────────────────────────────── -->
<header class="te-header">
<div class="te-header-left">
<button class="te-icon-btn" onclick={onClose} title="Close editor">
<ArrowLeft size={14} weight="bold" />
</button>
<input
bind:value={themeName}
class="te-name-input"
placeholder="Theme name"
maxlength={40}
spellcheck={false}
/>
</div>
<div class="te-header-actions">
{#if importError}
<span class="te-import-err">{importError}</span>
{/if}
<button class="te-action-btn" onclick={handleImport} title="Import from JSON">
<UploadSimple size={13} />
<span>Import</span>
</button>
<button class="te-action-btn" onclick={handleExport} title="Export as JSON">
<DownloadSimple size={13} />
<span>Export</span>
</button>
<button class="te-action-btn te-ghost" onclick={resetToDefaults} title="Reset all to dark defaults">
Reset
</button>
{#if editingId}
<button class="te-action-btn te-danger" onclick={handleDelete} title="Delete theme">
<Trash size={13} />
</button>
{/if}
<button class="te-save-btn" class:saved={saveStatus === "saved"} onclick={handleSave}>
<FloppyDisk size={13} />
<span>{saveStatus === "saved" ? "Saved!" : "Save Theme"}</span>
</button>
<button class="te-icon-btn" onclick={onClose} title="Close">
<X size={14} weight="bold" />
</button>
</div>
</header>
<!-- ── Body ───────────────────────────────────────────────────────── -->
<div class="te-body">
<!-- Left: live preview -->
<aside class="te-preview-pane">
<div class="te-pane-label">Live Preview</div>
<!--
FIX 1: toCssVars scoped only to this element, so only the
preview UI sees the draft tokens — not the editor shell.
-->
<div class="te-preview-ui" style={toCssVars(tokens)}>
<!-- Sidebar -->
<div class="prv-sidebar">
{#each [true, false, false, false] as active}
<div class="prv-sb-dot" class:active></div>
{/each}
</div>
<!-- Main -->
<div class="prv-main">
<div class="prv-titlebar">
<div class="prv-win-dots">
<span></span><span></span><span></span>
</div>
<div class="prv-win-title">Moku</div>
</div>
<div class="prv-content">
<div class="prv-row">
<div class="prv-bar" style="width:52px;background:var(--text-secondary);opacity:0.45"></div>
<div class="prv-bar" style="width:18px;background:var(--accent)"></div>
</div>
<div class="prv-grid">
{#each Array(6) as _, i}
<div class="prv-card" class:active-card={i === 0}>
<div class="prv-cover"></div>
<div class="prv-card-line"></div>
</div>
{/each}
</div>
<div class="prv-reader">
<div class="prv-page"></div>
</div>
<div class="prv-toast">
<div class="prv-toast-dot"></div>
<div class="prv-toast-lines">
<div class="prv-bar" style="width:80%;background:var(--text-secondary)"></div>
<div class="prv-bar" style="width:55%;background:var(--text-faint);margin-top:3px"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Swatch strip — scoped to draft tokens too -->
<div class="te-swatches" style={toCssVars(tokens)}>
{#each [
["bg-base","bg-base"],["bg-surface","bg-surface"],
["accent","accent"],["accent-fg","accent-fg"],
["text-primary","text-primary"],["text-muted","text-muted"],
["color-error","color-error"],
] as [varName, label]}
<div
class="te-swatch"
style="background: var(--{varName})"
title={label}
></div>
{/each}
</div>
</aside>
<!-- Right: token editor -->
<div class="te-editor-pane">
{#each TOKEN_GROUPS as group}
<div class="te-group">
<div class="te-group-label">{group.label}</div>
<div class="te-token-list">
{#each group.tokens as token}
<div class="te-token-row">
<span class="te-color-swatch" style="background: {tokens[token]}"></span>
<span class="te-token-name">{TOKEN_LABELS[token]}</span>
<span class="te-token-key">{token}</span>
<input
type="text"
class="te-hex-input"
value={tokens[token]}
spellcheck={false}
oninput={(e) => {
const v = (e.target as HTMLInputElement).value.trim();
if (/^#[0-9a-fA-F]{3,8}$/.test(v)) tokens = { ...tokens, [token]: v };
}}
onblur={(e) => {
const v = (e.target as HTMLInputElement).value.trim();
if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) {
(e.target as HTMLInputElement).value = tokens[token];
}
}}
/>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
</div>
<style>
/* ── Backdrop ─────────────────────────────────────────────────────────────── */
.te-backdrop {
position: fixed; inset: 0;
background: rgba(0, 0, 0, 0.72);
z-index: 200;
/* FIX 2: center the modal instead of stretch */
display: flex; align-items: center; justify-content: center;
animation: teBackdropIn 0.14s ease both;
}
@keyframes teBackdropIn { from { opacity: 0; } to { opacity: 1; } }
/* ── Shell ────────────────────────────────────────────────────────────────── */
.te-shell {
/* FIX 2: constrained dimensions so it doesn't fill the screen */
width: calc(100% - 48px);
max-width: 1100px;
height: calc(100% - 48px);
max-height: 760px;
display: flex; flex-direction: column;
background: var(--bg-base);
border: 1px solid var(--border-base);
border-radius: 10px;
animation: teShellIn 0.2s cubic-bezier(0.22, 1, 0.36, 1) both;
overflow: hidden;
}
@keyframes teShellIn {
from { transform: translateY(10px) scale(0.99); opacity: 0; }
to { transform: translateY(0) scale(1); opacity: 1; }
}
/* ── Header ───────────────────────────────────────────────────────────────── */
.te-header {
display: flex; align-items: center; justify-content: space-between;
gap: 12px; padding: 0 16px; height: 46px;
border-bottom: 1px solid var(--border-dim);
background: var(--bg-surface);
flex-shrink: 0;
}
.te-header-left {
display: flex; align-items: center; gap: 8px; flex: 1; min-width: 0;
}
.te-icon-btn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: 5px;
color: var(--text-muted);
transition: color 0.1s, background 0.1s;
flex-shrink: 0;
}
.te-icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
.te-name-input {
flex: 1; min-width: 0;
background: none; border: none; outline: none;
font-family: var(--font-sans); font-size: 13px; font-weight: 500;
color: var(--text-primary);
border-bottom: 1px solid transparent;
padding: 3px 0;
transition: border-color 0.12s;
}
.te-name-input:focus { border-color: var(--border-focus); }
.te-name-input::placeholder { color: var(--text-faint); }
.te-header-actions {
display: flex; align-items: center; gap: 6px; flex-shrink: 0;
}
.te-import-err {
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.04em;
color: var(--color-error); flex-shrink: 0;
}
.te-action-btn {
display: flex; align-items: center; gap: 5px;
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.06em;
padding: 4px 10px; border-radius: 4px;
border: 1px solid var(--border-dim);
background: none; color: var(--text-muted);
cursor: pointer; flex-shrink: 0;
transition: color 0.1s, border-color 0.1s, background 0.1s;
}
.te-action-btn:hover { color: var(--text-secondary); border-color: var(--border-strong); }
.te-ghost { border-color: transparent; }
.te-ghost:hover { border-color: var(--border-dim); }
.te-danger { color: var(--color-error); border-color: transparent; }
.te-danger:hover { background: var(--color-error-bg); border-color: var(--color-error); }
.te-save-btn {
display: flex; align-items: center; gap: 5px;
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.06em;
padding: 5px 14px; border-radius: 4px;
border: 1px solid var(--accent-dim);
background: var(--accent-muted); color: var(--accent-fg);
cursor: pointer; flex-shrink: 0;
transition: filter 0.1s, background 0.12s;
}
.te-save-btn:hover { filter: brightness(1.12); }
.te-save-btn.saved { background: var(--accent-dim); border-color: var(--accent); }
/* ── Body ─────────────────────────────────────────────────────────────────── */
.te-body { flex: 1; overflow: hidden; display: flex; min-height: 0; }
/* ── Preview pane ─────────────────────────────────────────────────────────── */
.te-preview-pane {
width: 260px; flex-shrink: 0;
border-right: 1px solid var(--border-dim);
background: var(--bg-void);
display: flex; flex-direction: column;
padding: 16px; gap: 12px;
}
.te-pane-label {
font-family: var(--font-ui); font-size: 10px;
letter-spacing: 0.1em; text-transform: uppercase;
color: var(--text-faint);
flex-shrink: 0;
}
/* te-preview-ui receives draft CSS vars via inline style */
.te-preview-ui {
flex: 1; min-height: 0;
border-radius: 8px; overflow: hidden;
border: 1px solid var(--border-base);
display: flex; background: var(--bg-void);
}
/* Sidebar strip */
.prv-sidebar {
width: 34px; flex-shrink: 0;
background: var(--bg-surface);
border-right: 1px solid var(--border-dim);
display: flex; flex-direction: column;
align-items: center; padding: 12px 0; gap: 9px;
}
.prv-sb-dot {
width: 10px; height: 10px; border-radius: 50%;
background: var(--text-faint); opacity: 0.4;
transition: background 0.15s, opacity 0.15s;
}
.prv-sb-dot.active { background: var(--accent); opacity: 1; }
.prv-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.prv-titlebar {
height: 26px; flex-shrink: 0;
background: var(--bg-raised);
border-bottom: 1px solid var(--border-dim);
display: flex; align-items: center; padding: 0 8px; gap: 7px;
}
.prv-win-dots { display: flex; gap: 4px; }
.prv-win-dots span { width: 6px; height: 6px; border-radius: 50%; background: var(--border-strong); }
.prv-win-title { font-family: var(--font-ui); font-size: 9px; letter-spacing: 0.1em; color: var(--text-faint); }
.prv-content {
flex: 1; overflow: hidden;
padding: 8px; display: flex; flex-direction: column; gap: 7px;
background: var(--bg-base);
}
.prv-row { display: flex; align-items: center; gap: 6px; flex-shrink: 0; }
.prv-bar { height: 3px; border-radius: 2px; }
.prv-grid {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 4px; flex-shrink: 0;
}
.prv-card {
border-radius: 4px; border: 1px solid var(--border-dim);
background: var(--bg-raised); overflow: hidden;
transition: border-color 0.15s;
}
.prv-card.active-card { border-color: var(--accent); }
.prv-cover { height: 34px; background: var(--bg-overlay); }
.prv-card-line { height: 3px; margin: 4px 4px; border-radius: 2px; background: var(--text-faint); opacity: 0.5; }
.prv-reader {
flex: 1; min-height: 0;
border-radius: 4px; border: 1px solid var(--border-dim);
background: var(--bg-overlay);
display: flex; align-items: center; justify-content: center;
}
.prv-page { width: 68%; height: 86%; background: var(--bg-subtle); border-radius: 2px; }
.prv-toast {
flex-shrink: 0;
display: flex; align-items: center; gap: 6px;
padding: 6px 8px; border-radius: 5px;
background: var(--bg-overlay); border: 1px solid var(--accent-dim);
}
.prv-toast-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
.prv-toast-lines { flex: 1; }
/* Swatch strip */
.te-swatches { display: flex; gap: 5px; flex-wrap: wrap; flex-shrink: 0; }
.te-swatch {
width: 22px; height: 22px; border-radius: 4px;
border: 1px solid rgba(255,255,255,0.07);
flex-shrink: 0; cursor: default;
}
/* ── Editor pane ──────────────────────────────────────────────────────────── */
.te-editor-pane {
flex: 1; overflow-y: auto;
padding: 16px 20px;
display: flex; flex-direction: column; gap: 22px;
}
.te-editor-pane::-webkit-scrollbar { width: 4px; }
.te-editor-pane::-webkit-scrollbar-track { background: transparent; }
.te-editor-pane::-webkit-scrollbar-thumb {
background: var(--border-strong); border-radius: 9999px;
}
.te-group { display: flex; flex-direction: column; gap: 2px; }
.te-group-label {
font-family: var(--font-ui); font-size: 10px;
letter-spacing: 0.1em; text-transform: uppercase;
color: var(--text-faint);
padding-bottom: 7px; margin-bottom: 4px;
border-bottom: 1px solid var(--border-dim);
}
.te-token-list { display: flex; flex-direction: column; gap: 1px; }
.te-token-row {
display: flex; align-items: center; gap: 10px;
padding: 5px 8px; border-radius: 5px;
transition: background 0.1s;
}
.te-token-row:hover { background: var(--bg-raised); }
.te-color-swatch {
width: 16px; height: 16px; border-radius: 4px;
flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.12);
box-shadow: 0 0 0 1px rgba(0,0,0,0.2);
}
.te-token-name {
flex: 1; font-size: 12px; color: var(--text-secondary);
}
.te-token-key {
font-family: var(--font-ui); font-size: 10px;
letter-spacing: 0.05em; color: var(--text-faint);
flex-shrink: 0; min-width: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
max-width: 160px;
}
.te-hex-input {
width: 82px; flex-shrink: 0;
font-family: var(--font-ui); font-size: 11px; letter-spacing: 0.05em;
color: var(--text-muted);
background: var(--bg-overlay);
border: 1px solid var(--border-dim);
border-radius: 3px; padding: 3px 7px;
outline: none;
transition: border-color 0.1s, color 0.1s;
}
.te-hex-input:focus { border-color: var(--border-focus); color: var(--text-primary); }
</style>
+5 -12
View File
@@ -1,21 +1,14 @@
import { store } from "../store/state.svelte";
const DEFAULT_URL = "http://127.0.0.1:4567";
function getSettings(): Record<string, any> {
try {
const raw = localStorage.getItem("moku-store");
if (raw) return JSON.parse(raw)?.settings ?? {};
} catch {}
return {};
}
function getServerUrl(): string {
const url = getSettings().serverUrl;
if (typeof url === "string" && url.trim()) return url.replace(/\/$/, "");
return DEFAULT_URL;
const url = store.settings.serverUrl;
return typeof url === "string" && url.trim() ? url.replace(/\/$/, "") : DEFAULT_URL;
}
function getAuthHeader(): Record<string, string> {
const s = getSettings();
const s = store.settings;
if (!s.serverAuthEnabled) return {};
const user = typeof s.serverAuthUser === "string" ? s.serverAuthUser.trim() : "";
const pass = typeof s.serverAuthPass === "string" ? s.serverAuthPass.trim() : "";
+90 -1
View File
@@ -7,7 +7,75 @@ export type LibraryFilter = "all" | "library" | "downloaded" | string;
export type NavPage = "home" | "library" | "sources" | "explore" | "downloads" | "extensions" | "history" | "search" | "tracking";
export type ReadingDirection = "ltr" | "rtl";
export type ChapterSortDir = "desc" | "asc";
export type Theme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
export type BuiltinTheme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
export type Theme = BuiltinTheme | string; // custom themes have string IDs like "custom:abc123"
export interface ThemeTokens {
/* Backgrounds */
"bg-void": string;
"bg-base": string;
"bg-surface": string;
"bg-raised": string;
"bg-overlay": string;
"bg-subtle": string;
/* Borders */
"border-dim": string;
"border-base": string;
"border-strong": string;
"border-focus": string;
/* Text */
"text-primary": string;
"text-secondary": string;
"text-muted": string;
"text-faint": string;
"text-disabled": string;
/* Accent */
"accent": string;
"accent-dim": string;
"accent-muted": string;
"accent-fg": string;
"accent-bright": string;
/* Semantic */
"color-error": string;
"color-error-bg": string;
"color-success": string;
"color-info": string;
"color-info-bg": string;
}
export interface CustomTheme {
id: string; // "custom:abc123"
name: string;
tokens: ThemeTokens;
}
export const DEFAULT_THEME_TOKENS: ThemeTokens = {
"bg-void": "#080808",
"bg-base": "#0c0c0c",
"bg-surface": "#101010",
"bg-raised": "#151515",
"bg-overlay": "#1a1a1a",
"bg-subtle": "#202020",
"border-dim": "#1c1c1c",
"border-base": "#242424",
"border-strong": "#2e2e2e",
"border-focus": "#4a5c4a",
"text-primary": "#f0efec",
"text-secondary": "#c8c6c0",
"text-muted": "#8a8880",
"text-faint": "#4e4d4a",
"text-disabled": "#2a2a28",
"accent": "#6b8f6b",
"accent-dim": "#2a3d2a",
"accent-muted": "#1a251a",
"accent-fg": "#a8c4a8",
"accent-bright": "#8fb88f",
"color-error": "#c47a7a",
"color-error-bg": "#1f1212",
"color-success": "#7aab7a",
"color-info": "#7a9ec4",
"color-info-bg": "#121a1f",
};
export const COMPLETED_FOLDER_ID = "completed";
@@ -111,6 +179,7 @@ export interface Settings {
folders: Folder[];
markReadOnNext: boolean;
readerDebounceMs: number;
overlayBars: boolean;
theme: Theme;
libraryBranches: boolean;
renderLimit: number;
@@ -133,6 +202,7 @@ export interface Settings {
flareSolverrFallback: boolean;
appLockEnabled: boolean;
appLockPin: string;
customThemes: CustomTheme[];
}
const COMPLETED_FOLDER_DEFAULT: Folder = {
@@ -173,6 +243,7 @@ export const DEFAULT_SETTINGS: Settings = {
folders: [COMPLETED_FOLDER_DEFAULT],
markReadOnNext: true,
readerDebounceMs: 120,
overlayBars: false,
theme: "dark",
libraryBranches: true,
renderLimit: 48,
@@ -195,6 +266,7 @@ export const DEFAULT_SETTINGS: Settings = {
flareSolverrFallback: false,
appLockEnabled: false,
appLockPin: "",
customThemes: [],
};
// ── Persistence ───────────────────────────────────────────────────────────────
@@ -258,6 +330,7 @@ function mergeSettings(saved: any): Settings {
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null],
mangaLinks: saved?.settings?.mangaLinks ?? {},
customThemes: saved?.settings?.customThemes ?? [],
};
}
@@ -549,6 +622,20 @@ class Store {
return this.settings.folders.filter(f => f.mangaIds.includes(mangaId));
}
saveCustomTheme(theme: CustomTheme) {
const existing = this.settings.customThemes.findIndex(t => t.id === theme.id);
const next = existing >= 0
? this.settings.customThemes.map((t, i) => i === existing ? theme : t)
: [...this.settings.customThemes, theme];
this.settings = { ...this.settings, customThemes: next };
}
deleteCustomTheme(id: string) {
const next = this.settings.customThemes.filter(t => t.id !== id);
const wasActive = this.settings.theme === id;
this.settings = { ...this.settings, customThemes: next, theme: wasActive ? "dark" : this.settings.theme };
}
clearDiscoverCache() {
this.discoverCache = new Map();
this.discoverLibraryIds = new Set();
@@ -598,3 +685,5 @@ export function assignMangaToFolder(folderId: string, mangaId: number) { store
export function removeMangaFromFolder(folderId: string, mangaId: number) { store.removeMangaFromFolder(folderId, mangaId); }
export function getMangaFolders(mangaId: number) { return store.getMangaFolders(mangaId); }
export function clearDiscoverCache() { store.clearDiscoverCache(); }
export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); }
export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); }