diff --git a/dev.moku.app.yml b/dev.moku.app.yml index 8ca9da4..6207be6 100644 --- a/dev.moku.app.yml +++ b/dev.moku.app.yml @@ -181,7 +181,7 @@ modules: path: . - type: file path: packaging/frontend-dist.tar.gz - sha256: bceb301e2cf8c20576d910294bb7fd94ea9dab82e7921c7a32f81072a2654c75 + sha256: cb2f65bad39db8d7411b15383965812fdd02c02697431e4eb3f3f05281eac49d - packaging/cargo-sources.json - type: inline dest: src-tauri/.cargo diff --git a/src/App.svelte b/src/App.svelte index c8e1b69..72321be 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -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(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>("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 = 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("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

("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; + } {#if devSplash} @@ -220,7 +275,8 @@ appReady = true} - onRetry={handleRetry} /> + onRetry={handleRetry} + onBypass={handleBypass} /> {:else}

{#if idle && !store.activeChapter} @@ -231,7 +287,13 @@
{#if store.activeChapter}{:else}{/if}
- {#if store.settingsOpen}{/if} + {#if store.settingsOpen}{/if} + {#if themeEditorOpen} + + {/if}
diff --git a/src/components/layout/SplashScreen.svelte b/src/components/layout/SplashScreen.svelte index c5739a3..5e63c18 100644 --- a/src/components/layout/SplashScreen.svelte +++ b/src/components/layout/SplashScreen.svelte @@ -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 @@ {:else} -
{#if !failed && !notConfigured}

moku

-
- -
- {#if notConfigured} + {#if failed || notConfigured}
-

Server not configured

-

Set the server path in Settings, then retry

-
- - +

+ {failed ? "Could not reach server" : "Server not configured"} +

+
+ +
- {:else if failed} -
-

Could not reach Suwayomi

-

Make sure tachidesk-server is on your PATH

- -
{:else}

{ringFull ? "" : `Initializing server${dots}`}

{/if}
- {#if lockEnabled}
@@ -426,7 +408,6 @@
{/if} -
{/if}
@@ -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; } diff --git a/src/components/reader/Reader.svelte b/src/components/reader/Reader.svelte index 1e4f0b8..b6a7921 100644 --- a/src/components/reader/Reader.svelte +++ b/src/components/reader/Reader.svelte @@ -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 @@ }); -