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