mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
Chore: Finalized Svelte-5 Rewrite (Testing Phase)
This commit is contained in:
+24
-20
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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) } }
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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); }
|
||||
Reference in New Issue
Block a user