Chore: Finalized Svelte-5 Rewrite (Testing Phase)

This commit is contained in:
Youwes09
2026-03-20 15:58:35 -05:00
parent 96bac1ad2b
commit 4903b066b1
26 changed files with 1460 additions and 1512 deletions
+22 -21
View File
@@ -5,7 +5,7 @@
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } from "../../lib/util";
import { settings, previewManga, activeSource, addFolder, assignMangaToFolder } from "../../store";
import { store, addFolder, assignMangaToFolder, setPreviewManga } from "../../store/state.svelte";
import type { Manga, Source } from "../../lib/types";
import ContextMenu from "../shared/ContextMenu.svelte";
import type { MenuEntry } from "../shared/ContextMenu.svelte";
@@ -35,17 +35,17 @@
`;
// ── State ─────────────────────────────────────────────────────────────────────
let allManga: Manga[] = []; // local library — loaded once, never triggers lag
let allSources: Source[] = []; // all deduped sources — loaded once
let loadingLib = true;
let loadError = false;
let allManga: Manga[] = $state([]); // local library — loaded once, never triggers lag
let allSources: Source[] = $state([]); // all deduped sources — loaded once
let loadingLib = $state(true);
let loadError = $state(false);
// Per-genre result map. Keyed by genre string.
// "All" key → local library deduped by title
// Each tab key → local + background source results, deduped id+title
let genreResults = new Map<string, Manga[]>();
let genreLoading = false; // true only during the initial local fetch for a new tab
let currentGenre = "All";
let genreResults = $state(new Map<string, Manga[]>());
let genreLoading = $state(false); // true only during the initial local fetch for a new tab
let currentGenre = $state("All");
let genreAbort: AbortController | null = null;
// batch timer handle for background source fan-out
@@ -54,15 +54,16 @@
let batchAccum = new Map<string, Manga[]>(); // genre → pending mangas
// Context menu
let ctx: { x: number; y: number; manga: Manga } | null = null;
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
let isLoading = $state(false);
// ── Derived ───────────────────────────────────────────────────────────────────
$: visibleGrid = genreResults.get(currentGenre) ?? [];
$: isLoading = genreLoading || (currentGenre === "All" && loadingLib);
const visibleGrid = $derived(genreResults.get(currentGenre) ?? []);
$effect(() => { isLoading = genreLoading || (currentGenre === "All" && loadingLib); });
// ── Dedup helper — always apply id first then title ───────────────────────────
function dedup(items: Manga[]): Manga[] {
return dedupeMangaByTitle(dedupeMangaById(items), $settings.mangaLinks);
return dedupeMangaByTitle(dedupeMangaById(items), store.settings.mangaLinks);
}
// ── Concurrent fan-out — conservative concurrency keeps connections free ──────
@@ -118,7 +119,7 @@
// Does NOT set genreLoading = true — the local result is already showing.
async function fanOutSources(genre: string, ctrl: AbortController) {
if (!allSources.length) return;
const lang = $settings.preferredExtensionLang || "en";
const lang = store.settings.preferredExtensionLang || "en";
const srcs = dedupeSources(allSources, lang);
startBatchFlush();
@@ -211,9 +212,9 @@
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
.then(() => cache.clear(CACHE_KEYS.LIBRARY)).catch(console.error),
},
...($settings.folders.length > 0 ? [
...(store.settings.folders.length > 0 ? [
{ separator: true } as MenuEntry,
...$settings.folders.map(f => ({
...store.settings.folders.map(f => ({
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name,
icon: Folder,
onClick: () => assignMangaToFolder(f.id, m.id),
@@ -235,7 +236,7 @@
// 2. Load source list in background (needed for genre fan-out, not needed for initial render)
function loadAll() {
loadingLib = true; loadError = false;
const lang = $settings.preferredExtensionLang || "en";
const lang = store.settings.preferredExtensionLang || "en";
// Local library — populates "All" tab
cache.get(CACHE_KEYS.DISCOVER, () =>
@@ -266,7 +267,7 @@
</script>
<!-- ── Source browse passthrough ─────────────────────────────────────────────── -->
{#if $activeSource}
{#if store.activeSource}
<SourceBrowse />
{:else}
<div class="root">
@@ -279,7 +280,7 @@
<button
class="genre-tab"
class:active={currentGenre === tab}
on:click={() => switchGenre(tab)}
onclick={() => switchGenre(tab)}
>
{#if tab === "All"}<Sparkle size={10} weight="fill" />{/if}
{tab}
@@ -302,7 +303,7 @@
{:else if loadError && visibleGrid.length === 0}
<div class="empty">
<span>Could not reach Suwayomi</span>
<button class="retry-btn" on:click={loadAll}>Retry</button>
<button class="retry-btn" onclick={loadAll}>Retry</button>
</div>
{:else if visibleGrid.length === 0}
@@ -313,8 +314,8 @@
{#each visibleGrid as m (m.id)}
<button
class="manga-card"
on:click={() => previewManga.set(m)}
on:contextmenu={(e) => openCtx(e, m)}
onclick={() => setPreviewManga(m)}
oncontextmenu={(e) => openCtx(e, m)}
>
<div class="cover-wrap">
<img
+14 -17
View File
@@ -1,21 +1,20 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { Play, Pause, Trash, CircleNotch, X } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_DOWNLOAD_STATUS, START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "../../lib/queries";
import { activeDownloads } from "../../store";
import { store, setActiveDownloads } from "../../store/state.svelte";
import type { DownloadStatus } from "../../lib/types";
let status: DownloadStatus | null = null;
let loading = true;
let togglingPlay = false;
let clearing = false;
let dequeueing = new Set<number>();
let status: DownloadStatus | null = $state(null);
let loading = $state(true);
let togglingPlay = $state(false);
let clearing = $state(false);
let dequeueing = $state(new Set<number>());
let interval: ReturnType<typeof setInterval>;
function applyStatus(ds: DownloadStatus) {
status = ds;
activeDownloads.set(ds.queue.map((item) => ({
setActiveDownloads(ds.queue.map((item) => ({
chapterId: item.chapter.id,
mangaId: item.chapter.mangaId,
progress: item.progress,
@@ -29,8 +28,7 @@
.finally(() => loading = false);
}
onMount(() => { poll(); interval = setInterval(poll, 2000); });
onDestroy(() => clearInterval(interval));
$effect(() => { poll(); interval = setInterval(poll, 2000); return () => clearInterval(interval); });
async function togglePlay() {
if (togglingPlay) return;
@@ -53,7 +51,7 @@
if (clearing) return;
clearing = true;
if (status) status = { ...status, queue: [] };
activeDownloads.set([]);
setActiveDownloads([]);
try {
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
applyStatus(d.clearDownloader.downloadStatus);
@@ -69,22 +67,21 @@
catch (e) { console.error(e); poll(); }
finally { dequeueing.delete(chapterId); dequeueing = new Set(dequeueing); }
}
$: queue = status?.queue ?? [];
$: isRunning = status?.state === "STARTED";
let queue = $derived(status?.queue ?? []);
const isRunning = $derived(status?.state === "STARTED");
</script>
<div class="root">
<div class="header">
<h1 class="heading">Downloads</h1>
<div class="header-actions">
<button class="icon-btn" class:loading={togglingPlay} on:click={togglePlay}
<button class="icon-btn" class:loading={togglingPlay} onclick={togglePlay}
disabled={togglingPlay || (queue.length === 0 && !isRunning)} title={isRunning ? "Pause" : "Resume"}>
{#if togglingPlay}<CircleNotch size={14} weight="light" class="anim-spin" />
{:else if isRunning}<Pause size={14} weight="fill" />
{:else}<Play size={14} weight="fill" />{/if}
</button>
<button class="icon-btn" class:loading={clearing} on:click={clear}
<button class="icon-btn" class:loading={clearing} onclick={clear}
disabled={clearing || queue.length === 0} title="Clear queue">
{#if clearing}<CircleNotch size={14} weight="light" class="anim-spin" />
{:else}<Trash size={14} weight="regular" />{/if}
@@ -133,7 +130,7 @@
<div class="row-right">
<span class="state-label">{item.state}</span>
{#if !isActive}
<button class="remove-btn" on:click={() => dequeue(item.chapter.id)} disabled={isRemoving} title="Remove from queue">
<button class="remove-btn" onclick={() => dequeue(item.chapter.id)} disabled={isRemoving} title="Remove from queue">
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
</button>
{/if}
+48 -53
View File
@@ -1,9 +1,9 @@
<script lang="ts">
import { onMount } from "svelte";
import { untrack } from "svelte";
import { MagnifyingGlass, ArrowsClockwise, Plus, CircleNotch, CaretRight, CaretDown, X, Check, GitBranch } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_EXTENSIONS, FETCH_EXTENSIONS, UPDATE_EXTENSION, INSTALL_EXTERNAL_EXTENSION, GET_SETTINGS, SET_EXTENSION_REPOS } from "../../lib/queries";
import { settings } from "../../store";
import { store } from "../../store/state.svelte";
import type { Extension } from "../../lib/types";
type Filter = "installed" | "available" | "updates" | "all";
@@ -11,23 +11,23 @@
function baseName(name: string): string { return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim(); }
let extensions: Extension[] = [];
let loading = true;
let refreshing = false;
let filter: Filter = "installed";
let search = "";
let working = new Set<string>();
let expanded = new Set<string>();
let panel: Panel = null;
let externalUrl = "";
let installing = false;
let installError: string|null = null;
let installSuccess = false;
let repos: string[] = [];
let reposLoading = false;
let newRepoUrl = "";
let repoError: string|null = null;
let savingRepos = false;
let extensions: Extension[] = $state([]);
let loading = $state(true);
let refreshing = $state(false);
let filter: Filter = $state("installed");
let search = $state("");
let working = $state(new Set<string>());
let expanded = $state(new Set<string>());
let panel: Panel = $state(null);
let externalUrl = $state("");
let installing = $state(false);
let installError: string|null = $state(null);
let installSuccess = $state(false);
let repos: string[] = $state([]);
let reposLoading = $state(false);
let newRepoUrl = $state("");
let repoError: string|null = $state(null);
let savingRepos = $state(false);
async function load() {
return gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS)
@@ -93,26 +93,25 @@
if (p === "repos") loadRepos();
}
onMount(() => { fetchFromRepo().finally(() => loading = false); });
$effect(() => { untrack(() => fetchFromRepo().finally(() => { loading = false; })); });
$: filtered = extensions.filter((e) => {
const filtered = $derived(extensions.filter((e) => {
const q = search.toLowerCase();
const matchSearch = e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q);
const matchFilter = filter === "installed" ? e.isInstalled : filter === "available" ? !e.isInstalled : filter === "updates" ? e.hasUpdate : true;
return matchSearch && matchFilter;
});
}));
$: groups = (() => {
const groups = $derived.by(() => {
const map = new Map<string, Extension[]>();
for (const ext of filtered) { const key = baseName(ext.name); if (!map.has(key)) map.set(key, []); map.get(key)!.push(ext); }
const preferredLang = $settings.preferredExtensionLang;
const preferredLang = store.settings.preferredExtensionLang;
return Array.from(map.entries()).map(([base, all]) => {
const primary = all.find((v) => v.lang === preferredLang) ?? all.find((v) => v.lang === "en") ?? all[0];
return { base, primary, variants: all.filter((v) => v.pkgName !== primary.pkgName) };
});
})();
$: updateCount = extensions.filter((e) => e.hasUpdate).length;
});
const updateCount = $derived(extensions.filter((e) => e.hasUpdate).length);
const FILTERS: { id: Filter; label: string }[] = [
{ id: "installed", label: "Installed" },
@@ -132,13 +131,13 @@
<div class="header">
<h1 class="heading">Extensions</h1>
<div class="header-actions">
<button class="icon-btn" class:active={panel === "repos"} on:click={() => openPanel("repos")} title="Manage repos">
<button class="icon-btn" class:active={panel === "repos"} onclick={() => openPanel("repos")} title="Manage repos">
<GitBranch size={14} weight="light" />
</button>
<button class="icon-btn" class:active={panel === "apk"} on:click={() => openPanel("apk")} title="Install from URL">
<button class="icon-btn" class:active={panel === "apk"} onclick={() => openPanel("apk")} title="Install from URL">
<Plus size={14} weight="light" />
</button>
<button class="icon-btn" on:click={fetchFromRepo} disabled={refreshing} title="Refresh repo">
<button class="icon-btn" onclick={fetchFromRepo} disabled={refreshing} title="Refresh repo">
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
</button>
</div>
@@ -148,15 +147,14 @@
<div class="ext-panel">
<div class="panel-header">
<span class="panel-title">Install from APK URL</span>
<button class="icon-btn" on:click={() => panel = null}><X size={14} weight="light" /></button>
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
</div>
<div class="ext-row">
<input class="ext-input" class:error={installError} placeholder="https://example.com/extension.apk"
bind:value={externalUrl} disabled={installing}
on:input={() => installError = null}
on:keydown={(e) => e.key === "Enter" && !installing && installExternal()}
use:focusEl />
<button class="install-btn" class:success={installSuccess} on:click={installExternal} disabled={installing || !externalUrl.trim()}>
oninput={() => installError = null}
onkeydown={(e) => e.key === "Enter" && !installing && installExternal()} autofocus />
<button class="install-btn" class:success={installSuccess} onclick={installExternal} disabled={installing || !externalUrl.trim()}>
{#if installing}<CircleNotch size={13} weight="light" class="anim-spin" />
{:else if installSuccess}<Check size={13} weight="bold" /> Done
{:else}Install{/if}
@@ -170,7 +168,7 @@
<div class="ext-panel">
<div class="panel-header">
<span class="panel-title">Extension Repositories</span>
<button class="icon-btn" on:click={() => panel = null}><X size={14} weight="light" /></button>
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
</div>
{#if reposLoading}
<div class="repo-loading"><CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
@@ -182,7 +180,7 @@
{#each repos as url}
<div class="repo-row">
<span class="repo-url">{url}</span>
<button class="repo-remove" on:click={() => removeRepo(url)} disabled={savingRepos} title="Remove repo">
<button class="repo-remove" onclick={() => removeRepo(url)} disabled={savingRepos} title="Remove repo">
{#if savingRepos}<CircleNotch size={12} weight="light" class="anim-spin" />{:else}<X size={12} weight="bold" />{/if}
</button>
</div>
@@ -192,9 +190,9 @@
<div class="ext-row" style="margin-top:var(--sp-2)">
<input class="ext-input" class:error={repoError} placeholder="https://example.com/index.min.json"
bind:value={newRepoUrl} disabled={savingRepos}
on:input={() => repoError = null}
on:keydown={(e) => e.key === "Enter" && !savingRepos && addRepo()} />
<button class="install-btn" on:click={addRepo} disabled={savingRepos || !newRepoUrl.trim()}>
oninput={() => repoError = null}
onkeydown={(e) => e.key === "Enter" && !savingRepos && addRepo()} />
<button class="install-btn" onclick={addRepo} disabled={savingRepos || !newRepoUrl.trim()}>
{#if savingRepos}<CircleNotch size={13} weight="light" class="anim-spin" />{:else}Add{/if}
</button>
</div>
@@ -206,7 +204,7 @@
<div class="controls">
<div class="tabs">
{#each FILTERS as f}
<button class="tab" class:active={filter === f.id} on:click={() => filter = f.id}>
<button class="tab" class:active={filter === f.id} onclick={() => filter = f.id}>
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
</button>
{/each}
@@ -228,7 +226,7 @@
{@const hasVariants = variants.length > 0}
<div class="group">
<div class="row">
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} class="icon" on:error={(e) => (e.target as HTMLImageElement).style.display = "none"} />
<img src={thumbUrl(primary.iconUrl)} alt={primary.name} class="icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
<div class="info">
<span class="name">{base}</span>
<span class="meta"><span class="lang-tag">{primary.lang.toUpperCase()}</span> v{primary.versionName}</span>
@@ -238,16 +236,16 @@
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
{:else if primary.hasUpdate}
<div class="row-actions">
<button class="action-btn" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, update: true }), primary.pkgName)}>Update</button>
<button class="action-btn-dim" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, update: true }), primary.pkgName)}>Update</button>
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
</div>
{:else if primary.isInstalled}
<button class="action-btn-dim" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, uninstall: true }), primary.pkgName)}>Remove</button>
{:else}
<button class="action-btn" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, install: true }), primary.pkgName)}>Install</button>
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: primary.pkgName, install: true }), primary.pkgName)}>Install</button>
{/if}
{#if hasVariants}
<button class="expand-btn" on:click={() => toggleExpand(base)} title="{variants.length + 1} languages">
<button class="expand-btn" onclick={() => toggleExpand(base)} title="{variants.length + 1} languages">
{#if isExpanded}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
<span class="expand-count">{variants.length + 1}</span>
</button>
@@ -265,11 +263,11 @@
{#if working.has(v.pkgName)}
<CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" />
{:else if v.hasUpdate}
<button class="action-btn" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, update: true }), v.pkgName)}>Update</button>
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, update: true }), v.pkgName)}>Update</button>
{:else if v.isInstalled}
<button class="action-btn-dim" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, uninstall: true }), v.pkgName)}>Remove</button>
<button class="action-btn-dim" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, uninstall: true }), v.pkgName)}>Remove</button>
{:else}
<button class="action-btn" on:click={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, install: true }), v.pkgName)}>Install</button>
<button class="action-btn" onclick={() => mutate(() => gql(UPDATE_EXTENSION, { id: v.pkgName, install: true }), v.pkgName)}>Install</button>
{/if}
</div>
</div>
@@ -282,9 +280,6 @@
{/if}
</div>
<script context="module">
function focusEl(node: HTMLElement) { node.focus(); }
</script>
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
+24 -26
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
import { untrack } from "svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
import { dedupeSources, dedupeMangaById } from "../../lib/util";
import { settings, genreFilter, previewManga, addFolder, assignMangaToFolder } from "../../store";
import { store, addFolder, assignMangaToFolder, setGenreFilter, setPreviewManga, setNavPage } from "../../store/state.svelte";
import type { Manga, Source } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
@@ -28,34 +28,32 @@
async function worker() { while (i < items.length) { if (signal.aborted) return; await fn(items[i++]).catch(() => {}); } }
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
}
const prevNavPage = store.navPage;
const tags = $derived(parseTags(store.genreFilter));
const primaryTag = $derived(tags[0] ?? "");
const label = $derived(tagsLabel(tags));
$: tags = parseTags($genreFilter);
$: primaryTag = tags[0] ?? "";
$: label = tagsLabel(tags);
let libraryManga: Manga[] = [];
let sourceManga: Manga[] = [];
let libraryManga: Manga[] = $state([]);
let sourceManga: Manga[] = $state([]);
let loadingInitial = true;
let loadingMore = false;
let visibleCount = PAGE_SIZE;
let ctx: { x: number; y: number; manga: Manga } | null = null;
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
const nextPageMap = new Map<string, number>();
let sources: Source[] = [];
let sources: Source[] = $state([]);
let abortCtrl: AbortController | null = null;
$: filtered = (() => {
const filtered = $derived.by(() => {
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags));
const libIds = new Set(libMatches.map((m) => m.id));
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id))]);
})();
$: visibleItems = filtered.slice(0, visibleCount);
$: hasMoreVisible = visibleCount < filtered.length;
$: hasMoreNetwork = sources.some((s) => (nextPageMap.get(s.id) ?? -1) > 0);
$: hasMore = hasMoreVisible || hasMoreNetwork;
$: if ($genreFilter) load($genreFilter);
});
const visibleItems = $derived(filtered.slice(0, visibleCount));
const hasMoreVisible = $derived(visibleCount < filtered.length);
const hasMoreNetwork = $derived(sources.some((s) => (nextPageMap.get(s.id) ?? -1) > 0));
const hasMore = $derived(hasMoreVisible || hasMoreNetwork);
$effect(() => { const f = store.genreFilter; if (f) untrack(() => load(f)); });
async function load(filter: string) {
abortCtrl?.abort();
@@ -67,7 +65,7 @@
visibleCount = PAGE_SIZE;
nextPageMap.clear();
const preferredLang = $settings.preferredExtensionLang || "en";
const preferredLang = store.settings.preferredExtensionLang || "en";
const t = parseTags(filter);
const pt = t[0] ?? "";
@@ -149,9 +147,9 @@
return [
{ label: m.inLibrary ? "In Library" : "Add to library", icon: BookmarkSimple, disabled: m.inLibrary,
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
...($settings.folders.length > 0 ? [
...(store.settings.folders.length > 0 ? [
{ separator: true } as MenuEntry,
...$settings.folders.map((f): MenuEntry => ({
...store.settings.folders.map((f): MenuEntry => ({
label: f.mangaIds.includes(m.id) ? `✓ ${f.name}` : f.name, icon: Folder,
onClick: () => assignMangaToFolder(f.id, m.id),
})),
@@ -161,12 +159,12 @@
];
}
onDestroy(() => abortCtrl?.abort());
$effect(() => () => { abortCtrl?.abort(); });
</script>
<div class="root">
<div class="header">
<button class="back" on:click={() => genreFilter.set("")}>
<button class="back" onclick={() => { setGenreFilter(""); setNavPage(prevNavPage); }}>
<ArrowLeft size={13} weight="light" /><span>Back</span>
</button>
<span class="title">{label}</span>
@@ -192,7 +190,7 @@
{:else}
<div class="grid">
{#each visibleItems as m (m.id)}
<button class="card" on:click={() => previewManga.set(m)} on:contextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.preventDefault(); e.stopPropagation(); ctx = { x: e.clientX, y: e.clientY, manga: m }; }}>
<div class="cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
@@ -202,7 +200,7 @@
{/each}
{#if hasMore}
<div class="show-more-cell">
<button class="show-more-btn" on:click={loadMore} disabled={loadingMore}>
<button class="show-more-btn" onclick={loadMore} disabled={loadingMore}>
{#if loadingMore}<CircleNotch size={13} weight="light" class="anim-spin" /> Loading…{:else}Show more{/if}
</button>
</div>
+14 -14
View File
@@ -2,8 +2,8 @@
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books, X as XIcon } from "phosphor-svelte";
import { thumbUrl, gql } from "../../lib/client";
import { GET_CHAPTERS } from "../../lib/queries";
import { history, readingStats, openReader, clearHistory, clearHistoryForManga } from "../../store";
import type { HistoryEntry } from "../../store";
import { store, openReader, clearHistory, clearHistoryForManga } from "../../store/state.svelte";
import type { HistoryEntry } from "../../store/state.svelte";
let search = $state("");
let confirmClearAll = $state(false);
@@ -67,8 +67,8 @@
}
const filtered = $derived(search.trim()
? history.filter(e => e.mangaTitle.toLowerCase().includes(search.toLowerCase()) || e.chapterName.toLowerCase().includes(search.toLowerCase()))
: history);
? store.history.filter(e => e.mangaTitle.toLowerCase().includes(search.toLowerCase()) || e.chapterName.toLowerCase().includes(search.toLowerCase()))
: store.history);
const sessions = $derived(buildSessions(filtered));
@@ -83,9 +83,9 @@
})());
const stats = $derived({
uniqueChapters: new Set(history.map(e => e.chapterId)).size,
uniqueManga: new Set(history.map(e => e.mangaId)).size,
estimatedMinutes: Math.round(new Set(history.map(e => e.chapterId)).size * 4.5),
uniqueChapters: new Set(store.history.map(e => e.chapterId)).size,
uniqueManga: new Set(store.history.map(e => e.mangaId)).size,
estimatedMinutes: Math.round(new Set(store.history.map(e => e.chapterId)).size * 4.5),
});
function doConfirmClear() { clearHistory(); confirmClearAll = false; }
@@ -106,14 +106,14 @@
<div class="header-right">
<div class="search-wrap">
<MagnifyingGlass size={12} class="search-icon" weight="light" />
<input class="search" placeholder="Search history…" bind:value={search} />
<input class="search" placeholder="Search store.history…" bind:value={search} />
{#if search}
<button class="search-clear" onclick={() => search = ""}>
<XIcon size={10} weight="bold" />
</button>
{/if}
</div>
{#if history.length > 0}
{#if store.history.length > 0}
{#if confirmClearAll}
<div class="confirm-row">
<span class="confirm-label">Clear all activity?</span>
@@ -135,16 +135,16 @@
<span class="stat-item"><span class="stat-val">{stats.uniqueManga}</span><span class="stat-label">series</span></span>
<span class="stat-sep"></span>
<span class="stat-item"><span class="stat-val">{formatReadTime(stats.estimatedMinutes)}</span><span class="stat-label">est. time</span></span>
{#if readingStats.currentStreakDays > 0}
{#if store.readingStats.currentStreakDays > 0}
<span class="stat-sep"></span>
<span class="stat-item"><span class="stat-val">{readingStats.currentStreakDays}d</span><span class="stat-label">streak</span></span>
<span class="stat-item"><span class="stat-val">{store.readingStats.currentStreakDays}d</span><span class="stat-label">streak</span></span>
{/if}
</div>
{#if history.length === 0}
{#if store.history.length === 0}
<div class="empty">
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
<p class="empty-text">No reading history yet</p>
<p class="empty-text">No reading store.history yet</p>
<p class="empty-hint">Chapters you read will appear here</p>
</div>
{:else if sessions.length === 0}
@@ -183,7 +183,7 @@
<span class="time">{timeAgo(session.readAt)}</span>
<Play size={11} weight="fill" class="play-icon" />
</button>
<button class="row-delete" onclick={() => clearHistoryForManga(session.mangaId)} title="Remove {session.mangaTitle} from history" aria-label="Remove from history">
<button class="row-delete" onclick={() => clearHistoryForManga(session.mangaId)} title="Remove {session.mangaTitle} from store.history" aria-label="Remove from store.history">
<XIcon size={9} weight="bold" />
</button>
</div>
+78 -65
View File
@@ -1,11 +1,11 @@
<script lang="ts">
import { onMount } from "svelte";
import { onMount, untrack } from "svelte";
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, Fire, TrendUp, CalendarBlank, CheckCircle, PushPin, X as XIcon, MagnifyingGlass, ListBullets } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_LIBRARY, GET_CHAPTERS } from "../../lib/queries";
import { GET_LIBRARY, GET_CHAPTERS, GET_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
import { history, readingStats, settings, activeManga, navPage, previewManga, openReader, COMPLETED_FOLDER_ID, setHeroSlot } from "../../store";
import type { HistoryEntry } from "../../store";
import { store, openReader, COMPLETED_FOLDER_ID, setHeroSlot, setActiveManga, setPreviewManga, setNavPage, setGenreFilter } from "../../store/state.svelte";
import type { HistoryEntry } from "../../store/state.svelte";
import type { Manga, Chapter } from "../../lib/types";
function timeAgo(ts: number): string {
@@ -31,20 +31,30 @@
function focusEl(node: HTMLElement) { node.focus(); }
let libraryManga: Manga[] = $state([]);
let extraManga: Manga[] = $state([]);
let loadingLibrary: boolean = $state(true);
onMount(() => {
cache.get(CACHE_KEYS.LIBRARY, () =>
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
).then(m => { libraryManga = m; })
).then(m => { libraryManga = m; fetchExtraCompleted(m); })
.catch(console.error)
.finally(() => loadingLibrary = false);
});
async function fetchExtraCompleted(library: Manga[]) {
const completedIds = store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? [];
const missingIds = completedIds.filter(id => !library.some(m => m.id === id));
if (!missingIds.length) return;
const results = await Promise.allSettled(missingIds.map(id => gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)));
const valid = results.flatMap(r => r.status === "fulfilled" && r.value ? [r.value] : []);
if (valid.length) extraManga = valid;
}
const continueReading = $derived((() => {
const seen = new Set<number>();
const out: HistoryEntry[] = [];
for (const e of history) {
for (const e of store.history) {
if (seen.has(e.mangaId)) continue;
seen.add(e.mangaId);
out.push(e);
@@ -57,7 +67,7 @@
interface HeroSlot { kind: "continue" | "pinned" | "empty"; entry?: HistoryEntry; manga?: Manga; slotIndex: number; }
const resolvedSlots = $derived((() => {
const pins = settings.heroSlots ?? [null, null, null, null];
const pins = store.settings.heroSlots ?? [null, null, null, null];
const slots: HeroSlot[] = [];
const first = continueReading[0];
slots.push(first ? { kind: "continue", entry: first, slotIndex: 0 } : { kind: "empty", slotIndex: 0 });
@@ -96,12 +106,14 @@
return () => window.removeEventListener("keydown", onKey);
});
let heroStageH = $state(300);
let heroChapters: Chapter[] = $state([]);
let loadingHeroChapters = $state(false);
let heroChaptersFor: number | null = null;
$effect(() => {
if (heroMangaId && heroMangaId !== heroChaptersFor) loadHeroChapters(heroMangaId);
const id = heroMangaId;
if (id && id !== heroChaptersFor) untrack(() => loadHeroChapters(id));
});
async function loadHeroChapters(mangaId: number) {
@@ -131,12 +143,12 @@
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
}
openReader(chapter, all);
} catch { activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
finally { resuming = false; }
}
async function resumeActive() {
if (!heroEntry && heroManga) { activeManga = heroManga; return; }
if (!heroEntry && heroManga) { store.activeManga = heroManga; return; }
if (!heroEntry) return;
const target = heroChapters.find(c => c.id === heroEntry!.chapterId) ?? heroChapters[0];
if (target && heroChapters.length) { await openChapter(target); return; }
@@ -146,8 +158,8 @@
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const ch = chapters.find(c => c.id === heroEntry!.chapterId) ?? chapters[0];
if (ch) openReader(ch, chapters);
else activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
} catch { activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
else store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
finally { resuming = false; }
}
@@ -157,8 +169,8 @@
const chapters = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const ch = chapters.find(c => c.id === entry.chapterId) ?? chapters[0];
if (ch) openReader(ch, chapters);
else activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
} catch { activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
}
let pickerOpen = $state(false);
@@ -174,10 +186,11 @@
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
function unpinSlot(i: 1|2|3) { setHeroSlot(i, null); }
const completedIds = $derived(settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? []);
const completedManga = $derived(completedIds.length > 0 ? libraryManga.filter(m => completedIds.includes(m.id)).slice(0, 10) : []);
const recentHistory = $derived(history.slice(0, 8));
const stats = $derived(readingStats);
const completedIds = $derived(store.settings.folders.find(f => f.id === COMPLETED_FOLDER_ID)?.mangaIds ?? []);
const completedPool = $derived([...libraryManga, ...extraManga.filter(m => !libraryManga.some(l => l.id === m.id))]);
const completedManga = $derived(completedIds.length > 0 ? completedPool.filter(m => completedIds.includes(m.id)).slice(0, 20) : []);
const recentHistory = $derived(store.history.slice(0, 8));
const stats = $derived(store.readingStats);
function handleRowWheel(e: WheelEvent) {
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
@@ -190,7 +203,7 @@
<div class="body">
<div class="hero-section">
<div class="hero-stage">
<div class="hero-stage" bind:clientHeight={heroStageH} style="--hero-h:{heroStageH}px">
{#if heroThumb}
<div class="hero-backdrop" style="background-image:url({heroThumb})"></div>
@@ -199,7 +212,7 @@
{/if}
<div class="hero-scrim"></div>
<button class="hero-cover-col" onclick={resumeActive} disabled={resuming || activeSlot?.kind === "empty"} title={heroTitle ? `Resume ${heroTitle}` : undefined} aria-label={heroTitle ? `Resume ${heroTitle}` : "No manga selected"}>
<button class="hero-cover-col" onclick={resumeActive} disabled={resuming || activeSlot?.kind === "empty"} aria-label={heroTitle ? `Resume ${heroTitle}` : "No manga selected"}>
{#if heroThumb}
<img src={heroThumb} alt={heroTitle} class="hero-cover" loading="eager" decoding="async" />
{#if activeSlot?.kind === "continue"}
@@ -227,7 +240,7 @@
<span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span>
{/if}
{#each (heroManga?.genre ?? []).slice(0, 3) as g}
<span class="hero-tag">{g}</span>
<button class="hero-tag hero-tag-genre" onclick={() => { setGenreFilter(g); setNavPage("explore"); }}>{g}</button>
{/each}
</div>
@@ -251,7 +264,7 @@
<Play size={11} weight="fill" />{resuming ? "Loading…" : "Resume"}
</button>
{:else if heroManga}
<button class="hero-cta" onclick={() => previewManga = heroManga!}>
<button class="hero-cta" onclick={() => store.previewManga = heroManga!}>
<BookOpen size={11} weight="light" /> View manga
</button>
{/if}
@@ -314,7 +327,7 @@
</button>
{/each}
{#if heroManga}
<button class="ch-view-all" onclick={() => { if (heroManga) activeManga = heroManga; }}>
<button class="ch-view-all" onclick={() => { if (heroManga) store.activeManga = heroManga; }}>
All chapters <ArrowRight size={9} weight="bold" />
</button>
{/if}
@@ -328,7 +341,7 @@
<div class="section">
<div class="section-header">
<span class="section-title"><Clock size={10} weight="bold" /> Recent Activity</span>
<button class="see-all" onclick={() => navPage = "history"}>Full history <ArrowRight size={9} weight="bold" /></button>
<button class="see-all" onclick={() => setNavPage("history")}>Full History <ArrowRight size={9} weight="bold" /></button>
</div>
<div class="activity-list">
{#each recentHistory as entry (entry.chapterId)}
@@ -347,7 +360,7 @@
{:else}
<div class="empty-state">
<p class="empty-text">Start reading to build your activity feed</p>
<button class="empty-cta" onclick={() => navPage = "library"}>Open Library <ArrowRight size={11} weight="bold" /></button>
<button class="empty-cta" onclick={() => store.navPage = "library"}>Open Library <ArrowRight size={11} weight="bold" /></button>
</div>
{/if}
@@ -356,13 +369,13 @@
<div class="bottom-section-hd">
<span class="section-title"><CheckCircle size={10} weight="bold" /> Completed</span>
{#if completedManga.length > 0}
<button class="see-all" onclick={() => navPage = "library"}>View all <ArrowRight size={9} weight="bold" /></button>
<button class="see-all" onclick={() => store.navPage = "library"}>View all <ArrowRight size={9} weight="bold" /></button>
{/if}
</div>
{#if completedManga.length > 0}
<div class="mini-row" onwheel={(e) => { e.preventDefault(); handleRowWheel(e); }}>
{#each completedManga as m (m.id)}
<button class="mini-card" onclick={() => previewManga = m}>
<button class="mini-card" onclick={() => store.previewManga = m}>
<div class="mini-cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="mini-cover" loading="lazy" decoding="async" />
<div class="mini-gradient"></div>
@@ -433,31 +446,31 @@
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
.body { flex: 1; overflow-y: auto; scrollbar-width: none; padding-bottom: var(--sp-8); }
.body::-webkit-scrollbar { display: none; }
.hero-section { padding: var(--sp-4) var(--sp-5) 0; }
.hero-stage { position: relative; display: flex; align-items: stretch; height: 340px; border-radius: var(--radius-xl); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 6px 28px rgba(0,0,0,0.28); }
.hero-backdrop { position: absolute; inset: -14px; background-size: cover; background-position: center 25%; filter: blur(24px) saturate(1.4) brightness(0.32); transform: scale(1.07); pointer-events: none; z-index: 0; }
.body { flex: 1; display: flex; flex-direction: column; overflow-y: auto; overflow-x: hidden; min-height: 0; }
.hero-section { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; }
.hero-stage { position: relative; display: flex; align-items: stretch; height: 374px; border-radius: var(--radius-xl); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 6px 28px rgba(0,0,0,0.28); }
.hero-backdrop { position: absolute; inset: -14px; background-size: cover; background-position: center 25%; filter: blur(20px) saturate(2.2) brightness(0.45); transform: scale(1.07); pointer-events: none; z-index: 0; }
.hero-bd-empty { background: var(--bg-void); filter: none; }
.hero-scrim { position: absolute; inset: 0; z-index: 1; pointer-events: none; background: linear-gradient(100deg, rgba(0,0,0,0.05) 0%, rgba(0,0,0,0.4) 100%); }
.hero-cover-col { position: relative; z-index: 2; width: clamp(150px, 30%, 195px); flex-shrink: 0; display: flex; align-items: center; justify-content: center; padding: var(--sp-5); background: none; border: none; cursor: pointer; border-right: 1px solid rgba(255,255,255,0.06); }
.hero-cover-col:hover .hero-cover { filter: brightness(1.1); }
.hero-scrim { position: absolute; inset: 0; z-index: 1; pointer-events: none; background: linear-gradient(100deg, rgba(0,0,0,0.0) 0%, rgba(0,0,0,0.55) 100%); }
.hero-cover-col { position: relative; z-index: 2; flex-shrink: 0; width: 263px; height: 374px; overflow: hidden; cursor: pointer; border-right: 1px solid rgba(255,255,255,0.08); background: var(--bg-raised); }
.hero-cover-col:hover .hero-cover { filter: brightness(1.08); }
.hero-cover-col:hover .cover-resume-hint { opacity: 1; }
.hero-cover-col:disabled { cursor: default; }
.hero-cover { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-lg); box-shadow: 0 10px 36px rgba(0,0,0,0.75), 0 2px 8px rgba(0,0,0,0.4); display: block; transition: filter 0.18s ease; }
.hero-cover-empty { width: 100%; aspect-ratio: 2/3; display: flex; align-items: center; justify-content: center; background: var(--bg-overlay); border-radius: var(--radius-lg); color: var(--text-faint); }
.cover-resume-hint { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; color: #fff; font-size: 32px; background: rgba(0,0,0,0.35); border-radius: var(--radius-lg); opacity: 0; transition: opacity 0.18s ease; pointer-events: none; }
.hero-details { position: relative; z-index: 2; flex: 1; min-width: 0; padding: var(--sp-5) var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: var(--sp-2); overflow: hidden; border-right: 1px solid rgba(255,255,255,0.06); }
.hero-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.2s ease; }
.hero-cover-empty { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; background: var(--bg-overlay); color: var(--text-faint); }
.cover-resume-hint { position: absolute; inset: var(--sp-3); display: flex; align-items: center; justify-content: center; color: #fff; font-size: 36px; background: rgba(0,0,0,0.4); border-radius: var(--radius-lg); opacity: 0; transition: opacity 0.18s ease; pointer-events: none; }
.hero-details { position: relative; z-index: 2; flex: 1; min-width: 0; padding: var(--sp-4) var(--sp-5) var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); overflow: hidden; border-right: 1px solid rgba(255,255,255,0.06); }
.hero-tags { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
.hero-tag { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-full); background: rgba(255,255,255,0.1); color: rgba(255,255,255,0.62); border: 1px solid rgba(255,255,255,0.14); }
.hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
.hero-tag-pinned { background: rgba(168,132,232,0.18); color: #c4a8f0; border-color: rgba(168,132,232,0.28); }
.hero-tag-genre { cursor: pointer; transition: background 0.15s ease, color 0.15s ease; }
.hero-tag-genre:hover { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); }
.hero-title { font-size: var(--text-xl); font-weight: var(--weight-medium); color: #fff; line-height: var(--leading-tight); margin: 0; flex-shrink: 0; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 2px 10px rgba(0,0,0,0.5); }
.hero-author { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.48); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.hero-progress { display: flex; align-items: center; gap: 5px; flex-shrink: 0; font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.58); letter-spacing: var(--tracking-wide); }
.hero-prog-page { color: rgba(255,255,255,0.38); }
.hero-prog-time { margin-left: auto; color: rgba(255,255,255,0.32); }
.hero-desc { font-size: var(--text-xs); color: rgba(255,255,255,0.42); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden; flex: 1; min-height: 0; }
.hero-desc { font-size: var(--text-xs); color: rgba(255,255,255,0.42); line-height: 1.55; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; flex-shrink: 0; }
.hero-empty-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: rgba(255,255,255,0.5); flex-shrink: 0; }
.hero-empty-sub { font-family: var(--font-ui); font-size: var(--text-xs); color: rgba(255,255,255,0.28); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
.hero-actions { display: flex; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; }
@@ -499,31 +512,31 @@
.sk-meta { height: 9px; width: 50%; }
.ch-view-all { display: flex; align-items: center; gap: 4px; margin-top: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: rgba(255,255,255,0.3); letter-spacing: var(--tracking-wide); background: none; border: none; cursor: pointer; padding: var(--sp-2) var(--sp-2) 0; transition: color var(--t-base); }
.ch-view-all:hover { color: var(--accent-fg); }
.section { border-top: 1px solid var(--border-dim); margin-top: var(--sp-4); }
.section-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-5) var(--sp-2); }
.section-title { display: inline-flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.see-all { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
.section { border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.section-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-2); }
.section-title { display: inline-flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.see-all { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
.see-all:hover { color: var(--accent-fg); }
.activity-list { display: flex; flex-direction: column; padding: 0 var(--sp-3); }
.activity-row { display: flex; align-items: center; gap: var(--sp-3); padding: 9px var(--sp-2); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; width: 100%; transition: background var(--t-fast), border-color var(--t-fast); }
.activity-list { display: flex; flex-direction: column; padding: 0 var(--sp-3); overflow: hidden; }
.activity-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; width: 100%; transition: background var(--t-fast), border-color var(--t-fast); }
.activity-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.activity-row:hover .activity-play { opacity: 1; }
.activity-thumb { width: 36px; height: 52px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.activity-info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
.activity-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.activity-sub { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.activity-time { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.activity-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.activity-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.activity-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.activity-sub { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-muted); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.activity-time { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.activity-play { color: var(--accent-fg); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
.bottom-row { display: grid; grid-template-columns: 1fr 1px 1fr; padding: 0 var(--sp-5) 0; margin-top: var(--sp-4); border-top: 1px solid var(--border-dim); align-items: start; }
.bottom-divider { background: var(--border-dim); align-self: stretch; min-height: 100%; }
.bottom-col { display: flex; flex-direction: column; min-width: 0; padding-top: var(--sp-3); }
.bottom-col:first-child { padding-right: var(--sp-5); }
.bottom-col:last-child { padding-left: var(--sp-5); }
.bottom-row { display: grid; grid-template-columns: 1fr 1px 1fr; padding: 0 var(--sp-4); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.bottom-divider { background: var(--border-dim); align-self: stretch; }
.bottom-col { display: flex; flex-direction: column; min-width: 0; padding-top: var(--sp-3); padding-bottom: var(--sp-4); }
.bottom-col:first-child { padding-right: var(--sp-4); }
.bottom-col:last-child { padding-left: var(--sp-4); }
.bottom-section-hd { display: flex; align-items: center; justify-content: space-between; padding-bottom: var(--sp-2); }
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-2) 0; }
.mini-row { display: flex; gap: var(--sp-3); overflow-x: auto; scrollbar-width: none; padding-bottom: var(--sp-2); }
.mini-row::-webkit-scrollbar { display: none; }
.mini-card { flex-shrink: 0; width: 120px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.bottom-empty { font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 0; }
.mini-row { display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr)); gap: var(--sp-3); }
.mini-card { width: 100%; background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.mini-card:hover .mini-cover { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); }
.mini-card:hover { will-change: transform; }
.mini-cover-wrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 12px rgba(0,0,0,0.35); }
@@ -533,16 +546,16 @@
.mini-card-title { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }
.mini-card-source { font-family: var(--font-ui); font-size: 9px; color: rgba(255,255,255,0.45); letter-spacing: var(--tracking-wide); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.stats-grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); }
.stat-card { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: var(--sp-2) var(--sp-3); }
.stat-card { display: flex; align-items: center; gap: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: var(--sp-2) var(--sp-3); }
.stat-icon-wrap { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); flex-shrink: 0; }
.stat-fire { background: rgba(251,146,60,0.15); color: #fb923c; }
.stat-accent { background: var(--accent-muted); color: var(--accent-fg); }
.stat-neutral { background: var(--bg-overlay); color: var(--text-faint); }
.stat-green { background: rgba(34,197,94,0.12); color: #22c55e; }
.stat-body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.stat-val { font-family: var(--font-ui); font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: 1; }
.stat-val { font-family: var(--font-ui); font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: 1; }
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: var(--sp-3); padding: var(--sp-7) var(--sp-6); }
.empty-state { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-6); flex-shrink: 0; }
.empty-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.empty-cta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-full); background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
.empty-cta:hover { filter: brightness(1.1); }
@@ -560,7 +573,7 @@
.picker-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; }
.picker-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
.picker-row:hover { background: var(--bg-raised); }
.picker-thumb { width: 34px; height: 50px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.picker-thumb { height: 50px; width: 35px; aspect-ratio: 1 / 1.42; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); background: var(--bg-raised); display: block; }
.picker-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.picker-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.picker-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
+61 -41
View File
@@ -1,27 +1,28 @@
<script lang="ts">
import { onMount } from "svelte";
import { onMount, untrack } from "svelte";
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_LIBRARY, GET_ALL_MANGA, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
import { GET_LIBRARY, GET_MANGA, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
import { settings, activeManga, libraryFilter, genreFilter, activeChapter } from "../../store";
import { addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders } from "../../store";
import { store, setActiveManga, setLibraryFilter } from "../../store/state.svelte";
import { addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders } from "../../store/state.svelte";
import { COMPLETED_FOLDER_ID } from "../../store/state.svelte";
import type { Manga, Chapter } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
const CARD_MIN_W = 130;
const CARD_GAP = 16;
let allManga: Manga[] = $state([]);
let allMangaUnfiltered: Manga[] = $state([]);
let loading: boolean = $state(true);
let error: string|null = $state(null);
let retryCount: number = $state(0);
let search: string = $state("");
let renderVisible: number = $state(0);
let scrollEl: HTMLDivElement;
let containerWidth: number = $state(800);
let allManga: Manga[] = $state([]);
let extraManga: Manga[] = $state([]); // non-library manga needed for folders (e.g. completed)
let loading: boolean = $state(true);
let error: string|null = $state(null);
let retryCount: number = $state(0);
let search: string = $state("");
let renderVisible: number = $state(0);
let scrollEl: HTMLDivElement;
let containerWidth: number = $state(800);
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
let emptyCtx: { x: number; y: number } | null = $state(null);
@@ -29,8 +30,8 @@
$effect(() => {
const wasOpen = prevChapterId !== null;
prevChapterId = activeChapter?.id ?? null;
if (wasOpen && !activeChapter) cache.clear(CACHE_KEYS.LIBRARY);
prevChapterId = store.activeChapter?.id ?? null;
if (wasOpen && !store.activeChapter) cache.clear(CACHE_KEYS.LIBRARY);
});
function fetchLibrary() {
@@ -44,47 +45,66 @@
function loadData() {
fetchLibrary()
.then(nodes => { allManga = dedupeMangaByTitle(dedupeMangaById(nodes), settings.mangaLinks); error = null; })
.then(nodes => { allManga = dedupeMangaByTitle(dedupeMangaById(nodes), store.settings.mangaLinks); error = null; })
.catch(e => error = e.message)
.finally(() => loading = false);
cache.get(CACHE_KEYS.ALL_MANGA, () =>
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA).then(d => d.mangas.nodes),
DEFAULT_TTL_MS, CACHE_GROUPS.LIBRARY,
).then(nodes => { allMangaUnfiltered = dedupeMangaById(nodes); }).catch(console.error);
}
$effect(() => {
retryCount;
loading = true; error = null;
if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY);
loadData();
untrack(() => loadData());
});
// Lazily fetch manga that are in a folder but not in the library (e.g. completed but removed from library)
$effect(() => {
const allIds = new Set(allManga.map(m => m.id));
const missingIds = store.settings.folders
.flatMap(f => f.mangaIds)
.filter(id => !allIds.has(id));
if (!missingIds.length) return;
const toFetch = [...new Set(missingIds)].filter(id => !extraManga.some(m => m.id === id));
if (!toFetch.length) return;
untrack(() => {
Promise.all(
toFetch.map(id =>
cache.get(CACHE_KEYS.MANGA(id), () =>
gql<{ manga: Manga }>(GET_MANGA, { id }).then(d => d.manga)
).catch(() => null)
)
).then(results => {
const valid = results.filter(Boolean) as Manga[];
if (valid.length) extraManga = dedupeMangaById([...extraManga, ...valid]);
});
});
});
$effect(() => { if (scrollEl) scrollEl.scrollTo({ top: 0 }); });
$effect(() => {
const f = settings.folders.find(f => f.id === libraryFilter);
if (f && !f.showTab) libraryFilter = "library";
const f = store.settings.folders.find(f => f.id === store.libraryFilter);
if (f && !f.showTab) untrack(() => { store.libraryFilter = "library"; });
});
const isBuiltin = (f: string) => f === "library" || f === "downloaded";
// All manga available for folder filtering — library + any extras fetched above
const folderPool = $derived((() => {
const seen = new Set(allManga.map(m => m.id));
return [...allManga, ...allMangaUnfiltered.filter(m => !seen.has(m.id))];
return [...allManga, ...extraManga.filter(m => !seen.has(m.id))];
})());
const filtered = $derived((() => {
const q = search.trim().toLowerCase();
if (libraryFilter === "library") {
if (store.libraryFilter === "library") {
return q ? allManga.filter(m => m.title.toLowerCase().includes(q)) : allManga;
}
if (libraryFilter === "downloaded") {
if (store.libraryFilter === "downloaded") {
const items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items;
}
const folder = settings.folders.find(f => f.id === libraryFilter);
const folder = store.settings.folders.find(f => f.id === store.libraryFilter);
if (folder) {
const items = folderPool.filter(m => folder.mangaIds.includes(m.id));
return q ? items.filter(m => m.title.toLowerCase().includes(q)) : items;
@@ -97,15 +117,15 @@
const hasMore = $derived(filtered.length > renderVisible);
const remainingCount = $derived(filtered.length - renderVisible);
$effect(() => { filtered; renderVisible = settings.renderLimit ?? 48; });
$effect(() => { filtered; untrack(() => { renderVisible = store.settings.renderLimit ?? 48; }); });
const counts = $derived({
library: allManga.length,
downloaded: allManga.filter(m => (m.downloadCount ?? 0) > 0).length,
...settings.folders.reduce((a, f) => ({ ...a, [f.id]: folderPool.filter(m => f.mangaIds.includes(m.id)).length }), {} as Record<string, number>),
...store.settings.folders.reduce((a, f) => ({ ...a, [f.id]: folderPool.filter(m => f.mangaIds.includes(m.id)).length }), {} as Record<string, number>),
});
function loadMore() { renderVisible += settings.renderLimit ?? 48; }
function loadMore() { renderVisible += store.settings.renderLimit ?? 48; }
async function removeFromLibrary(manga: Manga) {
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
@@ -128,7 +148,7 @@
function buildCtxItems(m: Manga): MenuEntry[] {
const mangaFolders = getMangaFolders(m.id);
const folderEntries: MenuEntry[] = settings.folders.map(f => {
const folderEntries: MenuEntry[] = store.settings.folders.map(f => {
const inFolder = mangaFolders.some(mf => mf.id === f.id);
return { label: inFolder ? `Remove from ${f.name}` : `Add to ${f.name}`, icon: Folder, onClick: () => inFolder ? removeMangaFromFolder(f.id, m.id) : assignMangaToFolder(f.id, m.id) };
});
@@ -163,7 +183,7 @@
emptyCtx = { x: e.clientX, y: e.clientY };
}}
>
{#if settings.libraryBranches ?? true}
{#if store.settings.libraryBranches ?? true}
<svg class="branches" viewBox="0 0 400 600" preserveAspectRatio="xMaxYMid slice" aria-hidden="true">
<g stroke="var(--accent)" stroke-width="0.6" fill="none" opacity="0.13">
<path d="M380 600 C380 500 340 460 310 400 C280 340 300 280 270 220"/>
@@ -196,15 +216,15 @@
<span class="heading">Library</span>
<div class="tabs">
{#each [["library","Saved"], ["downloaded","Downloaded"]] as [f, label]}
<button class="tab" class:active={libraryFilter === f} onclick={() => libraryFilter = f}>
<button class="tab" class:active={store.libraryFilter === f} onclick={() => store.libraryFilter = f}>
{#if f === "library"}<Books size={11} weight="bold" />
{:else if f === "downloaded"}<DownloadSimple size={11} weight="bold" />{/if}
{label}
<span class="tab-count">{counts[f] ?? 0}</span>
</button>
{/each}
{#each settings.folders.filter(f => f.showTab) as folder}
<button class="tab" class:active={libraryFilter === folder.id} onclick={() => libraryFilter = folder.id}>
{#each store.settings.folders.filter(f => f.showTab) as folder}
<button class="tab" class:active={store.libraryFilter === folder.id} onclick={() => store.libraryFilter = folder.id}>
<Folder size={11} weight="bold" />
{folder.name}
<span class="tab-count">{counts[folder.id] ?? 0}</span>
@@ -229,16 +249,16 @@
</div>
{:else if filtered.length === 0}
<div class="center">
{libraryFilter === "library" ? "No manga saved to library — browse sources to add some."
: libraryFilter === "downloaded" ? "No downloaded manga."
{store.libraryFilter === "library" ? "No manga saved to library — browse sources to add some."
: store.libraryFilter === "downloaded" ? "No downloaded manga."
: "No manga in this folder yet. Right-click manga anywhere to assign them."}
</div>
{:else}
<div class="grid" style="--cols:{cols}">
{#each visibleManga as m (m.id)}
<button class="card" onclick={() => activeManga = m} oncontextmenu={(e) => openCtx(e, m)}>
<button class="card" onclick={() => store.activeManga = m} oncontextmenu={(e) => openCtx(e, m)}>
<div class="cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" style="object-fit:{settings.libraryCropCovers ? 'cover' : 'contain'}" loading="lazy" decoding="async" />
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" style="object-fit:{store.settings.libraryCropCovers ? 'cover' : 'contain'}" loading="lazy" decoding="async" />
{#if m.downloadCount}<span class="badge-dl">{m.downloadCount}</span>{/if}
{#if m.unreadCount}<span class="badge-unread">{m.unreadCount}</span>{/if}
</div>
@@ -249,7 +269,7 @@
{#if hasMore}
<div class="load-more-row">
<button class="load-more-btn" onclick={loadMore}>
Show {Math.min(remainingCount, settings.renderLimit ?? 48)} more
Show {Math.min(remainingCount, store.settings.renderLimit ?? 48)} more
<span class="load-more-count">({remainingCount} remaining)</span>
</button>
</div>
+41 -40
View File
@@ -1,14 +1,16 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, UPDATE_CHAPTERS_PROGRESS } from "../../lib/queries";
import type { Manga, Source, Chapter } from "../../lib/types";
export let manga: Manga;
export let currentChapters: Chapter[];
export let onClose: () => void;
export let onMigrated: (newManga: Manga) => void;
interface Props {
manga: Manga;
currentChapters: Chapter[];
onClose: () => void;
onMigrated: (newManga: Manga) => void;
}
let { manga, currentChapters, onClose, onMigrated }: Props = $props();
type Step = "source" | "search" | "confirm";
@@ -30,35 +32,34 @@
return intersection / union;
}
let step: Step = "source";
let sources: Source[] = [];
let loadingSources = true;
let selectedSource: Source | null = null;
let query = manga.title;
let results: { manga: Manga; similarity: number }[] = [];
let searching = false;
let selectedMatch: Match | null = null;
let loadingMatchId: number | null = null;
let migrating = false;
let error: string | null = null;
let step: Step = $state("source");
let sources: Source[] = $state([]);
let loadingSources = $state(true);
let selectedSource: Source | null = $state(null);
const _initialTitle = manga.title;
let query = $state(_initialTitle);
let results: { manga: Manga; similarity: number }[] = $state([]);
let searching = $state(false);
let selectedMatch: Match | null = $state(null);
let loadingMatchId: number | null = $state(null);
let migrating = $state(false);
let error: string | null = $state(null);
const readCount = $derived(currentChapters.filter((c) => c.isRead).length);
const totalCount = $derived(currentChapters.length);
const chapterDiff = $derived(selectedMatch ? selectedMatch.chapters.length - totalCount : 0);
const STEPS = ["source", "search", "confirm"] as const satisfies Step[];
const stepIdx = $derived(STEPS.indexOf(step));
$: readCount = currentChapters.filter((c) => c.isRead).length;
$: totalCount = currentChapters.length;
$: chapterDiff = selectedMatch ? selectedMatch.chapters.length - totalCount : 0;
$: STEPS = (["source", "search", "confirm"] as Step[]);
$: stepIdx = STEPS.indexOf(step);
onMount(() => {
$effect(() => {
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => sources = d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id))
.then((d) => { sources = d.sources.nodes.filter((s) => s.id !== "0" && s.id !== manga.source?.id); })
.catch(console.error)
.finally(() => loadingSources = false);
.finally(() => { loadingSources = false; });
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
onDestroy(() => window.removeEventListener("keydown", onKey));
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
async function searchSource(src: Source, q: string) {
@@ -144,9 +145,9 @@
}
</script>
<!-- svelte-ignore a11y-click-events-have-key-events -->
<!-- svelte-ignore a11y-no-static-element-interactions -->
<div class="overlay" on:click={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="modal">
<!-- Header -->
@@ -155,7 +156,7 @@
<span class="modal-title-label">Migrate source</span>
<span class="modal-title-manga">{manga.title}</span>
</div>
<button class="close-btn" on:click={onClose}><X size={14} weight="light" /></button>
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
</div>
<!-- Step indicators -->
@@ -189,9 +190,9 @@
<button
class="source-row"
class:source-row-active={selectedSource?.id === src.id}
on:click={() => pickSource(src)}>
onclick={() => pickSource(src)}>
<img src={thumbUrl(src.iconUrl)} alt={src.name} class="source-icon"
on:error={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<div class="source-info">
<span class="source-name">{src.displayName}</span>
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
@@ -210,9 +211,9 @@
{#if selectedSource}
<div class="search-context">
<img src={thumbUrl(selectedSource.iconUrl)} alt="" class="search-context-icon"
on:error={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="search-context-name">{selectedSource.displayName}</span>
<button class="search-context-change" on:click={() => { step = "source"; results = []; }}>Change</button>
<button class="search-context-change" onclick={() => { step = "source"; results = []; }}>Change</button>
</div>
{/if}
@@ -220,11 +221,11 @@
<div class="search-bar">
<MagnifyingGlass size={13} weight="light" class="search-icon" />
<input class="search-input" bind:value={query}
on:keydown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
onkeydown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
placeholder="Search title…" autofocus />
</div>
<button class="search-btn"
on:click={() => selectedSource && searchSource(selectedSource, query)}
onclick={() => selectedSource && searchSource(selectedSource, query)}
disabled={searching || !selectedSource}>
{#if searching}
<CircleNotch size={13} weight="light" class="anim-spin" />
@@ -250,7 +251,7 @@
{:else}
{#each results as { manga: m, similarity }, idx}
<button class="result-row"
on:click={() => selectMatch(m, similarity)}
onclick={() => selectMatch(m, similarity)}
disabled={loadingMatchId !== null}>
<div class="result-cover-wrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="result-cover" />
@@ -345,8 +346,8 @@
{#if error}<p class="error"><Warning size={13} weight="light" /> {error}</p>{/if}
<div class="confirm-actions">
<button class="back-btn" on:click={() => step = "search"} disabled={migrating}>Back</button>
<button class="migrate-btn" on:click={migrate} disabled={migrating}>
<button class="back-btn" onclick={() => step = "search"} disabled={migrating}>Back</button>
<button class="migrate-btn" onclick={migrate} disabled={migrating}>
{#if migrating}
<CircleNotch size={13} weight="light" class="anim-spin" /> Migrating…
{:else}
+148 -215
View File
@@ -1,14 +1,12 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { onDestroy, untrack } from "svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
import { dedupeSources, dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
import { settings, searchPrefill, previewManga } from "../../store";
import { store, setSearchPrefill, setPreviewManga } from "../../store/state.svelte";
import type { Manga, Source } from "../../lib/types";
type SearchTab = "keyword" | "tag" | "source";
type TagMode = "AND" | "OR";
@@ -19,13 +17,7 @@
error: string | null;
}
const CONCURRENCY = 6; // more parallel source requests
// RESULTS_PER_SOURCE and TAG_PAGE_SIZE are driven by $settings.renderLimit
// (accessed inline) so changing the setting takes effect immediately.
// No MAX_TAG_SOURCES cap — we fan out to all deduped sources so the grid
// is fully populated. Concurrency + caching keep this fast.
const CONCURRENCY = 6;
const COMMON_GENRES = [
"Action","Adventure","Comedy","Drama","Fantasy","Romance",
@@ -35,13 +27,7 @@
"Magic","Music","Cooking","Medical","Military","Harem","Ecchi",
];
async function runConcurrent<T>(
items: T[],
fn: (item: T) => Promise<void>,
signal: AbortSignal,
): Promise<void> {
async function runConcurrent<T>(items: T[], fn: (item: T) => Promise<void>, signal: AbortSignal): Promise<void> {
let i = 0;
async function worker() {
while (i < items.length) {
@@ -77,24 +63,25 @@
}
`;
let tab: SearchTab = $state("keyword");
let tab: SearchTab = "keyword";
let preferredLang = store.settings?.preferredExtensionLang ?? "";
let preferredLang = $settings?.preferredExtensionLang ?? "";
let allSources: Source[] = $state([]);
let loadingSources = $state(false);
let pendingPrefill = $state("");
let allSources: Source[] = [];
let loadingSources = false;
let pendingPrefill = "";
$effect(() => {
if (store.searchPrefill) {
const prefill = store.searchPrefill;
untrack(() => {
pendingPrefill = prefill;
tab = "keyword";
setSearchPrefill("");
});
}
});
$: if ($searchPrefill) {
pendingPrefill = $searchPrefill;
tab = "keyword";
searchPrefill.set("");
}
loadingSources = true;
cache.get(
CACHE_KEYS.SOURCES,
@@ -106,35 +93,37 @@
.catch(console.error)
.finally(() => { loadingSources = false; });
$: availableLangs = Array.from(new Set<string>(allSources.map((s) => s.lang))).sort();
$: hasMultipleLangs = availableLangs.length > 1;
const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort());
const hasMultipleLangs = $derived(availableLangs.length > 1);
// ── Keyword search ────────────────────────────────────────────────────────
let kw_query = "";
let kw_submitted = "";
let kw_results: SourceResult[] = [];
let kw_showAdvanced = false;
let kw_selectedLangs: Set<string> = new Set();
let kw_includeNsfw = false;
let kw_inputEl: HTMLInputElement | null = null;
let kw_query = $state("");
let kw_submitted = $state("");
let kw_results: SourceResult[] = $state([]);
let kw_showAdvanced = $state(false);
let kw_selectedLangs: Set<string> = $state(new Set());
let kw_includeNsfw = $state(false);
let kw_inputEl: HTMLInputElement | null = $state(null);
let kw_abortCtrl: AbortController | null = null;
$: if (allSources.length) {
const available = new Set(allSources.map((s) => s.lang));
kw_selectedLangs = available.has(preferredLang)
? new Set([preferredLang])
: new Set(availableLangs.slice(0, 1));
}
$effect(() => {
if (allSources.length) {
const available = new Set(allSources.map((s) => s.lang));
kw_selectedLangs = available.has(preferredLang)
? new Set([preferredLang])
: new Set(availableLangs.slice(0, 1));
}
});
$: if (!loadingSources && pendingPrefill && !kw_submitted && allSources.length) {
const q = pendingPrefill;
pendingPrefill = "";
kw_query = q;
kwDoSearch(q);
}
$effect(() => {
if (!loadingSources && pendingPrefill && !kw_submitted && allSources.length) {
const q = pendingPrefill;
pendingPrefill = "";
kw_query = q;
kwDoSearch(q);
}
});
function kwGetVisibleSources(): Source[] {
let filtered = allSources;
@@ -150,21 +139,16 @@
if (!trimmed) return;
const visible = kwGetVisibleSources();
if (!visible.length) return;
kw_abortCtrl?.abort();
const ctrl = new AbortController();
kw_abortCtrl = ctrl;
kw_submitted = trimmed;
kw_results = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null }));
await runConcurrent(visible, async (src) => {
if (ctrl.signal.aborted) return;
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page: 1, query: trimmed },
ctrl.signal,
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page: 1, query: trimmed }, ctrl.signal,
);
if (ctrl.signal.aborted) return;
kw_results = kw_results.map((r) =>
@@ -186,72 +170,67 @@
kw_selectedLangs = next;
}
$: kw_visibleCount = kwGetVisibleSources().length;
$: kw_hasResults = kw_results.some((r) => r.mangas.length > 0);
$: kw_allDone = kw_results.length > 0 && kw_results.every((r) => !r.loading);
const kw_visibleCount = $derived(kwGetVisibleSources().length);
const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0));
const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading));
// ── Tag search ────────────────────────────────────────────────────────────
let tag_activeTags: string[] = [];
let tag_tagMode: TagMode = "AND";
let tag_tagFilter = "";
let tag_activeTags: string[] = $state([]);
let tag_tagMode: TagMode = $state("AND");
let tag_tagFilter = $state("");
let tag_localResults: Manga[] = [];
let tag_totalCount = 0;
let tag_loadingLocal = false;
let tag_loadingMoreLocal = false;
let tag_localOffset = 0;
let tag_localHasNext = false;
let tag_localResults: Manga[] = $state([]);
let tag_totalCount = $state(0);
let tag_loadingLocal = $state(false);
let tag_loadingMoreLocal = $state(false);
let tag_localOffset = $state(0);
let tag_localHasNext = $state(false);
let tag_abortLocal: AbortController | null = null;
let tag_searchSources = false;
let tag_sourceResults: Manga[] = [];
let tag_loadingSourceSearch = false;
let tag_loadingMoreSource = false;
let tag_srcNextPage: Map<string, number> = new Map();
let tag_searchSources = $state(false);
let tag_sourceResults: Manga[] = $state([]);
let tag_loadingSourceSearch = $state(false);
let tag_loadingMoreSource = $state(false);
let tag_srcNextPage: Map<string, number> = $state(new Map());
let tag_abortSource: AbortController | null = null;
$: tag_filteredGenres = (() => {
const tag_filteredGenres = $derived.by(() => {
const q = tag_tagFilter.trim().toLowerCase();
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES;
})();
});
$: tag_hasActiveTags = tag_activeTags.length > 0;
$: tag_localIds = new Set(tag_localResults.map((m) => m.id));
$: tag_mergedResults = dedupeMangaByTitle(dedupeMangaById(
const tag_hasActiveTags = $derived(tag_activeTags.length > 0);
const tag_localIds = $derived(new Set(tag_localResults.map((m) => m.id)));
const tag_mergedResults = $derived(dedupeMangaByTitle(dedupeMangaById(
tag_searchSources
? [...tag_localResults, ...tag_sourceResults.filter((m) => !tag_localIds.has(m.id))]
: tag_localResults
), $settings.mangaLinks);
$: tag_totalVisible = tag_mergedResults.length;
$: tag_sourceHasMore = tag_searchSources && [...tag_srcNextPage.values()].some((p) => p > 0);
), store.settings.mangaLinks));
const tag_totalVisible = $derived(tag_mergedResults.length);
const tag_sourceHasMore = $derived(tag_searchSources && [...tag_srcNextPage.values()].some((p) => p > 0));
$: {
$effect(() => {
const _activeTags = tag_activeTags;
const _tagMode = tag_tagMode;
tagFetchLocal(_activeTags, _tagMode);
}
untrack(() => tagFetchLocal(_activeTags, _tagMode));
});
// Auto-enable source search if local results are sparse (< 20 after initial load)
// Use a flag so this only fires once per tag set, not on every reactive update
let tag_autoSearchFired = false;
$: if (!tag_loadingLocal && tag_activeTags.length > 0 && !tag_autoSearchFired && !tag_searchSources && !loadingSources) {
if (tag_localResults.length < 20) {
tag_autoSearchFired = true;
tag_searchSources = true;
let tag_autoSearchFired = $state(false);
$effect(() => {
if (!tag_loadingLocal && tag_activeTags.length > 0 && !tag_autoSearchFired && !tag_searchSources && !loadingSources) {
if (tag_localResults.length < 20) {
untrack(() => { tag_autoSearchFired = true; tag_searchSources = true; });
}
}
}
// Reset the flag when tags change
$: { tag_activeTags; tag_autoSearchFired = false; }
$: {
const _search = tag_searchSources;
const _tags = tag_activeTags;
if (_search && _tags.length > 0 && !loadingSources) {
tagFetchSources(_tags);
});
$effect(() => { const _ = tag_activeTags; untrack(() => { tag_autoSearchFired = false; }); });
$effect(() => {
if (tag_searchSources && tag_activeTags.length > 0 && !loadingSources) {
const tags = tag_activeTags;
untrack(() => tagFetchSources(tags));
}
}
});
async function tagFetchLocal(activeTags: string[], tagMode: TagMode) {
if (activeTags.length === 0) {
@@ -263,17 +242,16 @@
tag_abortLocal = ctrl;
tag_localResults = []; tag_totalCount = 0; tag_localOffset = 0; tag_localHasNext = false;
tag_loadingLocal = true;
gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean }; totalCount: number } }>(
MANGAS_BY_GENRE,
{ filter: buildGenreFilter(activeTags, tagMode), first: ($settings.renderLimit ?? 48), offset: 0 },
{ filter: buildGenreFilter(activeTags, tagMode), first: (store.settings.renderLimit ?? 48), offset: 0 },
ctrl.signal,
).then((d) => {
if (ctrl.signal.aborted) return;
tag_localResults = d.mangas.nodes;
tag_totalCount = d.mangas.totalCount;
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
tag_localOffset = ($settings.renderLimit ?? 48);
tag_localOffset = (store.settings.renderLimit ?? 48);
}).catch((e: any) => {
if (e?.name !== "AbortError") console.error(e);
}).finally(() => {
@@ -285,50 +263,32 @@
tag_abortSource?.abort();
const ctrl = new AbortController();
tag_abortSource = ctrl;
// Don't blank existing results — keep them visible while new ones load.
// Only reset if the tags actually changed (tracked by the calling reactive block).
tag_srcNextPage = new Map();
tag_loadingSourceSearch = true;
// Fan out to ALL deduped sources — no arbitrary cap.
// Concurrency (6) + per-page caching keeps this fast without hammering connections.
const sources = dedupeSources(allSources, preferredLang);
const primaryTag = activeTags[0];
for (const src of sources) tag_srcNextPage.set(src.id, -1);
runConcurrent(sources, async (src) => {
if (ctrl.signal.aborted) return;
const ps = getPageSet(src.id, "SEARCH", activeTags);
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", 1, activeTags);
const result = await cache
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
pageKey,
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page: 1, query: primaryTag },
ctrl.signal,
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page: 1, query: primaryTag }, ctrl.signal,
).then((d) => d.fetchSourceManga),
)
.catch((e: any) => {
if (e?.name !== "AbortError") console.error(e);
return null;
});
.catch((e: any) => { if (e?.name !== "AbortError") console.error(e); return null; });
if (!result || ctrl.signal.aborted) return;
ps.add(1);
tag_srcNextPage.set(src.id, result.hasNextPage ? 2 : -1);
tag_srcNextPage = new Map(tag_srcNextPage);
const matching = activeTags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, activeTags))
: result.mangas;
if (matching.length > 0) {
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), $settings.mangaLinks);
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks);
tag_loadingSourceSearch = false;
}
}, ctrl.signal).finally(() => {
@@ -345,13 +305,13 @@
try {
const d = await gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean } } }>(
MANGAS_BY_GENRE,
{ filter: buildGenreFilter(tag_activeTags, tag_tagMode), first: ($settings.renderLimit ?? 48), offset: tag_localOffset },
{ filter: buildGenreFilter(tag_activeTags, tag_tagMode), first: (store.settings.renderLimit ?? 48), offset: tag_localOffset },
ctrl.signal,
);
if (ctrl.signal.aborted) return;
tag_localResults = [...tag_localResults, ...d.mangas.nodes];
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
tag_localOffset += ($settings.renderLimit ?? 48);
tag_localOffset += (store.settings.renderLimit ?? 48);
} catch (e: any) {
if (e?.name !== "AbortError") console.error(e);
} finally {
@@ -365,43 +325,31 @@
tag_abortSource?.abort();
const ctrl = new AbortController();
tag_abortSource = ctrl;
const sources = dedupeSources(allSources, preferredLang)
.filter((src) => (tag_srcNextPage.get(src.id) ?? -1) > 0);
const sources = dedupeSources(allSources, preferredLang).filter((src) => (tag_srcNextPage.get(src.id) ?? -1) > 0);
const primaryTag = tag_activeTags[0];
try {
await runConcurrent(sources, async (src) => {
const page = tag_srcNextPage.get(src.id)!;
if (ctrl.signal.aborted) return;
const ps = getPageSet(src.id, "SEARCH", tag_activeTags);
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tag_activeTags);
const result = await cache
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
pageKey,
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: primaryTag },
ctrl.signal,
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page, query: primaryTag }, ctrl.signal,
).then((d) => d.fetchSourceManga),
)
.catch((e: any) => {
if (e?.name !== "AbortError") tag_srcNextPage.set(src.id, -1);
return null;
});
.catch((e: any) => { if (e?.name !== "AbortError") tag_srcNextPage.set(src.id, -1); return null; });
if (!result || ctrl.signal.aborted) return;
ps.add(page);
tag_srcNextPage.set(src.id, result.hasNextPage ? page + 1 : -1);
tag_srcNextPage = new Map(tag_srcNextPage);
const matching = tag_activeTags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, tag_activeTags))
: result.mangas;
if (matching.length > 0) {
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), $settings.mangaLinks);
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks);
}
}, ctrl.signal);
} finally {
@@ -424,43 +372,33 @@
}
}
// ── Source browse ─────────────────────────────────────────────────────────
let src_selectedLang = "all";
let src_activeSource: Source | null = null;
let src_browseResults: Manga[] = [];
let src_loadingBrowse = false;
let src_browseQuery = "";
let src_submitted = "";
let src_hasNextPage = false;
let src_currentPage = 1;
let src_selectedLang = $state("all");
let src_activeSource: Source | null = $state(null);
let src_browseResults: Manga[] = $state([]);
let src_loadingBrowse = $state(false);
let src_browseQuery = $state("");
let src_submitted = $state("");
let src_hasNextPage = $state(false);
let src_currentPage = $state(1);
let src_abortCtrl: AbortController | null = null;
$: src_visibleSources = src_selectedLang === "all"
const src_visibleSources = $derived(src_selectedLang === "all"
? allSources
: allSources.filter((s) => s.lang === src_selectedLang);
: allSources.filter((s) => s.lang === src_selectedLang));
async function srcFetchBrowse(
src: Source,
type: "POPULAR" | "SEARCH",
q?: string,
page = 1,
) {
async function srcFetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string, page = 1) {
src_abortCtrl?.abort();
const ctrl = new AbortController();
src_abortCtrl = ctrl;
if (page === 1) { src_loadingBrowse = true; src_browseResults = []; }
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type, page, query: q ?? null },
ctrl.signal,
FETCH_SOURCE_MANGA, { source: src.id, type, page, query: q ?? null }, ctrl.signal,
);
if (ctrl.signal.aborted) return;
src_browseResults = page === 1
? d.fetchSourceManga.mangas
: [...src_browseResults, ...d.fetchSourceManga.mangas];
src_browseResults = page === 1 ? d.fetchSourceManga.mangas : [...src_browseResults, ...d.fetchSourceManga.mangas];
src_hasNextPage = d.fetchSourceManga.hasNextPage;
src_currentPage = page;
} catch (e: any) {
@@ -471,9 +409,7 @@
}
function srcSelectSource(src: Source) {
src_activeSource = src;
src_browseQuery = "";
src_submitted = "";
src_activeSource = src; src_browseQuery = ""; src_submitted = "";
srcFetchBrowse(src, "POPULAR");
}
@@ -484,13 +420,10 @@
}
function srcClearSearch() {
src_browseQuery = "";
src_submitted = "";
src_browseQuery = ""; src_submitted = "";
if (src_activeSource) srcFetchBrowse(src_activeSource, "POPULAR");
}
onDestroy(() => {
kw_abortCtrl?.abort();
tag_abortLocal?.abort();
@@ -507,7 +440,7 @@
<button
class="tab"
class:tabActive={tab === "keyword"}
on:click={() => (tab = "keyword")}
onclick={() => (tab = "keyword")}
>
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
@@ -518,7 +451,7 @@
<button
class="tab"
class:tabActive={tab === "tag"}
on:click={() => (tab = "tag")}
onclick={() => (tab = "tag")}
>
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
@@ -529,7 +462,7 @@
<button
class="tab"
class:tabActive={tab === "source"}
on:click={() => (tab = "source")}
onclick={() => (tab = "source")}
>
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
@@ -555,13 +488,13 @@
autofocus
class="searchInput"
placeholder="Search across sources…"
on:keydown={(e) => e.key === "Enter" && kwDoSearch(kw_query)}
onkeydown={(e) => e.key === "Enter" && kwDoSearch(kw_query)}
/>
{#if kw_query}
<button
class="clearBtn"
title="Clear"
on:click={() => { kw_query = ""; kw_inputEl?.focus(); }}
onclick={() => { kw_query = ""; kw_inputEl?.focus(); }}
>×</button>
{/if}
{#if hasMultipleLangs}
@@ -569,7 +502,7 @@
class="advancedBtn"
class:advancedBtnActive={kw_showAdvanced}
title="Language & filter options"
on:click={() => (kw_showAdvanced = !kw_showAdvanced)}
onclick={() => (kw_showAdvanced = !kw_showAdvanced)}
>
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
@@ -579,7 +512,7 @@
{/if}
<button
class="searchBtn"
on:click={() => kwDoSearch(kw_query)}
onclick={() => kwDoSearch(kw_query)}
disabled={!kw_query.trim() || loadingSources}
>
{#if loadingSources}
@@ -597,8 +530,8 @@
<div class="advancedHeader">
<span class="advancedTitle">Languages</span>
<div class="advancedActions">
<button class="advancedLink" on:click={() => (kw_selectedLangs = new Set(availableLangs))}>All</button>
<button class="advancedLink" on:click={() => (kw_selectedLangs = new Set([preferredLang]))}>Reset</button>
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set(availableLangs))}>All</button>
<button class="advancedLink" onclick={() => (kw_selectedLangs = new Set([preferredLang]))}>Reset</button>
</div>
</div>
<div class="langGrid">
@@ -606,7 +539,7 @@
<button
class="langChip"
class:langChipActive={kw_selectedLangs.has(lang)}
on:click={() => kwToggleLang(lang)}
onclick={() => kwToggleLang(lang)}
>
{lang === preferredLang ? `${lang.toUpperCase()} ` : lang.toUpperCase()}
</button>
@@ -638,7 +571,7 @@
{/if}
</p>
{#if hasMultipleLangs && !kw_showAdvanced}
<button class="advancedLinkStandalone" on:click={() => (kw_showAdvanced = true)}>
<button class="advancedLinkStandalone" onclick={() => (kw_showAdvanced = true)}>
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M40,88H73a32,32,0,0,0,62,0h81a8,8,0,0,0,0-16H135a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16Zm64-24A16,16,0,1,1,88,80,16,16,0,0,1,104,64ZM216,168H183a32,32,0,0,0-62,0H40a8,8,0,0,0,0,16h81a32,32,0,0,0,62,0h33a8,8,0,0,0,0-16Zm-64,24a16,16,0,1,1,16-16A16,16,0,0,1,152,192Z"/>
</svg>
@@ -663,7 +596,7 @@
src={thumbUrl(source.iconUrl)}
alt={source.displayName}
class="sourceIcon"
on:error={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
<span class="sourceName">{source.displayName}</span>
{#if hasMultipleLangs}
@@ -692,8 +625,8 @@
</div>
{:else if mangas.length > 0}
<div class="sourceRow">
{#each mangas.slice(0, ($settings.renderLimit ?? 48)) as m (m.id)}
<button class="card" on:click={() => previewManga.set(m)}>
{#each mangas.slice(0, (store.settings.renderLimit ?? 48)) as m (m.id)}
<button class="card" onclick={() => setPreviewManga(m)}>
<div class="coverWrap">
<img
src={thumbUrl(m.thumbnailUrl)}
@@ -737,7 +670,7 @@
placeholder="Filter tags…"
/>
{#if tag_tagFilter}
<button class="splitSearchClear" title="Clear" on:click={() => (tag_tagFilter = "")}>×</button>
<button class="splitSearchClear" title="Clear" onclick={() => (tag_tagFilter = "")}>×</button>
{/if}
</div>
<div class="splitList">
@@ -745,7 +678,7 @@
<button
class="splitItem"
class:splitItemActive={tag_activeTags.includes(tag)}
on:click={() => tagToggleTag(tag)}
onclick={() => tagToggleTag(tag)}
>
<span class="splitItemLabel">{tag}</span>
{#if tag_activeTags.includes(tag)}<span class="tagCheckMark"></span>{/if}
@@ -774,7 +707,7 @@
{#each tag_activeTags as tag (tag)}
<span class="tagPill">
{tag}
<button class="tagPillRemove" title="Remove {tag}" on:click={() => tagToggleTag(tag)}>×</button>
<button class="tagPillRemove" title="Remove {tag}" onclick={() => tagToggleTag(tag)}>×</button>
</span>
{/each}
</div>
@@ -785,13 +718,13 @@
class="tagModeBtn"
class:tagModeBtnActive={tag_tagMode === "AND"}
title="Match ALL tags"
on:click={() => (tag_tagMode = "AND")}
onclick={() => (tag_tagMode = "AND")}
>AND</button>
<button
class="tagModeBtn"
class:tagModeBtnActive={tag_tagMode === "OR"}
title="Match ANY tag"
on:click={() => (tag_tagMode = "OR")}
onclick={() => (tag_tagMode = "OR")}
>OR</button>
</div>
{/if}
@@ -800,7 +733,7 @@
class:tagModeBtnActive={tag_searchSources}
title="Also search across sources (slower, requires network)"
disabled={loadingSources}
on:click={tagToggleSearchSources}
onclick={tagToggleSearchSources}
>
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" style="margin-right:3px;vertical-align:middle" aria-hidden="true">
@@ -808,7 +741,7 @@
</svg>
Sources
</button>
<button class="tagClearAll" on:click={() => (tag_activeTags = [])}>Clear all</button>
<button class="tagClearAll" onclick={() => (tag_activeTags = [])}>Clear all</button>
</div>
</div>
@@ -843,7 +776,7 @@
{:else if tag_mergedResults.length > 0}
<div class="tagGrid">
{#each tag_mergedResults as m (m.id)}
<button class="card" on:click={() => previewManga.set(m)}>
<button class="card" onclick={() => setPreviewManga(m)}>
<div class="coverWrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
@@ -864,7 +797,7 @@
{#if tag_localHasNext || tag_sourceHasMore}
<div class="showMoreCell">
{#if tag_localHasNext}
<button class="showMoreBtn" on:click={tagLoadMoreLocal} disabled={tag_loadingMoreLocal}>
<button class="showMoreBtn" onclick={tagLoadMoreLocal} disabled={tag_loadingMoreLocal}>
{#if tag_loadingMoreLocal}
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" aria-hidden="true">
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
@@ -875,7 +808,7 @@
</button>
{/if}
{#if tag_sourceHasMore}
<button class="showMoreBtn" on:click={tagLoadMoreSource} disabled={tag_loadingMoreSource}>
<button class="showMoreBtn" onclick={tagLoadMoreSource} disabled={tag_loadingMoreSource}>
{#if tag_loadingMoreSource}
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" aria-hidden="true">
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/>
@@ -916,7 +849,7 @@
<button
class="langChip"
class:langChipActive={src_selectedLang === lang}
on:click={() => (src_selectedLang = lang)}
onclick={() => (src_selectedLang = lang)}
>
{lang === "all" ? "All" : lang.toUpperCase()}
</button>
@@ -936,13 +869,13 @@
<button
class="splitItem splitItemSource"
class:splitItemActive={src_activeSource?.id === src.id}
on:click={() => srcSelectSource(src)}
onclick={() => srcSelectSource(src)}
>
<img
src={thumbUrl(src.iconUrl)}
alt=""
class="splitSourceIcon"
on:error={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
<span class="splitItemLabel">{src.displayName}</span>
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
@@ -972,7 +905,7 @@
src={thumbUrl(src_activeSource.iconUrl)}
alt=""
class="splitSourceIcon"
on:error={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
/>
<span class="splitContentTitle">{src_activeSource.displayName}</span>
{#if src_loadingBrowse}
@@ -994,15 +927,15 @@
bind:value={src_browseQuery}
class="searchInput"
placeholder="Search {src_activeSource.displayName}…"
on:keydown={(e) => e.key === "Enter" && srcHandleSearch()}
onkeydown={(e) => e.key === "Enter" && srcHandleSearch()}
/>
{#if src_submitted}
<button class="clearBtn" title="Clear search" on:click={srcClearSearch}>×</button>
<button class="clearBtn" title="Clear search" onclick={srcClearSearch}>×</button>
{/if}
</div>
<button
class="searchBtn"
on:click={srcHandleSearch}
onclick={srcHandleSearch}
disabled={!src_browseQuery.trim() || src_loadingBrowse}
>
Search
@@ -1022,7 +955,7 @@
{:else if src_browseResults.length > 0}
<div class="tagGrid">
{#each src_browseResults as m (m.id)}
<button class="card" on:click={() => previewManga.set(m)}>
<button class="card" onclick={() => setPreviewManga(m)}>
<div class="coverWrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
@@ -1036,7 +969,7 @@
<button
class="showMoreBtn"
disabled={src_loadingBrowse}
on:click={() => src_activeSource && srcFetchBrowse(
onclick={() => src_activeSource && srcFetchBrowse(
src_activeSource,
src_submitted ? "SEARCH" : "POPULAR",
src_submitted || undefined,
+40 -41
View File
@@ -1,10 +1,10 @@
<script lang="ts">
import { onMount } from "svelte";
import { onMount, untrack } from "svelte";
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
import { settings, activeManga, activeChapter, genreFilter, navPage, addToast, updateSettings, addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders, openReader, checkAndMarkCompleted } from "../../store";
import { store, addToast, updateSettings, addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders, openReader, checkAndMarkCompleted, setActiveManga, setGenreFilter, setNavPage} from "../../store/state.svelte";
import type { Manga, Chapter } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
import MigrateModal from "./MigrateModal.svelte";
@@ -40,8 +40,8 @@
let rangeTo: string = $state("");
let showRange: boolean = $state(false);
let migrateOpen: boolean = $state(false);
let dlDropRef: HTMLDivElement;
let folderPickerRef: HTMLDivElement;
let dlDropRef: HTMLDivElement | undefined = $state();
let folderPickerRef: HTMLDivElement | undefined = $state();
let mangaAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null;
@@ -56,10 +56,10 @@
function applyChapters(nodes: Chapter[]) {
chapters = nodes;
if (activeManga && nodes.length > 0) checkAndMarkCompleted(activeManga.id, nodes);
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
}
const sortDir = $derived(settings.chapterSortDir);
const sortDir = $derived(store.settings.chapterSortDir);
const sortedChapters = $derived(sortDir === "desc" ? [...chapters].reverse() : [...chapters]);
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
@@ -80,7 +80,7 @@
})());
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
const assignedFolders = $derived(activeManga ? getMangaFolders(activeManga.id) : []);
const assignedFolders = $derived(store.activeManga ? getMangaFolders(store.activeManga.id) : []);
const hasFolders = $derived(assignedFolders.length > 0);
function loadManga(id: number) {
@@ -141,14 +141,18 @@
}
$effect(() => {
if (activeManga) { loadManga(activeManga.id); loadChapters(activeManga.id); }
const m = store.activeManga;
if (m) untrack(() => { loadManga(m.id); loadChapters(m.id); });
});
let prevChapterId: number | null = null;
$effect(() => {
const wasOpen = prevChapterId !== null;
prevChapterId = activeChapter?.id ?? null;
if (wasOpen && !activeChapter && activeManga) { loadChapters(activeManga.id); cache.clear(CACHE_KEYS.LIBRARY); }
prevChapterId = store.activeChapter?.id ?? null;
if (wasOpen && !store.activeChapter && store.activeManga) {
const id = store.activeManga.id;
untrack(() => { loadChapters(id); cache.clear(CACHE_KEYS.LIBRARY); });
}
});
async function toggleLibrary() {
@@ -174,20 +178,20 @@
await gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error);
addToast({ kind: "download", title: "Download queued", body: ch.name });
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing);
if (activeManga) reloadChapters(activeManga.id);
if (store.activeManga) reloadChapters(store.activeManga.id);
}
async function enqueueMultiple(chapterIds: number[]) {
if (!chapterIds.length) return;
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
addToast({ kind: "download", title: "Download queued", body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added` });
if (activeManga) reloadChapters(activeManga.id);
if (store.activeManga) reloadChapters(store.activeManga.id);
}
async function markRead(chapterId: number, isRead: boolean) {
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
if (activeManga) { chapterStore.set(activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(activeManga.id, chapters); }
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
}
async function markBulk(ids: number[], isRead: boolean) {
@@ -195,7 +199,7 @@
await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error);
const idSet = new Set(ids);
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
if (activeManga) { chapterStore.set(activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(activeManga.id, chapters); }
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
}
const markAboveRead = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => !c.isRead).map(c => c.id), true);
@@ -206,7 +210,7 @@
async function deleteDownloaded(chapterId: number) {
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error);
chapters = chapters.map(c => c.id === chapterId ? { ...c, isDownloaded: false } : c);
if (activeManga) chapterStore.set(activeManga.id, { data: chapters, fetchedAt: Date.now() });
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
}
async function deleteAllDownloads() {
@@ -215,16 +219,16 @@
deletingAll = true;
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
chapters = chapters.map(c => ({ ...c, isDownloaded: false }));
if (activeManga) chapterStore.set(activeManga.id, { data: chapters, fetchedAt: Date.now() });
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
deletingAll = false;
}
async function refreshChapters() {
if (!activeManga || refreshing) return;
if (!store.activeManga || refreshing) return;
refreshing = true;
chapterStore.delete(activeManga.id);
gql(FETCH_CHAPTERS, { mangaId: activeManga.id })
.then(() => reloadChapters(activeManga!.id))
chapterStore.delete(store.activeManga.id);
gql(FETCH_CHAPTERS, { mangaId: store.activeManga.id })
.then(() => reloadChapters(store.activeManga!.id))
.then(() => addToast({ kind: "success", title: "Chapters refreshed" }))
.catch(e => addToast({ kind: "error", title: "Refresh failed", body: e?.message }))
.finally(() => refreshing = false);
@@ -276,25 +280,25 @@
function createFolder() {
const name = folderNewName.trim();
if (!name || !activeManga) return;
if (!name || !store.activeManga) return;
const id = addFolder(name);
assignMangaToFolder(id, activeManga.id);
assignMangaToFolder(id, store.activeManga.id);
folderNewName = ""; folderCreating = false;
}
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
</script>
{#if activeManga}
{#if store.activeManga}
<div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}>
<div class="sidebar">
<button class="back" onclick={() => activeManga = null}>
<button class="back" onclick={() => setActiveManga(null)}>
<ArrowLeft size={13} weight="light" /> Back
</button>
<div class="cover-wrap">
<img src={thumbUrl(activeManga.thumbnailUrl)} alt={activeManga.title} class="cover" />
<img src={thumbUrl(store.activeManga.thumbnailUrl)} alt={store.activeManga.title} class="cover" />
</div>
{#if loadingManga}
@@ -314,7 +318,7 @@
{#if manga?.genre?.length}
<div class="genres">
{#each (genresExpanded ? manga.genre : manga.genre.slice(0, 5)) as g}
<button class="genre" onclick={() => { genreFilter = g; navPage = "explore"; activeManga = null; }}>{g}</button>
<button class="genre" onclick={() => { setGenreFilter(g); setNavPage("explore"); setActiveManga(null); }}>{g}</button>
{/each}
{#if manga.genre.length > 5}
<button class="genre-toggle" onclick={() => genresExpanded = !genresExpanded}>
@@ -415,13 +419,13 @@
</button>
{#if folderPickerOpen}
<div class="fp-menu">
{#if settings.folders.length === 0 && !folderCreating}
{#if store.settings.folders.length === 0 && !folderCreating}
<p class="fp-empty">No folders yet</p>
{/if}
{#each settings.folders as folder}
{@const isIn = activeManga ? folder.mangaIds.includes(activeManga.id) : false}
{#each store.settings.folders as folder}
{@const isIn = store.activeManga ? folder.mangaIds.includes(store.activeManga.id) : false}
<button class="fp-item" class:fp-item-active={isIn}
onclick={() => activeManga && (isIn ? removeMangaFromFolder(folder.id, activeManga.id) : assignMangaToFolder(folder.id, activeManga.id))}>
onclick={() => store.activeManga && (isIn ? removeMangaFromFolder(folder.id, store.activeManga.id) : assignMangaToFolder(folder.id, store.activeManga.id))}>
<span class="fp-check">{isIn ? "✓" : ""}</span>{folder.name}
</button>
{/each}
@@ -429,8 +433,7 @@
{#if folderCreating}
<div class="fp-create">
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName}
onkeydown={(e) => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }}
use:focus />
onkeydown={(e) => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} autofocus />
<button class="fp-confirm" onclick={createFolder} disabled={!folderNewName.trim()}>Add</button>
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}>
<X size={12} weight="light" />
@@ -449,8 +452,7 @@
<button class="jump-toggle" onclick={() => { jumpOpen = true; jumpInput = ""; }}>Go to…</button>
{:else}
<div class="jump-row">
<input class="jump-input" type="text" placeholder="Ch. #" bind:value={jumpInput}
use:focus
<input class="jump-input" type="text" placeholder="Ch. #" bind:value={jumpInput} autofocus
onkeydown={(e) => {
if (e.key === "Escape") { jumpOpen = false; return; }
if (e.key === "Enter") {
@@ -499,7 +501,7 @@
{:else}
<div class="dl-range-row">
<button class="dl-range-back" onclick={() => showRange = false}></button>
<input class="dl-range-input" placeholder="From" bind:value={rangeFrom} onkeydown={(e) => e.key === "Enter" && enqueueRange()} use:focus />
<input class="dl-range-input" placeholder="From" bind:value={rangeFrom} onkeydown={(e) => e.key === "Enter" && enqueueRange()} autofocus />
<span class="dl-range-sep"></span>
<input class="dl-range-input" placeholder="To" bind:value={rangeTo} onkeydown={(e) => e.key === "Enter" && enqueueRange()} />
<button class="dl-range-go" disabled={!rangeFrom.trim() || !rangeTo.trim()} onclick={enqueueRange}>Go</button>
@@ -571,11 +573,11 @@
<div class="ch-right">
{#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
{#if ch.isDownloaded}
<button class="dl-btn" onclick|stopPropagation={() => deleteDownloaded(ch.id)}><Trash size={13} weight="light" /></button>
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }}><Trash size={13} weight="light" /></button>
{:else if enqueueing.has(ch.id)}
<CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" />
{:else}
<button class="dl-btn" onclick|stopPropagation={(e) => enqueue(ch, e)}><Download size={13} weight="light" /></button>
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); enqueue(ch, e); }}><Download size={13} weight="light" /></button>
{/if}
</div>
</div>
@@ -602,14 +604,11 @@
{manga}
currentChapters={chapters}
onClose={() => migrateOpen = false}
onMigrated={(newManga) => { activeManga = newManga; migrateOpen = false; cache.clear(CACHE_KEYS.LIBRARY); }}
onMigrated={(newManga) => { setActiveManga(newManga); migrateOpen = false; cache.clear(CACHE_KEYS.LIBRARY); }}
/>
{/if}
{/if}
<script context="module">
function focus(node: HTMLElement) { node.focus(); }
</script>
<style>
.root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }