mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Fix: SplashScreen Default
This commit is contained in:
+115
-53
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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 & 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>
|
||||
|
||||
@@ -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
@@ -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() : "";
|
||||
|
||||
@@ -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); }
|
||||
|
||||
Reference in New Issue
Block a user