Chore: Finalized Svelte-5 Rewrite (Testing Phase)

This commit is contained in:
Youwes09
2026-03-20 15:58:35 -05:00
parent 96bac1ad2b
commit 4903b066b1
26 changed files with 1460 additions and 1512 deletions
+24 -20
View File
@@ -4,7 +4,7 @@
import { listen } from "@tauri-apps/api/event";
import { gql } from "./lib/client";
import { GET_DOWNLOAD_STATUS } from "./lib/queries";
import { activeChapter, settingsOpen, settings, activeDownloads, addToast } from "./store";
import { store, addToast, setActiveDownloads } from "./store/state.svelte";
import type { DownloadStatus, DownloadQueueItem } from "./lib/types";
import Layout from "./components/layout/Layout.svelte";
import Reader from "./components/reader/Reader.svelte";
@@ -16,8 +16,8 @@
const MAX_ATTEMPTS = 30;
let serverProbeOk = $state(!settings.autoStartServer);
let appReady = $state(!settings.autoStartServer);
let serverProbeOk = $state(!store.settings.autoStartServer);
let appReady = $state(!store.settings.autoStartServer);
let failed = $state(false);
let idle = $state(false);
let devSplash = $state(false);
@@ -42,15 +42,15 @@
function applyQueue(next: DownloadQueueItem[]) {
detectCompletions(prevQueue, next);
prevQueue = next;
activeDownloads = next.map(item => ({
setActiveDownloads(next.map(item => ({
chapterId: item.chapter.id, mangaId: item.chapter.mangaId, progress: item.progress,
}));
})));
}
function resetIdle() {
if (idle) return;
if (idleTimer) clearTimeout(idleTimer);
const ms = (settings.idleTimeoutMin ?? 5) * 60 * 1000;
const ms = (store.settings.idleTimeoutMin ?? 5) * 60 * 1000;
if (ms === 0) return;
idleTimer = setTimeout(() => idle = true, ms);
}
@@ -65,11 +65,15 @@
});
$effect(() => {
document.documentElement.style.zoom = `${settings.uiScale * 1.5}%`;
const scale = store.settings.uiScale * 1.5;
document.documentElement.style.zoom = `${scale}%`;
document.documentElement.style.setProperty("--ui-scale", String(scale));
// --visual-vh gives true viewport height independent of zoom
document.documentElement.style.setProperty("--visual-vh", `${window.innerHeight / (scale / 100)}px`);
});
$effect(() => {
document.documentElement.setAttribute("data-theme", settings.theme ?? "dark");
document.documentElement.setAttribute("data-theme", store.settings.theme ?? "dark");
});
$effect(() => {
@@ -85,8 +89,8 @@
document.addEventListener("contextmenu", e => e.preventDefault());
(window as any).__mokuShowSplash = () => devSplash = true;
if (settings.autoStartServer) {
invoke("spawn_server", { binary: settings.serverBinary }).catch(err =>
if (store.settings.autoStartServer) {
invoke("spawn_server", { binary: store.settings.serverBinary }).catch(err =>
console.warn("Could not start server:", err));
}
@@ -96,7 +100,7 @@
if (cancelled) return;
tries++;
try {
const res = await fetch(`${settings.serverUrl}/api/graphql`, {
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),
@@ -110,10 +114,10 @@
}
type P = { chapterId: number; mangaId: number; progress: number }[];
unlistenDownload = await listen<P>("download-progress", e => { activeDownloads = e.payload; });
unlistenDownload = await listen<P>("download-progress", e => { setActiveDownloads(e.payload); });
return () => {
if (settings.autoStartServer) invoke("kill_server").catch(() => {});
if (store.settings.autoStartServer) invoke("kill_server").catch(() => {});
if (idleTimer) clearTimeout(idleTimer);
if (pollInterval) clearInterval(pollInterval);
unlistenDownload?.();
@@ -125,24 +129,24 @@
</script>
{#if devSplash}
<SplashScreen mode="idle" showFps showCards={settings.splashCards ?? true}
<SplashScreen mode="idle" showFps showCards={store.settings.splashCards ?? true}
onDismiss={() => setTimeout(() => devSplash = false, 340)} />
{:else if !appReady}
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed}
showCards={settings.splashCards ?? true}
showCards={store.settings.splashCards ?? true}
onReady={() => appReady = true}
onRetry={handleRetry} />
{:else}
<div class="root">
{#if idle && !activeChapter}
<SplashScreen mode="idle" showCards={settings.splashCards ?? true}
{#if idle && !store.activeChapter}
<SplashScreen mode="idle" showCards={store.settings.splashCards ?? true}
onDismiss={() => setTimeout(() => idle = false, 340)} />
{/if}
{#if !activeChapter}<TitleBar />{/if}
{#if !store.activeChapter}<TitleBar />{/if}
<div class="content">
{#if activeChapter}<Reader />{:else}<Layout />{/if}
{#if store.activeChapter}<Reader />{:else}<Layout />{/if}
</div>
{#if settingsOpen}<Settings />{/if}
{#if store.settingsOpen}<Settings />{/if}
<MangaPreview />
<Toaster />
</div>
+21 -18
View File
@@ -1,34 +1,37 @@
<script lang="ts">
import { navPage, activeManga } from "../../store";
import Sidebar from "./Sidebar.svelte";
import Home from "../pages/Home.svelte";
import Library from "../pages/Library.svelte";
import SeriesDetail from "../pages/SeriesDetail.svelte";
import History from "../pages/History.svelte";
import Search from "../pages/Search.svelte";
import Discover from "../pages/Discover.svelte";
import Downloads from "../pages/Downloads.svelte";
import Extensions from "../pages/Extensions.svelte";
import { store } from "../../store/state.svelte";
import Sidebar from "./Sidebar.svelte";
import Home from "../pages/Home.svelte";
import Library from "../pages/Library.svelte";
import SeriesDetail from "../pages/SeriesDetail.svelte";
import History from "../pages/History.svelte";
import Search from "../pages/Search.svelte";
import Discover from "../pages/Discover.svelte";
import GenreDrillPage from "../pages/GenreDrillPage.svelte";
import Downloads from "../pages/Downloads.svelte";
import Extensions from "../pages/Extensions.svelte";
</script>
<div class="root">
<Sidebar />
<main class="main">
{#if activeManga}
{#if store.activeManga}
<SeriesDetail />
{:else if navPage === "home"}
{:else if store.navPage === "home"}
<Home />
{:else if navPage === "library"}
{:else if store.navPage === "library"}
<Library />
{:else if navPage === "search"}
{:else if store.navPage === "search"}
<Search />
{:else if navPage === "history"}
{:else if store.navPage === "history"}
<History />
{:else if navPage === "explore" || navPage === "sources"}
{:else if (store.navPage === "explore" || store.navPage === "sources") && store.genreFilter}
<GenreDrillPage />
{:else if store.navPage === "explore" || store.navPage === "sources"}
<Discover />
{:else if navPage === "downloads"}
{:else if store.navPage === "downloads"}
<Downloads />
{:else if navPage === "extensions"}
{:else if store.navPage === "extensions"}
<Extensions />
{:else}
<Home />
+51 -57
View File
@@ -1,13 +1,12 @@
<script lang="ts">
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, Fire, BookOpen, Clock, TrendUp } from "phosphor-svelte";
import { thumbUrl } from "../../lib/client";
import { history, readingStats, clearHistory, activeManga, activeChapterList, openReader } from "../../store";
import type { HistoryEntry } from "../../store";
import { store, clearHistory, openReader, setActiveManga } from "../../store/state.svelte";
import type { HistoryEntry } from "../../store/state.svelte";
let search = "";
let confirmClear = false;
let search = $state("");
let confirmClear = $state(false);
// ── Helpers ───────────────────────────────────────────────────────────────────
function timeAgo(ts: number): string {
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
if (m < 1) return "Just now";
@@ -34,13 +33,18 @@
return r === 0 ? `${h}h` : `${h}h ${r}m`;
}
// ── Session grouping — collapses rapid same-manga reads ───────────────────────
const SESSION_GAP_MS = 30 * 60 * 1000;
interface Session {
mangaId: number; mangaTitle: string; thumbnailUrl: string;
latestChapterId: number; latestChapterName: string; latestPageNumber: number;
firstChapterName: string; chapterCount: number; readAt: number;
mangaId: number;
mangaTitle: string;
thumbnailUrl: string;
latestChapterId: number;
latestChapterName: string;
latestPageNumber: number;
firstChapterName: string;
chapterCount: number;
readAt: number;
}
function buildSessions(entries: HistoryEntry[]): Session[] {
@@ -53,28 +57,37 @@
let j = i + 1;
while (j < entries.length) {
const next = entries[j];
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) { group.push(next); j++; }
else break;
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) {
group.push(next); j++;
} else break;
}
const latest = group[0], oldest = group[group.length - 1];
sessions.push({
mangaId: latest.mangaId, mangaTitle: latest.mangaTitle, thumbnailUrl: latest.thumbnailUrl,
latestChapterId: latest.chapterId, latestChapterName: latest.chapterName,
latestPageNumber: latest.pageNumber, firstChapterName: oldest.chapterName,
chapterCount: group.length, readAt: latest.readAt,
mangaId: latest.mangaId,
mangaTitle: latest.mangaTitle,
thumbnailUrl: latest.thumbnailUrl,
latestChapterId: latest.chapterId,
latestChapterName: latest.chapterName,
latestPageNumber: latest.pageNumber,
firstChapterName: oldest.chapterName,
chapterCount: group.length,
readAt: latest.readAt,
});
i = j;
}
return sessions;
}
$: filtered = search.trim()
? $history.filter(e => e.mangaTitle.toLowerCase().includes(search.toLowerCase()) || e.chapterName.toLowerCase().includes(search.toLowerCase()))
: $history;
const filtered = $derived(search.trim()
? store..filter((e) =>
e.mangaTitle.toLowerCase().includes(search.toLowerCase()) ||
e.chapterName.toLowerCase().includes(search.toLowerCase())
)
: store.);
$: sessions = buildSessions(filtered);
const sessions = $derived(buildSessions(filtered));
$: groups = (() => {
const groups = $derived.by(() => {
const map = new Map<string, Session[]>();
for (const s of sessions) {
const l = dayLabel(s.readAt);
@@ -82,12 +95,12 @@
map.get(l)!.push(s);
}
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
})();
});
function resume(session: Session) {
const ch = $activeChapterList.find(c => c.id === session.latestChapterId);
if (ch && $activeChapterList.length > 0) openReader(ch, $activeChapterList);
else activeManga.set({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
const ch = store..find((c) => c.id === session.latestChapterId);
if (ch && store..length > 0) openReader(ch, );
else setActiveManga({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
}
function handleClear() {
@@ -98,18 +111,17 @@
<div class="root">
<!-- ── Header ──────────────────────────────────────────────────────────────── -->
<div class="page-header">
<span class="heading">History</span>
<div class="header-right">
<div class="search-wrap">
<MagnifyingGlass size={12} class="search-icon" weight="light" />
<input class="search" placeholder="Search history…" bind:value={search} />
{#if search}<button class="search-clear" on:click={() => search = ""}>×</button>{/if}
<input class="search" placeholder="Search store.…" bind:value={search} />
{#if search}<button class="search-clear" onclick={() => search = ""}>×</button>{/if}
</div>
{#if $history.length > 0}
<button class="clear-btn" class:confirm={confirmClear} on:click={handleClear}
title={confirmClear ? "Click again to confirm" : "Clear history feed"}>
{#if store..length > 0}
<button class="clear-btn" class:confirm={confirmClear} onclick={handleClear}
title={confirmClear ? "Click again to confirm" : "Clear store. feed"}>
<Trash size={14} weight="light" />
{#if confirmClear}<span class="clear-label">Confirm?</span>{/if}
</button>
@@ -117,46 +129,44 @@
</div>
</div>
<!-- ── Persistent stats bar — never cleared ────────────────────────────────── -->
{#if $readingStats.totalChaptersRead > 0}
{#if store..totalChaptersRead > 0}
<div class="stats-bar">
<div class="stat-group">
<Fire size={13} weight="fill" class="stat-fire" />
<span class="stat-val accent">{$readingStats.currentStreakDays}</span>
<span class="stat-val accent">{store..currentStreakDays}</span>
<span class="stat-label">day streak</span>
</div>
<div class="stat-sep"></div>
<div class="stat-group">
<BookOpen size={13} weight="light" class="stat-icon-neutral" />
<span class="stat-val">{$readingStats.totalChaptersRead}</span>
<span class="stat-val">{store..totalChaptersRead}</span>
<span class="stat-label">chapters</span>
</div>
<div class="stat-sep"></div>
<div class="stat-group">
<Clock size={13} weight="light" class="stat-icon-neutral" />
<span class="stat-val">{formatReadTime($readingStats.totalMinutesRead)}</span>
<span class="stat-val">{formatReadTime(store..totalMinutesRead)}</span>
<span class="stat-label">read time</span>
</div>
<div class="stat-sep"></div>
<div class="stat-group">
<TrendUp size={13} weight="light" class="stat-icon-neutral" />
<span class="stat-val">{$readingStats.totalMangaRead}</span>
<span class="stat-val">{store..totalMangaRead}</span>
<span class="stat-label">series</span>
</div>
<div class="stat-sep"></div>
<div class="stat-group">
<span class="stat-val muted">{$readingStats.longestStreakDays}d</span>
<span class="stat-val muted">{store..longestStreakDays}d</span>
<span class="stat-label">best streak</span>
</div>
<span class="stats-note">Stats are preserved when you clear the feed</span>
</div>
{/if}
<!-- ── Empty states ────────────────────────────────────────────────────────── -->
{#if $history.length === 0}
{#if store..length === 0}
<div class="empty">
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
<p class="empty-text">No reading history</p>
<p class="empty-text">No reading store.</p>
<p class="empty-hint">Chapters you read will appear here</p>
</div>
{:else if sessions.length === 0}
@@ -164,8 +174,6 @@
<Books size={28} weight="light" class="empty-icon" />
<p class="empty-text">No results for "{search}"</p>
</div>
<!-- ── Timeline ────────────────────────────────────────────────────────────── -->
{:else}
<div class="timeline">
{#each groups as { label, items }}
@@ -176,17 +184,13 @@
</div>
<div class="session-list">
{#each items as session (session.latestChapterId)}
<button class="session-row" on:click={() => resume(session)}>
<!-- Cover -->
<button class="session-row" onclick={() => resume(session)}>
<div class="thumb-wrap">
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} class="thumb" />
{#if session.chapterCount > 1}
<span class="session-count">{session.chapterCount}</span>
{/if}
</div>
<!-- Info -->
<div class="session-info">
<span class="session-title">{session.mangaTitle}</span>
<span class="session-chapter">
@@ -202,13 +206,10 @@
{/if}
</span>
</div>
<!-- Time + play -->
<span class="session-time">{timeAgo(session.readAt)}</span>
<div class="play-pill">
<Play size={10} weight="fill" /> Resume
</div>
</button>
{/each}
</div>
@@ -222,7 +223,6 @@
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
/* ── Header ──────────────────────────────────────────────────────────────── */
.page-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
@@ -250,7 +250,6 @@
.clear-btn.confirm { color: var(--color-error); background: var(--color-error-bg); border-color: var(--color-error); }
.clear-label { font-size: var(--text-2xs); }
/* ── Stats bar — persisted, never clears ────────────────────────────────── */
.stats-bar {
display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap;
padding: var(--sp-3) var(--sp-6); border-bottom: 1px solid var(--border-dim);
@@ -266,7 +265,6 @@
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.stats-note { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.5; letter-spacing: var(--tracking-wide); font-style: italic; }
/* ── Timeline ────────────────────────────────────────────────────────────── */
.timeline { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
.day-group { margin-bottom: var(--sp-5); }
@@ -285,7 +283,6 @@
.session-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.session-row:hover .play-pill { opacity: 1; transform: translateX(0); }
/* Thumb */
.thumb-wrap { position: relative; flex-shrink: 0; }
.thumb { width: 38px; height: 54px; border-radius: var(--radius-sm); object-fit: cover; display: block; background: var(--bg-raised); border: 1px solid var(--border-dim); }
.session-count {
@@ -295,14 +292,12 @@
padding: 1px 4px; border-radius: 6px; line-height: 1.4; pointer-events: none;
}
/* Info */
.session-info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
.session-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.session-chapter { font-size: var(--text-xs); color: var(--text-muted); display: flex; align-items: center; gap: var(--sp-1); min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ch-arrow { color: var(--text-faint); font-size: 10px; flex-shrink: 0; }
.ch-page { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
/* Time & play */
.session-time { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
.play-pill {
display: flex; align-items: center; gap: 4px; flex-shrink: 0;
@@ -313,7 +308,6 @@
transition: opacity var(--t-base), transform var(--t-base);
}
/* ── Empty ───────────────────────────────────────────────────────────────── */
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); }
:global(.empty-icon) { color: var(--text-faint); }
.empty-text { font-size: var(--text-base); color: var(--text-muted); }
+13 -13
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import { House, Books, MagnifyingGlass, ClockCounterClockwise, Compass, DownloadSimple, PuzzlePiece, GearSix } from "phosphor-svelte";
import { navPage, activeManga, activeSource, libraryFilter, genreFilter, settingsOpen } from "../../store";
import type { NavPage } from "../../store";
import { store, setNavPage, setActiveManga, setActiveSource, setLibraryFilter, setGenreFilter, setSettingsOpen } from "../../store/state.svelte";
import type { NavPage } from "../../store/state.svelte";
const TABS: { id: NavPage; label: string; icon: any }[] = [
{ id: "home", label: "Home", icon: House },
@@ -14,18 +14,18 @@
];
function navigate(id: NavPage) {
navPage = id;
activeManga = null;
genreFilter = "";
if (id !== "explore") activeSource = null;
store.navPage = id;
store.activeManga = null;
store.genreFilter = "";
if (id !== "explore") store.activeSource = null;
}
function goHome() {
navPage = "home";
activeSource = null;
activeManga = null;
libraryFilter = "library";
genreFilter = "";
store.navPage = "home";
store.activeSource = null;
store.activeManga = null;
store.libraryFilter = "library";
store.genreFilter = "";
}
</script>
@@ -35,14 +35,14 @@
</button>
<nav class="nav">
{#each TABS as tab}
<button class="tab" class:active={navPage === tab.id}
<button class="tab" class:active={store.navPage === tab.id}
title={tab.label} onclick={() => navigate(tab.id)}>
<tab.icon size={18} weight="light" />
</button>
{/each}
</nav>
<div class="bottom">
<button class="settings-btn" onclick={() => settingsOpen = true} title="Settings">
<button class="settings-btn" onclick={() => store.settingsOpen = true} title="Settings">
<GearSix size={18} weight="light" />
</button>
</div>
+1 -1
View File
@@ -24,7 +24,7 @@
let exiting = $state(false);
let exitLock = false;
let fpsEl: HTMLSpanElement;
let fpsEl = $state<HTMLSpanElement | undefined>(undefined);
function triggerExit(cb?: () => void) {
if (exitLock) return;
+3 -3
View File
@@ -6,17 +6,17 @@
<div class="bar" data-tauri-drag-region>
<span class="title" data-tauri-drag-region>Moku</span>
<div class="controls">
<button on:click={() => win.minimize()} title="Minimize" aria-label="Minimize">
<button onclick={() => win.minimize()} title="Minimize" aria-label="Minimize">
<svg width="10" height="1" viewBox="0 0 10 1">
<line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5" />
</svg>
</button>
<button on:click={() => win.toggleMaximize()} title="Maximize" aria-label="Maximize">
<button onclick={() => win.toggleMaximize()} title="Maximize" aria-label="Maximize">
<svg width="9" height="9" viewBox="0 0 9 9">
<rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5" />
</svg>
</button>
<button class="close" on:click={() => win.close()} title="Close" aria-label="Close">
<button class="close" onclick={() => win.close()} title="Close" aria-label="Close">
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" />
+9 -9
View File
@@ -1,7 +1,6 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { toasts, dismissToast } from "../../store";
import type { Toast } from "../../store";
import { store, dismissToast } from "../../store/state.svelte";
import type { Toast } from "../../store/state.svelte";
const timers = new Map<string, ReturnType<typeof setTimeout>>();
@@ -12,9 +11,10 @@
timers.set(t.id, setTimeout(() => dismissToast(t.id), dur));
}
$: $toasts.forEach(schedule);
onDestroy(() => timers.forEach(clearTimeout));
$effect(() => {
store.toasts.forEach(schedule);
return () => timers.forEach(clearTimeout);
});
const icons: Record<Toast["kind"], string> = {
success: "M9 12l2 2 4-4M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2z",
@@ -24,9 +24,9 @@
};
</script>
{#if $toasts.length}
{#if store.toasts.length}
<div class="toaster" aria-live="polite">
{#each $toasts as t (t.id)}
{#each store.toasts as t (t.id)}
<div class="toast toast-{t.kind}" role="alert">
<span class="icon">
<svg width="15" height="15" viewBox="0 0 24 24" fill="none"
@@ -38,7 +38,7 @@
<p class="title">{t.title}</p>
{#if t.body}<p class="sub">{t.body}</p>{/if}
</div>
<button class="close" on:click={() => dismissToast(t.id)} title="Dismiss">
<button class="close" onclick={() => dismissToast(t.id)} title="Dismiss">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none"
stroke="currentColor" stroke-width="1.5" stroke-linecap="round">
<path d="M18 6L6 18M6 6l12 12" />
+22 -21
View File
@@ -5,7 +5,7 @@
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } from "../../lib/util";
import { settings, previewManga, activeSource, addFolder, assignMangaToFolder } from "../../store";
import { store, addFolder, assignMangaToFolder, setPreviewManga } from "../../store/state.svelte";
import type { Manga, Source } from "../../lib/types";
import ContextMenu from "../shared/ContextMenu.svelte";
import type { MenuEntry } from "../shared/ContextMenu.svelte";
@@ -35,17 +35,17 @@
`;
// ── State ─────────────────────────────────────────────────────────────────────
let allManga: Manga[] = []; // local library — loaded once, never triggers lag
let allSources: Source[] = []; // all deduped sources — loaded once
let loadingLib = true;
let loadError = false;
let allManga: Manga[] = $state([]); // local library — loaded once, never triggers lag
let allSources: Source[] = $state([]); // all deduped sources — loaded once
let loadingLib = $state(true);
let loadError = $state(false);
// Per-genre result map. Keyed by genre string.
// "All" key → local library deduped by title
// Each tab key → local + background source results, deduped id+title
let genreResults = new Map<string, Manga[]>();
let genreLoading = false; // true only during the initial local fetch for a new tab
let currentGenre = "All";
let genreResults = $state(new Map<string, Manga[]>());
let genreLoading = $state(false); // true only during the initial local fetch for a new tab
let currentGenre = $state("All");
let genreAbort: AbortController | null = null;
// batch timer handle for background source fan-out
@@ -54,15 +54,16 @@
let batchAccum = new Map<string, Manga[]>(); // genre → pending mangas
// Context menu
let ctx: { x: number; y: number; manga: Manga } | null = null;
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
let isLoading = $state(false);
// ── Derived ───────────────────────────────────────────────────────────────────
$: visibleGrid = genreResults.get(currentGenre) ?? [];
$: isLoading = genreLoading || (currentGenre === "All" && loadingLib);
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
$effect(() => { isLoading = genreLoading || (currentGenre === "All" && loadingLib); });
// ── Dedup helper — always apply id first then title ───────────────────────────
function dedup(items: Manga[]): Manga[] {
return dedupeMangaByTitle(dedupeMangaById(items), $settings.mangaLinks);
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
}
// ── Concurrent fan-out — conservative concurrency keeps connections free ──────
@@ -118,7 +119,7 @@
// Does NOT set genreLoading = true — the local result is already showing.
async function fanOutSources(genre: string, ctrl: AbortController) {
if (!allSources.length) return;
const lang = $settings.preferredExtensionLang || "en";
const lang = store.settings.preferredExtensionLang || "en";
const srcs = dedupeSources(allSources, lang);
startBatchFlush();
@@ -211,9 +212,9 @@
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
.then(() => cache.clear(CACHE_KEYS.LIBRARY)).catch(console.error),
},
...($settings.folders.length > 0 ? [
...(store.settings.folders.length > 0 ? [
{ separator: true } as MenuEntry,
...$settings.folders.map(f => ({
...store.settings.folders.map(f => ({
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
icon: Folder,
onClick: () => assignMangaToFolder(f.id, m.id),
@@ -235,7 +236,7 @@
// 2. Load source list in background (needed for genre fan-out, not needed for initial render)
function loadAll() {
loadingLib = true; loadError = false;
const lang = $settings.preferredExtensionLang || "en";
const lang = store.settings.preferredExtensionLang || "en";
// Local library — populates "All" tab
cache.get(CACHE_KEYS.DISCOVER, () =>
@@ -266,7 +267,7 @@
</script>
<!-- ── Source browse passthrough ─────────────────────────────────────────────── -->
{#if $activeSource}
{#if store.activeSource}
<SourceBrowse />
{:else}
<div class="root">
@@ -279,7 +280,7 @@
<button
class="genre-tab"
class:active={currentGenre === tab}
on:click={() => switchGenre(tab)}
onclick={() => switchGenre(tab)}
>
{#if tab === "All"}<Sparkle size={10} weight="fill" />{/if}
{tab}
@@ -302,7 +303,7 @@
{:else if loadError && visibleGrid.length === 0}
<div class="empty">
<span>Could not reach Suwayomi</span>
<button class="retry-btn" on:click={loadAll}>Retry</button>
<button class="retry-btn" onclick={loadAll}>Retry</button>
</div>
{:else if visibleGrid.length === 0}
@@ -313,8 +314,8 @@
{#each visibleGrid as m (m.id)}
<button
class="manga-card"
on:click={() => previewManga.set(m)}
on:contextmenu={(e) => openCtx(e, m)}
onclick={() => setPreviewManga(m)}
oncontextmenu={(e) => openCtx(e, m)}
>
<div class="cover-wrap">
<img
+14 -17
View File
@@ -1,21 +1,20 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "../../lib/queries";
import { activeDownloads } from "../../store";
import { store, setActiveDownloads } from "../../store/state.svelte";
import type { DownloadStatus } from "../../lib/types";
let status: DownloadStatus | null = null;
let loading = true;
let togglingPlay = false;
let clearing = false;
let dequeueing = new Set<number>();
let status: DownloadStatus | null = $state(null);
let loading = $state(true);
let togglingPlay = $state(false);
let clearing = $state(false);
let dequeueing = $state(new Set<number>());
let interval: ReturnType<typeof setInterval>;
function applyStatus(ds: DownloadStatus) {
status = ds;
activeDownloads.set(ds.queue.map((item) => ({
setActiveDownloads(ds.queue.map((item) => ({
chapterId: item.chapter.id,
mangaId: item.chapter.mangaId,
progress: item.progress,
@@ -29,8 +28,7 @@
.finally(() => loading = false);
}
onMount(() => { poll(); interval = setInterval(poll, 2000); });
onDestroy(() => clearInterval(interval));
$effect(() => { poll(); interval = setInterval(poll, 2000); return () => clearInterval(interval); });
async function togglePlay() {
if (togglingPlay) return;
@@ -53,7 +51,7 @@
if (clearing) return;
clearing = true;
if (status) status = { ...status, queue: [] };
activeDownloads.set([]);
setActiveDownloads([]);
try {
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
applyStatus(d.clearDownloader.downloadStatus);
@@ -69,22 +67,21 @@
catch (e) { console.error(e); poll(); }
finally { dequeueing.delete(chapterId); dequeueing = new Set(dequeueing); }
}
$: queue = status?.queue ?? [];
$: isRunning = status?.state === "STARTED";
let queue = $derived(status?.queue ?? []);
const isRunning = $derived(status?.state === "STARTED");
</script>
<div class="root">
<div class="header">
<h1 class="heading">Downloads</h1>
<div class="header-actions">
<button class="icon-btn" class:loading={togglingPlay} on:click={togglePlay}
<button class="icon-btn" class:loading={togglingPlay} onclick={togglePlay}
disabled={togglingPlay || (queue.length === 0 && !isRunning)} title={isRunning ? "Pause" : "Resume"}>
{#if togglingPlay}<CircleNotch size={14} weight="light" class="anim-spin" />
{:else if isRunning}<Pause size={14} weight="fill" />
{:else}<Play size={14} weight="fill" />{/if}
</button>
<button class="icon-btn" class:loading={clearing} on:click={clear}
<button class="icon-btn" class:loading={clearing} onclick={clear}
disabled={clearing || queue.length === 0} title="Clear queue">
{#if clearing}<CircleNotch size={14} weight="light" class="anim-spin" />
{:else}<Trash size={14} weight="regular" />{/if}
@@ -133,7 +130,7 @@
<div class="row-right">
<span class="state-label">{item.state}</span>
{#if !isActive}
<button class="remove-btn" on:click={() => dequeue(item.chapter.id)} disabled={isRemoving} title="Remove from queue">
<button class="remove-btn" onclick={() => dequeue(item.chapter.id)} disabled={isRemoving} title="Remove from queue">
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
</button>
{/if}
+48 -53
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import { onMount } from "svelte";
import { untrack } from "svelte";
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, GET_SETTINGS, SET_EXTENSION_REPOS } from "../../lib/queries";
import { settings } from "../../store";
import { store } from "../../store/state.svelte";
import type { Extension } from "../../lib/types";
type Filter = "installed" | "available" | "updates" | "all";
@@ -11,23 +11,23 @@
function baseName(name: string): string { return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim(); }
let extensions: Extension[] = [];
let loading = true;
let refreshing = false;
let filter: Filter = "installed";
let search = "";
let working = new Set<string>();
let expanded = new Set<string>();
let panel: Panel = null;
let externalUrl = "";
let installing = false;
let installError: string|null = null;
let installSuccess = false;
let repos: string[] = [];
let reposLoading = false;
let newRepoUrl = "";
let repoError: string|null = null;
let savingRepos = false;
let extensions: Extension[] = $state([]);
let loading = $state(true);
let refreshing = $state(false);
let filter: Filter = $state("installed");
let search = $state("");
let working = $state(new Set<string>());
let expanded = $state(new Set<string>());
let panel: Panel = $state(null);
let externalUrl = $state("");
let installing = $state(false);
let installError: string|null = $state(null);
let installSuccess = $state(false);
let repos: string[] = $state([]);
let reposLoading = $state(false);
let newRepoUrl = $state("");
let repoError: string|null = $state(null);
let savingRepos = $state(false);
async function load() {
return gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS)
@@ -93,26 +93,25 @@
if (p === "repos") loadRepos();
}
onMount(() => { fetchFromRepo().finally(() => loading = false); });
$effect(() => { untrack(() => fetchFromRepo().finally(() => { loading = false; })); });
$: filtered = extensions.filter((e) => {
const filtered = $derived(extensions.filter((e) => {
const q = search.toLowerCase();
const matchSearch = e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q);
const matchFilter = filter === "installed" ? e.isInstalled : filter === "available" ? !e.isInstalled : filter === "updates" ? e.hasUpdate : true;
return matchSearch && matchFilter;
});
}));
$: groups = (() => {
const groups = $derived.by(() => {
const map = new Map<string, Extension[]>();
for (const ext of filtered) { const key = baseName(ext.name); if (!map.has(key)) map.set(key, []); map.get(key)!.push(ext); }
const preferredLang = $settings.preferredExtensionLang;
const preferredLang = store.settings.preferredExtensionLang;
return Array.from(map.entries()).map(([base, all]) => {
const primary = all.find((v) => v.lang === preferredLang) ?? all.find((v) => v.lang === "en") ?? all[0];
return { base, primary, variants: all.filter((v) => v.pkgName !== primary.pkgName) };
});
})();
$: updateCount = extensions.filter((e) => e.hasUpdate).length;
});
const updateCount = $derived(extensions.filter((e) => e.hasUpdate).length);
const FILTERS: { id: Filter; label: string }[] = [
{ id: "installed", label: "Installed" },
@@ -132,13 +131,13 @@
<div class="header">
<h1 class="heading">Extensions</h1>
<div class="header-actions">
<button class="icon-btn" class:active={panel === "repos"} on:click={() => openPanel("repos")} title="Manage repos">
<button class="icon-btn" class:active={panel === "repos"} onclick={() => openPanel("repos")} title="Manage repos">
<GitBranch size={14} weight="light" />
</button>
<button class="icon-btn" class:active={panel === "apk"} on:click={() => openPanel("apk")} title="Install from URL">
<button class="icon-btn" class:active={panel === "apk"} onclick={() => openPanel("apk")} title="Install from URL">
<Plus size={14} weight="light" />
</button>
<button class="icon-btn" on:click={fetchFromRepo} disabled={refreshing} title="Refresh repo">
<button class="icon-btn" onclick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
</button>
</div>
@@ -148,15 +147,14 @@
<div class="ext-panel">
<div class="panel-header">
<span class="panel-title">Install from APK URL</span>
<button class="icon-btn" on:click={() => panel = null}><X size={14} weight="light" /></button>
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
</div>
<div class="ext-row">
<input class="ext-input" class:error={installError} placeholder="https://example.com/extension.apk"
bind:value={externalUrl} disabled={installing}
on:input={() => installError = null}
on:keydown={(e) => e.key === "Enter" && !installing && installExternal()}
use:focusEl />
<button class="install-btn" class:success={installSuccess} on:click={installExternal} disabled={installing || !externalUrl.trim()}>
oninput={() => installError = null}
onkeydown={(e) => e.key === "Enter" && !installing && installExternal()} autofocus />
<button class="install-btn" class:success={installSuccess} onclick={installExternal} disabled={installing || !externalUrl.trim()}>
{#if installing}<CircleNotch size={13} weight="light" class="anim-spin" />
{:else if installSuccess}<Check size={13} weight="bold" /> Done
{:else}Install{/if}
@@ -170,7 +168,7 @@
<div class="ext-panel">
<div class="panel-header">
<span class="panel-title">Extension Repositories</span>
<button class="icon-btn" on:click={() => panel = null}><X size={14} weight="light" /></button>
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
</div>
{#if reposLoading}
<div class="repo-loading"><CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
@@ -182,7 +180,7 @@
{#each repos as url}
<div class="repo-row">
<span class="repo-url">{url}</span>
<button class="repo-remove" on:click={() => removeRepo(url)} disabled={savingRepos} title="Remove repo">
<button class="repo-remove" onclick={() => removeRepo(url)} disabled={savingRepos} title="Remove repo">
{#if savingRepos}<CircleNotch size={12} weight="light" class="anim-spin" />{:else}<X size={12} weight="bold" />{/if}
</button>
</div>
@@ -192,9 +190,9 @@
<div class="ext-row" style="margin-top:var(--sp-2)">
<input class="ext-input" class:error={repoError} placeholder="https://example.com/index.min.json"
bind:value={newRepoUrl} disabled={savingRepos}
on:input={() => repoError = null}
on:keydown={(e) => e.key === "Enter" && !savingRepos && addRepo()} />
<button class="install-btn" on:click={addRepo} disabled={savingRepos || !newRepoUrl.trim()}>
oninput={() => repoError = null}
onkeydown={(e) => e.key === "Enter" && !savingRepos && addRepo()} />
<button class="install-btn" onclick={addRepo} disabled={savingRepos || !newRepoUrl.trim()}>
{#if savingRepos}<CircleNotch size={13} weight="light" class="anim-spin" />{:else}Add{/if}
</button>
</div>
@@ -206,7 +204,7 @@
<div class="controls">
<div class="tabs">
{#each FILTERS as f}
<button class="tab" class:active={filter === f.id} on:click={() => filter = f.id}>
<button class="tab" class:active={filter === f.id} onclick={() => filter = f.id}>
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
</button>
{/each}
@@ -228,7 +226,7 @@
{@const hasVariants = variants.length > 0}
<div class="group">
<div class="row">
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} class="icon" on:error={(e) => (e.target as HTMLImageElement).style.display = "none"} />
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} class="icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
<div class="info">
<span class="name">{base}</span>
<span class="meta"><span class="lang-tag">{primary.lang.toUpperCase()}</span> v{primary.versionName}</span>
@@ -238,16 +236,16 @@
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
{:else if primary.hasUpdate}
<div class="row-actions">
<button class="action-btn" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, update: true }), primary.pkgName)}>Update</button>
<button class="action-btn-dim" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, update: true }), primary.pkgName)}>Update</button>
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
</div>
{:else if primary.isInstalled}
<button class="action-btn-dim" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
{:else}
<button class="action-btn" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, install: true }), primary.pkgName)}>Install</button>
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, install: true }), primary.pkgName)}>Install</button>
{/if}
{#if hasVariants}
<button class="expand-btn" on:click={() => toggleExpand(base)} title="{variants.length + 1} languages">
<button class="expand-btn" onclick={() => toggleExpand(base)} title="{variants.length + 1} languages">
{#if isExpanded}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
<span class="expand-count">{variants.length + 1}</span>
</button>
@@ -265,11 +263,11 @@
{#if working.has(v.pkgName)}
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
{:else if v.hasUpdate}
<button class="action-btn" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, update: true }), v.pkgName)}>Update</button>
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, update: true }), v.pkgName)}>Update</button>
{:else if v.isInstalled}
<button class="action-btn-dim" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, uninstall: true }), v.pkgName)}>Remove</button>
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, uninstall: true }), v.pkgName)}>Remove</button>
{:else}
<button class="action-btn" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, install: true }), v.pkgName)}>Install</button>
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, install: true }), v.pkgName)}>Install</button>
{/if}
</div>
</div>
@@ -282,9 +280,6 @@
{/if}
</div>
<script context="module">
function focusEl(node: HTMLElement) { node.focus(); }
</script>
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
+24 -26
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
import { untrack } from "svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
import { dedupeSources, dedupeMangaById } from "../../lib/util";
import { settings, genreFilter, previewManga, addFolder, assignMangaToFolder } from "../../store";
import { store, addFolder, assignMangaToFolder, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte";
import type { Manga, Source } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
@@ -28,34 +28,32 @@
async function worker() { while (i < items.length) { if (signal.aborted) return; await fn(items[i++]).catch(() => {}); } }
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
}
const prevNavPage = store.navPage;
const tags = $derived(parseTags(store.genreFilter));
const primaryTag = $derived(tags[0] ?? "");
const label = $derived(tagsLabel(tags));
$: tags = parseTags($genreFilter);
$: primaryTag = tags[0] ?? "";
$: label = tagsLabel(tags);
let libraryManga: Manga[] = [];
let sourceManga: Manga[] = [];
let libraryManga: Manga[] = $state([]);
let sourceManga: Manga[] = $state([]);
let loadingInitial = true;
let loadingMore = false;
let visibleCount = PAGE_SIZE;
let ctx: { x: number; y: number; manga: Manga } | null = null;
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
const nextPageMap = new Map<string, number>();
let sources: Source[] = [];
let sources: Source[] = $state([]);
let abortCtrl: AbortController | null = null;
$: filtered = (() => {
const filtered = $derived.by(() => {
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags));
const libIds = new Set(libMatches.map((m) => m.id));
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id))]);
})();
$: visibleItems = filtered.slice(0, visibleCount);
$: hasMoreVisible = visibleCount < filtered.length;
$: hasMoreNetwork = sources.some((s) => (nextPageMap.get(s.id) ?? -1) > 0);
$: hasMore = hasMoreVisible || hasMoreNetwork;
$: if ($genreFilter) load($genreFilter);
});
const visibleItems = $derived(filtered.slice(0, visibleCount));
const hasMoreVisible = $derived(visibleCount < filtered.length);
const hasMoreNetwork = $derived(sources.some((s) => (nextPageMap.get(s.id) ?? -1) > 0));
const hasMore = $derived(hasMoreVisible || hasMoreNetwork);
$effect(() => { const f = store.genreFilter; if (f) untrack(() => load(f)); });
async function load(filter: string) {
abortCtrl?.abort();
@@ -67,7 +65,7 @@
visibleCount = PAGE_SIZE;
nextPageMap.clear();
const preferredLang = $settings.preferredExtensionLang || "en";
const preferredLang = store.settings.preferredExtensionLang || "en";
const t = parseTags(filter);
const pt = t[0] ?? "";
@@ -149,9 +147,9 @@
return [
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
...($settings.folders.length > 0 ? [
...(store.settings.folders.length > 0 ? [
{ separator: true } as MenuEntry,
...$settings.folders.map((f): MenuEntry => ({
...store.settings.folders.map((f): MenuEntry => ({
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
onClick: () => assignMangaToFolder(f.id, m.id),
})),
@@ -161,12 +159,12 @@
];
}
onDestroy(() => abortCtrl?.abort());
$effect(() => () => { abortCtrl?.abort(); });
</script>
<div class="root">
<div class="header">
<button class="back" on:click={() => genreFilter.set("")}>
<button class="back" onclick={() => { setGenreFilter(""); setNavPage(prevNavPage); }}>
<ArrowLeft size={13} weight="light" /><span>Back</span>
</button>
<span class="title">{label}</span>
@@ -192,7 +190,7 @@
{:else}
<div class="grid">
{#each visibleItems as m (m.id)}
<button class="card" on:click={() => previewManga.set(m)} on:contextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
<div class="cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
@@ -202,7 +200,7 @@
{/each}
{#if hasMore}
<div class="show-more-cell">
<button class="show-more-btn" on:click={loadMore} disabled={loadingMore}>
<button class="show-more-btn" onclick={loadMore} disabled={loadingMore}>
{#if loadingMore}<CircleNotch size={13} weight="light" class="anim-spin" /> Loading…{:else}Show more{/if}
</button>
</div>
+14 -14
View File
@@ -2,8 +2,8 @@
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, X as XIcon } from "phosphor-svelte";
import { thumbUrl, gql } from "../../lib/client";
import { GET_CHAPTERS } from "../../lib/queries";
import { history, readingStats, openReader, clearHistory, clearHistoryForManga } from "../../store";
import type { HistoryEntry } from "../../store";
import { store, openReader, clearHistory, clearHistoryForManga } from "../../store/state.svelte";
import type { HistoryEntry } from "../../store/state.svelte";
let search = $state("");
let confirmClearAll = $state(false);
@@ -67,8 +67,8 @@
}
const filtered = $derived(search.trim()
? history.filter(e => e.mangaTitle.toLowerCase().includes(search.toLowerCase()) || e.chapterName.toLowerCase().includes(search.toLowerCase()))
: history);
? store.history.filter(e => e.mangaTitle.toLowerCase().includes(search.toLowerCase()) || e.chapterName.toLowerCase().includes(search.toLowerCase()))
: store.history);
const sessions = $derived(buildSessions(filtered));
@@ -83,9 +83,9 @@
})());
const stats = $derived({
uniqueChapters: new Set(history.map(e => e.chapterId)).size,
uniqueManga: new Set(history.map(e => e.mangaId)).size,
estimatedMinutes: Math.round(new Set(history.map(e => e.chapterId)).size * 4.5),
uniqueChapters: new Set(store.history.map(e => e.chapterId)).size,
uniqueManga: new Set(store.history.map(e => e.mangaId)).size,
estimatedMinutes: Math.round(new Set(store.history.map(e => e.chapterId)).size * 4.5),
});
function doConfirmClear() { clearHistory(); confirmClearAll = false; }
@@ -106,14 +106,14 @@
<div class="header-right">
<div class="search-wrap">
<MagnifyingGlass size={12} class="search-icon" weight="light" />
<input class="search" placeholder="Search history…" bind:value={search} />
<input class="search" placeholder="Search store.history…" bind:value={search} />
{#if search}
<button class="search-clear" onclick={() => search = ""}>
<XIcon size={10} weight="bold" />
</button>
{/if}
</div>
{#if history.length > 0}
{#if store.history.length > 0}
{#if confirmClearAll}
<div class="confirm-row">
<span class="confirm-label">Clear all activity?</span>
@@ -135,16 +135,16 @@
<span class="stat-item"><span class="stat-val">{stats.uniqueManga}</span><span class="stat-label">series</span></span>
<span class="stat-sep"></span>
<span class="stat-item"><span class="stat-val">{formatReadTime(stats.estimatedMinutes)}</span><span class="stat-label">est. time</span></span>
{#if readingStats.currentStreakDays > 0}
{#if store.readingStats.currentStreakDays > 0}
<span class="stat-sep"></span>
<span class="stat-item"><span class="stat-val">{readingStats.currentStreakDays}d</span><span class="stat-label">streak</span></span>
<span class="stat-item"><span class="stat-val">{store.readingStats.currentStreakDays}d</span><span class="stat-label">streak</span></span>
{/if}
</div>
{#if history.length === 0}
{#if store.history.length === 0}
<div class="empty">
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
<p class="empty-text">No reading history yet</p>
<p class="empty-text">No reading store.history yet</p>
<p class="empty-hint">Chapters you read will appear here</p>
</div>
{:else if sessions.length === 0}
@@ -183,7 +183,7 @@
<span class="time">{timeAgo(session.readAt)}</span>
<Play size={11} weight="fill" class="play-icon" />
</button>
<button class="row-delete" onclick={() => clearHistoryForManga(session.mangaId)} title="Remove {session.mangaTitle} from history" aria-label="Remove from history">
<button class="row-delete" onclick={() => clearHistoryForManga(session.mangaId)} title="Remove {session.mangaTitle} from store.history" aria-label="Remove from store.history">
<XIcon size={9} weight="bold" />
</button>
</div>
+78 -65
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import { onMount } from "svelte";
import { onMount, untrack } from "svelte";
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_LIBRARY, GET_CHAPTERS } from "../../lib/queries";
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
import { history, readingStats, settings, activeManga, navPage, previewManga, openReader, COMPLETED_FOLDER_ID, setHeroSlot } from "../../store";
import type { HistoryEntry } from "../../store";
import { store, openReader, COMPLETED_FOLDER_ID, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter } from "../../store/state.svelte";
import type { HistoryEntry } from "../../store/state.svelte";
import type { Manga, Chapter } from "../../lib/types";
function timeAgo(ts: number): string {
@@ -31,20 +31,30 @@
function focusEl(node: HTMLElement) { node.focus(); }
let libraryManga: Manga[] = $state([]);
let extraManga: Manga[] = $state([]);
let loadingLibrary: boolean = $state(true);
onMount(() => {
cache.get(CACHE_KEYS.LIBRARY, () =>
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
).then(m => { libraryManga = m; })
).then(m => { libraryManga = m; fetchExtraCompleted(m); })
.catch(console.error)
.finally(() => loadingLibrary = false);
});
async function fetchExtraCompleted(library: Manga[]) {
const completedIds = store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? [];
const missingIds = completedIds.filter(id => !library.some(m => m.id === id));
if (!missingIds.length) return;
const results = await Promise.allSettled(missingIds.map(id => gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)));
const valid = results.flatMap(r => r.status === "fulfilled" && r.value ? [r.value] : []);
if (valid.length) extraManga = valid;
}
const continueReading = $derived((() => {
const seen = new Set<number>();
const out: HistoryEntry[] = [];
for (const e of history) {
for (const e of store.history) {
if (seen.has(e.mangaId)) continue;
seen.add(e.mangaId);
out.push(e);
@@ -57,7 +67,7 @@
interface HeroSlot { kind: "continue" | "pinned" | "empty"; entry?: HistoryEntry; manga?: Manga; slotIndex: number; }
const resolvedSlots = $derived((() => {
const pins = settings.heroSlots ?? [null, null, null, null];
const pins = store.settings.heroSlots ?? [null, null, null, null];
const slots: HeroSlot[] = [];
const first = continueReading[0];
slots.push(first ? { kind: "continue", entry: first, slotIndex: 0 } : { kind: "empty", slotIndex: 0 });
@@ -96,12 +106,14 @@
return () => window.removeEventListener("keydown", onKey);
});
let heroStageH = $state(300);
let heroChapters: Chapter[] = $state([]);
let loadingHeroChapters = $state(false);
let heroChaptersFor: number | null = null;
$effect(() => {
if (heroMangaId && heroMangaId !== heroChaptersFor) loadHeroChapters(heroMangaId);
const id = heroMangaId;
if (id && id !== heroChaptersFor) untrack(() => loadHeroChapters(id));
});
async function loadHeroChapters(mangaId: number) {
@@ -131,12 +143,12 @@
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
}
openReader(chapter, all);
} catch { activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
finally { resuming = false; }
}
async function resumeActive() {
if (!heroEntry && heroManga) { activeManga = heroManga; return; }
if (!heroEntry && heroManga) { store.activeManga = heroManga; return; }
if (!heroEntry) return;
const target = heroChapters.find(c => c.id === heroEntry!.chapterId) ?? heroChapters[0];
if (target && heroChapters.length) { await openChapter(target); return; }
@@ -146,8 +158,8 @@
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0];
if (ch) openReader(ch, chapters);
else activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
} catch { activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
else store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
finally { resuming = false; }
}
@@ -157,8 +169,8 @@
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0];
if (ch) openReader(ch, chapters);
else activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
} catch { activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
}
let pickerOpen = $state(false);
@@ -174,10 +186,11 @@
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
function unpinSlot(i: 1|2|3) { setHeroSlot(i, null); }
const completedIds = $derived(settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? []);
const completedManga = $derived(completedIds.length > 0 ? libraryManga.filter(m => completedIds.includes(m.id)).slice(0, 10) : []);
const recentHistory = $derived(history.slice(0, 8));
const stats = $derived(readingStats);
const completedIds = $derived(store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? []);
const completedPool = $derived([...libraryManga, ...extraManga.filter(m => !libraryManga.some(l => l.id === m.id))]);
const completedManga = $derived(completedIds.length > 0 ? completedPool.filter(m => completedIds.includes(m.id)).slice(0, 20) : []);
const recentHistory = $derived(store.history.slice(0, 8));
const stats = $derived(store.readingStats);
function handleRowWheel(e: WheelEvent) {
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
@@ -190,7 +203,7 @@
<div class="body">
<div class="hero-section">
<div class="hero-stage">
<div class="hero-stage" bind:clientHeight={heroStageH} style="--hero-h:{heroStageH}px">
{#if heroThumb}
<div class="hero-backdrop" style="background-image:url({heroThumb})"></div>
@@ -199,7 +212,7 @@
{/if}
<div class="hero-scrim"></div>
<button class="hero-cover-col" onclick={resumeActive} disabled={resuming || activeSlot?.kind === "empty"} title={heroTitle ? `Resume ${heroTitle}` : undefined} aria-label={heroTitle ? `Resume ${heroTitle}` : "No manga selected"}>
<button class="hero-cover-col" onclick={resumeActive} disabled={resuming || activeSlot?.kind === "empty"} aria-label={heroTitle ? `Resume ${heroTitle}` : "No manga selected"}>
{#if heroThumb}
<img src={heroThumb} alt={heroTitle} class="hero-cover" loading="eager" decoding="async" />
{#if activeSlot?.kind === "continue"}
@@ -227,7 +240,7 @@
<span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span>
{/if}
{#each (heroManga?.genre ?? []).slice(0, 3) as g}
<span class="hero-tag">{g}</span>
<button class="hero-tag hero-tag-genre" onclick={() => { setGenreFilter(g); setNavPage("explore"); }}>{g}</button>
{/each}
</div>
@@ -251,7 +264,7 @@
<Play size={11} weight="fill" />{resuming ? "Loading…" : "Resume"}
</button>
{:else if heroManga}
<button class="hero-cta" onclick={() => previewManga = heroManga!}>
<button class="hero-cta" onclick={() => store.previewManga = heroManga!}>
<BookOpen size={11} weight="light" /> View manga
</button>
{/if}
@@ -314,7 +327,7 @@
</button>
{/each}
{#if heroManga}
<button class="ch-view-all" onclick={() => { if (heroManga) activeManga = heroManga; }}>
<button class="ch-view-all" onclick={() => { if (heroManga) store.activeManga = heroManga; }}>
All chapters <ArrowRight size={9} weight="bold" />
</button>
{/if}
@@ -328,7 +341,7 @@
<div class="section">
<div class="section-header">
<span class="section-title"><Clock size={10} weight="bold" /> Recent Activity</span>
<button class="see-all" onclick={() => navPage = "history"}>Full history <ArrowRight size={9} weight="bold" /></button>
<button class="see-all" onclick={() => setNavPage("history")}>Full History <ArrowRight size={9} weight="bold" /></button>
</div>
<div class="activity-list">
{#each recentHistory as entry (entry.chapterId)}
@@ -347,7 +360,7 @@
{:else}
<div class="empty-state">
<p class="empty-text">Start reading to build your activity feed</p>
<button class="empty-cta" onclick={() => navPage = "library"}>Open Library <ArrowRight size={11} weight="bold" /></button>
<button class="empty-cta" onclick={() => store.navPage = "library"}>Open Library <ArrowRight size={11} weight="bold" /></button>
</div>
{/if}
@@ -356,13 +369,13 @@
<div class="bottom-section-hd">
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span>
{#if completedManga.length > 0}
<button class="see-all" onclick={() => navPage = "library"}>View all <ArrowRight size={9} weight="bold" /></button>
<button class="see-all" onclick={() => store.navPage = "library"}>View all <ArrowRight size={9} weight="bold" /></button>
{/if}
</div>
{#if completedManga.length > 0}
<div class="mini-row" onwheel={(e) => { e.preventDefault(); handleRowWheel(e); }}>
{#each completedManga as m (m.id)}
<button class="mini-card" onclick={() => previewManga = m}>
<button class="mini-card" onclick={() => store.previewManga = m}>
<div class="mini-cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="mini-cover" loading="lazy" decoding="async" />
<div class="mini-gradient"></div>
@@ -433,31 +446,31 @@
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
.body { flex: 1; overflow-y: auto; scrollbar-width: none; padding-bottom: var(--sp-8); }
.body::-webkit-scrollbar { display: none; }
.hero-section { padding: var(--sp-4) var(--sp-5) 0; }
.hero-stage { position: relative; display: flex; align-items: stretch; height: 340px; border-radius: var(--radius-xl); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 6px 28px rgba(0,0,0,0.28); }
.hero-backdrop { position: absolute; inset: -14px; background-size: cover; background-position: center 25%; filter: blur(24px) saturate(1.4) brightness(0.32); transform: scale(1.07); pointer-events: none; z-index: 0; }
.body { flex: 1; display: flex; flex-direction: column; overflow-y: auto; overflow-x: hidden; min-height: 0; }
.hero-section { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; }
.hero-stage { position: relative; display: flex; align-items: stretch; height: 374px; border-radius: var(--radius-xl); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 6px 28px rgba(0,0,0,0.28); }
.hero-backdrop { position: absolute; inset: -14px; background-size: cover; background-position: center 25%; filter: blur(20px) saturate(2.2) brightness(0.45); transform: scale(1.07); pointer-events: none; z-index: 0; }
.hero-bd-empty { background: var(--bg-void); filter: none; }
.hero-scrim { position: absolute; inset: 0; z-index: 1; pointer-events: none; background: linear-gradient(100deg, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0.4) 100%); }
.hero-cover-col { position: relative; z-index: 2; width: clamp(150px, 30%, 195px); flex-shrink: 0; display: flex; align-items: center; justify-content: center; padding: var(--sp-5); background: none; border: none; cursor: pointer; border-right: 1px solid rgba(255,255,255,0.06); }
.hero-cover-col:hover .hero-cover { filter: brightness(1.1); }
.hero-scrim { position: absolute; inset: 0; z-index: 1; pointer-events: none; background: linear-gradient(100deg, rgba(0,0,0,0.0) 0%, rgba(0,0,0,0.55) 100%); }
.hero-cover-col { position: relative; z-index: 2; flex-shrink: 0; width: 263px; height: 374px; overflow: hidden; cursor: pointer; border-right: 1px solid rgba(255,255,255,0.08); background: var(--bg-raised); }
.hero-cover-col:hover .hero-cover { filter: brightness(1.08); }
.hero-cover-col:hover .cover-resume-hint { opacity: 1; }
.hero-cover-col:disabled { cursor: default; }
.hero-cover { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-lg); box-shadow: 0 10px 36px rgba(0,0,0,0.75), 0 2px 8px rgba(0,0,0,0.4); display: block; transition: filter 0.18s ease; }
.hero-cover-empty { width: 100%; aspect-ratio: 2/3; display: flex; align-items: center; justify-content: center; background: var(--bg-overlay); border-radius: var(--radius-lg); color: var(--text-faint); }
.cover-resume-hint { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 32px; background: rgba(0,0,0,0.35); border-radius: var(--radius-lg); opacity: 0; transition: opacity 0.18s ease; pointer-events: none; }
.hero-details { position: relative; z-index: 2; flex: 1; min-width: 0; padding: var(--sp-5) var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-2); overflow: hidden; border-right: 1px solid rgba(255,255,255,0.06); }
.hero-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.2s ease; }
.hero-cover-empty { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: var(--bg-overlay); color: var(--text-faint); }
.cover-resume-hint { position: absolute; inset: var(--sp-3); display: flex; align-items: center; justify-content: center; color: #fff; font-size: 36px; background: rgba(0,0,0,0.4); border-radius: var(--radius-lg); opacity: 0; transition: opacity 0.18s ease; pointer-events: none; }
.hero-details { position: relative; z-index: 2; flex: 1; min-width: 0; padding: var(--sp-4) var(--sp-5) var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); overflow: hidden; border-right: 1px solid rgba(255,255,255,0.06); }
.hero-tags { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
.hero-tag { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-full); background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.62); border: 1px solid rgba(255,255,255,0.14); }
.hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
.hero-tag-pinned { background: rgba(168,132,232,0.18); color: #c4a8f0; border-color: rgba(168,132,232,0.28); }
.hero-tag-genre { cursor: pointer; transition: background 0.15s ease, color 0.15s ease; }
.hero-tag-genre:hover { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); }
.hero-title { font-size: var(--text-xl); font-weight: var(--weight-medium); color: #fff; line-height: var(--leading-tight); margin: 0; flex-shrink: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 2px 10px rgba(0,0,0,0.5); }
.hero-author { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.48); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.hero-progress { display: flex; align-items: center; gap: 5px; flex-shrink: 0; font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.58); letter-spacing: var(--tracking-wide); }
.hero-prog-page { color: rgba(255,255,255,0.38); }
.hero-prog-time { margin-left: auto; color: rgba(255,255,255,0.32); }
.hero-desc { font-size: var(--text-xs); color: rgba(255,255,255,0.42); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; flex: 1; min-height: 0; }
.hero-desc { font-size: var(--text-xs); color: rgba(255,255,255,0.42); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; flex-shrink: 0; }
.hero-empty-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: rgba(255,255,255,0.5); flex-shrink: 0; }
.hero-empty-sub { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
.hero-actions { display: flex; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; }
@@ -499,31 +512,31 @@
.sk-meta { height: 9px; width: 50%; }
.ch-view-all { display: flex; align-items: center; gap: 4px; margin-top: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: rgba(255,255,255,0.3); letter-spacing: var(--tracking-wide); background: none; border: none; cursor: pointer; padding: var(--sp-2) var(--sp-2) 0; transition: color var(--t-base); }
.ch-view-all:hover { color: var(--accent-fg); }
.section { border-top: 1px solid var(--border-dim); margin-top: var(--sp-4); }
.section-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-5) var(--sp-2); }
.section-title { display: inline-flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.see-all { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
.section { border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.section-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-2); }
.section-title { display: inline-flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.see-all { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
.see-all:hover { color: var(--accent-fg); }
.activity-list { display: flex; flex-direction: column; padding: 0 var(--sp-3); }
.activity-row { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-2); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; width: 100%; transition: background var(--t-fast), border-color var(--t-fast); }
.activity-list { display: flex; flex-direction: column; padding: 0 var(--sp-3); overflow: hidden; }
.activity-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; width: 100%; transition: background var(--t-fast), border-color var(--t-fast); }
.activity-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.activity-row:hover .activity-play { opacity: 1; }
.activity-thumb { width: 36px; height: 52px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.activity-info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
.activity-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.activity-sub { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.activity-time { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.activity-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.activity-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.activity-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.activity-sub { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-muted); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.activity-time { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.activity-play { color: var(--accent-fg); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
.bottom-row { display: grid; grid-template-columns: 1fr 1px 1fr; padding: 0 var(--sp-5) 0; margin-top: var(--sp-4); border-top: 1px solid var(--border-dim); align-items: start; }
.bottom-divider { background: var(--border-dim); align-self: stretch; min-height: 100%; }
.bottom-col { display: flex; flex-direction: column; min-width: 0; padding-top: var(--sp-3); }
.bottom-col:first-child { padding-right: var(--sp-5); }
.bottom-col:last-child { padding-left: var(--sp-5); }
.bottom-row { display: grid; grid-template-columns: 1fr 1px 1fr; padding: 0 var(--sp-4); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.bottom-divider { background: var(--border-dim); align-self: stretch; }
.bottom-col { display: flex; flex-direction: column; min-width: 0; padding-top: var(--sp-3); padding-bottom: var(--sp-4); }
.bottom-col:first-child { padding-right: var(--sp-4); }
.bottom-col:last-child { padding-left: var(--sp-4); }
.bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-2) 0; }
.mini-row { display: flex; gap: var(--sp-3); overflow-x: auto; scrollbar-width: none; padding-bottom: var(--sp-2); }
.mini-row::-webkit-scrollbar { display: none; }
.mini-card { flex-shrink: 0; width: 120px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 0; }
.mini-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: var(--sp-3); }
.mini-card { width: 100%; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.mini-card:hover .mini-cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
.mini-card:hover { will-change: transform; }
.mini-cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 12px rgba(0,0,0,0.35); }
@@ -533,16 +546,16 @@
.mini-card-title { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
.mini-card-source { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); }
.stat-card { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: var(--sp-2) var(--sp-3); }
.stat-card { display: flex; align-items: center; gap: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: var(--sp-2) var(--sp-3); }
.stat-icon-wrap { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); flex-shrink: 0; }
.stat-fire { background: rgba(251,146,60,0.15); color: #fb923c; }
.stat-accent { background: var(--accent-muted); color: var(--accent-fg); }
.stat-neutral { background: var(--bg-overlay); color: var(--text-faint); }
.stat-green { background: rgba(34,197,94,0.12); color: #22c55e; }
.stat-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.stat-val { font-family: var(--font-ui); font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: 1; }
.stat-val { font-family: var(--font-ui); font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: 1; }
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); padding: var(--sp-7) var(--sp-6); }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-6); flex-shrink: 0; }
.empty-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.empty-cta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-full); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
.empty-cta:hover { filter: brightness(1.1); }
@@ -560,7 +573,7 @@
.picker-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; }
.picker-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
.picker-row:hover { background: var(--bg-raised); }
.picker-thumb { width: 34px; height: 50px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.picker-thumb { height: 50px; width: 35px; aspect-ratio: 1 / 1.42; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); background: var(--bg-raised); display: block; }
.picker-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.picker-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.picker-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
+61 -41
View File
@@ -1,27 +1,28 @@
<script lang="ts">
import { onMount } from "svelte";
import { onMount, untrack } from "svelte";
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_LIBRARY, GET_ALL_MANGA, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
import { GET_LIBRARY, GET_MANGA, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
import { settings, activeManga, libraryFilter, genreFilter, activeChapter } from "../../store";
import { addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders } from "../../store";
import { store, setActiveManga, setLibraryFilter } from "../../store/state.svelte";
import { addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders } from "../../store/state.svelte";
import { COMPLETED_FOLDER_ID } from "../../store/state.svelte";
import type { Manga, Chapter } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
const CARD_MIN_W = 130;
const CARD_GAP = 16;
let allManga: Manga[] = $state([]);
let allMangaUnfiltered: Manga[] = $state([]);
let loading: boolean = $state(true);
let error: string|null = $state(null);
let retryCount: number = $state(0);
let search: string = $state("");
let renderVisible: number = $state(0);
let scrollEl: HTMLDivElement;
let containerWidth: number = $state(800);
let allManga: Manga[] = $state([]);
let extraManga: Manga[] = $state([]); // non-library manga needed for folders (e.g. completed)
let loading: boolean = $state(true);
let error: string|null = $state(null);
let retryCount: number = $state(0);
let search: string = $state("");
let renderVisible: number = $state(0);
let scrollEl: HTMLDivElement;
let containerWidth: number = $state(800);
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
let emptyCtx: { x: number; y: number } | null = $state(null);
@@ -29,8 +30,8 @@
$effect(() => {
const wasOpen = prevChapterId !== null;
prevChapterId = activeChapter?.id ?? null;
if (wasOpen && !activeChapter) cache.clear(CACHE_KEYS.LIBRARY);
prevChapterId = store.activeChapter?.id ?? null;
if (wasOpen && !store.activeChapter) cache.clear(CACHE_KEYS.LIBRARY);
});
function fetchLibrary() {
@@ -44,47 +45,66 @@
function loadData() {
fetchLibrary()
.then(nodes => { allManga = dedupeMangaByTitle(dedupeMangaById(nodes), settings.mangaLinks); error = null; })
.then(nodes => { allManga = dedupeMangaByTitle(dedupeMangaById(nodes), store.settings.mangaLinks); error = null; })
.catch(e => error = e.message)
.finally(() => loading = false);
cache.get(CACHE_KEYS.ALL_MANGA, () =>
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA).then(d => d.mangas.nodes),
DEFAULT_TTL_MS, CACHE_GROUPS.LIBRARY,
).then(nodes => { allMangaUnfiltered = dedupeMangaById(nodes); }).catch(console.error);
}
$effect(() => {
retryCount;
loading = true; error = null;
if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY);
loadData();
untrack(() => loadData());
});
// Lazily fetch manga that are in a folder but not in the library (e.g. completed but removed from library)
$effect(() => {
const allIds = new Set(allManga.map(m => m.id));
const missingIds = store.settings.folders
.flatMap(f => f.mangaIds)
.filter(id => !allIds.has(id));
if (!missingIds.length) return;
const toFetch = [...new Set(missingIds)].filter(id => !extraManga.some(m => m.id === id));
if (!toFetch.length) return;
untrack(() => {
Promise.all(
toFetch.map(id =>
cache.get(CACHE_KEYS.MANGA(id), () =>
gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)
).catch(() => null)
)
).then(results => {
const valid = results.filter(Boolean) as Manga[];
if (valid.length) extraManga = dedupeMangaById([...extraManga, ...valid]);
});
});
});
$effect(() => { if (scrollEl) scrollEl.scrollTo({ top: 0 }); });
$effect(() => {
const f = settings.folders.find(f => f.id === libraryFilter);
if (f && !f.showTab) libraryFilter = "library";
const f = store.settings.folders.find(f => f.id === store.libraryFilter);
if (f && !f.showTab) untrack(() => { store.libraryFilter = "library"; });
});
const isBuiltin = (f: string) => f === "library" || f === "downloaded";
// All manga available for folder filtering — library + any extras fetched above
const folderPool = $derived((() => {
const seen = new Set(allManga.map(m => m.id));
return [...allManga, ...allMangaUnfiltered.filter(m => !seen.has(m.id))];
return [...allManga, ...extraManga.filter(m => !seen.has(m.id))];
})());
const filtered = $derived((() => {
const q = search.trim().toLowerCase();
if (libraryFilter === "library") {
if (store.libraryFilter === "library") {
return q ? allManga.filter(m => m.title.toLowerCase().includes(q)) : allManga;
}
if (libraryFilter === "downloaded") {
if (store.libraryFilter === "downloaded") {
const items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items;
}
const folder = settings.folders.find(f => f.id === libraryFilter);
const folder = store.settings.folders.find(f => f.id === store.libraryFilter);
if (folder) {
const items = folderPool.filter(m => folder.mangaIds.includes(m.id));
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items;
@@ -97,15 +117,15 @@
const hasMore = $derived(filtered.length > renderVisible);
const remainingCount = $derived(filtered.length - renderVisible);
$effect(() => { filtered; renderVisible = settings.renderLimit ?? 48; });
$effect(() => { filtered; untrack(() => { renderVisible = store.settings.renderLimit ?? 48; }); });
const counts = $derived({
library: allManga.length,
downloaded: allManga.filter(m => (m.downloadCount ?? 0) > 0).length,
...settings.folders.reduce((a, f) => ({ ...a, [f.id]: folderPool.filter(m => f.mangaIds.includes(m.id)).length }), {} as Record<string, number>),
...store.settings.folders.reduce((a, f) => ({ ...a, [f.id]: folderPool.filter(m => f.mangaIds.includes(m.id)).length }), {} as Record<string, number>),
});
function loadMore() { renderVisible += settings.renderLimit ?? 48; }
function loadMore() { renderVisible += store.settings.renderLimit ?? 48; }
async function removeFromLibrary(manga: Manga) {
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
@@ -128,7 +148,7 @@
function buildCtxItems(m: Manga): MenuEntry[] {
const mangaFolders = getMangaFolders(m.id);
const folderEntries: MenuEntry[] = settings.folders.map(f => {
const folderEntries: MenuEntry[] = store.settings.folders.map(f => {
const inFolder = mangaFolders.some(mf => mf.id === f.id);
return { label: inFolder ? `Remove from ${f.name}` : `Add to ${f.name}`, icon: Folder, onClick: () => inFolder ? removeMangaFromFolder(f.id, m.id) : assignMangaToFolder(f.id, m.id) };
});
@@ -163,7 +183,7 @@
emptyCtx = { x: e.clientX, y: e.clientY };
}}
>
{#if settings.libraryBranches ?? true}
{#if store.settings.libraryBranches ?? true}
<svg class="branches" viewBox="0 0 400 600" preserveAspectRatio="xMaxYMid slice" aria-hidden="true">
<g stroke="var(--accent)" stroke-width="0.6" fill="none" opacity="0.13">
<path d="M380 600 C380 500 340 460 310 400 C280 340 300 280 270 220"/>
@@ -196,15 +216,15 @@
<span class="heading">Library</span>
<div class="tabs">
{#each [["library","Saved"], ["downloaded","Downloaded"]] as [f, label]}
<button class="tab" class:active={libraryFilter === f} onclick={() => libraryFilter = f}>
<button class="tab" class:active={store.libraryFilter === f} onclick={() => store.libraryFilter = f}>
{#if f === "library"}<Books size={11} weight="bold" />
{:else if f === "downloaded"}<DownloadSimple size={11} weight="bold" />{/if}
{label}
<span class="tab-count">{counts[f] ?? 0}</span>
</button>
{/each}
{#each settings.folders.filter(f => f.showTab) as folder}
<button class="tab" class:active={libraryFilter === folder.id} onclick={() => libraryFilter = folder.id}>
{#each store.settings.folders.filter(f => f.showTab) as folder}
<button class="tab" class:active={store.libraryFilter === folder.id} onclick={() => store.libraryFilter = folder.id}>
<Folder size={11} weight="bold" />
{folder.name}
<span class="tab-count">{counts[folder.id] ?? 0}</span>
@@ -229,16 +249,16 @@
</div>
{:else if filtered.length === 0}
<div class="center">
{libraryFilter === "library" ? "No manga saved to library — browse sources to add some."
: libraryFilter === "downloaded" ? "No downloaded manga."
{store.libraryFilter === "library" ? "No manga saved to library — browse sources to add some."
: store.libraryFilter === "downloaded" ? "No downloaded manga."
: "No manga in this folder yet. Right-click manga anywhere to assign them."}
</div>
{:else}
<div class="grid" style="--cols:{cols}">
{#each visibleManga as m (m.id)}
<button class="card" onclick={() => activeManga = m} oncontextmenu={(e) => openCtx(e, m)}>
<button class="card" onclick={() => store.activeManga = m} oncontextmenu={(e) => openCtx(e, m)}>
<div class="cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" style="object-fit:{settings.libraryCropCovers ? 'cover' : 'contain'}" loading="lazy" decoding="async" />
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" style="object-fit:{store.settings.libraryCropCovers ? 'cover' : 'contain'}" loading="lazy" decoding="async" />
{#if m.downloadCount}<span class="badge-dl">{m.downloadCount}</span>{/if}
{#if m.unreadCount}<span class="badge-unread">{m.unreadCount}</span>{/if}
</div>
@@ -249,7 +269,7 @@
{#if hasMore}
<div class="load-more-row">
<button class="load-more-btn" onclick={loadMore}>
Show {Math.min(remainingCount, settings.renderLimit ?? 48)} more
Show {Math.min(remainingCount, store.settings.renderLimit ?? 48)} more
<span class="load-more-count">({remainingCount} remaining)</span>
</button>
</div>
+41 -40
View File
@@ -1,14 +1,16 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
import type { Manga, Source, Chapter } from "../../lib/types";
export let manga: Manga;
export let currentChapters: Chapter[];
export let onClose: () => void;
export let onMigrated: (newManga: Manga) => void;
interface Props {
manga: Manga;
currentChapters: Chapter[];
onClose: () => void;
onMigrated: (newManga: Manga) => void;
}
let { manga, currentChapters, onClose, onMigrated }: Props = $props();
type Step = "source" | "search" | "confirm";
@@ -30,35 +32,34 @@
return intersection / union;
}
let step: Step = "source";
let sources: Source[] = [];
let loadingSources = true;
let selectedSource: Source | null = null;
let query = manga.title;
let results: { manga: Manga; similarity: number }[] = [];
let searching = false;
let selectedMatch: Match | null = null;
let loadingMatchId: number | null = null;
let migrating = false;
let error: string | null = null;
let step: Step = $state("source");
let sources: Source[] = $state([]);
let loadingSources = $state(true);
let selectedSource: Source | null = $state(null);
const _initialTitle = manga.title;
let query = $state(_initialTitle);
let results: { manga: Manga; similarity: number }[] = $state([]);
let searching = $state(false);
let selectedMatch: Match | null = $state(null);
let loadingMatchId: number | null = $state(null);
let migrating = $state(false);
let error: string | null = $state(null);
const readCount = $derived(currentChapters.filter((c) => c.isRead).length);
const totalCount = $derived(currentChapters.length);
const chapterDiff = $derived(selectedMatch ? selectedMatch.chapters.length - totalCount : 0);
const STEPS = ["source", "search", "confirm"] as const satisfies Step[];
const stepIdx = $derived(STEPS.indexOf(step));
$: readCount = currentChapters.filter((c) => c.isRead).length;
$: totalCount = currentChapters.length;
$: chapterDiff = selectedMatch ? selectedMatch.chapters.length - totalCount : 0;
$: STEPS = (["source", "search", "confirm"] as Step[]);
$: stepIdx = STEPS.indexOf(step);
onMount(() => {
$effect(() => {
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => sources = d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id))
.then((d) => { sources = d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id); })
.catch(console.error)
.finally(() => loadingSources = false);
.finally(() => { loadingSources = false; });
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
onDestroy(() => window.removeEventListener("keydown", onKey));
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
async function searchSource(src: Source, q: string) {
@@ -144,9 +145,9 @@
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="overlay" on:click={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="modal">
<!-- Header -->
@@ -155,7 +156,7 @@
<span class="modal-title-label">Migrate source</span>
<span class="modal-title-manga">{manga.title}</span>
</div>
<button class="close-btn" on:click={onClose}><X size={14} weight="light" /></button>
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
</div>
<!-- Step indicators -->
@@ -189,9 +190,9 @@
<button
class="source-row"
class:source-row-active={selectedSource?.id === src.id}
on:click={() => pickSource(src)}>
onclick={() => pickSource(src)}>
<img src={thumbUrl(src.iconUrl)} alt={src.name} class="source-icon"
on:error={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<div class="source-info">
<span class="source-name">{src.displayName}</span>
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
@@ -210,9 +211,9 @@
{#if selectedSource}
<div class="search-context">
<img src={thumbUrl(selectedSource.iconUrl)} alt="" class="search-context-icon"
on:error={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="search-context-name">{selectedSource.displayName}</span>
<button class="search-context-change" on:click={() => { step = "source"; results = []; }}>Change</button>
<button class="search-context-change" onclick={() => { step = "source"; results = []; }}>Change</button>
</div>
{/if}
@@ -220,11 +221,11 @@
<div class="search-bar">
<MagnifyingGlass size={13} weight="light" class="search-icon" />
<input class="search-input" bind:value={query}
on:keydown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
onkeydown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
placeholder="Search title…" autofocus />
</div>
<button class="search-btn"
on:click={() => selectedSource && searchSource(selectedSource, query)}
onclick={() => selectedSource && searchSource(selectedSource, query)}
disabled={searching || !selectedSource}>
{#if searching}
<CircleNotch size={13} weight="light" class="anim-spin" />
@@ -250,7 +251,7 @@
{:else}
{#each results as { manga: m, similarity }, idx}
<button class="result-row"
on:click={() => selectMatch(m, similarity)}
onclick={() => selectMatch(m, similarity)}
disabled={loadingMatchId !== null}>
<div class="result-cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="result-cover" />
@@ -345,8 +346,8 @@
{#if error}<p class="error"><Warning size={13} weight="light" /> {error}</p>{/if}
<div class="confirm-actions">
<button class="back-btn" on:click={() => step = "search"} disabled={migrating}>Back</button>
<button class="migrate-btn" on:click={migrate} disabled={migrating}>
<button class="back-btn" onclick={() => step = "search"} disabled={migrating}>Back</button>
<button class="migrate-btn" onclick={migrate} disabled={migrating}>
{#if migrating}
<CircleNotch size={13} weight="light" class="anim-spin" /> Migrating…
{:else}
+148 -215
View File
@@ -1,14 +1,12 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { onDestroy, untrack } from "svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
import { dedupeSources, dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
import { settings, searchPrefill, previewManga } from "../../store";
import { store, setSearchPrefill, setPreviewManga } from "../../store/state.svelte";
import type { Manga, Source } from "../../lib/types";
type SearchTab = "keyword" | "tag" | "source";
type TagMode = "AND" | "OR";
@@ -19,13 +17,7 @@
error: string | null;
}
const CONCURRENCY = 6; // more parallel source requests
// RESULTS_PER_SOURCE and TAG_PAGE_SIZE are driven by $settings.renderLimit
// (accessed inline) so changing the setting takes effect immediately.
// No MAX_TAG_SOURCES cap — we fan out to all deduped sources so the grid
// is fully populated. Concurrency + caching keep this fast.
const CONCURRENCY = 6;
const COMMON_GENRES = [
"Action","Adventure","Comedy","Drama","Fantasy","Romance",
@@ -35,13 +27,7 @@
"Magic","Music","Cooking","Medical","Military","Harem","Ecchi",
];
async function runConcurrent<T>(
items: T[],
fn: (item: T) => Promise<void>,
signal: AbortSignal,
): Promise<void> {
async function runConcurrent<T>(items: T[], fn: (item: T) => Promise<void>, signal: AbortSignal): Promise<void> {
let i = 0;
async function worker() {
while (i < items.length) {
@@ -77,24 +63,25 @@
}
`;
let tab: SearchTab = $state("keyword");
let tab: SearchTab = "keyword";
let preferredLang = store.settings?.preferredExtensionLang ?? "";
let preferredLang = $settings?.preferredExtensionLang ?? "";
let allSources: Source[] = $state([]);
let loadingSources = $state(false);
let pendingPrefill = $state("");
let allSources: Source[] = [];
let loadingSources = false;
let pendingPrefill = "";
$effect(() => {
if (store.searchPrefill) {
const prefill = store.searchPrefill;
untrack(() => {
pendingPrefill = prefill;
tab = "keyword";
setSearchPrefill("");
});
}
});
$: if ($searchPrefill) {
pendingPrefill = $searchPrefill;
tab = "keyword";
searchPrefill.set("");
}
loadingSources = true;
cache.get(
CACHE_KEYS.SOURCES,
@@ -106,35 +93,37 @@
.catch(console.error)
.finally(() => { loadingSources = false; });
$: availableLangs = Array.from(new Set<string>(allSources.map((s) => s.lang))).sort();
$: hasMultipleLangs = availableLangs.length > 1;
const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort());
const hasMultipleLangs = $derived(availableLangs.length > 1);
// ── Keyword search ────────────────────────────────────────────────────────
let kw_query = "";
let kw_submitted = "";
let kw_results: SourceResult[] = [];
let kw_showAdvanced = false;
let kw_selectedLangs: Set<string> = new Set();
let kw_includeNsfw = false;
let kw_inputEl: HTMLInputElement | null = null;
let kw_query = $state("");
let kw_submitted = $state("");
let kw_results: SourceResult[] = $state([]);
let kw_showAdvanced = $state(false);
let kw_selectedLangs: Set<string> = $state(new Set());
let kw_includeNsfw = $state(false);
let kw_inputEl: HTMLInputElement | null = $state(null);
let kw_abortCtrl: AbortController | null = null;
$: if (allSources.length) {
const available = new Set(allSources.map((s) => s.lang));
kw_selectedLangs = available.has(preferredLang)
? new Set([preferredLang])
: new Set(availableLangs.slice(0, 1));
}
$effect(() => {
if (allSources.length) {
const available = new Set(allSources.map((s) => s.lang));
kw_selectedLangs = available.has(preferredLang)
? new Set([preferredLang])
: new Set(availableLangs.slice(0, 1));
}
});
$: if (!loadingSources && pendingPrefill && !kw_submitted && allSources.length) {
const q = pendingPrefill;
pendingPrefill = "";
kw_query = q;
kwDoSearch(q);
}
$effect(() => {
if (!loadingSources && pendingPrefill && !kw_submitted && allSources.length) {
const q = pendingPrefill;
pendingPrefill = "";
kw_query = q;
kwDoSearch(q);
}
});
function kwGetVisibleSources(): Source[] {
let filtered = allSources;
@@ -150,21 +139,16 @@
if (!trimmed) return;
const visible = kwGetVisibleSources();
if (!visible.length) return;
kw_abortCtrl?.abort();
const ctrl = new AbortController();
kw_abortCtrl = ctrl;
kw_submitted = trimmed;
kw_results = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null }));
await runConcurrent(visible, async (src) => {
if (ctrl.signal.aborted) return;
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page: 1, query: trimmed },
ctrl.signal,
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page: 1, query: trimmed }, ctrl.signal,
);
if (ctrl.signal.aborted) return;
kw_results = kw_results.map((r) =>
@@ -186,72 +170,67 @@
kw_selectedLangs = next;
}
$: kw_visibleCount = kwGetVisibleSources().length;
$: kw_hasResults = kw_results.some((r) => r.mangas.length > 0);
$: kw_allDone = kw_results.length > 0 && kw_results.every((r) => !r.loading);
const kw_visibleCount = $derived(kwGetVisibleSources().length);
const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0));
const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading));
// ── Tag search ────────────────────────────────────────────────────────────
let tag_activeTags: string[] = [];
let tag_tagMode: TagMode = "AND";
let tag_tagFilter = "";
let tag_activeTags: string[] = $state([]);
let tag_tagMode: TagMode = $state("AND");
let tag_tagFilter = $state("");
let tag_localResults: Manga[] = [];
let tag_totalCount = 0;
let tag_loadingLocal = false;
let tag_loadingMoreLocal = false;
let tag_localOffset = 0;
let tag_localHasNext = false;
let tag_localResults: Manga[] = $state([]);
let tag_totalCount = $state(0);
let tag_loadingLocal = $state(false);
let tag_loadingMoreLocal = $state(false);
let tag_localOffset = $state(0);
let tag_localHasNext = $state(false);
let tag_abortLocal: AbortController | null = null;
let tag_searchSources = false;
let tag_sourceResults: Manga[] = [];
let tag_loadingSourceSearch = false;
let tag_loadingMoreSource = false;
let tag_srcNextPage: Map<string, number> = new Map();
let tag_searchSources = $state(false);
let tag_sourceResults: Manga[] = $state([]);
let tag_loadingSourceSearch = $state(false);
let tag_loadingMoreSource = $state(false);
let tag_srcNextPage: Map<string, number> = $state(new Map());
let tag_abortSource: AbortController | null = null;
$: tag_filteredGenres = (() => {
const tag_filteredGenres = $derived.by(() => {
const q = tag_tagFilter.trim().toLowerCase();
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES;
})();
});
$: tag_hasActiveTags = tag_activeTags.length > 0;
$: tag_localIds = new Set(tag_localResults.map((m) => m.id));
$: tag_mergedResults = dedupeMangaByTitle(dedupeMangaById(
const tag_hasActiveTags = $derived(tag_activeTags.length > 0);
const tag_localIds = $derived(new Set(tag_localResults.map((m) => m.id)));
const tag_mergedResults = $derived(dedupeMangaByTitle(dedupeMangaById(
tag_searchSources
? [...tag_localResults, ...tag_sourceResults.filter((m) => !tag_localIds.has(m.id))]
: tag_localResults
), $settings.mangaLinks);
$: tag_totalVisible = tag_mergedResults.length;
$: tag_sourceHasMore = tag_searchSources && [...tag_srcNextPage.values()].some((p) => p > 0);
), store.settings.mangaLinks));
const tag_totalVisible = $derived(tag_mergedResults.length);
const tag_sourceHasMore = $derived(tag_searchSources && [...tag_srcNextPage.values()].some((p) => p > 0));
$: {
$effect(() => {
const _activeTags = tag_activeTags;
const _tagMode = tag_tagMode;
tagFetchLocal(_activeTags, _tagMode);
}
untrack(() => tagFetchLocal(_activeTags, _tagMode));
});
// Auto-enable source search if local results are sparse (< 20 after initial load)
// Use a flag so this only fires once per tag set, not on every reactive update
let tag_autoSearchFired = false;
$: if (!tag_loadingLocal && tag_activeTags.length > 0 && !tag_autoSearchFired && !tag_searchSources && !loadingSources) {
if (tag_localResults.length < 20) {
tag_autoSearchFired = true;
tag_searchSources = true;
let tag_autoSearchFired = $state(false);
$effect(() => {
if (!tag_loadingLocal && tag_activeTags.length > 0 && !tag_autoSearchFired && !tag_searchSources && !loadingSources) {
if (tag_localResults.length < 20) {
untrack(() => { tag_autoSearchFired = true; tag_searchSources = true; });
}
}
}
// Reset the flag when tags change
$: { tag_activeTags; tag_autoSearchFired = false; }
$: {
const _search = tag_searchSources;
const _tags = tag_activeTags;
if (_search && _tags.length > 0 && !loadingSources) {
tagFetchSources(_tags);
});
$effect(() => { const _ = tag_activeTags; untrack(() => { tag_autoSearchFired = false; }); });
$effect(() => {
if (tag_searchSources && tag_activeTags.length > 0 && !loadingSources) {
const tags = tag_activeTags;
untrack(() => tagFetchSources(tags));
}
}
});
async function tagFetchLocal(activeTags: string[], tagMode: TagMode) {
if (activeTags.length === 0) {
@@ -263,17 +242,16 @@
tag_abortLocal = ctrl;
tag_localResults = []; tag_totalCount = 0; tag_localOffset = 0; tag_localHasNext = false;
tag_loadingLocal = true;
gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean }; totalCount: number } }>(
MANGAS_BY_GENRE,
{ filter: buildGenreFilter(activeTags, tagMode), first: ($settings.renderLimit ?? 48), offset: 0 },
{ filter: buildGenreFilter(activeTags, tagMode), first: (store.settings.renderLimit ?? 48), offset: 0 },
ctrl.signal,
).then((d) => {
if (ctrl.signal.aborted) return;
tag_localResults = d.mangas.nodes;
tag_totalCount = d.mangas.totalCount;
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
tag_localOffset = ($settings.renderLimit ?? 48);
tag_localOffset = (store.settings.renderLimit ?? 48);
}).catch((e: any) => {
if (e?.name !== "AbortError") console.error(e);
}).finally(() => {
@@ -285,50 +263,32 @@
tag_abortSource?.abort();
const ctrl = new AbortController();
tag_abortSource = ctrl;
// Don't blank existing results — keep them visible while new ones load.
// Only reset if the tags actually changed (tracked by the calling reactive block).
tag_srcNextPage = new Map();
tag_loadingSourceSearch = true;
// Fan out to ALL deduped sources — no arbitrary cap.
// Concurrency (6) + per-page caching keeps this fast without hammering connections.
const sources = dedupeSources(allSources, preferredLang);
const primaryTag = activeTags[0];
for (const src of sources) tag_srcNextPage.set(src.id, -1);
runConcurrent(sources, async (src) => {
if (ctrl.signal.aborted) return;
const ps = getPageSet(src.id, "SEARCH", activeTags);
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", 1, activeTags);
const result = await cache
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
pageKey,
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page: 1, query: primaryTag },
ctrl.signal,
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page: 1, query: primaryTag }, ctrl.signal,
).then((d) => d.fetchSourceManga),
)
.catch((e: any) => {
if (e?.name !== "AbortError") console.error(e);
return null;
});
.catch((e: any) => { if (e?.name !== "AbortError") console.error(e); return null; });
if (!result || ctrl.signal.aborted) return;
ps.add(1);
tag_srcNextPage.set(src.id, result.hasNextPage ? 2 : -1);
tag_srcNextPage = new Map(tag_srcNextPage);
const matching = activeTags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, activeTags))
: result.mangas;
if (matching.length > 0) {
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), $settings.mangaLinks);
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks);
tag_loadingSourceSearch = false;
}
}, ctrl.signal).finally(() => {
@@ -345,13 +305,13 @@
try {
const d = await gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean } } }>(
MANGAS_BY_GENRE,
{ filter: buildGenreFilter(tag_activeTags, tag_tagMode), first: ($settings.renderLimit ?? 48), offset: tag_localOffset },
{ filter: buildGenreFilter(tag_activeTags, tag_tagMode), first: (store.settings.renderLimit ?? 48), offset: tag_localOffset },
ctrl.signal,
);
if (ctrl.signal.aborted) return;
tag_localResults = [...tag_localResults, ...d.mangas.nodes];
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
tag_localOffset += ($settings.renderLimit ?? 48);
tag_localOffset += (store.settings.renderLimit ?? 48);
} catch (e: any) {
if (e?.name !== "AbortError") console.error(e);
} finally {
@@ -365,43 +325,31 @@
tag_abortSource?.abort();
const ctrl = new AbortController();
tag_abortSource = ctrl;
const sources = dedupeSources(allSources, preferredLang)
.filter((src) => (tag_srcNextPage.get(src.id) ?? -1) > 0);
const sources = dedupeSources(allSources, preferredLang).filter((src) => (tag_srcNextPage.get(src.id) ?? -1) > 0);
const primaryTag = tag_activeTags[0];
try {
await runConcurrent(sources, async (src) => {
const page = tag_srcNextPage.get(src.id)!;
if (ctrl.signal.aborted) return;
const ps = getPageSet(src.id, "SEARCH", tag_activeTags);
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tag_activeTags);
const result = await cache
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
pageKey,
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: primaryTag },
ctrl.signal,
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page, query: primaryTag }, ctrl.signal,
).then((d) => d.fetchSourceManga),
)
.catch((e: any) => {
if (e?.name !== "AbortError") tag_srcNextPage.set(src.id, -1);
return null;
});
.catch((e: any) => { if (e?.name !== "AbortError") tag_srcNextPage.set(src.id, -1); return null; });
if (!result || ctrl.signal.aborted) return;
ps.add(page);
tag_srcNextPage.set(src.id, result.hasNextPage ? page + 1 : -1);
tag_srcNextPage = new Map(tag_srcNextPage);
const matching = tag_activeTags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, tag_activeTags))
: result.mangas;
if (matching.length > 0) {
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), $settings.mangaLinks);
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks);
}
}, ctrl.signal);
} finally {
@@ -424,43 +372,33 @@
}
}
// ── Source browse ─────────────────────────────────────────────────────────
let src_selectedLang = "all";
let src_activeSource: Source | null = null;
let src_browseResults: Manga[] = [];
let src_loadingBrowse = false;
let src_browseQuery = "";
let src_submitted = "";
let src_hasNextPage = false;
let src_currentPage = 1;
let src_selectedLang = $state("all");
let src_activeSource: Source | null = $state(null);
let src_browseResults: Manga[] = $state([]);
let src_loadingBrowse = $state(false);
let src_browseQuery = $state("");
let src_submitted = $state("");
let src_hasNextPage = $state(false);
let src_currentPage = $state(1);
let src_abortCtrl: AbortController | null = null;
$: src_visibleSources = src_selectedLang === "all"
const src_visibleSources = $derived(src_selectedLang === "all"
? allSources
: allSources.filter((s) => s.lang === src_selectedLang);
: allSources.filter((s) => s.lang === src_selectedLang));
async function srcFetchBrowse(
src: Source,
type: "POPULAR" | "SEARCH",
q?: string,
page = 1,
) {
async function srcFetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string, page = 1) {
src_abortCtrl?.abort();
const ctrl = new AbortController();
src_abortCtrl = ctrl;
if (page === 1) { src_loadingBrowse = true; src_browseResults = []; }
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type, page, query: q ?? null },
ctrl.signal,
FETCH_SOURCE_MANGA, { source: src.id, type, page, query: q ?? null }, ctrl.signal,
);
if (ctrl.signal.aborted) return;
src_browseResults = page === 1
? d.fetchSourceManga.mangas
: [...src_browseResults, ...d.fetchSourceManga.mangas];
src_browseResults = page === 1 ? d.fetchSourceManga.mangas : [...src_browseResults, ...d.fetchSourceManga.mangas];
src_hasNextPage = d.fetchSourceManga.hasNextPage;
src_currentPage = page;
} catch (e: any) {
@@ -471,9 +409,7 @@
}
function srcSelectSource(src: Source) {
src_activeSource = src;
src_browseQuery = "";
src_submitted = "";
src_activeSource = src; src_browseQuery = ""; src_submitted = "";
srcFetchBrowse(src, "POPULAR");
}
@@ -484,13 +420,10 @@
}
function srcClearSearch() {
src_browseQuery = "";
src_submitted = "";
src_browseQuery = ""; src_submitted = "";
if (src_activeSource) srcFetchBrowse(src_activeSource, "POPULAR");
}
onDestroy(() => {
kw_abortCtrl?.abort();
tag_abortLocal?.abort();
@@ -507,7 +440,7 @@
<button
class="tab"
class:tabActive={tab === "keyword"}
on:click={() => (tab = "keyword")}
onclick={() => (tab = "keyword")}
>
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
@@ -518,7 +451,7 @@
<button
class="tab"
class:tabActive={tab === "tag"}
on:click={() => (tab = "tag")}
onclick={() => (tab = "tag")}
>
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
@@ -529,7 +462,7 @@
<button
class="tab"
class:tabActive={tab === "source"}
on:click={() => (tab = "source")}
onclick={() => (tab = "source")}
>
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
@@ -555,13 +488,13 @@
autofocus
class="searchInput"
placeholder="Search across sources…"
on:keydown={(e) => e.key === "Enter" && kwDoSearch(kw_query)}
onkeydown={(e) => e.key === "Enter" && kwDoSearch(kw_query)}
/>
{#if kw_query}
<button
class="clearBtn"
title="Clear"
on:click={() => { kw_query = ""; kw_inputEl?.focus(); }}
onclick={() => { kw_query = ""; kw_inputEl?.focus(); }}
>×</button>
{/if}
{#if hasMultipleLangs}
@@ -569,7 +502,7 @@
class="advancedBtn"
class:advancedBtnActive={kw_showAdvanced}
title="Language & filter options"
on:click={() => (kw_showAdvanced = !kw_showAdvanced)}
onclick={() => (kw_showAdvanced = !kw_showAdvanced)}
>
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
@@ -579,7 +512,7 @@
{/if}
<button
class="searchBtn"
on:click={() => kwDoSearch(kw_query)}
onclick={() => kwDoSearch(kw_query)}
disabled={!kw_query.trim() || loadingSources}
>
{#if loadingSources}
@@ -597,8 +530,8 @@
<div class="advancedHeader">
<span class="advancedTitle">Languages</span>
<div class="advancedActions">
<button class="advancedLink" on:click={() => (kw_selectedLangs = new Set(availableLangs))}>All</button>
<button class="advancedLink" on:click={() => (kw_selectedLangs = new Set([preferredLang]))}>Reset</button>
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set(availableLangs))}>All</button>
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set([preferredLang]))}>Reset</button>
</div>
</div>
<div class="langGrid">
@@ -606,7 +539,7 @@
<button
class="langChip"
class:langChipActive={kw_selectedLangs.has(lang)}
on:click={() => kwToggleLang(lang)}
onclick={() => kwToggleLang(lang)}
>
{lang === preferredLang ? `${lang.toUpperCase()} ` : lang.toUpperCase()}
</button>
@@ -638,7 +571,7 @@
{/if}
</p>
{#if hasMultipleLangs && !kw_showAdvanced}
<button class="advancedLinkStandalone" on:click={() => (kw_showAdvanced = true)}>
<button class="advancedLinkStandalone" onclick={() => (kw_showAdvanced = true)}>
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M40,88H73a32,32,0,0,0,62,0h81a8,8,0,0,0,0-16H135a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16Zm64-24A16,16,0,1,1,88,80,16,16,0,0,1,104,64ZM216,168H183a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16h81a32,32,0,0,0,62,0h33a8,8,0,0,0,0-16Zm-64,24a16,16,0,1,1,16-16A16,16,0,0,1,152,192Z"/>
</svg>
@@ -663,7 +596,7 @@
src={thumbUrl(source.iconUrl)}
alt={source.displayName}
class="sourceIcon"
on:error={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
<span class="sourceName">{source.displayName}</span>
{#if hasMultipleLangs}
@@ -692,8 +625,8 @@
</div>
{:else if mangas.length > 0}
<div class="sourceRow">
{#each mangas.slice(0, ($settings.renderLimit ?? 48)) as m (m.id)}
<button class="card" on:click={() => previewManga.set(m)}>
{#each mangas.slice(0, (store.settings.renderLimit ?? 48)) as m (m.id)}
<button class="card" onclick={() => setPreviewManga(m)}>
<div class="coverWrap">
<img
src={thumbUrl(m.thumbnailUrl)}
@@ -737,7 +670,7 @@
placeholder="Filter tags…"
/>
{#if tag_tagFilter}
<button class="splitSearchClear" title="Clear" on:click={() => (tag_tagFilter = "")}>×</button>
<button class="splitSearchClear" title="Clear" onclick={() => (tag_tagFilter = "")}>×</button>
{/if}
</div>
<div class="splitList">
@@ -745,7 +678,7 @@
<button
class="splitItem"
class:splitItemActive={tag_activeTags.includes(tag)}
on:click={() => tagToggleTag(tag)}
onclick={() => tagToggleTag(tag)}
>
<span class="splitItemLabel">{tag}</span>
{#if tag_activeTags.includes(tag)}<span class="tagCheckMark"></span>{/if}
@@ -774,7 +707,7 @@
{#each tag_activeTags as tag (tag)}
<span class="tagPill">
{tag}
<button class="tagPillRemove" title="Remove {tag}" on:click={() => tagToggleTag(tag)}>×</button>
<button class="tagPillRemove" title="Remove {tag}" onclick={() => tagToggleTag(tag)}>×</button>
</span>
{/each}
</div>
@@ -785,13 +718,13 @@
class="tagModeBtn"
class:tagModeBtnActive={tag_tagMode === "AND"}
title="Match ALL tags"
on:click={() => (tag_tagMode = "AND")}
onclick={() => (tag_tagMode = "AND")}
>AND</button>
<button
class="tagModeBtn"
class:tagModeBtnActive={tag_tagMode === "OR"}
title="Match ANY tag"
on:click={() => (tag_tagMode = "OR")}
onclick={() => (tag_tagMode = "OR")}
>OR</button>
</div>
{/if}
@@ -800,7 +733,7 @@
class:tagModeBtnActive={tag_searchSources}
title="Also search across sources (slower, requires network)"
disabled={loadingSources}
on:click={tagToggleSearchSources}
onclick={tagToggleSearchSources}
>
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" style="margin-right:3px;vertical-align:middle" aria-hidden="true">
@@ -808,7 +741,7 @@
</svg>
Sources
</button>
<button class="tagClearAll" on:click={() => (tag_activeTags = [])}>Clear all</button>
<button class="tagClearAll" onclick={() => (tag_activeTags = [])}>Clear all</button>
</div>
</div>
@@ -843,7 +776,7 @@
{:else if tag_mergedResults.length > 0}
<div class="tagGrid">
{#each tag_mergedResults as m (m.id)}
<button class="card" on:click={() => previewManga.set(m)}>
<button class="card" onclick={() => setPreviewManga(m)}>
<div class="coverWrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
@@ -864,7 +797,7 @@
{#if tag_localHasNext || tag_sourceHasMore}
<div class="showMoreCell">
{#if tag_localHasNext}
<button class="showMoreBtn" on:click={tagLoadMoreLocal} disabled={tag_loadingMoreLocal}>
<button class="showMoreBtn" onclick={tagLoadMoreLocal} disabled={tag_loadingMoreLocal}>
{#if tag_loadingMoreLocal}
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" aria-hidden="true">
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
@@ -875,7 +808,7 @@
</button>
{/if}
{#if tag_sourceHasMore}
<button class="showMoreBtn" on:click={tagLoadMoreSource} disabled={tag_loadingMoreSource}>
<button class="showMoreBtn" onclick={tagLoadMoreSource} disabled={tag_loadingMoreSource}>
{#if tag_loadingMoreSource}
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" aria-hidden="true">
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
@@ -916,7 +849,7 @@
<button
class="langChip"
class:langChipActive={src_selectedLang === lang}
on:click={() => (src_selectedLang = lang)}
onclick={() => (src_selectedLang = lang)}
>
{lang === "all" ? "All" : lang.toUpperCase()}
</button>
@@ -936,13 +869,13 @@
<button
class="splitItem splitItemSource"
class:splitItemActive={src_activeSource?.id === src.id}
on:click={() => srcSelectSource(src)}
onclick={() => srcSelectSource(src)}
>
<img
src={thumbUrl(src.iconUrl)}
alt=""
class="splitSourceIcon"
on:error={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
<span class="splitItemLabel">{src.displayName}</span>
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
@@ -972,7 +905,7 @@
src={thumbUrl(src_activeSource.iconUrl)}
alt=""
class="splitSourceIcon"
on:error={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
<span class="splitContentTitle">{src_activeSource.displayName}</span>
{#if src_loadingBrowse}
@@ -994,15 +927,15 @@
bind:value={src_browseQuery}
class="searchInput"
placeholder="Search {src_activeSource.displayName}…"
on:keydown={(e) => e.key === "Enter" && srcHandleSearch()}
onkeydown={(e) => e.key === "Enter" && srcHandleSearch()}
/>
{#if src_submitted}
<button class="clearBtn" title="Clear search" on:click={srcClearSearch}>×</button>
<button class="clearBtn" title="Clear search" onclick={srcClearSearch}>×</button>
{/if}
</div>
<button
class="searchBtn"
on:click={srcHandleSearch}
onclick={srcHandleSearch}
disabled={!src_browseQuery.trim() || src_loadingBrowse}
>
Search
@@ -1022,7 +955,7 @@
{:else if src_browseResults.length > 0}
<div class="tagGrid">
{#each src_browseResults as m (m.id)}
<button class="card" on:click={() => previewManga.set(m)}>
<button class="card" onclick={() => setPreviewManga(m)}>
<div class="coverWrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
@@ -1036,7 +969,7 @@
<button
class="showMoreBtn"
disabled={src_loadingBrowse}
on:click={() => src_activeSource && srcFetchBrowse(
onclick={() => src_activeSource && srcFetchBrowse(
src_activeSource,
src_submitted ? "SEARCH" : "POPULAR",
src_submitted || undefined,
+40 -41
View File
@@ -1,10 +1,10 @@
<script lang="ts">
import { onMount } from "svelte";
import { onMount, untrack } from "svelte";
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
import { settings, activeManga, activeChapter, genreFilter, navPage, addToast, updateSettings, addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders, openReader, checkAndMarkCompleted } from "../../store";
import { store, addToast, updateSettings, addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders, openReader, checkAndMarkCompleted, setActiveManga, setGenreFilter, setNavPage} from "../../store/state.svelte";
import type { Manga, Chapter } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
import MigrateModal from "./MigrateModal.svelte";
@@ -40,8 +40,8 @@
let rangeTo: string = $state("");
let showRange: boolean = $state(false);
let migrateOpen: boolean = $state(false);
let dlDropRef: HTMLDivElement;
let folderPickerRef: HTMLDivElement;
let dlDropRef: HTMLDivElement | undefined = $state();
let folderPickerRef: HTMLDivElement | undefined = $state();
let mangaAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null;
@@ -56,10 +56,10 @@
function applyChapters(nodes: Chapter[]) {
chapters = nodes;
if (activeManga && nodes.length > 0) checkAndMarkCompleted(activeManga.id, nodes);
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
}
const sortDir = $derived(settings.chapterSortDir);
const sortDir = $derived(store.settings.chapterSortDir);
const sortedChapters = $derived(sortDir === "desc" ? [...chapters].reverse() : [...chapters]);
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
@@ -80,7 +80,7 @@
})());
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
const assignedFolders = $derived(activeManga ? getMangaFolders(activeManga.id) : []);
const assignedFolders = $derived(store.activeManga ? getMangaFolders(store.activeManga.id) : []);
const hasFolders = $derived(assignedFolders.length > 0);
function loadManga(id: number) {
@@ -141,14 +141,18 @@
}
$effect(() => {
if (activeManga) { loadManga(activeManga.id); loadChapters(activeManga.id); }
const m = store.activeManga;
if (m) untrack(() => { loadManga(m.id); loadChapters(m.id); });
});
let prevChapterId: number | null = null;
$effect(() => {
const wasOpen = prevChapterId !== null;
prevChapterId = activeChapter?.id ?? null;
if (wasOpen && !activeChapter && activeManga) { loadChapters(activeManga.id); cache.clear(CACHE_KEYS.LIBRARY); }
prevChapterId = store.activeChapter?.id ?? null;
if (wasOpen && !store.activeChapter && store.activeManga) {
const id = store.activeManga.id;
untrack(() => { loadChapters(id); cache.clear(CACHE_KEYS.LIBRARY); });
}
});
async function toggleLibrary() {
@@ -174,20 +178,20 @@
await gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error);
addToast({ kind: "download", title: "Download queued", body: ch.name });
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing);
if (activeManga) reloadChapters(activeManga.id);
if (store.activeManga) reloadChapters(store.activeManga.id);
}
async function enqueueMultiple(chapterIds: number[]) {
if (!chapterIds.length) return;
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
addToast({ kind: "download", title: "Download queued", body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added` });
if (activeManga) reloadChapters(activeManga.id);
if (store.activeManga) reloadChapters(store.activeManga.id);
}
async function markRead(chapterId: number, isRead: boolean) {
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
if (activeManga) { chapterStore.set(activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(activeManga.id, chapters); }
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
}
async function markBulk(ids: number[], isRead: boolean) {
@@ -195,7 +199,7 @@
await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error);
const idSet = new Set(ids);
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
if (activeManga) { chapterStore.set(activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(activeManga.id, chapters); }
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
}
const markAboveRead = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => !c.isRead).map(c => c.id), true);
@@ -206,7 +210,7 @@
async function deleteDownloaded(chapterId: number) {
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error);
chapters = chapters.map(c => c.id === chapterId ? { ...c, isDownloaded: false } : c);
if (activeManga) chapterStore.set(activeManga.id, { data: chapters, fetchedAt: Date.now() });
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
}
async function deleteAllDownloads() {
@@ -215,16 +219,16 @@
deletingAll = true;
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
chapters = chapters.map(c => ({ ...c, isDownloaded: false }));
if (activeManga) chapterStore.set(activeManga.id, { data: chapters, fetchedAt: Date.now() });
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
deletingAll = false;
}
async function refreshChapters() {
if (!activeManga || refreshing) return;
if (!store.activeManga || refreshing) return;
refreshing = true;
chapterStore.delete(activeManga.id);
gql(FETCH_CHAPTERS, { mangaId: activeManga.id })
.then(() => reloadChapters(activeManga!.id))
chapterStore.delete(store.activeManga.id);
gql(FETCH_CHAPTERS, { mangaId: store.activeManga.id })
.then(() => reloadChapters(store.activeManga!.id))
.then(() => addToast({ kind: "success", title: "Chapters refreshed" }))
.catch(e => addToast({ kind: "error", title: "Refresh failed", body: e?.message }))
.finally(() => refreshing = false);
@@ -276,25 +280,25 @@
function createFolder() {
const name = folderNewName.trim();
if (!name || !activeManga) return;
if (!name || !store.activeManga) return;
const id = addFolder(name);
assignMangaToFolder(id, activeManga.id);
assignMangaToFolder(id, store.activeManga.id);
folderNewName = ""; folderCreating = false;
}
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
</script>
{#if activeManga}
{#if store.activeManga}
<div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}>
<div class="sidebar">
<button class="back" onclick={() => activeManga = null}>
<button class="back" onclick={() => setActiveManga(null)}>
<ArrowLeft size={13} weight="light" /> Back
</button>
<div class="cover-wrap">
<img src={thumbUrl(activeManga.thumbnailUrl)} alt={activeManga.title} class="cover" />
<img src={thumbUrl(store.activeManga.thumbnailUrl)} alt={store.activeManga.title} class="cover" />
</div>
{#if loadingManga}
@@ -314,7 +318,7 @@
{#if manga?.genre?.length}
<div class="genres">
{#each (genresExpanded ? manga.genre : manga.genre.slice(0, 5)) as g}
<button class="genre" onclick={() => { genreFilter = g; navPage = "explore"; activeManga = null; }}>{g}</button>
<button class="genre" onclick={() => { setGenreFilter(g); setNavPage("explore"); setActiveManga(null); }}>{g}</button>
{/each}
{#if manga.genre.length > 5}
<button class="genre-toggle" onclick={() => genresExpanded = !genresExpanded}>
@@ -415,13 +419,13 @@
</button>
{#if folderPickerOpen}
<div class="fp-menu">
{#if settings.folders.length === 0 && !folderCreating}
{#if store.settings.folders.length === 0 && !folderCreating}
<p class="fp-empty">No folders yet</p>
{/if}
{#each settings.folders as folder}
{@const isIn = activeManga ? folder.mangaIds.includes(activeManga.id) : false}
{#each store.settings.folders as folder}
{@const isIn = store.activeManga ? folder.mangaIds.includes(store.activeManga.id) : false}
<button class="fp-item" class:fp-item-active={isIn}
onclick={() => activeManga && (isIn ? removeMangaFromFolder(folder.id, activeManga.id) : assignMangaToFolder(folder.id, activeManga.id))}>
onclick={() => store.activeManga && (isIn ? removeMangaFromFolder(folder.id, store.activeManga.id) : assignMangaToFolder(folder.id, store.activeManga.id))}>
<span class="fp-check">{isIn ? "✓" : ""}</span>{folder.name}
</button>
{/each}
@@ -429,8 +433,7 @@
{#if folderCreating}
<div class="fp-create">
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName}
onkeydown={(e) => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }}
use:focus />
onkeydown={(e) => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} autofocus />
<button class="fp-confirm" onclick={createFolder} disabled={!folderNewName.trim()}>Add</button>
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}>
<X size={12} weight="light" />
@@ -449,8 +452,7 @@
<button class="jump-toggle" onclick={() => { jumpOpen = true; jumpInput = ""; }}>Go to…</button>
{:else}
<div class="jump-row">
<input class="jump-input" type="text" placeholder="Ch. #" bind:value={jumpInput}
use:focus
<input class="jump-input" type="text" placeholder="Ch. #" bind:value={jumpInput} autofocus
onkeydown={(e) => {
if (e.key === "Escape") { jumpOpen = false; return; }
if (e.key === "Enter") {
@@ -499,7 +501,7 @@
{:else}
<div class="dl-range-row">
<button class="dl-range-back" onclick={() => showRange = false}></button>
<input class="dl-range-input" placeholder="From" bind:value={rangeFrom} onkeydown={(e) => e.key === "Enter" && enqueueRange()} use:focus />
<input class="dl-range-input" placeholder="From" bind:value={rangeFrom} onkeydown={(e) => e.key === "Enter" && enqueueRange()} autofocus />
<span class="dl-range-sep"></span>
<input class="dl-range-input" placeholder="To" bind:value={rangeTo} onkeydown={(e) => e.key === "Enter" && enqueueRange()} />
<button class="dl-range-go" disabled={!rangeFrom.trim() || !rangeTo.trim()} onclick={enqueueRange}>Go</button>
@@ -571,11 +573,11 @@
<div class="ch-right">
{#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
{#if ch.isDownloaded}
<button class="dl-btn" onclick|stopPropagation={() => deleteDownloaded(ch.id)}><Trash size={13} weight="light" /></button>
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }}><Trash size={13} weight="light" /></button>
{:else if enqueueing.has(ch.id)}
<CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" />
{:else}
<button class="dl-btn" onclick|stopPropagation={(e) => enqueue(ch, e)}><Download size={13} weight="light" /></button>
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); enqueue(ch, e); }}><Download size={13} weight="light" /></button>
{/if}
</div>
</div>
@@ -602,14 +604,11 @@
{manga}
currentChapters={chapters}
onClose={() => migrateOpen = false}
onMigrated={(newManga) => { activeManga = newManga; migrateOpen = false; cache.clear(CACHE_KEYS.LIBRARY); }}
onMigrated={(newManga) => { setActiveManga(newManga); migrateOpen = false; cache.clear(CACHE_KEYS.LIBRARY); }}
/>
{/if}
{/if}
<script context="module">
function focus(node: HTMLElement) { node.focus(); }
</script>
<style>
.root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
+138 -114
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import { onMount, tick } from "svelte";
import { onMount, tick, untrack } from "svelte";
import { X, CaretLeft, CaretRight, ArrowLeft, ArrowRight, Square, Rows, Download, ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical, CircleNotch } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { FETCH_CHAPTER_PAGES, MARK_CHAPTER_READ, ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
import { settings, activeManga, activeChapter, activeChapterList, pageUrls, pageNumber, closeReader, openReader, settingsOpen, addHistory, updateSettings, checkAndMarkCompleted } from "../../store";
import { store, closeReader, openReader, addHistory, updateSettings, checkAndMarkCompleted, setSettingsOpen } from "../../store/state.svelte";
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "../../lib/keybinds";
import type { FitMode } from "../../store";
import type { FitMode } from "../../store/state.svelte";
const pageCache = new Map<number, string[]>();
const inflight = new Map<number, Promise<string[]>>();
@@ -73,17 +73,17 @@
let sentinelEl: HTMLDivElement;
let hideTimer: ReturnType<typeof setTimeout> | null = null;
let loading: boolean = $state(true);
let error: string | null = $state(null);
let dlOpen: boolean = $state(false);
let zoomOpen: boolean = $state(false);
let uiVisible: boolean = $state(true);
let pageReady: boolean = $state(false);
let pageGroups: number[][] = $state([]);
let stripChapters: StripChapter[] = $state([]);
let visibleChapterId: number | null = $state(null);
let nextN: number = $state(5);
let dlBusy: boolean = $state(false);
let loading = $state(true);
let error: string | null = $state(null);
let dlOpen = $state(false);
let zoomOpen = $state(false);
let uiVisible = $state(true);
let pageReady = $state(false);
let pageGroups: number[][] = $state([]);
let stripChapters: StripChapter[] = $state([]);
let visibleChapterId: number | null = $state(null);
let nextN = $state(5);
let dlBusy = $state(false);
let markedRead = new Set<number>();
let appended = new Set<number>();
let appending = false;
@@ -91,32 +91,33 @@
let loadingId: number | null = null;
let scrollAnchor: { scrollTop: number; scrollHeight: number } | null = null;
const rtl = $derived(settings.readingDirection === "rtl");
const fit = $derived((settings.fitMode ?? "width") as FitMode);
const style = $derived(settings.pageStyle ?? "single");
const maxW = $derived(settings.maxPageWidth ?? 900);
const autoNext = $derived(settings.autoNextChapter ?? false);
const markOnNext = $derived(settings.markReadOnNext ?? true);
const lastPage = $derived(pageUrls.length);
const rtl = $derived(store.settings.readingDirection === "rtl");
const fit = $derived((store.settings.fitMode ?? "width") as FitMode);
const style = $derived(store.settings.pageStyle ?? "single");
const maxW = $derived(store.settings.maxPageWidth ?? 900);
const autoNext = $derived(store.settings.autoNextChapter ?? false);
const markOnNext = $derived(store.settings.markReadOnNext ?? true);
const lastPage = $derived(store.pageUrls.length);
const displayChapter = $derived((style === "longstrip" && autoNext && visibleChapterId)
? (activeChapterList.find(c => c.id === visibleChapterId) ?? activeChapter)
: activeChapter);
? (store.activeChapterList.find(c => c.id === visibleChapterId) ?? store.activeChapter)
: store.activeChapter);
const adjacent = $derived((() => {
const ref = displayChapter ?? activeChapter;
if (!ref || !activeChapterList.length) return { prev: null, next: null, remaining: [] };
const idx = activeChapterList.findIndex(c => c.id === ref.id);
const ref = displayChapter ?? store.activeChapter;
if (!ref || !store.activeChapterList.length) return { prev: null, next: null, remaining: [] };
const idx = store.activeChapterList.findIndex(c => c.id === ref.id);
return {
prev: idx > 0 ? activeChapterList[idx - 1] : null,
next: idx < activeChapterList.length - 1 ? activeChapterList[idx + 1] : null,
remaining: activeChapterList.slice(idx + 1),
prev: idx > 0 ? store.activeChapterList[idx - 1] : null,
next: idx < store.activeChapterList.length - 1 ? store.activeChapterList[idx + 1] : null,
remaining: store.activeChapterList.slice(idx + 1),
};
})());
const visibleChunkLastPage = $derived((() => {
if (style !== "longstrip" || !autoNext) return lastPage;
const chId = visibleChapterId ?? activeChapter?.id;
const chId = visibleChapterId ?? store.activeChapter?.id;
const chunk = stripChapters.find(c => c.chapterId === chId);
return chunk?.urls.length ?? lastPage;
})());
@@ -127,21 +128,21 @@
fit === "height" && "fit-height",
fit === "screen" && "fit-screen",
fit === "original" && "fit-original",
settings.optimizeContrast && "optimize-contrast",
store.settings.optimizeContrast && "optimize-contrast",
].filter(Boolean).join(" "));
const fitLabel = $derived({ width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]);
const styleLabel = $derived(style);
function maybeMarkCurrentRead() {
const ch = activeChapter;
const ch = store.activeChapter;
if (!ch || !markOnNext || markedRead.has(ch.id)) return;
markedRead.add(ch.id);
gql(MARK_CHAPTER_READ, { id: ch.id, isRead: true })
.then(() => {
if (activeManga) {
const updated = activeChapterList.map(c => c.id === ch.id ? { ...c, isRead: true } : c);
checkAndMarkCompleted(activeManga.id, updated);
if (store.activeManga) {
const updated = store.activeChapterList.map(c => c.id === ch.id ? { ...c, isRead: true } : c);
checkAndMarkCompleted(store.activeManga.id, updated);
}
})
.catch(e => { markedRead.delete(ch.id); console.error(e); });
@@ -153,7 +154,10 @@
hideTimer = setTimeout(() => uiVisible = false, 3000);
}
$effect(() => { if (activeChapter) loadChapter(activeChapter.id); });
$effect(() => {
const ch = store.activeChapter;
if (ch) untrack(() => loadChapter(ch.id));
});
async function loadChapter(id: number) {
abortCtrl?.abort();
@@ -170,15 +174,15 @@
pageReady = false;
stripChapters = [];
visibleChapterId = null;
pageUrls = [];
pageNumber = 1;
store.pageUrls = [];
store.pageNumber = 1;
try {
const urls = await fetchPages(id, ctrl.signal);
if (ctrl.signal.aborted) return;
pageUrls = urls;
store.pageUrls = urls;
pageReady = true;
if (style === "longstrip" && autoNext) {
stripChapters = [{ chapterId: id, chapterName: activeChapter?.name ?? "", urls, startGlobalIdx: 0 }];
stripChapters = [{ chapterId: id, chapterName: store.activeChapter?.name ?? "", urls, startGlobalIdx: 0 }];
visibleChapterId = id;
}
loading = false;
@@ -193,7 +197,7 @@
if (appending) return;
const lastChunk = stripChapters[stripChapters.length - 1];
if (!lastChunk) return;
const list = activeChapterList;
const list = store.activeChapterList;
const lastIdx = list.findIndex(c => c.id === lastChunk.chapterId);
if (lastIdx < 0 || lastIdx >= list.length - 1) return;
const next = list[lastIdx + 1];
@@ -239,26 +243,26 @@
else break;
}
if (activeLocalPage === null && imgs.length > 0) { activeLocalPage = Number(imgs[0].dataset.localPage); activeChId = Number(imgs[0].dataset.chapter); }
if (activeLocalPage !== null) pageNumber = activeLocalPage;
if (activeLocalPage !== null) store.pageNumber = activeLocalPage;
if (activeChId && activeChId !== visibleChapterId) visibleChapterId = activeChId;
if (settings.autoMarkRead && activeLocalPage !== null && activeChId) {
if (store.settings.autoMarkRead && activeLocalPage !== null && activeChId) {
const chunk = stripChapters.find(c => c.chapterId === activeChId);
const total = chunk ? chunk.urls.length : pageUrls.length;
const total = chunk ? chunk.urls.length : store.pageUrls.length;
if (total > 0 && activeLocalPage >= total - 1 && !markedRead.has(activeChId)) {
markedRead.add(activeChId);
const chIdSnap = activeChId;
gql(MARK_CHAPTER_READ, { id: chIdSnap, isRead: true })
.then(() => { if (activeManga) { const updated = activeChapterList.map(c => c.id === chIdSnap ? { ...c, isRead: true } : c); checkAndMarkCompleted(activeManga.id, updated); } })
.then(() => { if (store.activeManga) { const updated = store.activeChapterList.map(c => c.id === chIdSnap ? { ...c, isRead: true } : c); checkAndMarkCompleted(store.activeManga.id, updated); } })
.catch(e => { markedRead.delete(chIdSnap); console.error(e); });
}
}
if (containerEl.scrollTop + containerEl.clientHeight < containerEl.scrollHeight - 40) return;
const last = stripChapters[stripChapters.length - 1];
if (last && settings.autoMarkRead && !markedRead.has(last.chapterId)) {
if (last && store.settings.autoMarkRead && !markedRead.has(last.chapterId)) {
markedRead.add(last.chapterId);
const lastIdSnap = last.chapterId;
gql(MARK_CHAPTER_READ, { id: lastIdSnap, isRead: true })
.then(() => { if (activeManga) { const updated = activeChapterList.map(c => c.id === lastIdSnap ? { ...c, isRead: true } : c); checkAndMarkCompleted(activeManga.id, updated); } })
.then(() => { if (store.activeManga) { const updated = store.activeChapterList.map(c => c.id === lastIdSnap ? { ...c, isRead: true } : c); checkAndMarkCompleted(store.activeManga.id, updated); } })
.catch(console.error);
}
}
@@ -274,34 +278,34 @@
function advanceGroup(forward: boolean) {
if (!pageGroups.length) return;
const gi = pageGroups.findIndex(g => g.includes(pageNumber));
const gi = pageGroups.findIndex(g => g.includes(store.pageNumber));
if (forward) {
if (gi < pageGroups.length - 1) pageNumber = pageGroups[gi + 1][0];
else if (adjacent.next) { pageNumber = 1; openReader(adjacent.next, activeChapterList); }
if (gi < pageGroups.length - 1) store.pageNumber = pageGroups[gi + 1][0];
else if (adjacent.next) { store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); }
else closeReader();
} else {
if (gi > 0) pageNumber = pageGroups[gi - 1][0];
else if (adjacent.prev) openReader(adjacent.prev, activeChapterList);
if (gi > 0) store.pageNumber = pageGroups[gi - 1][0];
else if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList);
}
}
function goForward() {
if (loading) return;
if (style === "longstrip") { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, activeChapterList); } return; }
if (style === "longstrip") { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, store.activeChapterList); } return; }
if (style === "double" && pageGroups.length) { advanceGroup(true); return; }
if (!pageUrls.length) return;
if (pageNumber < lastPage) { decodeImage(pageUrls[pageNumber]).then(() => pageNumber++); }
else if (adjacent.next) { maybeMarkCurrentRead(); pageNumber = 1; openReader(adjacent.next, activeChapterList); }
if (!store.pageUrls.length) return;
if (store.pageNumber < lastPage) { decodeImage(store.pageUrls[store.pageNumber]).then(() => store.pageNumber++); }
else if (adjacent.next) { maybeMarkCurrentRead(); store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); }
else closeReader();
}
function goBack() {
if (loading) return;
if (style === "longstrip") { if (adjacent.prev) openReader(adjacent.prev, activeChapterList); return; }
if (style === "longstrip") { if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList); return; }
if (style === "double" && pageGroups.length) { advanceGroup(false); return; }
if (!pageUrls.length) return;
if (pageNumber > 1) { decodeImage(pageUrls[pageNumber - 2]).then(() => pageNumber--); }
else if (adjacent.prev) openReader(adjacent.prev, activeChapterList);
if (!store.pageUrls.length) return;
if (store.pageNumber > 1) { decodeImage(store.pageUrls[store.pageNumber - 2]).then(() => store.pageNumber--); }
else if (adjacent.prev) openReader(adjacent.prev, store.activeChapterList);
}
const goNext = $derived(rtl ? goBack : goForward);
@@ -319,27 +323,35 @@
}
$effect(() => {
if (activeChapter && lastPage && activeManga) {
addHistory({ mangaId: activeManga.id, mangaTitle: activeManga.title, thumbnailUrl: activeManga.thumbnailUrl, chapterId: activeChapter.id, chapterName: activeChapter.name, pageNumber, readAt: Date.now() });
if (style !== "longstrip" && settings.autoMarkRead && pageNumber === lastPage) {
if (!markedRead.has(activeChapter.id)) {
markedRead.add(activeChapter.id);
const chIdSnap = activeChapter.id;
gql(MARK_CHAPTER_READ, { id: chIdSnap, isRead: true })
.then(() => { if (activeManga) { const updated = activeChapterList.map(c => c.id === chIdSnap ? { ...c, isRead: true } : c); checkAndMarkCompleted(activeManga.id, updated); } })
.catch(console.error);
if (store.activeChapter && lastPage && store.activeManga) {
const chapterId = store.activeChapter.id;
const chapterName = store.activeChapter.name;
const mangaId = store.activeManga.id;
const mangaTitle = store.activeManga.title;
const thumbUrl = store.activeManga.thumbnailUrl;
const pageNum = store.pageNumber;
const atLast = store.pageNumber === lastPage;
untrack(() => {
addHistory({ mangaId, mangaTitle, thumbnailUrl: thumbUrl, chapterId, chapterName, pageNumber: pageNum, readAt: Date.now() });
if (style !== "longstrip" && store.settings.autoMarkRead && atLast) {
if (!markedRead.has(chapterId)) {
markedRead.add(chapterId);
gql(MARK_CHAPTER_READ, { id: chapterId, isRead: true })
.then(() => { if (store.activeManga) { const updated = store.activeChapterList.map(c => c.id === chapterId ? { ...c, isRead: true } : c); checkAndMarkCompleted(store.activeManga.id, updated); } })
.catch(console.error);
}
}
}
});
}
});
$effect(() => {
if (style === "double" && pageUrls.length) {
if (style === "double" && store.pageUrls.length) {
let cancelled = false;
const snap = pageUrls;
const snap = store.pageUrls;
Promise.all(snap.map(measureAspect)).then(aspects => {
if (cancelled || snap !== pageUrls) return;
const offset = settings.offsetDoubleSpreads;
if (cancelled || snap !== store.pageUrls) return;
const offset = store.settings.offsetDoubleSpreads;
const groups: number[][] = [[1]];
if (offset) groups.push([2]);
let i = offset ? 3 : 2;
@@ -355,36 +367,36 @@
});
$effect(() => {
const ahead = settings.preloadPages ?? 3;
for (let i = 1; i <= ahead; i++) { const url = pageUrls[pageNumber - 1 + i]; if (url) decodeImage(url); }
const behind = pageUrls[pageNumber - 2];
const ahead = store.settings.preloadPages ?? 3;
for (let i = 1; i <= ahead; i++) { const url = store.pageUrls[store.pageNumber - 1 + i]; if (url) decodeImage(url); }
const behind = store.pageUrls[store.pageNumber - 2];
if (behind) preloadImage(behind);
});
$effect(() => {
if (activeChapter && activeChapterList.length) {
const idx = activeChapterList.findIndex(c => c.id === activeChapter!.id);
if (store.activeChapter && store.activeChapterList.length) {
const idx = store.activeChapterList.findIndex(c => c.id === store.activeChapter!.id);
if (idx >= 0) {
const toPin: number[] = [activeChapter.id];
const toPin: number[] = [store.activeChapter.id];
for (let i = 1; i <= 3; i++) {
const entry = activeChapterList[idx + i];
const entry = store.activeChapterList[idx + i];
if (!entry) break;
toPin.push(entry.id);
fetchPages(entry.id).then(urls => { const n = i === 1 ? 8 : i === 2 ? 4 : 2; urls.slice(0, n).forEach(preloadImage); }).catch(() => {});
}
if (idx > 0) { const prev = activeChapterList[idx - 1]; toPin.push(prev.id); fetchPages(prev.id).catch(() => {}); }
if (idx > 0) { const prev = store.activeChapterList[idx - 1]; toPin.push(prev.id); fetchPages(prev.id).catch(() => {}); }
cacheEvict(new Set(toPin));
}
}
});
$effect(() => {
if (style === "longstrip" && pageUrls.length && activeChapter) {
appended = new Set([activeChapter.id]);
if (style === "longstrip" && store.pageUrls.length && store.activeChapter) {
appended = new Set([store.activeChapter.id]);
appending = false;
if (autoNext) {
stripChapters = [{ chapterId: activeChapter.id, chapterName: activeChapter.name, urls: pageUrls, startGlobalIdx: 0 }];
visibleChapterId = activeChapter.id;
stripChapters = [{ chapterId: store.activeChapter.id, chapterName: store.activeChapter.name, urls: store.pageUrls, startGlobalIdx: 0 }];
visibleChapterId = store.activeChapter.id;
} else {
stripChapters = [];
visibleChapterId = null;
@@ -393,7 +405,7 @@
}
});
$effect(() => { if (activeChapter?.id && containerEl) containerEl.scrollTop = 0; });
$effect(() => { if (store.activeChapter?.id && containerEl) containerEl.scrollTop = 0; });
$effect(() => { if (style !== "longstrip" && containerEl) containerEl.scrollTop = 0; });
function onWheel(e: WheelEvent) {
@@ -404,9 +416,9 @@
function onKey(e: KeyboardEvent) {
if ((e.target as HTMLElement).tagName === "INPUT") return;
const kb = settings.keybinds ?? DEFAULT_KEYBINDS;
const mW = settings.maxPageWidth ?? 900;
const r = settings.readingDirection === "rtl";
const kb = store.settings.keybinds ?? DEFAULT_KEYBINDS;
const mW = store.settings.maxPageWidth ?? 900;
const r = store.settings.readingDirection === "rtl";
if (e.key === "Escape") {
e.preventDefault();
if (zoomOpen) { zoomOpen = false; return; }
@@ -419,24 +431,24 @@
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); closeReader(); }
else if (matchesKeybind(e, kb.pageRight)) { e.preventDefault(); goForward(); }
else if (matchesKeybind(e, kb.pageLeft)) { e.preventDefault(); goBack(); }
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); pageNumber = 1; }
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); pageNumber = lastPage; }
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); store.pageNumber = 1; }
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); store.pageNumber = lastPage; }
else if (matchesKeybind(e, kb.chapterRight)) {
e.preventDefault();
const list = activeChapterList, idx = list.findIndex(c => c.id === loadingId);
const list = store.activeChapterList, idx = list.findIndex(c => c.id === loadingId);
const next = idx >= 0 && idx < list.length - 1 ? list[idx + 1] : null;
if (next) { maybeMarkCurrentRead(); openReader(next, list); }
}
else if (matchesKeybind(e, kb.chapterLeft)) {
e.preventDefault();
const list = activeChapterList, idx = list.findIndex(c => c.id === loadingId);
const list = store.activeChapterList, idx = list.findIndex(c => c.id === loadingId);
const prev = idx > 0 ? list[idx - 1] : null;
if (prev) openReader(prev, list);
}
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); cycleStyle(); }
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); updateSettings({ readingDirection: r ? "ltr" : "rtl" }); }
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); settingsOpen = true; }
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); setSettingsOpen(true); }
}
function handleTap(e: MouseEvent) {
@@ -469,31 +481,43 @@
};
});
$effect(() => {
if (!containerEl) return;
const _style = style;
const _len = store.pageUrls.length;
const _auto = autoNext;
untrack(() => {
scrollCleanup?.();
scrollCleanup = setupScrollTracking();
});
return () => { scrollCleanup?.(); };
});
const stripToRender = $derived(style === "longstrip"
? (autoNext && stripChapters.length > 0
? stripChapters
: [{ chapterId: activeChapter?.id ?? 0, chapterName: activeChapter?.name ?? "", urls: pageUrls, startGlobalIdx: 0 }])
: [{ chapterId: store.activeChapter?.id ?? 0, chapterName: store.activeChapter?.name ?? "", urls: store.pageUrls, startGlobalIdx: 0 }])
: []);
const currentGroup = $derived(style === "double" && pageGroups.length
? (pageGroups.find(g => g.includes(pageNumber)) ?? [pageNumber])
: [pageNumber]);
? (pageGroups.find(g => g.includes(store.pageNumber)) ?? [store.pageNumber])
: [store.pageNumber]);
</script>
<div class="root" 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>
<button class="icon-btn" onclick={() => { if (adjacent.prev) { maybeMarkCurrentRead(); openReader(adjacent.prev, activeChapterList); } }} disabled={!adjacent.prev}>
<button class="icon-btn" onclick={() => { if (adjacent.prev) { maybeMarkCurrentRead(); openReader(adjacent.prev, store.activeChapterList); } }} disabled={!adjacent.prev}>
<CaretLeft size={14} weight="light" />
</button>
<span class="ch-label">
<span class="ch-title">{activeManga?.title}</span>
<span class="ch-title">{store.activeManga?.title}</span>
<span class="ch-sep">/</span>
<span>{displayChapter?.name}</span>
</span>
<span class="page-label">{pageNumber} / {visibleChunkLastPage || "…"}</span>
<button class="icon-btn" onclick={() => { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, activeChapterList); } }} disabled={!adjacent.next}>
<span class="page-label">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
<button class="icon-btn" onclick={() => { if (adjacent.next) { maybeMarkCurrentRead(); openReader(adjacent.next, store.activeChapterList); } }} disabled={!adjacent.next}>
<CaretRight size={14} weight="light" />
</button>
<div class="top-sep"></div>
@@ -522,7 +546,7 @@
<span class="mode-label">{styleLabel}</span>
</button>
{#if style !== "single"}
<button class="mode-btn" class:active={settings.pageGap} onclick={() => updateSettings({ pageGap: !settings.pageGap })}>
<button class="mode-btn" class:active={store.settings.pageGap} onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}>
<span class="mode-label">Gap</span>
</button>
{/if}
@@ -562,7 +586,7 @@
{#if style === "longstrip"}
{#each stripToRender as chunk}
{#each chunk.urls as url, i}
<img src={url} alt="{chunk.chapterName} Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{settings.pageGap ? ' strip-gap' : ''}" loading={i < 3 ? "eager" : "lazy"} decoding="async" height="1000" />
<img src={url} alt="{chunk.chapterName} Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 3 ? "eager" : "lazy"} decoding="async" height="1000" />
{/each}
{/each}
<div bind:this={sentinelEl} style="height:1px;flex-shrink:0;overflow-anchor:none"></div>
@@ -570,33 +594,33 @@
{#if style === "double" && pageGroups.length}
<div class="double-wrap">
{#each currentGroup as pg}
<img src={pageUrls[pg - 1]} alt="Page {pg}" class="{imgCls} page-half {pg === currentGroup[0] ? 'gap-left' : 'gap-right'}" decoding="async" />
<img src={store.pageUrls[pg - 1]} alt="Page {pg}" class="{imgCls} page-half {pg === currentGroup[0] ? 'gap-left' : 'gap-right'}" decoding="async" />
{/each}
</div>
{:else}
<img src={pageUrls[pageNumber - 1]} alt="Page {pageNumber}" class={imgCls} decoding="async" style="transition:opacity 0.1s ease" />
<img src={store.pageUrls[store.pageNumber - 1]} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="transition:opacity 0.1s ease" />
{/if}
{/if}
</div>
<div class="bottombar" class:hidden={!uiVisible}>
<button class="nav-btn" onclick={goPrev} disabled={loading || (style === "longstrip" ? !adjacent.prev : (pageNumber === 1 && !adjacent.prev))}>
<button class="nav-btn" onclick={goPrev} disabled={loading || (style === "longstrip" ? !adjacent.prev : (store.pageNumber === 1 && !adjacent.prev))}>
<ArrowLeft size={13} weight="light" />
</button>
<button class="nav-btn" onclick={goNext} disabled={loading || (style === "longstrip" ? !adjacent.next : (pageNumber === lastPage && !adjacent.next))}>
<button class="nav-btn" onclick={goNext} disabled={loading || (style === "longstrip" ? !adjacent.next : (store.pageNumber === lastPage && !adjacent.next))}>
<ArrowRight size={13} weight="light" />
</button>
</div>
{#if dlOpen && activeChapter}
{#if dlOpen && store.activeChapter}
{@const queueable = adjacent.remaining.filter(c => !c.isDownloaded)}
<div class="dl-backdrop" role="presentation" onclick={() => dlOpen = false}>
<div class="dl-modal" role="presentation" onclick|stopPropagation>
<div class="dl-modal" role="presentation" onclick={(e) => e.stopPropagation()}>
<p class="dl-title">Download</p>
<button class="dl-option" disabled={dlBusy || !!activeChapter.isDownloaded}
onclick={() => runDl(() => gql(ENQUEUE_DOWNLOAD, { chapterId: activeChapter!.id }))}>
<button class="dl-option" disabled={dlBusy || !!store.activeChapter.isDownloaded}
onclick={() => runDl(() => gql(ENQUEUE_DOWNLOAD, { chapterId: store.activeChapter!.id }))}>
This chapter
<span class="dl-sub">{activeChapter.isDownloaded ? "Already downloaded" : activeChapter.name}</span>
<span class="dl-sub">{store.activeChapter.isDownloaded ? "Already downloaded" : store.activeChapter.name}</span>
</button>
<div class="dl-row">
<button class="dl-option" disabled={dlBusy || queueable.length === 0}
@@ -604,7 +628,7 @@
Next chapters
<span class="dl-sub">{Math.min(nextN, queueable.length)} not yet downloaded</span>
</button>
<div class="dl-stepper" role="presentation" onclick|stopPropagation>
<div class="dl-stepper" role="presentation" onclick={(e) => e.stopPropagation()}>
<button class="dl-step-btn" onclick={() => nextN = Math.max(1, nextN - 1)} disabled={nextN <= 1}></button>
<span class="dl-step-val">{nextN}</span>
<button class="dl-step-btn" onclick={() => nextN = Math.min(queueable.length || 1, nextN + 1)} disabled={nextN >= queueable.length}>+</button>
+120 -118
View File
@@ -1,13 +1,13 @@
<script lang="ts">
import { onMount, onDestroy, tick } from "svelte";
import { tick } from "svelte";
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Plus, Pencil, Trash, Wrench, PaintBrush } from "phosphor-svelte";
import { invoke } from "@tauri-apps/api/core";
import { gql } from "../../lib/client";
import { GET_DOWNLOADS_PATH } from "../../lib/queries";
import { settings, settingsOpen, history, updateSettings, resetKeybinds, addFolder, removeFolder, renameFolder, toggleFolderTab, clearHistory, wipeAllData } from "../../store";
import { store, updateSettings, resetKeybinds, addFolder, removeFolder, renameFolder, toggleFolderTab, clearHistory, wipeAllData, setSettingsOpen } 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";
import type { Settings, FitMode, Theme } from "../../store/state.svelte";
import type { Keybinds } from "../../lib/keybinds";
type Tab = "general" | "appearance" | "reader" | "library" | "performance" | "keybinds" | "storage" | "folders" | "about" | "devtools";
@@ -34,19 +34,20 @@
{ id: "warm", label: "Warm", description: "Amber and sepia tones", swatches: ["#16130c","#1c1810","#e0b860","#f5f0e0"] },
];
let tab: Tab = "general";
let tab: Tab = $state("general");
let contentBodyEl: HTMLDivElement;
$effect(() => { tab; tick().then(() => contentBodyEl?.scrollTo({ top: 0 })); });
$: { tab; tick().then(() => contentBodyEl?.scrollTo({ top: 0 })); }
function close() { settingsOpen.set(false); }
function close() { setSettingsOpen(false); }
function onKey(e: KeyboardEvent) { if (e.key === "Escape" && !listeningKey) close(); }
onMount(() => window.addEventListener("keydown", onKey));
onDestroy(() => window.removeEventListener("keydown", onKey));
$effect(() => {
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
let listeningKey: keyof Keybinds | null = null;
let listeningKey: keyof Keybinds | null = $state(null);
function startListen(key: keyof Keybinds) {
listeningKey = listeningKey === key ? null : key;
@@ -57,23 +58,24 @@
e.preventDefault(); e.stopPropagation();
const bind = eventToKeybind(e);
if (!bind) return;
updateSettings({ keybinds: { ...$settings.keybinds, [listeningKey]: bind } });
updateSettings({ keybinds: { ...store.settings.keybinds, [listeningKey]: bind } });
listeningKey = null;
}
$: if (listeningKey) {
window.addEventListener("keydown", onKeyCapture, true);
} else {
window.removeEventListener("keydown", onKeyCapture, true);
}
$effect(() => {
if (listeningKey) {
window.addEventListener("keydown", onKeyCapture, true);
return () => window.removeEventListener("keydown", onKeyCapture, true);
}
});
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string; }
let storageInfo: StorageInfo | null = null;
let storageLoading = false;
let storageError: string | null = null;
let clearing = false;
let cleared = false;
let storageInfo: StorageInfo | null = $state(null);
let storageLoading = $state(false);
let storageError: string | null = $state(null);
let clearing = $state(false);
let cleared = $state(false);
async function fetchStorage() {
storageLoading = true; storageError = null;
@@ -83,8 +85,7 @@
} catch (e: any) { storageError = e instanceof Error ? e.message : String(e); }
finally { storageLoading = false; }
}
$: if (tab === "storage" && !storageInfo && !storageLoading) fetchStorage();
$effect(() => { if (tab === "storage" && !storageInfo && !storageLoading) fetchStorage(); });
function handleClearCache() {
clearing = true;
@@ -108,7 +109,7 @@
newestEntryMs: number | null;
}
let perfSnapshot: PerfSnapshot | null = null;
let perfSnapshot: PerfSnapshot | null = $state(null);
function refreshPerfMetrics() {
// cache.list() isn't exported, but we can probe known keys to build a snapshot
@@ -139,8 +140,7 @@
perfSnapshot = { cacheEntries: entries, cacheKeys: foundKeys, oldestEntryMs: oldest, newestEntryMs: newest };
}
$: if (tab === "performance") refreshPerfMetrics();
$effect(() => { if (tab === "performance") refreshPerfMetrics(); });
function fmtAge(ts: number | null): string {
if (ts === null) return "—";
@@ -152,7 +152,7 @@
}
// Storage limit input state
let storageLimitInput = String($settings.storageLimitGb ?? "");
let storageLimitInput = $state(String(store.settings.storageLimitGb ?? ""));
function applyStorageLimit() {
const v = storageLimitInput.trim();
@@ -162,9 +162,9 @@
}
let newFolderName = "";
let editingId: string | null = null;
let editingName = "";
let newFolderName = $state("");
let editingId: string | null = $state(null);
let editingName = $state("");
function createFolder() {
const name = newFolderName.trim();
@@ -180,7 +180,7 @@
}
let selectOpen: string | null = null;
let selectOpen: string | null = $state(null);
function toggleSelect(id: string) { selectOpen = selectOpen === id ? null : id; }
@@ -188,11 +188,13 @@
if (selectOpen && !(e.target as HTMLElement).closest(".select-wrap")) selectOpen = null;
}
onMount(() => document.addEventListener("mousedown", onSelectOutside));
onDestroy(() => document.removeEventListener("mousedown", onSelectOutside));
$effect(() => {
document.addEventListener("mousedown", onSelectOutside);
return () => document.removeEventListener("mousedown", onSelectOutside);
});
let splashTriggered = false;
let splashTriggered = $state(false);
function triggerSplash() {
splashTriggered = true;
setTimeout(() => splashTriggered = false, 200);
@@ -200,14 +202,14 @@
}
</script>
<div class="backdrop" role="presentation" on:click={(e) => { if (e.target === e.currentTarget) close(); }} on:keydown={(e) => { if (e.key === "Escape") close(); }}>
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) close(); }} onkeydown={(e) => { if (e.key === "Escape") close(); }}>
<div class="modal" role="dialog" aria-label="Settings">
<div class="sidebar">
<p class="modal-title">Settings</p>
<nav class="nav">
{#each TABS as t}
<button class="nav-item" class:active={tab === t.id} on:click={() => tab = t.id}>
<svelte:component this={t.icon} size={14} weight="light" />
<button class="nav-item" class:active={tab === t.id} onclick={() => tab = t.id}>
<t.icon size={14} weight="light" />
<span>{t.label}</span>
</button>
{/each}
@@ -217,7 +219,7 @@
<div class="content">
<div class="content-header">
<p class="content-title">{TABS.find((t) => t.id === tab)?.label}</p>
<button class="close-btn" aria-label="Close settings" on:click={close}><X size={15} weight="light" /></button>
<button class="close-btn" aria-label="Close settings" onclick={close}><X size={15} weight="light" /></button>
</div>
<div class="content-body" bind:this={contentBodyEl}>
@@ -228,14 +230,14 @@
<div class="section">
<p class="section-title">Interface Scale</p>
<div class="scale-row">
<input type="range" min={70} max={150} step={5} value={$settings.uiScale}
on:input={(e) => updateSettings({ uiScale: Number(e.currentTarget.value) })} class="scale-slider" />
<span class="scale-val">{$settings.uiScale}%</span>
<button class="step-btn" on:click={() => updateSettings({ uiScale: 100 })} disabled={$settings.uiScale === 100} title="Reset"></button>
<input type="range" min={70} max={150} step={5} value={store.settings.uiScale}
oninput={(e) => updateSettings({ uiScale: Number(e.currentTarget.value) })} class="scale-slider" />
<span class="scale-val">{store.settings.uiScale}%</span>
<button class="step-btn" onclick={() => updateSettings({ uiScale: 100 })} disabled={store.settings.uiScale === 100} title="Reset"></button>
</div>
<p class="scale-hint">
{#each [70,80,90,100,110,125,150] as v}
<button class="scale-preset" class:active={$settings.uiScale === v} on:click={() => updateSettings({ uiScale: v })}>{v}%</button>
<button class="scale-preset" class:active={store.settings.uiScale === v} onclick={() => updateSettings({ uiScale: v })}>{v}%</button>
{/each}
</p>
</div>
@@ -243,15 +245,15 @@
<p class="section-title">Server</p>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Server URL</span><span class="toggle-desc">Base URL of your Suwayomi instance</span></div>
<input class="text-input" value={$settings.serverUrl ?? "http://localhost:4567"} on:input={(e) => updateSettings({ serverUrl: e.currentTarget.value })} placeholder="http://localhost:4567" spellcheck="false" />
<input class="text-input" value={store.settings.serverUrl ?? "http://localhost:4567"} oninput={(e) => updateSettings({ serverUrl: e.currentTarget.value })} placeholder="http://localhost:4567" spellcheck="false" />
</div>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Server binary</span><span class="toggle-desc">Path or command to launch tachidesk-server</span></div>
<input class="text-input" value={$settings.serverBinary} on:input={(e) => updateSettings({ serverBinary: e.currentTarget.value })} placeholder="tachidesk-server" spellcheck="false" />
<input class="text-input" value={store.settings.serverBinary} oninput={(e) => updateSettings({ serverBinary: e.currentTarget.value })} placeholder="tachidesk-server" spellcheck="false" />
</div>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Auto-start server</span><span class="toggle-desc">Launch tachidesk-server when Moku opens</span></div>
<button role="switch" aria-checked={$settings.autoStartServer} aria-label="Auto-start server" class="toggle" class:on={$settings.autoStartServer} on:click={() => updateSettings({ autoStartServer: !$settings.autoStartServer })}><span class="toggle-thumb"></span></button>
<button role="switch" aria-checked={store.settings.autoStartServer} aria-label="Auto-start server" class="toggle" class:on={store.settings.autoStartServer} onclick={() => updateSettings({ autoStartServer: !store.settings.autoStartServer })}><span class="toggle-thumb"></span></button>
</label>
</div>
<div class="section">
@@ -259,14 +261,14 @@
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Idle screen timeout</span><span class="toggle-desc">Show the Moku idle splash after this much inactivity.</span></div>
<div class="select-wrap" id="idle-timeout">
<button class="select-btn" on:click={() => toggleSelect("idle-timeout")}>
<span>{{ "0":"Never","1":"1 minute","2":"2 minutes","5":"5 minutes","10":"10 minutes","15":"15 minutes","30":"30 minutes" }[String($settings.idleTimeoutMin ?? 5)] ?? `${$settings.idleTimeoutMin} min`}</span>
<button class="select-btn" onclick={() => toggleSelect("idle-timeout")}>
<span>{{ "0":"Never","1":"1 minute","2":"2 minutes","5":"5 minutes","10":"10 minutes","15":"15 minutes","30":"30 minutes" }[String(store.settings.idleTimeoutMin ?? 5)] ?? `${store.settings.idleTimeoutMin} min`}</span>
<svg class="select-caret" class:open={selectOpen === "idle-timeout"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "idle-timeout"}
<div class="select-menu">
{#each [["0","Never"],["1","1 minute"],["2","2 minutes"],["5","5 minutes"],["10","10 minutes"],["15","15 minutes"],["30","30 minutes"]] as [v, l]}
<button class="select-option" class:active={String($settings.idleTimeoutMin ?? 5) === v} on:click={() => { updateSettings({ idleTimeoutMin: Number(v) }); selectOpen = null; }}>{l}</button>
<button class="select-option" class:active={String(store.settings.idleTimeoutMin ?? 5) === v} onclick={() => { updateSettings({ idleTimeoutMin: Number(v) }); selectOpen = null; }}>{l}</button>
{/each}
</div>
{/if}
@@ -282,8 +284,8 @@
<p class="section-title">Theme</p>
<div class="theme-grid">
{#each THEMES as theme}
{@const active = ($settings.theme ?? "dark") === theme.id}
<button class="theme-card" class:active on:click={() => updateSettings({ theme: theme.id })} title={theme.description}>
{@const active = (store.settings.theme ?? "dark") === theme.id}
<button class="theme-card" class:active onclick={() => updateSettings({ theme: theme.id })} title={theme.description}>
<div class="theme-preview">
<div class="theme-preview-bg" style="background:{theme.swatches[0]}">
<div class="theme-preview-sidebar" style="background:{theme.swatches[1]}"></div>
@@ -313,14 +315,14 @@
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Default layout</span><span class="toggle-desc">How chapters open by default</span></div>
<div class="select-wrap" id="page-style">
<button class="select-btn" on:click={() => toggleSelect("page-style")}>
<span>{{ "single":"Single page","longstrip":"Long strip" }[$settings.pageStyle === "double" ? "single" : $settings.pageStyle]}</span>
<button class="select-btn" onclick={() => toggleSelect("page-style")}>
<span>{{ "single":"Single page","longstrip":"Long strip" }[store.settings.pageStyle === "double" ? "single" : store.settings.pageStyle]}</span>
<svg class="select-caret" class:open={selectOpen === "page-style"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "page-style"}
<div class="select-menu">
{#each [["single","Single page"],["longstrip","Long strip"]] as [v, l]}
<button class="select-option" class:active={($settings.pageStyle === "double" ? "single" : $settings.pageStyle) === v} on:click={() => { updateSettings({ pageStyle: v as Settings["pageStyle"] }); selectOpen = null; }}>{l}</button>
<button class="select-option" class:active={(store.settings.pageStyle === "double" ? "single" : store.settings.pageStyle) === v} onclick={() => { updateSettings({ pageStyle: v as Settings["pageStyle"] }); selectOpen = null; }}>{l}</button>
{/each}
</div>
{/if}
@@ -329,14 +331,14 @@
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Reading direction</span><span class="toggle-desc">Left-to-right for most manga, right-to-left for Japanese</span></div>
<div class="select-wrap" id="reading-dir">
<button class="select-btn" on:click={() => toggleSelect("reading-dir")}>
<span>{{ "ltr":"Left to right","rtl":"Right to left" }[$settings.readingDirection]}</span>
<button class="select-btn" onclick={() => toggleSelect("reading-dir")}>
<span>{{ "ltr":"Left to right","rtl":"Right to left" }[store.settings.readingDirection]}</span>
<svg class="select-caret" class:open={selectOpen === "reading-dir"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "reading-dir"}
<div class="select-menu">
{#each [["ltr","Left to right"],["rtl","Right to left"]] as [v, l]}
<button class="select-option" class:active={$settings.readingDirection === v} on:click={() => { updateSettings({ readingDirection: v as Settings["readingDirection"] }); selectOpen = null; }}>{l}</button>
<button class="select-option" class:active={store.settings.readingDirection === v} onclick={() => { updateSettings({ readingDirection: v as Settings["readingDirection"] }); selectOpen = null; }}>{l}</button>
{/each}
</div>
{/if}
@@ -344,7 +346,7 @@
</div>
<label class="toggle-row">
<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={$settings.pageGap} aria-label="Page gap" class="toggle" class:on={$settings.pageGap} on:click={() => updateSettings({ pageGap: !$settings.pageGap })}><span class="toggle-thumb"></span></button>
<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>
</div>
<div class="section">
@@ -352,14 +354,14 @@
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Default fit mode</span><span class="toggle-desc">How pages are sized to fit the screen</span></div>
<div class="select-wrap" id="fit-mode">
<button class="select-btn" on:click={() => toggleSelect("fit-mode")}>
<span>{{ "width":"Fit width","height":"Fit height","screen":"Fit screen","original":"Original (1:1)" }[$settings.fitMode ?? "width"]}</span>
<button class="select-btn" onclick={() => toggleSelect("fit-mode")}>
<span>{{ "width":"Fit width","height":"Fit height","screen":"Fit screen","original":"Original (1:1)" }[store.settings.fitMode ?? "width"]}</span>
<svg class="select-caret" class:open={selectOpen === "fit-mode"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "fit-mode"}
<div class="select-menu">
{#each [["width","Fit width"],["height","Fit height"],["screen","Fit screen"],["original","Original (1:1)"]] as [v, l]}
<button class="select-option" class:active={($settings.fitMode ?? "width") === v} on:click={() => { updateSettings({ fitMode: v as FitMode }); selectOpen = null; }}>{l}</button>
<button class="select-option" class:active={(store.settings.fitMode ?? "width") === v} onclick={() => { updateSettings({ fitMode: v as FitMode }); selectOpen = null; }}>{l}</button>
{/each}
</div>
{/if}
@@ -368,38 +370,38 @@
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Max page width</span><span class="toggle-desc">Pixel cap for fit-width mode.</span></div>
<div class="step-controls">
<button class="step-btn" on:click={() => updateSettings({ maxPageWidth: Math.max(200, ($settings.maxPageWidth ?? 900) - 100) })}></button>
<span class="step-val">{$settings.maxPageWidth ?? 900}px</span>
<button class="step-btn" on:click={() => updateSettings({ maxPageWidth: Math.min(2400, ($settings.maxPageWidth ?? 900) + 100) })}>+</button>
<button class="step-btn" onclick={() => updateSettings({ maxPageWidth: Math.max(200, (store.settings.maxPageWidth ?? 900) - 100) })}></button>
<span class="step-val">{store.settings.maxPageWidth ?? 900}px</span>
<button class="step-btn" onclick={() => updateSettings({ maxPageWidth: Math.min(2400, (store.settings.maxPageWidth ?? 900) + 100) })}>+</button>
</div>
</div>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Optimize contrast</span><span class="toggle-desc">Use webkit-optimize-contrast rendering</span></div>
<button role="switch" aria-checked={$settings.optimizeContrast} aria-label="Optimize contrast" class="toggle" class:on={$settings.optimizeContrast} on:click={() => updateSettings({ optimizeContrast: !$settings.optimizeContrast })}><span class="toggle-thumb"></span></button>
<button role="switch" aria-checked={store.settings.optimizeContrast} aria-label="Optimize contrast" class="toggle" class:on={store.settings.optimizeContrast} onclick={() => updateSettings({ optimizeContrast: !store.settings.optimizeContrast })}><span class="toggle-thumb"></span></button>
</label>
</div>
<div class="section">
<p class="section-title">Behaviour</p>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Auto-mark chapters read</span><span class="toggle-desc">Mark a chapter as read when you reach the last page</span></div>
<button role="switch" aria-checked={$settings.autoMarkRead} aria-label="Auto-mark chapters read" class="toggle" class:on={$settings.autoMarkRead} on:click={() => updateSettings({ autoMarkRead: !$settings.autoMarkRead })}><span class="toggle-thumb"></span></button>
<button role="switch" aria-checked={store.settings.autoMarkRead} aria-label="Auto-mark chapters read" class="toggle" class:on={store.settings.autoMarkRead} onclick={() => updateSettings({ autoMarkRead: !store.settings.autoMarkRead })}><span class="toggle-thumb"></span></button>
</label>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Auto-advance chapters</span><span class="toggle-desc">Automatically open the next chapter at the end of a long strip</span></div>
<button role="switch" aria-checked={$settings.autoNextChapter ?? false} aria-label="Auto-advance chapters" class="toggle" class:on={$settings.autoNextChapter} on:click={() => updateSettings({ autoNextChapter: !($settings.autoNextChapter ?? false) })}><span class="toggle-thumb"></span></button>
<button role="switch" aria-checked={store.settings.autoNextChapter ?? false} aria-label="Auto-advance chapters" class="toggle" class:on={store.settings.autoNextChapter} onclick={() => updateSettings({ autoNextChapter: !(store.settings.autoNextChapter ?? false) })}><span class="toggle-thumb"></span></button>
</label>
{#if !($settings.autoNextChapter ?? false)}
{#if !(store.settings.autoNextChapter ?? false)}
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Mark read when skipping to next chapter</span><span class="toggle-desc">Mark chapter as read when you tap next before finishing</span></div>
<button role="switch" aria-checked={$settings.markReadOnNext ?? true} aria-label="Mark read when skipping" class="toggle" class:on={$settings.markReadOnNext ?? true} on:click={() => updateSettings({ markReadOnNext: !($settings.markReadOnNext ?? true) })}><span class="toggle-thumb"></span></button>
<button role="switch" aria-checked={store.settings.markReadOnNext ?? true} aria-label="Mark read when skipping" class="toggle" class:on={store.settings.markReadOnNext ?? true} onclick={() => updateSettings({ markReadOnNext: !(store.settings.markReadOnNext ?? true) })}><span class="toggle-thumb"></span></button>
</label>
{/if}
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Pages to preload</span><span class="toggle-desc">Images loaded ahead of the current page</span></div>
<div class="step-controls">
<button class="step-btn" on:click={() => updateSettings({ preloadPages: Math.max(0, $settings.preloadPages - 1) })} disabled={$settings.preloadPages <= 0}></button>
<span class="step-val">{$settings.preloadPages}</span>
<button class="step-btn" on:click={() => updateSettings({ preloadPages: Math.min(10, $settings.preloadPages + 1) })} disabled={$settings.preloadPages >= 10}>+</button>
<button class="step-btn" onclick={() => updateSettings({ preloadPages: Math.max(0, store.settings.preloadPages - 1) })} disabled={store.settings.preloadPages <= 0}></button>
<span class="step-val">{store.settings.preloadPages}</span>
<button class="step-btn" onclick={() => updateSettings({ preloadPages: Math.min(10, store.settings.preloadPages + 1) })} disabled={store.settings.preloadPages >= 10}>+</button>
</div>
</div>
</div>
@@ -412,11 +414,11 @@
<p class="section-title">Display</p>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Crop cover images</span><span class="toggle-desc">Fill grid cells — may crop cover edges</span></div>
<button role="switch" aria-checked={$settings.libraryCropCovers} aria-label="Crop cover images" class="toggle" class:on={$settings.libraryCropCovers} on:click={() => updateSettings({ libraryCropCovers: !$settings.libraryCropCovers })}><span class="toggle-thumb"></span></button>
<button role="switch" aria-checked={store.settings.libraryCropCovers} aria-label="Crop cover images" class="toggle" class:on={store.settings.libraryCropCovers} onclick={() => updateSettings({ libraryCropCovers: !store.settings.libraryCropCovers })}><span class="toggle-thumb"></span></button>
</label>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Show NSFW sources</span><span class="toggle-desc">Display adult content sources in the sources list</span></div>
<button role="switch" aria-checked={$settings.showNsfw} aria-label="Show NSFW sources" class="toggle" class:on={$settings.showNsfw} on:click={() => updateSettings({ showNsfw: !$settings.showNsfw })}><span class="toggle-thumb"></span></button>
<button role="switch" aria-checked={store.settings.showNsfw} aria-label="Show NSFW sources" class="toggle" class:on={store.settings.showNsfw} onclick={() => updateSettings({ showNsfw: !store.settings.showNsfw })}><span class="toggle-thumb"></span></button>
</label>
</div>
<div class="section">
@@ -424,14 +426,14 @@
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Default sort direction</span></div>
<div class="select-wrap" id="sort-dir">
<button class="select-btn" on:click={() => toggleSelect("sort-dir")}>
<span>{{ "desc":"Newest first","asc":"Oldest first" }[$settings.chapterSortDir]}</span>
<button class="select-btn" onclick={() => toggleSelect("sort-dir")}>
<span>{{ "desc":"Newest first","asc":"Oldest first" }[store.settings.chapterSortDir]}</span>
<svg class="select-caret" class:open={selectOpen === "sort-dir"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "sort-dir"}
<div class="select-menu">
{#each [["desc","Newest first"],["asc","Oldest first"]] as [v, l]}
<button class="select-option" class:active={$settings.chapterSortDir === v} on:click={() => { updateSettings({ chapterSortDir: v as Settings["chapterSortDir"] }); selectOpen = null; }}>{l}</button>
<button class="select-option" class:active={store.settings.chapterSortDir === v} onclick={() => { updateSettings({ chapterSortDir: v as Settings["chapterSortDir"] }); selectOpen = null; }}>{l}</button>
{/each}
</div>
{/if}
@@ -441,15 +443,15 @@
<div class="section">
<p class="section-title">History</p>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Reading history</span><span class="toggle-desc">{$history.length} entries stored</span></div>
<button class="danger-btn" on:click={clearHistory} disabled={$history.length === 0}>Clear activity</button>
<div class="toggle-info"><span class="toggle-label">Reading store.history</span><span class="toggle-desc">{store.history.length} entries stored</span></div>
<button class="danger-btn" onclick={clearHistory} disabled={store.history.length === 0}>Clear activity</button>
</div>
<div class="step-row">
<div class="toggle-info">
<span class="toggle-label">Full data cleanse</span>
<span class="toggle-desc">Removes history, stats, completed list, hero pins, and manga links</span>
<span class="toggle-desc">Removes store.history, stats, completed list, hero pins, and manga links</span>
</div>
<button class="danger-btn" on:click={wipeAllData}>Wipe all data</button>
<button class="danger-btn" onclick={wipeAllData}>Wipe all data</button>
</div>
</div>
</div>
@@ -466,14 +468,14 @@
<span class="toggle-desc">Library and Search render this many items before showing a "Load more" button. Lower = faster scrolling on large libraries.</span>
</div>
<div class="step-controls">
<button class="step-btn" on:click={() => updateSettings({ renderLimit: Math.max(12, ($settings.renderLimit ?? 48) - 12) })} disabled={($settings.renderLimit ?? 48) <= 12}></button>
<span class="step-val">{$settings.renderLimit ?? 48}</span>
<button class="step-btn" on:click={() => updateSettings({ renderLimit: Math.min(200, ($settings.renderLimit ?? 48) + 12) })} disabled={($settings.renderLimit ?? 48) >= 200}>+</button>
<button class="step-btn" onclick={() => updateSettings({ renderLimit: Math.max(12, (store.settings.renderLimit ?? 48) - 12) })} disabled={(store.settings.renderLimit ?? 48) <= 12}></button>
<span class="step-val">{store.settings.renderLimit ?? 48}</span>
<button class="step-btn" onclick={() => updateSettings({ renderLimit: Math.min(200, (store.settings.renderLimit ?? 48) + 12) })} disabled={(store.settings.renderLimit ?? 48) >= 200}>+</button>
</div>
</div>
<p class="scale-hint">
{#each [12, 24, 48, 96, 200] as v}
<button class="scale-preset" class:active={($settings.renderLimit ?? 48) === v} on:click={() => updateSettings({ renderLimit: v })}>{v}</button>
<button class="scale-preset" class:active={(store.settings.renderLimit ?? 48) === v} onclick={() => updateSettings({ renderLimit: v })}>{v}</button>
{/each}
</p>
</div>
@@ -482,7 +484,7 @@
<p class="section-title">Rendering</p>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">GPU acceleration</span><span class="toggle-desc">Promote reader and library to compositor layers</span></div>
<button role="switch" aria-checked={$settings.gpuAcceleration} aria-label="GPU acceleration" class="toggle" class:on={$settings.gpuAcceleration} on:click={() => updateSettings({ gpuAcceleration: !$settings.gpuAcceleration })}><span class="toggle-thumb"></span></button>
<button role="switch" aria-checked={store.settings.gpuAcceleration} aria-label="GPU acceleration" class="toggle" class:on={store.settings.gpuAcceleration} onclick={() => updateSettings({ gpuAcceleration: !store.settings.gpuAcceleration })}><span class="toggle-thumb"></span></button>
</label>
</div>
@@ -490,7 +492,7 @@
<p class="section-title">Idle / Splash Screen</p>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Animated card background</span><span class="toggle-desc">Show floating manga cards on splash and idle screens.</span></div>
<button role="switch" aria-checked={$settings.splashCards ?? true} aria-label="Animated card background" class="toggle" class:on={$settings.splashCards ?? true} on:click={() => updateSettings({ splashCards: !($settings.splashCards ?? true) })}><span class="toggle-thumb"></span></button>
<button role="switch" aria-checked={store.settings.splashCards ?? true} aria-label="Animated card background" class="toggle" class:on={store.settings.splashCards ?? true} onclick={() => updateSettings({ splashCards: !(store.settings.splashCards ?? true) })}><span class="toggle-thumb"></span></button>
</label>
</div>
@@ -498,7 +500,7 @@
<p class="section-title">Interface</p>
<label class="toggle-row">
<div class="toggle-info"><span class="toggle-label">Compact sidebar</span><span class="toggle-desc">Reduce sidebar icon spacing</span></div>
<button role="switch" aria-checked={$settings.compactSidebar} aria-label="Compact sidebar" class="toggle" class:on={$settings.compactSidebar} on:click={() => updateSettings({ compactSidebar: !$settings.compactSidebar })}><span class="toggle-thumb"></span></button>
<button role="switch" aria-checked={store.settings.compactSidebar} aria-label="Compact sidebar" class="toggle" class:on={store.settings.compactSidebar} onclick={() => updateSettings({ compactSidebar: !store.settings.compactSidebar })}><span class="toggle-thumb"></span></button>
</label>
</div>
@@ -511,7 +513,7 @@
</div>
<div class="perf-stat-group">
<span class="perf-stat">{perfSnapshot?.cacheEntries ?? 0} entries</span>
<button class="kb-reset" on:click={refreshPerfMetrics} title="Refresh"></button>
<button class="kb-reset" onclick={refreshPerfMetrics} title="Refresh"></button>
</div>
</div>
{#if perfSnapshot && perfSnapshot.cacheEntries > 0}
@@ -540,21 +542,21 @@
<div class="section">
<div class="kb-header">
<p class="section-title">Keyboard shortcuts</p>
<button class="reset-all-btn" on:click={resetKeybinds}>Reset all</button>
<button class="reset-all-btn" onclick={resetKeybinds}>Reset all</button>
</div>
<p class="kb-hint">Click a key to rebind, then press the new combination.</p>
<div class="kb-list">
{#each Object.keys(KEYBIND_LABELS) as key}
{@const k = key as keyof Keybinds}
{@const isListening = listeningKey === k}
{@const isDefault = $settings.keybinds[k] === DEFAULT_KEYBINDS[k]}
{@const isDefault = store.settings.keybinds[k] === DEFAULT_KEYBINDS[k]}
<div class="kb-row">
<span class="kb-label">{KEYBIND_LABELS[k]}</span>
<div class="kb-right">
<button class="kb-bind" class:listening={isListening} on:click={() => startListen(k)}>
{isListening ? "Press key…" : $settings.keybinds[k]}
<button class="kb-bind" class:listening={isListening} onclick={() => startListen(k)}>
{isListening ? "Press key…" : store.settings.keybinds[k]}
</button>
<button class="kb-reset" on:click={() => updateSettings({ keybinds: { ...$settings.keybinds, [k]: DEFAULT_KEYBINDS[k] } })} disabled={isDefault} title="Reset"></button>
<button class="kb-reset" onclick={() => updateSettings({ keybinds: { ...store.settings.keybinds, [k]: DEFAULT_KEYBINDS[k] } })} disabled={isDefault} title="Reset"></button>
</div>
</div>
{/each}
@@ -573,7 +575,7 @@
{@const mangaBytes = storageInfo.manga_bytes}
{@const totalBytes = storageInfo.total_bytes}
{@const freeBytes = storageInfo.free_bytes}
{@const limitGb = $settings.storageLimitGb ?? null}
{@const limitGb = store.settings.storageLimitGb ?? null}
{@const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null}
{@const available = mangaBytes + freeBytes}
{@const cap = limitBytes !== null ? Math.min(limitBytes, available) : available}
@@ -599,7 +601,7 @@
<p class="section-title">Cache</p>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Image cache</span><span class="toggle-desc">Cached page images stored by the webview</span></div>
<button class="danger-btn" on:click={handleClearCache} disabled={clearing}>
<button class="danger-btn" onclick={handleClearCache} disabled={clearing}>
{cleared ? "Cleared" : clearing ? "Clearing…" : "Clear cache"}
</button>
</div>
@@ -610,35 +612,35 @@
<div class="toggle-info">
<span class="toggle-label">Limit download storage</span>
<span class="toggle-desc">
{$settings.storageLimitGb === null
{store.settings.storageLimitGb === null
? "No limit — uses full drive capacity"
: `Warn when downloads exceed ${$settings.storageLimitGb} GB`}
: `Warn when downloads exceed ${store.settings.storageLimitGb} GB`}
</span>
</div>
{#if $settings.storageLimitGb === null}
{#if store.settings.storageLimitGb === null}
<button class="step-btn" style="width:auto;padding:0 var(--sp-3);font-size:var(--text-xs);letter-spacing:var(--tracking-wide)"
on:click={() => updateSettings({ storageLimitGb: 10 })}>
onclick={() => updateSettings({ storageLimitGb: 10 })}>
Set limit
</button>
{:else}
<div class="step-controls">
<button class="step-btn"
on:click={() => updateSettings({ storageLimitGb: Math.max(1, ($settings.storageLimitGb ?? 10) - 1) })}
disabled={($settings.storageLimitGb ?? 10) <= 1}></button>
onclick={() => updateSettings({ storageLimitGb: Math.max(1, (store.settings.storageLimitGb ?? 10) - 1) })}
disabled={(store.settings.storageLimitGb ?? 10) <= 1}></button>
<input
type="number" min="1" step="1"
class="storage-limit-input"
value={$settings.storageLimitGb}
on:input={(e) => {
value={store.settings.storageLimitGb}
oninput={(e) => {
const n = parseFloat(e.currentTarget.value);
if (!isNaN(n) && n > 0) updateSettings({ storageLimitGb: n });
}}
/>
<span class="storage-limit-unit">GB</span>
<button class="step-btn"
on:click={() => updateSettings({ storageLimitGb: ($settings.storageLimitGb ?? 10) + 1 })}>+</button>
onclick={() => updateSettings({ storageLimitGb: (store.settings.storageLimitGb ?? 10) + 1 })}>+</button>
<button class="kb-reset" title="Remove limit"
on:click={() => updateSettings({ storageLimitGb: null })}>↺</button>
onclick={() => updateSettings({ storageLimitGb: null })}>↺</button>
</div>
{/if}
</div>
@@ -653,31 +655,31 @@
<p class="toggle-desc" style="padding:0 var(--sp-3) var(--sp-3);display:block">Assign manga to folders from the series detail page.</p>
<div class="folder-create-row">
<input class="text-input" placeholder="New folder name…" bind:value={newFolderName}
on:keydown={(e) => e.key === "Enter" && createFolder()} style="flex:1;width:auto" />
<button class="folder-create-btn" on:click={createFolder} disabled={!newFolderName.trim()}>
onkeydown={(e) => e.key === "Enter" && createFolder()} style="flex:1;width:auto" />
<button class="folder-create-btn" onclick={createFolder} disabled={!newFolderName.trim()}>
<Plus size={13} weight="bold" /> Create
</button>
</div>
{#if $settings.folders.length === 0}
{#if store.settings.folders.length === 0}
<p class="storage-loading">No folders yet. Create one above.</p>
{:else}
<div class="folder-list">
{#each $settings.folders as folder}
{#each store.settings.folders as folder}
<div class="folder-row">
{#if editingId === folder.id}
<input class="text-input" bind:value={editingName}
on:keydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") editingId = null; }}
on:blur={commitEdit} style="flex:1;width:auto" use:focusInput />
<button class="kb-reset" on:click={commitEdit} title="Save"></button>
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") editingId = null; }}
onblur={commitEdit} style="flex:1;width:auto" use:focusInput />
<button class="kb-reset" onclick={commitEdit} title="Save"></button>
{:else}
<FolderSimple size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<span class="folder-row-name">{folder.name}</span>
<span class="folder-row-count">{folder.mangaIds.length} manga</span>
<button class="folder-tab-toggle" class:on={folder.showTab} on:click={() => toggleFolderTab(folder.id)}>
<button class="folder-tab-toggle" class:on={folder.showTab} onclick={() => toggleFolderTab(folder.id)}>
{folder.showTab ? "Tab on" : "Tab off"}
</button>
<button class="kb-reset" on:click={() => startEdit(folder.id, folder.name)} title="Rename"><Pencil size={12} weight="light" /></button>
<button class="kb-reset folder-delete" on:click={() => removeFolder(folder.id)} title="Delete"><Trash size={12} weight="light" /></button>
<button class="kb-reset" onclick={() => startEdit(folder.id, folder.name)} title="Rename"><Pencil size={12} weight="light" /></button>
<button class="kb-reset folder-delete" onclick={() => removeFolder(folder.id)} title="Delete"><Trash size={12} weight="light" /></button>
{/if}
</div>
{/each}
@@ -705,7 +707,7 @@
<p class="section-title">Splash Screen</p>
<div class="step-row">
<div class="toggle-info"><span class="toggle-label">Preview idle screen</span><span class="toggle-desc">Show the idle splash — dismiss with any click or key</span></div>
<button class="danger-btn" on:click={triggerSplash}
<button class="danger-btn" onclick={triggerSplash}
style={splashTriggered ? "background:var(--accent-fg);color:var(--bg-base);border-color:var(--accent-fg);transition:all 0.15s ease" : ""}>
Show idle
</button>
@@ -725,7 +727,7 @@
</div>
</div>
<script context="module">
<script module>
function focusInput(node: HTMLElement) { node.focus(); }
</script>
+8 -6
View File
@@ -22,9 +22,13 @@
let focused = $state(-1);
let el = $state<HTMLDivElement | undefined>(undefined);
const actionable = items
.map((_, i) => i)
.filter((i) => !("separator" in items[i]) && !(items[i] as MenuItem).disabled);
const actionable = $derived(
items
.map((_, i) => i)
.filter((i) => !("separator" in items[i]) && !(items[i] as MenuItem).disabled)
);
$effect(() => { if (actionable.length && focused === -1) focused = actionable[0]; });
const pos = $derived.by(() => {
const zoom = parseFloat(document.documentElement.style.zoom || "100") / 100 || 1;
@@ -37,8 +41,6 @@
};
});
if (actionable.length) focused = actionable[0];
function onMouseDown(e: MouseEvent) {
if (el && !el.contains(e.target as Node)) onClose();
}
@@ -74,7 +76,7 @@
});
</script>
<div bind:this={el} class="menu" role="menu" style="left:{pos.left}px;top:{pos.top}px"
<div bind:this={el} class="menu" role="menu" tabindex="-1" style="left:{pos.left}px;top:{pos.top}px"
oncontextmenu={(e) => e.preventDefault()}>
{#each items as item, i}
{#if "separator" in item}
+92 -136
View File
@@ -1,54 +1,85 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
import { GET_ALL_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
import { settings, previewManga, activeManga, navPage, genreFilter, openReader, addToast, addFolder, assignMangaToFolder, removeMangaFromFolder, checkAndMarkCompleted, linkManga, unlinkManga } from "../../store";
import { store, openReader, addToast, addFolder, assignMangaToFolder, removeMangaFromFolder, checkAndMarkCompleted, linkManga, unlinkManga, setPreviewManga, setActiveManga, setNavPage, setGenreFilter } from "../../store/state.svelte";
import type { Manga, Chapter } from "../../lib/types";
let manga: Manga | null = $state(null);
let chapters: Chapter[] = $state([]);
let loadingDetail = $state(false);
let loadingChapters = $state(false);
let togglingLib = $state(false);
let descExpanded = $state(false);
let folderOpen = $state(false);
let newFolderName = $state("");
let creatingFolder = $state(false);
let queueingAll = $state(false);
let fetchError: string | null = $state(null);
let folderRef = $state<HTMLDivElement | undefined>(undefined);
let manga: Manga | null = $state(null);
let chapters: Chapter[] = $state([]);
let loadingDetail = $state(false);
let loadingChapters = $state(false);
let togglingLib = $state(false);
let descExpanded = $state(false);
let folderOpen = $state(false);
let newFolderName = $state("");
let creatingFolder = $state(false);
let queueingAll = $state(false);
let fetchError: string|null = $state(null);
let folderRef: HTMLDivElement = $state() as HTMLDivElement;
let linkPickerOpen = $state(false);
let linkSearch = $state("");
let allMangaForLink: Manga[] = $state([]);
let loadingLinkList = $state(false);
let linkPickerOpen = $state(false);
let linkSearch = $state("");
let allMangaForLink: Manga[] = $state([]);
let loadingLinkList = $state(false);
const linkedIds = $derived(previewManga ? (settings.mangaLinks?.[previewManga.id] ?? []) : []);
const linkedIds = $derived(store.previewManga ? (store.settings.mangaLinks?.[store.previewManga.id] ?? []) : []);
const linkPickerResults = $derived.by(() => {
const others = allMangaForLink.filter((m) => m.id !== previewManga?.id);
const others = allMangaForLink.filter((m) => m.id !== store.previewManga?.id);
const q = linkSearch.trim().toLowerCase();
const filtered = q ? others.filter((m) => m.title.toLowerCase().includes(q)) : others;
const linked = filtered.filter((m) => linkedIds.includes(m.id));
const rest = filtered.filter((m) => !linkedIds.includes(m.id)).slice(0, 30);
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
const linked = filtered.filter(m => linkedIds.includes(m.id));
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
return [...linked, ...rest];
});
const displayManga = $derived(manga ?? previewManga);
async function openLinkPicker() {
linkPickerOpen = true; linkSearch = "";
if (allMangaForLink.length) return;
loadingLinkList = true;
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
.then(d => { allMangaForLink = d.mangas.nodes; })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
}
function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; }
function handleLink(other: Manga) {
if (!store.previewManga) return;
if (linkedIds.includes(other.id)) unlinkManga(store.previewManga.id, other.id);
else linkManga(store.previewManga.id, other.id);
}
let detailAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null;
function close() {
detailAbort?.abort(); chapterAbort?.abort();
setPreviewManga(null);
manga = null; chapters = []; descExpanded = false;
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
}
function formatDate(d: Date) { return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); }
const displayManga = $derived(manga ?? store.previewManga);
const totalCount = $derived(chapters.length);
const readCount = $derived(chapters.filter((c) => c.isRead).length);
const unreadCount = $derived(totalCount - readCount);
const downloadedCount = $derived(chapters.filter((c) => c.isDownloaded).length);
const bookmarkCount = $derived(chapters.filter((c) => c.isBookmarked).length);
const inLibrary = $derived(manga?.inLibrary ?? previewManga?.inLibrary ?? false);
const inLibrary = $derived(manga?.inLibrary ?? store.previewManga?.inLibrary ?? false);
const scanlators = $derived([...new Set(chapters.map((c) => c.scanlator).filter((s): s is string => !!s?.trim()))]);
const uploadDates = $derived(chapters.map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null).filter((d): d is number => d !== null && !isNaN(d)));
const firstUpload = $derived(uploadDates.length ? new Date(Math.min(...uploadDates)) : null);
const lastUpload = $derived(uploadDates.length ? new Date(Math.max(...uploadDates)) : null);
const statusLabel = $derived(displayManga?.status ? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase() : null);
const assignedFolders = $derived(previewManga ? settings.folders.filter((f) => f.mangaIds.includes(previewManga!.id)) : []);
const assignedFolders = $derived(store.previewManga ? store.settings.folders.filter((f) => f.mangaIds.includes(store.previewManga!.id)) : []);
const continueChapter = $derived.by(() => {
if (!chapters.length) return null;
@@ -59,61 +90,19 @@
return { ch: chapters[0], label: "Read again" };
});
let detailAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null;
$effect(() => {
if (previewManga) load(previewManga.id);
});
$effect(() => {
return () => { window.removeEventListener("keydown", onKey); detailAbort?.abort(); chapterAbort?.abort(); };
});
$effect(() => {
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
$effect(() => {
if (!folderOpen) {
document.removeEventListener("mousedown", handleFolderOutside);
return;
}
const timer = setTimeout(() => document.addEventListener("mousedown", handleFolderOutside), 0);
return () => {
clearTimeout(timer);
document.removeEventListener("mousedown", handleFolderOutside);
};
});
function close() {
detailAbort?.abort(); chapterAbort?.abort();
previewManga = null;
manga = null;
chapters = [];
descExpanded = false;
folderOpen = false;
creatingFolder = false;
newFolderName = "";
fetchError = null;
}
function formatDate(d: Date) {
return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
$effect(() => { if (store.previewManga) load(store.previewManga.id); });
async function load(id: number) {
detailAbort?.abort(); chapterAbort?.abort();
const dCtrl = new AbortController(), cCtrl = new AbortController();
detailAbort = dCtrl; chapterAbort = cCtrl;
manga = previewManga as Manga;
manga = store.previewManga as Manga;
chapters = []; descExpanded = false; fetchError = null;
loadingDetail = true; loadingChapters = true;
(async (): Promise<Manga> => {
const key = CACHE_KEYS.MANGA(id);
if (cache.has(key)) return cache.get(key, () => Promise.resolve(previewManga as Manga)) as Promise<Manga>;
if (cache.has(key)) return cache.get(key, () => Promise.resolve(store.previewManga as Manga)) as Promise<Manga>;
try {
const d = await gql<{ fetchManga: { manga: Manga } }>(FETCH_MANGA, { id }, dCtrl.signal);
return d.fetchManga.manga;
@@ -129,8 +118,8 @@
manga = fullManga; loadingDetail = false;
}).catch((e) => {
if (e?.name === "AbortError") return;
manga = previewManga as Manga;
fetchError = "Could not load full details — showing cached data";
manga = store.previewManga as Manga;
fetchError = "Could not load full details — showing cached data";
loadingDetail = false;
});
@@ -156,7 +145,7 @@
async function toggleLibrary() {
if (!manga) return;
togglingLib = true;
const next = !manga.inLibrary;
const next = !manga.inLibrary;
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
manga = { ...manga, inLibrary: next };
cache.clear(CACHE_KEYS.MANGA(manga.id));
@@ -177,79 +166,58 @@
function openSeriesDetail() {
if (!displayManga) return;
activeManga = displayManga;
navPage = "library";
setActiveManga(displayManga);
setNavPage("library");
close();
}
function handleFolderCreate() {
const name = newFolderName.trim();
if (!name || !previewManga) return;
if (!name || !store.previewManga) return;
const id = addFolder(name);
assignMangaToFolder(id, previewManga.id);
assignMangaToFolder(id, store.previewManga.id);
newFolderName = ""; creatingFolder = false;
}
function handleFolderOutside(e: MouseEvent) {
if (folderRef && !folderRef.contains(e.target as Node)) {
folderOpen = false; creatingFolder = false; newFolderName = "";
}
if (folderRef && !folderRef.contains(e.target as Node)) { folderOpen = false; creatingFolder = false; newFolderName = ""; }
}
$effect(() => {
if (folderOpen) {
setTimeout(() => document.addEventListener("mousedown", handleFolderOutside), 0);
return () => document.removeEventListener("mousedown", handleFolderOutside);
}
});
function onKey(e: KeyboardEvent) { if (e.key === "Escape") close(); }
async function openLinkPicker() {
linkPickerOpen = true;
linkSearch = "";
if (allMangaForLink.length) return;
loadingLinkList = true;
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
.then((d) => { allMangaForLink = d.mangas.nodes; })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
}
function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; }
function handleLink(other: Manga) {
if (!previewManga) return;
if (linkedIds.includes(other.id)) {
unlinkManga(previewManga.id, other.id);
} else {
linkManga(previewManga.id, other.id);
}
}
onMount(() => window.addEventListener("keydown", onKey));
onDestroy(() => { window.removeEventListener("keydown", onKey); detailAbort?.abort(); chapterAbort?.abort(); });
</script>
{#if previewManga}
<div class="backdrop" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) close(); }}
onkeydown={(e) => { if (e.key === "Escape") close(); }}>
{#if store.previewManga}
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) close(); }} onkeydown={(e) => { if (e.key === "Escape") close(); }}>
<div class="modal" role="dialog" aria-label="Manga preview">
<!-- Cover column -->
<div class="cover-col">
<div class="cover-wrap">
<img src={thumbUrl(previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" />
<img src={thumbUrl(store.previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" />
{#if loadingDetail}
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
{/if}
</div>
<div class="cover-actions">
<!-- Library -->
<button class="action-btn" class:active={inLibrary} onclick={toggleLibrary} disabled={togglingLib || loadingDetail}>
<span class="action-icon"><BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} /></span>
<span class="action-label">{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}</span>
</button>
<!-- Series Detail -->
<button class="action-btn" onclick={openSeriesDetail}>
<span class="action-icon"><Books size={13} weight="light" /></span>
<span class="action-label">Series Detail</span>
</button>
<!-- Folders -->
<div class="folder-wrap" bind:this={folderRef}>
<button class="action-btn" class:active={assignedFolders.length > 0} onclick={() => folderOpen = !folderOpen}>
<span class="action-icon"><FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} /></span>
@@ -257,11 +225,11 @@
</button>
{#if folderOpen}
<div class="folder-menu">
{#if settings.folders.length === 0 && !creatingFolder}<p class="folder-empty">No folders yet</p>{/if}
{#each settings.folders as f}
{@const isIn = previewManga ? f.mangaIds.includes(previewManga.id) : false}
{#if store.settings.folders.length === 0 && !creatingFolder}<p class="folder-empty">No folders yet</p>{/if}
{#each store.settings.folders as f}
{@const isIn = store.previewManga ? f.mangaIds.includes(store.previewManga.id) : false}
<button class="folder-item" class:folder-item-on={isIn}
onclick={() => previewManga && (isIn ? removeMangaFromFolder(f.id, previewManga.id) : assignMangaToFolder(f.id, previewManga.id))}>
onclick={() => store.previewManga && (isIn ? removeMangaFromFolder(f.id, store.previewManga.id) : assignMangaToFolder(f.id, store.previewManga.id))}>
<Folder size={12} weight={isIn ? "fill" : "light"} />{isIn ? "✓ " : ""}{f.name}
</button>
{/each}
@@ -269,8 +237,8 @@
{#if creatingFolder}
<div class="folder-create-row">
<input class="folder-input" placeholder="Folder name…" bind:value={newFolderName}
autofocus
onkeydown={(e) => { if (e.key === "Enter") handleFolderCreate(); if (e.key === "Escape") { creatingFolder = false; newFolderName = ""; } }} />
onkeydown={(e) => { if (e.key === "Enter") handleFolderCreate(); if (e.key === "Escape") { creatingFolder = false; newFolderName = ""; } }}
use:focusAction />
<button class="folder-ok" onclick={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button>
</div>
{:else}
@@ -280,7 +248,6 @@
{/if}
</div>
<!-- Series Link -->
<button class="action-btn" class:active={linkedIds.length > 0} onclick={openLinkPicker}>
<span class="action-icon"><LinkSimpleHorizontalBreak size={13} weight={linkedIds.length > 0 ? "fill" : "light"} /></span>
<span class="action-label">{linkedIds.length > 0 ? `Series Link (${linkedIds.length})` : "Series Link"}</span>
@@ -289,7 +256,6 @@
</div>
</div>
<!-- Content column -->
<div class="content">
<div class="content-header">
<div class="title-block">
@@ -306,7 +272,6 @@
<div class="content-body">
{#if fetchError}<div class="error-banner">{fetchError}</div>{/if}
<!-- Badges -->
{#if loadingDetail}
<div class="sk-row"><div class="sk-badge"></div><div class="sk-badge" style="width:72px"></div></div>
{:else}
@@ -319,7 +284,6 @@
</div>
{/if}
<!-- Chapter box -->
<div class="chapter-box">
{#if loadingChapters}
<div class="chapter-loading">
@@ -351,7 +315,6 @@
{/if}
</div>
<!-- Description -->
{#if loadingDetail}
<div class="sk-desc">
<div class="sk-line" style="width:100%"></div>
@@ -370,16 +333,14 @@
</div>
{/if}
<!-- Genres -->
{#if !loadingDetail && displayManga?.genre?.length}
<div class="genres">
{#each displayManga.genre as g}
<button class="genre-tag" onclick={() => { genreFilter = g; navPage = "explore"; close(); }}>{g}</button>
<button class="genre-tag" onclick={() => { setGenreFilter(g); setNavPage("explore"); close(); }}>{g}</button>
{/each}
</div>
{/if}
<!-- Meta table -->
{#if !loadingDetail}
<div class="meta-table">
{#if displayManga?.author}<div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga.author}</span></div>{/if}
@@ -402,7 +363,6 @@
</div>
</div>
<!-- Link picker modal -->
{#if linkPickerOpen}
<div class="link-backdrop" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
@@ -417,7 +377,7 @@
Click a linked entry again to unlink.
</p>
<div class="link-search-wrap">
<input class="link-search" placeholder="Search your library…" bind:value={linkSearch} autofocus />
<input class="link-search" placeholder="Search your library…" bind:value={linkSearch} use:focusAction />
</div>
<div class="link-list">
{#if loadingLinkList}
@@ -444,6 +404,10 @@
{/if}
<script module>
function focusAction(node: HTMLElement) { node.focus(); }
</script>
<style>
.backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.12s ease both; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
.modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); }
@@ -452,14 +416,7 @@
.cover { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--border-dim); display: block; }
.cover-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: var(--radius-md); color: var(--text-faint); }
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
.action-btn {
display: flex; align-items: center; gap: var(--sp-2); width: 100%;
padding: 7px var(--sp-3); border-radius: var(--radius-md);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
border: 1px solid var(--border-strong); background: none; color: var(--text-muted);
cursor: pointer; text-align: left;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.action-btn { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; text-align: left; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.action-btn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
.action-btn:disabled { opacity: 0.4; cursor: default; }
.action-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
@@ -546,7 +503,6 @@
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.link-status { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); }
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
+31 -44
View File
@@ -2,37 +2,37 @@
import { ArrowLeft, MagnifyingGlass, ArrowLeft as Prev, ArrowRight as Next, BookmarkSimple, FolderSimplePlus, Folder } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
import { activeSource, activeManga, navPage, settings, addFolder, assignMangaToFolder } from "../../store";
import { store, addFolder, assignMangaToFolder, setActiveSource, setActiveManga, setNavPage } from "../../store/state.svelte";
import type { Manga } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
type BrowseType = "POPULAR" | "LATEST" | "SEARCH";
let mangas: Manga[] = $state([]);
let loading = $state(true);
let page = $state(1);
let hasNextPage = $state(false);
let browseType: BrowseType = $state("POPULAR");
let search = $state("");
let searchInput = $state("");
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
let mangas: Manga[] = [];
let loading = true;
let page = 1;
let hasNextPage = false;
let browseType: BrowseType = "POPULAR";
let search = "";
let searchInput = "";
let ctx: { x: number; y: number; manga: Manga } | null = null;
async function fetchMangas(type: BrowseType, p: number, q: string) {
if (!activeSource) return;
if (!$store.activeSource) return;
loading = true; mangas = [];
gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA, { source: activeSource.id, type, page: p, query: q || null }
FETCH_SOURCE_MANGA, { source: $store.activeSource.id, type, page: p, query: q || null }
).then((d) => { mangas = d.fetchSourceManga.mangas; hasNextPage = d.fetchSourceManga.hasNextPage; })
.catch(console.error)
.finally(() => { loading = false; });
.finally(() => loading = false);
}
$effect(() => { if (activeSource) fetchMangas(browseType, page, search); });
$: if ($store.activeSource) fetchMangas(browseType, page, search);
function submitSearch() {
search = searchInput.trim();
search = searchInput.trim();
browseType = "SEARCH";
page = 1;
page = 1;
}
function setMode(mode: BrowseType) {
@@ -42,48 +42,36 @@
function buildCtxItems(m: Manga): MenuEntry[] {
return [
{
label: m.inLibrary ? "In Library" : "Add to library",
icon: BookmarkSimple,
disabled: m.inLibrary,
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
.then(() => { mangas = mangas.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x); })
.catch(console.error),
},
...(settings.folders.length > 0 ? [
.then(() => mangas = mangas.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x))
.catch(console.error) },
...($store.settings.folders.length > 0 ? [
{ separator: true } as MenuEntry,
...settings.folders.map((f): MenuEntry => ({
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
icon: Folder,
...$store.settings.folders.map((f): MenuEntry => ({
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
onClick: () => assignMangaToFolder(f.id, m.id),
})),
] : []),
{ separator: true },
{
label: "New folder & add",
icon: FolderSimplePlus,
onClick: () => {
const name = prompt("Folder name:");
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
},
},
{ label: "New folder & add", icon: FolderSimplePlus, onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); } } },
];
}
</script>
{#if activeSource}
{#if $store.activeSource}
<div class="root">
<div class="header">
<button class="back" onclick={() => activeSource = null}>
<button class="back" on:click={() => store.activeSource.set(null)}>
<ArrowLeft size={13} weight="light" /><span>Sources</span>
</button>
<span class="source-name">{activeSource.displayName}</span>
<span class="source-name">{$store.activeSource.displayName}</span>
</div>
<div class="toolbar">
<div class="tabs">
{#each (["POPULAR", "LATEST"] as BrowseType[]) as mode}
<button class="tab" class:active={browseType === mode && !search} onclick={() => setMode(mode)}>
<button class="tab" class:active={browseType === mode && !search} on:click={() => setMode(mode)}>
{mode.charAt(0) + mode.slice(1).toLowerCase()}
</button>
{/each}
@@ -92,7 +80,7 @@
<div class="search-wrap">
<MagnifyingGlass size={12} class="search-icon" weight="light" />
<input class="search" placeholder="Search source…" bind:value={searchInput}
onkeydown={(e) => e.key === "Enter" && submitSearch()} />
on:keydown={(e) => e.key === "Enter" && submitSearch()} />
</div>
</div>
@@ -107,9 +95,8 @@
{:else}
<div class="grid">
{#each mangas as m (m.id)}
<button class="card"
onclick={() => { activeManga = m; navPage = "library"; }}
oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
<button class="card" on:click={() => { store.activeManga.set(m); store.navPage.set("library"); }}
on:contextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
<div class="cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" />
{#if m.inLibrary}<span class="in-library-badge">In Library</span>{/if}
@@ -122,11 +109,11 @@
{#if !loading && (page > 1 || hasNextPage)}
<div class="pagination">
<button class="page-btn" onclick={() => page = Math.max(1, page - 1)} disabled={page === 1}>
<button class="page-btn" on:click={() => page = Math.max(1, page - 1)} disabled={page === 1}>
<Prev size={13} weight="light" /> Prev
</button>
<span class="page-num">{page}</span>
<button class="page-btn" onclick={() => page++} disabled={!hasNextPage}>
<button class="page-btn" on:click={() => page++} disabled={!hasNextPage}>
Next <Next size={13} weight="light" />
</button>
</div>
+3 -3
View File
@@ -2,7 +2,7 @@
import { MagnifyingGlass, CircleNotch, CaretDown, CaretRight } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES } from "../../lib/queries";
import { activeSource } from "../../store";
import { store } from "../../store/state.svelte";
import type { Source } from "../../lib/types";
let sources: Source[] = $state([]);
@@ -71,7 +71,7 @@
{@const single = g.sources.length === 1}
{@const open = expanded.has(g.name)}
<div>
<button class="row" onclick={() => single ? activeSource = g.sources[0] : toggleGroup(g.name)}>
<button class="row" onclick={() => single ? store.activeSource = g.sources[0] : toggleGroup(g.name)}>
<img src={thumbUrl(g.icon)} alt={g.name} class="icon"
onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
<div class="info">
@@ -84,7 +84,7 @@
</button>
{#if !single && open}
{#each g.sources as src}
<button class="row row-indented" onclick={() => activeSource = src}>
<button class="row row-indented" onclick={() => store.activeSource = src}>
<div class="indent-spacer"></div>
<div class="info"><span class="name">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span></div>
<span class="arrow"></span>
+8 -4
View File
@@ -58,7 +58,11 @@ export function matchesKeybind(e: KeyboardEvent, bind: string): boolean {
}
export async function toggleFullscreen(): Promise<void> {
const win = getCurrentWindow();
const isFs = await win.isFullscreen();
await win.setFullscreen(!isFs);
}
try {
const win = getCurrentWindow();
const isFs = await win.isFullscreen();
await win.setFullscreen(!isFs);
} catch (e) {
console.warn("toggleFullscreen unavailable:", e);
}
}
-433
View File
@@ -1,433 +0,0 @@
import type { Manga, Chapter, Source } from "../lib/types";
import { DEFAULT_KEYBINDS, type Keybinds } from "../lib/keybinds";
export type PageStyle = "single" | "double" | "longstrip";
export type FitMode = "width" | "height" | "screen" | "original";
export type LibraryFilter = "all" | "library" | "downloaded" | string;
export type NavPage = "home" | "library" | "sources" | "explore" | "downloads" | "extensions" | "history" | "search";
export type ReadingDirection = "ltr" | "rtl";
export type ChapterSortDir = "desc" | "asc";
export type Theme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
export const COMPLETED_FOLDER_ID = "completed";
export interface HistoryEntry {
mangaId: number;
mangaTitle: string;
thumbnailUrl: string;
chapterId: number;
chapterName: string;
pageNumber: number;
readAt: number;
}
export interface ReadingStats {
totalChaptersRead: number;
totalMangaRead: number;
totalMinutesRead: number;
firstReadAt: number;
lastReadAt: number;
currentStreakDays: number;
longestStreakDays: number;
lastStreakDate: string;
}
const AVG_MIN_PER_CHAPTER = 5;
export const DEFAULT_READING_STATS: ReadingStats = {
totalChaptersRead: 0,
totalMangaRead: 0,
totalMinutesRead: 0,
firstReadAt: 0,
lastReadAt: 0,
currentStreakDays: 0,
longestStreakDays: 0,
lastStreakDate: "",
};
export interface Toast {
id: string;
kind: "success" | "error" | "info" | "download";
title: string;
body?: string;
duration?: number;
}
export interface ActiveDownload {
chapterId: number;
mangaId: number;
progress: number;
}
export interface Folder {
id: string;
name: string;
mangaIds: number[];
showTab: boolean;
system?: boolean;
}
export interface Settings {
pageStyle: PageStyle;
readingDirection: ReadingDirection;
fitMode: FitMode;
maxPageWidth: number;
pageGap: boolean;
optimizeContrast: boolean;
offsetDoubleSpreads: boolean;
preloadPages: number;
autoMarkRead: boolean;
autoNextChapter: boolean;
libraryCropCovers: boolean;
libraryPageSize: number;
showNsfw: boolean;
chapterSortDir: ChapterSortDir;
chapterPageSize: number;
uiScale: number;
compactSidebar: boolean;
gpuAcceleration: boolean;
serverUrl: string;
serverBinary: string;
autoStartServer: boolean;
preferredExtensionLang: string;
keybinds: Keybinds;
idleTimeoutMin?: number;
splashCards?: boolean;
storageLimitGb: number | null;
folders: Folder[];
markReadOnNext: boolean;
readerDebounceMs: number;
theme: Theme;
libraryBranches: boolean;
renderLimit: number;
heroSlots: (number | null)[];
mangaLinks: Record<number, number[]>;
}
const COMPLETED_FOLDER_DEFAULT: Folder = {
id: COMPLETED_FOLDER_ID,
name: "Completed",
mangaIds: [],
showTab: true,
system: true,
};
export const DEFAULT_SETTINGS: Settings = {
pageStyle: "longstrip",
readingDirection: "ltr",
fitMode: "width",
maxPageWidth: 900,
pageGap: true,
optimizeContrast: false,
offsetDoubleSpreads: false,
preloadPages: 3,
autoMarkRead: true,
autoNextChapter: true,
libraryCropCovers: true,
libraryPageSize: 48,
showNsfw: false,
chapterSortDir: "desc",
chapterPageSize: 25,
uiScale: 100,
compactSidebar: false,
gpuAcceleration: true,
serverUrl: "http://localhost:4567",
serverBinary: "tachidesk-server",
autoStartServer: true,
preferredExtensionLang: "en",
keybinds: DEFAULT_KEYBINDS,
idleTimeoutMin: 5,
splashCards: true,
storageLimitGb: null,
folders: [COMPLETED_FOLDER_DEFAULT],
markReadOnNext: true,
readerDebounceMs: 120,
theme: "dark",
libraryBranches: true,
renderLimit: 48,
heroSlots: [null, null, null, null],
mangaLinks: {},
};
// ── Persistence ───────────────────────────────────────────────────────────────
function loadPersisted(): any {
try {
const raw = localStorage.getItem("moku-store");
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function persist(patch: Record<string, unknown>) {
try {
const current = loadPersisted() ?? {};
localStorage.setItem("moku-store", JSON.stringify({ ...current, ...patch }));
} catch {}
}
const saved = loadPersisted();
function mergeSettings(saved: any): Settings {
const userFolders: Folder[] = saved?.settings?.folders ?? [];
const existingCompleted = userFolders.find(f => f.id === COMPLETED_FOLDER_ID);
const completedFolder: Folder = existingCompleted
? { ...COMPLETED_FOLDER_DEFAULT, mangaIds: existingCompleted.mangaIds }
: COMPLETED_FOLDER_DEFAULT;
const otherFolders = userFolders.filter(f => f.id !== COMPLETED_FOLDER_ID);
return {
...DEFAULT_SETTINGS,
...saved?.settings,
folders: [completedFolder, ...otherFolders],
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null],
mangaLinks: saved?.settings?.mangaLinks ?? {},
};
}
function mergeStats(saved: any): ReadingStats {
return { ...DEFAULT_READING_STATS, ...saved?.readingStats };
}
// ── State ─────────────────────────────────────────────────────────────────────
export let navPage: NavPage = $state(saved?.navPage ?? "home");
export let libraryFilter: LibraryFilter = $state(saved?.libraryFilter ?? "library");
export let history: HistoryEntry[] = $state(saved?.history ?? []);
export let readingStats: ReadingStats = $state(mergeStats(saved));
export let settings: Settings = $state(mergeSettings(saved));
export let genreFilter: string = $state("");
export let searchPrefill: string = $state("");
export let activeManga: Manga | null = $state(null);
export let previewManga: Manga | null = $state(null);
export let activeSource: Source | null = $state(null);
export let pageUrls: string[] = $state([]);
export let pageNumber: number = $state(1);
export let libraryTagFilter: string[] = $state([]);
export let settingsOpen: boolean = $state(false);
export let activeDownloads: ActiveDownload[] = $state([]);
export let toasts: Toast[] = $state([]);
export let activeChapter: Chapter | null = $state(null);
export let activeChapterList: Chapter[] = $state([]);
// ── Persistence effects ───────────────────────────────────────────────────────
$effect.root(() => {
$effect(() => { persist({ navPage }); });
$effect(() => { persist({ libraryFilter }); });
$effect(() => { persist({ history }); });
$effect(() => { persist({ readingStats }); });
$effect(() => { persist({ settings }); });
});
// ── Reader ────────────────────────────────────────────────────────────────────
export function openReader(chapter: Chapter, chapterList: Chapter[]) {
activeChapter = chapter;
activeChapterList = chapterList;
pageUrls = [];
pageNumber = 1;
}
export function closeReader() {
activeChapter = null;
activeChapterList = [];
pageUrls = [];
pageNumber = 1;
}
// ── History ───────────────────────────────────────────────────────────────────
function todayStr(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
export function addHistory(entry: HistoryEntry) {
const isNewChapter = !history.some(x => x.chapterId === entry.chapterId);
if (history[0]?.chapterId === entry.chapterId) {
history[0] = { ...history[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
} else {
history = [entry, ...history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300);
}
const uniqueChapters = new Set(history.map(e => e.chapterId));
const uniqueManga = new Set(history.map(e => e.mangaId));
const today = todayStr();
let { currentStreakDays, longestStreakDays, lastStreakDate } = readingStats;
if (lastStreakDate !== today) {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, "0")}-${String(yesterday.getDate()).padStart(2, "0")}`;
currentStreakDays = lastStreakDate === yStr ? currentStreakDays + 1 : 1;
longestStreakDays = Math.max(longestStreakDays, currentStreakDays);
lastStreakDate = today;
}
readingStats = {
totalChaptersRead: Math.max(readingStats.totalChaptersRead, uniqueChapters.size),
totalMangaRead: Math.max(readingStats.totalMangaRead, uniqueManga.size),
totalMinutesRead: readingStats.totalMinutesRead + (isNewChapter ? AVG_MIN_PER_CHAPTER : 0),
firstReadAt: readingStats.firstReadAt === 0 ? entry.readAt : readingStats.firstReadAt,
lastReadAt: entry.readAt,
currentStreakDays,
longestStreakDays,
lastStreakDate,
};
}
export function clearHistory() {
history = [];
}
export function clearHistoryForManga(mangaId: number) {
history = history.filter(x => x.mangaId !== mangaId);
}
export function wipeAllData() {
history = [];
readingStats = { ...DEFAULT_READING_STATS };
settings = { ...settings, folders: [COMPLETED_FOLDER_DEFAULT], heroSlots: [null, null, null, null], mangaLinks: {} };
}
// ── Completed manga ───────────────────────────────────────────────────────────
export function markMangaCompleted(mangaId: number) {
let folders = settings.folders.map(f => {
if (f.id !== COMPLETED_FOLDER_ID) return f;
if (f.mangaIds.includes(mangaId)) return f;
return { ...f, mangaIds: [...f.mangaIds, mangaId] };
});
if (!settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)) {
folders = [{ ...COMPLETED_FOLDER_DEFAULT, mangaIds: [mangaId] }, ...folders];
}
settings = { ...settings, folders };
}
export function unmarkMangaCompleted(mangaId: number) {
settings = {
...settings,
folders: settings.folders.map(f =>
f.id === COMPLETED_FOLDER_ID
? { ...f, mangaIds: f.mangaIds.filter(id => id !== mangaId) }
: f
),
};
}
export function isCompleted(mangaId: number): boolean {
return settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds.includes(mangaId) ?? false;
}
export function checkAndMarkCompleted(mangaId: number, chapters: Chapter[]) {
if (!chapters.length) return;
if (chapters.every(c => c.isRead)) markMangaCompleted(mangaId);
else unmarkMangaCompleted(mangaId);
}
// ── Manga links ───────────────────────────────────────────────────────────────
export function linkManga(idA: number, idB: number) {
if (idA === idB) return;
const links = { ...settings.mangaLinks };
links[idA] = [...new Set([...(links[idA] ?? []), idB])];
links[idB] = [...new Set([...(links[idB] ?? []), idA])];
settings = { ...settings, mangaLinks: links };
}
export function unlinkManga(idA: number, idB: number) {
const links = { ...settings.mangaLinks };
links[idA] = (links[idA] ?? []).filter(id => id !== idB);
links[idB] = (links[idB] ?? []).filter(id => id !== idA);
if (!links[idA].length) delete links[idA];
if (!links[idB].length) delete links[idB];
settings = { ...settings, mangaLinks: links };
}
export function getLinkedMangaIds(mangaId: number): number[] {
return settings.mangaLinks[mangaId] ?? [];
}
// ── Hero slots ────────────────────────────────────────────────────────────────
export function setHeroSlot(index: 1 | 2 | 3, mangaId: number | null) {
const slots = [...(settings.heroSlots ?? [null, null, null, null])];
slots[index] = mangaId;
settings = { ...settings, heroSlots: slots };
}
// ── Toasts ────────────────────────────────────────────────────────────────────
export function addToast(toast: Omit<Toast, "id">) {
toasts = [...toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5);
}
export function dismissToast(id: string) {
toasts = toasts.filter(x => x.id !== id);
}
// ── Settings ──────────────────────────────────────────────────────────────────
export function updateSettings(patch: Partial<Settings>) {
settings = { ...settings, ...patch };
}
export function resetKeybinds() {
settings = { ...settings, keybinds: DEFAULT_KEYBINDS };
}
// ── Folders ───────────────────────────────────────────────────────────────────
const genId = () => Math.random().toString(36).slice(2, 10);
export function addFolder(name: string): string {
const id = genId();
settings = { ...settings, folders: [...settings.folders, { id, name: name.trim(), mangaIds: [], showTab: false }] };
return id;
}
export function removeFolder(id: string) {
settings = { ...settings, folders: settings.folders.filter(f => f.id !== id || f.system) };
}
export function renameFolder(id: string, name: string) {
settings = {
...settings,
folders: settings.folders.map(f => f.id === id && !f.system ? { ...f, name: name.trim() } : f),
};
}
export function toggleFolderTab(id: string) {
settings = {
...settings,
folders: settings.folders.map(f => f.id === id ? { ...f, showTab: !f.showTab } : f),
};
}
export function assignMangaToFolder(folderId: string, mangaId: number) {
settings = {
...settings,
folders: settings.folders.map(f =>
f.id === folderId && !f.mangaIds.includes(mangaId)
? { ...f, mangaIds: [...f.mangaIds, mangaId] }
: f
),
};
}
export function removeMangaFromFolder(folderId: string, mangaId: number) {
settings = {
...settings,
folders: settings.folders.map(f =>
f.id === folderId ? { ...f, mangaIds: f.mangaIds.filter(id => id !== mangaId) } : f
),
};
}
export function getMangaFolders(mangaId: number): Folder[] {
return settings.folders.filter(f => f.mangaIds.includes(mangaId));
}
+448
View File
@@ -0,0 +1,448 @@
import type { Manga, Chapter, Source } from "../lib/types";
import { DEFAULT_KEYBINDS, type Keybinds } from "../lib/keybinds";
export type PageStyle = "single" | "double" | "longstrip";
export type FitMode = "width" | "height" | "screen" | "original";
export type LibraryFilter = "all" | "library" | "downloaded" | string;
export type NavPage = "home" | "library" | "sources" | "explore" | "downloads" | "extensions" | "history" | "search";
export type ReadingDirection = "ltr" | "rtl";
export type ChapterSortDir = "desc" | "asc";
export type Theme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
export const COMPLETED_FOLDER_ID = "completed";
export interface HistoryEntry {
mangaId: number;
mangaTitle: string;
thumbnailUrl: string;
chapterId: number;
chapterName: string;
pageNumber: number;
readAt: number;
}
export interface ReadingStats {
totalChaptersRead: number;
totalMangaRead: number;
totalMinutesRead: number;
firstReadAt: number;
lastReadAt: number;
currentStreakDays: number;
longestStreakDays: number;
lastStreakDate: string;
}
const AVG_MIN_PER_CHAPTER = 5;
export const DEFAULT_READING_STATS: ReadingStats = {
totalChaptersRead: 0,
totalMangaRead: 0,
totalMinutesRead: 0,
firstReadAt: 0,
lastReadAt: 0,
currentStreakDays: 0,
longestStreakDays: 0,
lastStreakDate: "",
};
export interface Toast {
id: string;
kind: "success" | "error" | "info" | "download";
title: string;
body?: string;
duration?: number;
}
export interface ActiveDownload {
chapterId: number;
mangaId: number;
progress: number;
}
export interface Folder {
id: string;
name: string;
mangaIds: number[];
showTab: boolean;
system?: boolean;
}
export interface Settings {
pageStyle: PageStyle;
readingDirection: ReadingDirection;
fitMode: FitMode;
maxPageWidth: number;
pageGap: boolean;
optimizeContrast: boolean;
offsetDoubleSpreads: boolean;
preloadPages: number;
autoMarkRead: boolean;
autoNextChapter: boolean;
libraryCropCovers: boolean;
libraryPageSize: number;
showNsfw: boolean;
chapterSortDir: ChapterSortDir;
chapterPageSize: number;
uiScale: number;
compactSidebar: boolean;
gpuAcceleration: boolean;
serverUrl: string;
serverBinary: string;
autoStartServer: boolean;
preferredExtensionLang: string;
keybinds: Keybinds;
idleTimeoutMin?: number;
splashCards?: boolean;
storageLimitGb: number | null;
folders: Folder[];
markReadOnNext: boolean;
readerDebounceMs: number;
theme: Theme;
libraryBranches: boolean;
renderLimit: number;
heroSlots: (number | null)[];
mangaLinks: Record<number, number[]>;
}
const COMPLETED_FOLDER_DEFAULT: Folder = {
id: COMPLETED_FOLDER_ID,
name: "Completed",
mangaIds: [],
showTab: true,
system: true,
};
export const DEFAULT_SETTINGS: Settings = {
pageStyle: "longstrip",
readingDirection: "ltr",
fitMode: "width",
maxPageWidth: 900,
pageGap: true,
optimizeContrast: false,
offsetDoubleSpreads: false,
preloadPages: 3,
autoMarkRead: true,
autoNextChapter: true,
libraryCropCovers: true,
libraryPageSize: 48,
showNsfw: false,
chapterSortDir: "desc",
chapterPageSize: 25,
uiScale: 100,
compactSidebar: false,
gpuAcceleration: true,
serverUrl: "http://localhost:4567",
serverBinary: "tachidesk-server",
autoStartServer: true,
preferredExtensionLang: "en",
keybinds: DEFAULT_KEYBINDS,
idleTimeoutMin: 5,
splashCards: true,
storageLimitGb: null,
folders: [COMPLETED_FOLDER_DEFAULT],
markReadOnNext: true,
readerDebounceMs: 120,
theme: "dark",
libraryBranches: true,
renderLimit: 48,
heroSlots: [null, null, null, null],
mangaLinks: {},
};
// ── Persistence ───────────────────────────────────────────────────────────────
function loadPersisted(): any {
try {
const raw = localStorage.getItem("moku-store");
return raw ? JSON.parse(raw) : null;
} catch {
return null;
}
}
function persist(patch: Record<string, unknown>) {
try {
const current = loadPersisted() ?? {};
localStorage.setItem("moku-store", JSON.stringify({ ...current, ...patch }));
} catch {}
}
const saved = loadPersisted();
function mergeSettings(saved: any): Settings {
const userFolders: Folder[] = saved?.settings?.folders ?? [];
const existingCompleted = userFolders.find(f => f.id === COMPLETED_FOLDER_ID);
const completedFolder: Folder = existingCompleted
? { ...COMPLETED_FOLDER_DEFAULT, mangaIds: existingCompleted.mangaIds }
: COMPLETED_FOLDER_DEFAULT;
const otherFolders = userFolders.filter(f => f.id !== COMPLETED_FOLDER_ID);
return {
...DEFAULT_SETTINGS,
...saved?.settings,
folders: [completedFolder, ...otherFolders],
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null],
mangaLinks: saved?.settings?.mangaLinks ?? {},
};
}
function mergeStats(saved: any): ReadingStats {
return { ...DEFAULT_READING_STATS, ...saved?.readingStats };
}
function todayStr(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
const genId = () => Math.random().toString(36).slice(2, 10);
// ── Store ─────────────────────────────────────────────────────────────────────
class Store {
navPage: NavPage = $state(saved?.navPage ?? "home");
libraryFilter: LibraryFilter = $state(saved?.libraryFilter ?? "library");
history: HistoryEntry[] = $state(saved?.history ?? []);
readingStats: ReadingStats = $state(mergeStats(saved));
settings: Settings = $state(mergeSettings(saved));
genreFilter: string = $state("");
searchPrefill: string = $state("");
activeManga: Manga | null = $state(null);
previewManga: Manga | null = $state(null);
activeSource: Source | null = $state(null);
pageUrls: string[] = $state([]);
pageNumber: number = $state(1);
libraryTagFilter: string[] = $state([]);
settingsOpen: boolean = $state(false);
activeDownloads: ActiveDownload[] = $state([]);
toasts: Toast[] = $state([]);
activeChapter: Chapter | null = $state(null);
activeChapterList: Chapter[] = $state([]);
constructor() {
$effect.root(() => {
$effect(() => { persist({ navPage: this.navPage }); });
$effect(() => { persist({ libraryFilter: this.libraryFilter }); });
$effect(() => { persist({ history: this.history }); });
$effect(() => { persist({ readingStats: this.readingStats }); });
$effect(() => { persist({ settings: this.settings }); });
});
}
openReader(chapter: Chapter, chapterList: Chapter[]) {
this.activeChapter = chapter;
this.activeChapterList = chapterList;
this.pageUrls = [];
this.pageNumber = 1;
}
closeReader() {
this.activeChapter = null;
this.activeChapterList = [];
this.pageUrls = [];
this.pageNumber = 1;
}
addHistory(entry: HistoryEntry) {
const isNewChapter = !this.history.some(x => x.chapterId === entry.chapterId);
if (this.history[0]?.chapterId === entry.chapterId) {
this.history[0] = { ...this.history[0], pageNumber: entry.pageNumber, readAt: entry.readAt };
} else {
this.history = [entry, ...this.history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300);
}
const uniqueChapters = new Set(this.history.map(e => e.chapterId));
const uniqueManga = new Set(this.history.map(e => e.mangaId));
const today = todayStr();
let { currentStreakDays, longestStreakDays, lastStreakDate } = this.readingStats;
if (lastStreakDate !== today) {
const yesterday = new Date();
yesterday.setDate(yesterday.getDate() - 1);
const yStr = `${yesterday.getFullYear()}-${String(yesterday.getMonth() + 1).padStart(2, "0")}-${String(yesterday.getDate()).padStart(2, "0")}`;
currentStreakDays = lastStreakDate === yStr ? currentStreakDays + 1 : 1;
longestStreakDays = Math.max(longestStreakDays, currentStreakDays);
lastStreakDate = today;
}
this.readingStats = {
totalChaptersRead: Math.max(this.readingStats.totalChaptersRead, uniqueChapters.size),
totalMangaRead: Math.max(this.readingStats.totalMangaRead, uniqueManga.size),
totalMinutesRead: this.readingStats.totalMinutesRead + (isNewChapter ? AVG_MIN_PER_CHAPTER : 0),
firstReadAt: this.readingStats.firstReadAt === 0 ? entry.readAt : this.readingStats.firstReadAt,
lastReadAt: entry.readAt,
currentStreakDays,
longestStreakDays,
lastStreakDate,
};
}
clearHistory() { this.history = []; }
clearHistoryForManga(mangaId: number) { this.history = this.history.filter(x => x.mangaId !== mangaId); }
wipeAllData() {
this.history = [];
this.readingStats = { ...DEFAULT_READING_STATS };
this.settings = { ...this.settings, folders: [COMPLETED_FOLDER_DEFAULT], heroSlots: [null, null, null, null], mangaLinks: {} };
}
markMangaCompleted(mangaId: number) {
const folder = this.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID);
if (!folder) return;
if (!folder.mangaIds.includes(mangaId))
folder.mangaIds = [...folder.mangaIds, mangaId];
}
unmarkMangaCompleted(mangaId: number) {
const folder = this.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID);
if (!folder) return;
folder.mangaIds = folder.mangaIds.filter(id => id !== mangaId);
}
isCompleted(mangaId: number): boolean {
return this.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds.includes(mangaId) ?? false;
}
checkAndMarkCompleted(mangaId: number, chapters: Chapter[]) {
if (!chapters.length) return;
if (chapters.every(c => c.isRead)) this.markMangaCompleted(mangaId);
else this.unmarkMangaCompleted(mangaId);
}
linkManga(idA: number, idB: number) {
if (idA === idB) return;
const links = { ...this.settings.mangaLinks };
links[idA] = [...new Set([...(links[idA] ?? []), idB])];
links[idB] = [...new Set([...(links[idB] ?? []), idA])];
this.settings = { ...this.settings, mangaLinks: links };
}
unlinkManga(idA: number, idB: number) {
const links = { ...this.settings.mangaLinks };
links[idA] = (links[idA] ?? []).filter(id => id !== idB);
links[idB] = (links[idB] ?? []).filter(id => id !== idA);
if (!links[idA].length) delete links[idA];
if (!links[idB].length) delete links[idB];
this.settings = { ...this.settings, mangaLinks: links };
}
getLinkedMangaIds(mangaId: number): number[] { return this.settings.mangaLinks[mangaId] ?? []; }
setHeroSlot(index: 1 | 2 | 3, mangaId: number | null) {
const slots = [...(this.settings.heroSlots ?? [null, null, null, null])];
slots[index] = mangaId;
this.settings = { ...this.settings, heroSlots: slots };
}
addToast(toast: Omit<Toast, "id">) {
this.toasts = [...this.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5);
}
dismissToast(id: string) { this.toasts = this.toasts.filter(x => x.id !== id); }
setActiveDownloads(next: ActiveDownload[]) { this.activeDownloads = next; }
setNavPage(next: NavPage) { this.navPage = next; }
setLibraryFilter(next: LibraryFilter) { this.libraryFilter = next; }
setGenreFilter(next: string) { this.genreFilter = next; }
setSearchPrefill(next: string) { this.searchPrefill = next; }
setActiveManga(next: Manga | null) { this.activeManga = next; }
setPreviewManga(next: Manga | null) { this.previewManga = next; }
setActiveSource(next: Source | null) { this.activeSource = next; }
setPageUrls(next: string[]) { this.pageUrls = next; }
setPageNumber(next: number) { this.pageNumber = next; }
setLibraryTagFilter(next: string[]) { this.libraryTagFilter = next; }
setSettingsOpen(next: boolean) { this.settingsOpen = next; }
updateSettings(patch: Partial<Settings>) { this.settings = { ...this.settings, ...patch }; }
resetKeybinds() { this.settings = { ...this.settings, keybinds: DEFAULT_KEYBINDS }; }
addFolder(name: string): string {
const id = genId();
this.settings = { ...this.settings, folders: [...this.settings.folders, { id, name: name.trim(), mangaIds: [], showTab: false }] };
return id;
}
removeFolder(id: string) {
this.settings = { ...this.settings, folders: this.settings.folders.filter(f => f.id !== id || f.system) };
}
renameFolder(id: string, name: string) {
this.settings = {
...this.settings,
folders: this.settings.folders.map(f => f.id === id && !f.system ? { ...f, name: name.trim() } : f),
};
}
toggleFolderTab(id: string) {
this.settings = {
...this.settings,
folders: this.settings.folders.map(f => f.id === id ? { ...f, showTab: !f.showTab } : f),
};
}
assignMangaToFolder(folderId: string, mangaId: number) {
this.settings = {
...this.settings,
folders: this.settings.folders.map(f =>
f.id === folderId && !f.mangaIds.includes(mangaId)
? { ...f, mangaIds: [...f.mangaIds, mangaId] }
: f
),
};
}
removeMangaFromFolder(folderId: string, mangaId: number) {
this.settings = {
...this.settings,
folders: this.settings.folders.map(f =>
f.id === folderId ? { ...f, mangaIds: f.mangaIds.filter(id => id !== mangaId) } : f
),
};
}
getMangaFolders(mangaId: number): Folder[] {
return this.settings.folders.filter(f => f.mangaIds.includes(mangaId));
}
}
export const store = new Store();
// ── Function re-exports — zero call-site changes for actions ──────────────────
export function openReader(chapter: Chapter, chapterList: Chapter[]) { store.openReader(chapter, chapterList); }
export function closeReader() { store.closeReader(); }
export function addHistory(entry: HistoryEntry) { store.addHistory(entry); }
export function clearHistory() { store.clearHistory(); }
export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); }
export function wipeAllData() { store.wipeAllData(); }
export function markMangaCompleted(mangaId: number) { store.markMangaCompleted(mangaId); }
export function unmarkMangaCompleted(mangaId: number) { store.unmarkMangaCompleted(mangaId); }
export function isCompleted(mangaId: number) { return store.isCompleted(mangaId); }
export function checkAndMarkCompleted(mangaId: number, c: Chapter[]) { store.checkAndMarkCompleted(mangaId, c); }
export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); }
export function unlinkManga(idA: number, idB: number) { store.unlinkManga(idA, idB); }
export function getLinkedMangaIds(mangaId: number) { return store.getLinkedMangaIds(mangaId); }
export function setHeroSlot(i: 1|2|3, mangaId: number | null) { store.setHeroSlot(i, mangaId); }
export function addToast(toast: Omit<Toast, "id">) { store.addToast(toast); }
export function dismissToast(id: string) { store.dismissToast(id); }
export function setActiveDownloads(next: ActiveDownload[]) { store.setActiveDownloads(next); }
export function setNavPage(next: NavPage) { store.setNavPage(next); }
export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); }
export function setGenreFilter(next: string) { store.setGenreFilter(next); }
export function setSearchPrefill(next: string) { store.setSearchPrefill(next); }
export function setActiveManga(next: Manga | null) { store.setActiveManga(next); }
export function setPreviewManga(next: Manga | null) { store.setPreviewManga(next); }
export function setActiveSource(next: Source | null) { store.setActiveSource(next); }
export function setPageUrls(next: string[]) { store.setPageUrls(next); }
export function setPageNumber(next: number) { store.setPageNumber(next); }
export function setLibraryTagFilter(next: string[]) { store.setLibraryTagFilter(next); }
export function setSettingsOpen(next: boolean) { store.setSettingsOpen(next); }
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
export function resetKeybinds() { store.resetKeybinds(); }
export function addFolder(name: string) { return store.addFolder(name); }
export function removeFolder(id: string) { store.removeFolder(id); }
export function renameFolder(id: string, name: string) { store.renameFolder(id, name); }
export function toggleFolderTab(id: string) { store.toggleFolderTab(id); }
export function assignMangaToFolder(folderId: string, mangaId: number) { store.assignMangaToFolder(folderId, mangaId); }
export function removeMangaFromFolder(folderId: string, mangaId: number) { store.removeMangaFromFolder(folderId, mangaId); }
export function getMangaFolders(mangaId: number) { return store.getMangaFolders(mangaId); }