Chore: Port over Extensions & Search

This commit is contained in:
Youwes09
2026-05-31 00:30:36 -05:00
parent 6de5207ce7
commit 13f2a483ca
47 changed files with 6086 additions and 1016 deletions
+1
View File
@@ -40,6 +40,7 @@
"@tauri-apps/plugin-shell": "^2.3.5",
"@tauri-apps/plugin-store": "^2.4.3",
"capacitor-native-biometric": "^4.2.2",
"clsx": "^2.1.1",
"phosphor-svelte": "^3.1.0"
}
}
+3
View File
@@ -44,6 +44,9 @@ importers:
capacitor-native-biometric:
specifier: ^4.2.2
version: 4.2.2
clsx:
specifier: ^2.1.1
version: 2.1.1
phosphor-svelte:
specifier: ^3.1.0
version: 3.1.0(svelte@5.55.5(@typescript-eslint/types@8.57.1))(vite@8.0.10)
@@ -0,0 +1,254 @@
<script lang="ts">
import { untrack } from "svelte";
import { getAdapter } from "$lib/request-manager";
import { settingsState } from "$lib/state/settings.svelte";
import { setPreviewManga } from "$lib/state/series.svelte";
import { dedupeMangaById, shouldHideNsfw } from "$lib/core/util";
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
import ContextMenu from "$lib/components/shared/ui/ContextMenu.svelte";
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
import type { Manga, Source, Category } from "$lib/types";
import type { MenuEntry } from "$lib/components/shared/ui/ContextMenu.svelte";
import {
PAGE_SIZE, INITIAL_PAGES, MAX_SOURCES,
parseTags, tagsLabel, matchesAllTags, runConcurrent,
} from "$lib/components/browse/lib/searchFilter";
interface Props {
genre: string;
onBack: () => void;
}
let { genre, onBack }: Props = $props();
const tags = $derived(parseTags(genre));
const primaryTag = $derived(tags[0] ?? "");
const label = $derived(tagsLabel(tags));
let libraryManga: Manga[] = $state([]);
let sourceManga: Manga[] = $state([]);
let loadingInitial = $state(true);
let loadingMore = $state(false);
let visibleCount = $state(PAGE_SIZE);
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
let categories: Category[] = $state([]);
let catsLoaded = false;
const nextPageMap = new Map<string, number>();
let sources: Source[] = $state([]);
let abortCtrl: AbortController | null = null;
const filtered = $derived.by(() => {
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags) && !shouldHideNsfw(m as any, settingsState.settings));
const libIds = new Set(libMatches.map((m) => m.id));
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id) && !shouldHideNsfw(m as any, settingsState.settings))]);
});
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 = genre; if (f) untrack(() => load(f)); });
async function load(filter: string) {
abortCtrl?.abort();
const ctrl = new AbortController();
abortCtrl = ctrl;
loadingInitial = true;
sourceManga = [];
libraryManga = [];
visibleCount = PAGE_SIZE;
nextPageMap.clear();
const t = parseTags(filter);
const pt = t[0] ?? "";
getAdapter().getMangaList({}).then((result) => {
if (!ctrl.signal.aborted) libraryManga = result.items;
}).catch(() => {});
getAdapter().getSources().then(async (allSources) => {
if (ctrl.signal.aborted) return;
const srcs = allSources.filter((s: Source) => s.id !== "0").slice(0, MAX_SOURCES);
sources = srcs;
for (const src of srcs) nextPageMap.set(src.id, -1);
await runConcurrent(srcs, async (src) => {
if (ctrl.signal.aborted) return;
const pageItems: Manga[] = [];
for (let page = 1; page <= INITIAL_PAGES; page++) {
if (ctrl.signal.aborted) return;
let result: { items: Manga[]; hasNextPage: boolean } | null = null;
try {
result = await getAdapter().searchSource(src.id, pt, page, ctrl.signal);
} catch { break; }
if (!result || ctrl.signal.aborted) break;
const matching = t.length > 1 ? result.items.filter((m) => matchesAllTags(m, t)) : result.items;
pageItems.push(...matching);
if (!result.hasNextPage) { nextPageMap.set(src.id, -1); break; }
else if (page === INITIAL_PAGES) nextPageMap.set(src.id, INITIAL_PAGES + 1);
}
if (!ctrl.signal.aborted && pageItems.length > 0) {
sourceManga = dedupeMangaById([...sourceManga, ...pageItems]);
loadingInitial = false;
}
}, ctrl.signal);
if (!ctrl.signal.aborted) loadingInitial = false;
}).catch(() => { if (!ctrl.signal.aborted) loadingInitial = false; });
}
async function loadMore() {
if (loadingMore) return;
if (hasMoreVisible) { visibleCount += PAGE_SIZE; return; }
const srcs = sources.filter((s) => (nextPageMap.get(s.id) ?? -1) > 0);
if (!srcs.length) return;
loadingMore = true;
abortCtrl?.abort();
const ctrl = new AbortController();
abortCtrl = ctrl;
try {
await runConcurrent(srcs, async (src) => {
const page = nextPageMap.get(src.id)!;
if (ctrl.signal.aborted) return;
let result: { items: Manga[]; hasNextPage: boolean } | null = null;
try {
result = await getAdapter().searchSource(src.id, primaryTag, page, ctrl.signal);
} catch { nextPageMap.set(src.id, -1); return; }
if (!result || ctrl.signal.aborted) return;
nextPageMap.set(src.id, result.hasNextPage ? page + 1 : -1);
const matching = tags.length > 1 ? result.items.filter((m) => matchesAllTags(m, tags)) : result.items;
if (matching.length > 0) sourceManga = dedupeMangaById([...sourceManga, ...matching]);
}, ctrl.signal);
} finally {
if (!ctrl.signal.aborted) { visibleCount += PAGE_SIZE; loadingMore = false; }
}
}
function openCtx(e: MouseEvent, m: Manga) {
e.preventDefault();
ctx = { x: e.clientX, y: e.clientY, manga: m };
if (!catsLoaded) {
catsLoaded = true;
getAdapter().getCategories()
.then((cats) => { categories = cats.filter((c) => c.id !== 0); })
.catch(console.error);
}
}
function buildCtxItems(m: Manga): MenuEntry[] {
return [
{
label: m.inLibrary ? "In Library" : "Add to library",
icon: BookmarkSimple,
disabled: m.inLibrary,
onClick: () => getAdapter().addToLibrary(String(m.id))
.then(() => { sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x); })
.catch(console.error),
},
...(categories.length > 0 ? [
{ separator: true } as MenuEntry,
...categories.map((cat): MenuEntry => ({
label: (cat.mangas?.nodes ?? []).some((x: { id: number }) => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
icon: Folder,
onClick: () => getAdapter().updateMangaCategories(String(m.id), [cat.id], []).catch(console.error),
})),
] : []),
{ separator: true },
{
label: "New folder & add",
icon: FolderSimplePlus,
onClick: async () => {
const name = prompt("Folder name:");
if (!name?.trim()) return;
const cat = await getAdapter().createCategory(name.trim()).catch(console.error);
if (cat) {
categories = [...categories, cat];
await getAdapter().updateMangaCategories(String(m.id), [cat.id], []).catch(console.error);
}
},
},
];
}
$effect(() => () => { abortCtrl?.abort(); });
</script>
<div class="root">
<div class="header">
<button class="back" onclick={onBack}>
<ArrowLeft size={13} weight="light" /><span>Back</span>
</button>
<span class="title">{label}</span>
{#if !loadingInitial || filtered.length > 0}
<span class="result-count">{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}</span>
{/if}
{#if !loadingInitial && hasMoreNetwork}
<span class="loading-hint">More loading…</span>
{/if}
</div>
{#if loadingInitial && filtered.length === 0}
<div class="grid">
{#each Array(50) as _}
<div class="card-skeleton">
<div class="cover-skeleton skeleton"></div>
<div class="title-skeleton skeleton"></div>
</div>
{/each}
</div>
{:else if filtered.length === 0}
<div class="empty">No manga found for "{label}".</div>
{:else}
<div class="grid">
{#each visibleItems as m, i (m.id)}
<button class="card" onclick={() => setPreviewManga(m)} oncontextmenu={(e) => { e.stopPropagation(); openCtx(e, m); }}>
<div class="cover-wrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
{#if m.inLibrary}<span class="in-library-badge">Saved</span>{/if}
</div>
<p class="card-title">{m.title}</p>
</button>
{/each}
{#if hasMore}
<div class="show-more-cell">
<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>
{/if}
</div>
{/if}
</div>
{#if ctx}
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
{/if}
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
.header { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.back { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); flex-shrink: 0; }
.back:hover { color: var(--text-secondary); }
.title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); letter-spacing: var(--tracking-tight); }
.result-count, .loading-hint { margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(100px,13vw,140px),1fr)); gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; -webkit-overflow-scrolling: touch; contain: layout style; }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.card:hover :global(.cover) { filter: brightness(1.06); }
.card:hover .card-title { color: var(--text-primary); }
.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); transform: translateZ(0); }
:global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); will-change: filter; }
.in-library-badge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); padding: 2px 5px; border-radius: var(--radius-sm); }
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
.card-skeleton { padding: 0; }
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
.title-skeleton { height: 11px; margin-top: var(--sp-2); width: 75%; }
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
.show-more-cell { grid-column: 1/-1; display: flex; justify-content: center; padding: var(--sp-2) 0 var(--sp-4); }
.show-more-btn { 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 20px; border-radius: var(--radius-md); background: var(--bg-raised); color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
.show-more-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); }
.show-more-btn:disabled { opacity: 0.5; cursor: default; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
+322
View File
@@ -0,0 +1,322 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { getAdapter } from "$lib/request-manager";
import { settingsState } from "$lib/state/settings.svelte";
import { shouldHideNsfw, shouldHideSource, dedupeMangaById, dedupeMangaByTitle } from "$lib/core/util";
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
import type { Manga, Source } from "$lib/types";
import type { CachedManga } from "$lib/components/browse/lib/searchFilter";
interface Props {
allSources: Source[];
availableLangs: string[];
hasMultipleLangs: boolean;
loadingSources: boolean;
pendingPrefill: string;
popularResults: (Manga & { _priority: number })[];
popularLoading: boolean;
sourceCache: Map<number, CachedManga>;
query: string;
onQueryChange: (q: string) => void;
onPrefillConsumed: () => void;
onPreview: (m: Manga) => void;
}
let {
allSources, availableLangs, hasMultipleLangs, loadingSources,
pendingPrefill, popularResults, popularLoading,
sourceCache,
query, onQueryChange,
onPrefillConsumed, onPreview,
}: Props = $props();
const preferredLang = $derived(settingsState.settings.preferredExtensionLang ?? "en");
let kw_results: SourceResult[] = $state([]);
let kw_showAdvanced = $state(false);
let kw_selectedLangs: Set<string> = $state(new Set());
let kw_inputEl: HTMLInputElement | null = $state(null);
let kw_abortCtrl: AbortController | null = null;
let kw_debounceTimer: ReturnType<typeof setTimeout> | null = null;
interface SourceResult {
source: Source;
mangas: Manga[];
loading: boolean;
error: string | null;
}
$effect(() => {
if (!allSources.length) return;
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 (!loadingSources && pendingPrefill && allSources.length) {
const q = pendingPrefill;
onPrefillConsumed();
onQueryChange(q);
kwDoSearch(q);
}
});
$effect(() => {
const q = query;
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
if (!q.trim()) { kw_abortCtrl?.abort(); kw_results = []; return; }
kw_debounceTimer = setTimeout(() => kwDoSearch(q), 350);
return () => { if (kw_debounceTimer) clearTimeout(kw_debounceTimer); };
});
function kwGetVisibleSources(): Source[] {
let srcs = allSources;
if (kw_selectedLangs.size > 0)
srcs = srcs.filter((s) => kw_selectedLangs.has(s.lang));
if (settingsState.settings.contentLevel !== "unrestricted")
srcs = srcs.filter((s) => !shouldHideSource(s, settingsState.settings));
return srcs;
}
async function kwDoSearch(q: string) {
const trimmed = q.trim();
if (!trimmed) return;
const visible = kwGetVisibleSources();
if (!visible.length) return;
kw_abortCtrl?.abort();
const ctrl = new AbortController();
kw_abortCtrl = ctrl;
kw_results = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null }));
const idxOf = new Map(visible.map((src, i) => [src.id, i]));
await Promise.allSettled(visible.map(async (src) => {
const idx = idxOf.get(src.id)!;
try {
const result = await getAdapter().searchSource(src.id, trimmed, 1, ctrl.signal);
if (ctrl.signal.aborted) return;
const mangas = result.items.filter((m) => !shouldHideNsfw(m as any, settingsState.settings));
kw_results = kw_results.map((r, i) => i === idx ? { ...r, mangas, loading: false } : r);
} catch (e: any) {
if (ctrl.signal.aborted || e?.name === "AbortError") return;
kw_results = kw_results.map((r, i) => i === idx ? { ...r, loading: false, error: e.message ?? "Error" } : r);
}
}));
}
function kwToggleLang(lang: string) {
const next = new Set(kw_selectedLangs);
if (next.has(lang)) { if (next.size === 1) return; next.delete(lang); }
else next.add(lang);
kw_selectedLangs = next;
}
const kw_visibleCount = $derived(kwGetVisibleSources().length);
const kw_anyLoading = $derived(kw_results.some((r) => r.loading));
const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading));
const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0));
const kw_flatResults = $derived.by(() => {
const all = kw_results.flatMap((r) =>
r.mangas.map((m) => ({ ...m, _sourceName: r.source.displayName }))
);
const deduped = dedupeMangaByTitle(
dedupeMangaById(all),
settingsState.settings.mangaLinks,
) as (Manga & { _sourceName?: string })[];
return deduped.map((m, i) => ({ ...m, _priority: i < 12 ? 12 - i : 0 }));
});
onDestroy(() => {
kw_abortCtrl?.abort();
if (kw_debounceTimer) clearTimeout(kw_debounceTimer);
});
</script>
<div class="keywordBar">
<div class="searchBar">
<svg width="14" height="14" viewBox="0 0 256 256" fill="currentColor" class="searchIcon" aria-hidden="true">
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
</svg>
<input
bind:this={kw_inputEl}
value={query}
oninput={(e) => onQueryChange((e.target as HTMLInputElement).value)}
class="searchInput"
placeholder="Search across sources…"
use:focusOnMount
/>
{#if kw_anyLoading}
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint);flex-shrink:0" 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"/>
</svg>
{:else if query}
<button class="clearBtn" title="Clear" onclick={() => { onQueryChange(""); kw_results = []; kw_inputEl?.focus(); }}>×</button>
{/if}
{#if hasMultipleLangs}
<button
class="advancedBtn"
class:advancedBtnActive={kw_showAdvanced}
title="Language & filter options"
onclick={() => (kw_showAdvanced = !kw_showAdvanced)}
>
<svg width="13" height="13" 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>
</button>
{/if}
</div>
{#if kw_showAdvanced && hasMultipleLangs}
<div class="advancedPanel">
<div class="advancedHeader">
<span class="advancedTitle">LANGUAGES</span>
<div class="advancedActions">
<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">
{#each availableLangs as lang (lang)}
<button class="langChip" class:langChipActive={kw_selectedLangs.has(lang)} onclick={() => kwToggleLang(lang)}>
{lang === preferredLang ? `${lang.toUpperCase()} ` : lang.toUpperCase()}
</button>
{/each}
</div>
<div class="advancedDivider"></div>
<div class="advancedFooter">
Searching <strong>{kw_visibleCount}</strong> source{kw_visibleCount !== 1 ? "s" : ""}
</div>
</div>
{/if}
</div>
{#if !query.trim()}
{#if popularLoading && popularResults.length === 0}
<div class="searchGrid">
{#each Array(24) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
</div>
{:else if popularResults.length > 0}
<div class="searchHeader">
<span class="searchLabel">Popular right now</span>
</div>
<div class="searchGrid">
{#each popularResults as m (m.id)}
<button class="srchCard" onclick={() => onPreview(m)}>
<div class="srchCoverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} />
<div class="srchGradient"></div>
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
<div class="srchFooter">
<p class="srchTitle">{m.title}</p>
{#if m.source?.displayName}<p class="srchSource">{m.source.displayName}</p>{/if}
</div>
</div>
</button>
{/each}
{#if popularLoading}
{#each Array(12) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
{/if}
</div>
{:else}
<div class="empty">
<svg width="36" height="36" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
</svg>
<p class="emptyText">Search across sources</p>
<p class="emptyHint">
{#if hasMultipleLangs}
{kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""} · {kw_selectedLangs.size} language{kw_selectedLangs.size !== 1 ? "s" : ""}
{:else}
{kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""}
{/if}
</p>
</div>
{/if}
{:else}
{#if kw_flatResults.length > 0}
<div class="searchHeader">
<span class="searchLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""}</span>
</div>
<div class="searchGrid">
{#each kw_flatResults as m (m.id)}
<button class="srchCard" onclick={() => onPreview(m)}>
<div class="srchCoverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={m._priority} />
<div class="srchGradient"></div>
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
<div class="srchFooter">
<p class="srchTitle">{m.title}</p>
{#if (m as any)._sourceName}<p class="srchSource">{(m as any)._sourceName}</p>{/if}
</div>
</div>
</button>
{/each}
{#if kw_anyLoading}
{#each Array(6) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
{/if}
</div>
{:else if kw_anyLoading}
<div class="searchGrid">
{#each Array(12) as _, i (i)}<div class="skCard"><div class="skeleton skCover"></div></div>{/each}
</div>
{:else if kw_allDone && !kw_hasResults}
<div class="empty">
<p class="emptyText">No results for "{query.trim()}"</p>
<p class="emptyHint">Try a different spelling or fewer words</p>
</div>
{/if}
{/if}
<script module>
function focusOnMount(node: HTMLElement) { node.focus(); }
</script>
<style>
.keywordBar { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
.searchBar:focus-within { border-color: var(--border-strong); }
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
.searchInput { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
.searchInput::placeholder { color: var(--text-faint); }
.clearBtn { color: var(--text-faint); font-size: 16px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
.clearBtn:hover { color: var(--text-muted); }
.advancedBtn { display: flex; align-items: center; padding: 4px; border-radius: var(--radius-sm); border: 1px solid transparent; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
.advancedBtn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.advancedBtnActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.advancedPanel { background: var(--bg-surface); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); }
.advancedHeader { display: flex; align-items: center; justify-content: space-between; }
.advancedTitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.advancedActions { display: flex; gap: var(--sp-2); }
.advancedLink { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; transition: opacity var(--t-base); }
.advancedLink:hover { opacity: 0.75; }
.langGrid { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.langChip { padding: 3px 8px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
.langChip:hover { color: var(--text-muted); background: var(--bg-raised); }
.langChipActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.advancedDivider { height: 1px; background: var(--border-dim); }
.advancedFooter { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); }
.searchHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-1); flex-shrink: 0; }
.searchLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.searchGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); padding: var(--sp-2) var(--sp-4) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; }
.srchCard { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.srchCard:hover .srchCoverWrap { filter: brightness(1.08) saturate(1.05); }
.srchCoverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); transition: filter var(--t-base); }
.srchGradient { position: absolute; inset: 0; z-index: 1; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
.srchFooter { position: absolute; bottom: 0; left: 0; right: 0; z-index: 2; padding: var(--sp-2); pointer-events: none; }
.srchTitle { 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); }
.srchSource { 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; }
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); z-index: 2; font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); flex-shrink: 0; width: 100%; }
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
.skCover { aspect-ratio: 2 / 3; width: 100%; border-radius: var(--radius-md); }
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
.emptyIcon { color: var(--text-faint); opacity: 0.5; }
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
.anim-spin { animation: anim-spin 0.8s linear infinite; }
</style>
+292
View File
@@ -0,0 +1,292 @@
<script lang="ts">
import { onDestroy, untrack } from "svelte";
import { page } from "$app/stores";
import { goto } from "$app/navigation";
import { getAdapter } from "$lib/request-manager";
import { settingsState } from "$lib/state/settings.svelte";
import { setPreviewManga } from "$lib/state/series.svelte";
import { dedupeMangaById } from "$lib/core/util";
import { toCachedManga, shouldHideNsfw, runConcurrent, type CachedManga } from "$lib/components/browse/lib/searchFilter";
import type { Manga, Source } from "$lib/types";
import KeywordTab from "$lib/components/browse/KeywordTab.svelte";
import TagTab from "$lib/components/browse/TagTab.svelte";
import SourceTab from "$lib/components/browse/SourceTab.svelte";
interface Props {
initialTab?: "keyword" | "tag" | "source";
preselectedSourceId?: string;
}
let { initialTab, preselectedSourceId }: Props = $props();
const anims = $derived(settingsState.settings.qolAnimations ?? true);
type SearchTab = "keyword" | "tag" | "source";
const urlTab = $derived(($page.url.searchParams.get("tab") as SearchTab | null) ?? initialTab ?? "keyword");
const urlQuery = $derived($page.url.searchParams.get("q") ?? "");
function setTab(next: SearchTab) {
const u = new URL($page.url);
u.searchParams.set("tab", next);
goto(u.toString(), { replaceState: true, noScroll: true });
}
function setQuery(next: string) {
const u = new URL($page.url);
if (next) u.searchParams.set("q", next);
else u.searchParams.delete("q");
goto(u.toString(), { replaceState: true, noScroll: true });
}
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
let tabIndicator = $state({ left: 0, width: 0 });
function updateIndicator() {
if (!tabsEl) return;
const active = tabsEl.querySelector<HTMLElement>(".tab.tabActive");
if (!active) return;
tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
}
$effect(() => { urlTab; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
const SEARCH_PAGES = 3;
const SEARCH_LIMIT = 200;
const SEARCH_BATCH = 20;
const POPULAR_CACHE_PAGES = 3;
let allSources: Source[] = $state([]);
let localSource: Source | null = $state(null);
let loadingSources = $state(false);
const preferredLang = $derived(settingsState.settings.preferredExtensionLang ?? "en");
const availableLangs = $derived(Array.from(new Set<string>(allSources.map((s) => s.lang))).sort());
const hasMultipleLangs = $derived(availableLangs.length > 1);
loadingSources = true;
getAdapter().getSources()
.then((nodes) => {
localSource = nodes.find((s: Source) => s.id === "0") ?? null;
allSources = nodes.filter((s: Source) => s.id !== "0");
startSourceCacheBuild();
popularStart(allSources);
})
.catch(console.error)
.finally(() => { loadingSources = false; });
let popular_raw: Manga[] = $state([]);
let popular_loading = $state(false);
let popular_abortCtrl: AbortController | null = null;
let popular_sourcePool: Source[] = $state([]);
let popular_sourceCursor = $state(0);
let popular_seenIds = new Set<number>();
let popular_seenTitles = new Set<string>();
const popular_results = $derived(popular_raw.map((m, i) => ({ ...m, _priority: Math.max(0, 50 - i) })));
function popular_push(incoming: Manga[]) {
const toAdd: Manga[] = [];
for (const m of incoming) {
if (shouldHideNsfw(m as any, settingsState.settings)) continue;
if (popular_seenIds.has(m.id)) continue;
const norm = m.title.toLowerCase().replace(/[^a-z0-9\s]/g, " ").trim();
if (popular_seenTitles.has(norm)) continue;
popular_seenIds.add(m.id);
popular_seenTitles.add(norm);
toAdd.push(m);
}
if (!toAdd.length) return;
popular_raw = [...popular_raw, ...toAdd].slice(0, SEARCH_LIMIT);
}
async function popular_fanOut(signal: AbortSignal) {
const batch = popular_sourcePool.slice(popular_sourceCursor, popular_sourceCursor + SEARCH_BATCH);
if (!batch.length) return;
await runConcurrent(batch, async (src) => {
for (let p = 1; p <= SEARCH_PAGES; p++) {
if (signal.aborted) return;
try {
const result = await getAdapter().browseSource(src.id, p);
if (signal.aborted) return;
popular_push(result.items as Manga[]);
if (!result.hasNextPage) break;
} catch { break; }
}
}, signal);
popular_sourceCursor += batch.length;
}
function popularStart(sources: Source[]) {
if (popular_raw.length > 0) return;
popular_abortCtrl?.abort();
const ctrl = new AbortController();
popular_abortCtrl = ctrl;
popular_seenIds.clear();
popular_seenTitles.clear();
popular_raw = [];
popular_sourcePool = sources;
popular_sourceCursor = 0;
popular_loading = true;
(async () => {
try {
while (!ctrl.signal.aborted && popular_sourceCursor < popular_sourcePool.length) {
await popular_fanOut(ctrl.signal);
}
} catch {}
if (!ctrl.signal.aborted) popular_loading = false;
})();
}
export const sourceCache = new Map<number, CachedManga>();
let sourceCacheReady = $state(false);
let sourceCacheLoading = $state(false);
let sourceCacheEnriching = $state(false);
let sourceCacheAbort: AbortController | null = null;
async function buildSourceCache(sources: Source[], signal: AbortSignal) {
const tasks: { src: Source; page: number }[] = [];
for (const src of sources) {
for (let p = 1; p <= POPULAR_CACHE_PAGES; p++) tasks.push({ src, page: p });
}
await runConcurrent(tasks, async ({ src, page: p }) => {
if (signal.aborted) return;
try {
const result = await getAdapter().browseSource(src.id, p);
if (signal.aborted) return;
for (const m of result.items as Manga[]) {
if (!sourceCache.has(m.id)) sourceCache.set(m.id, toCachedManga(m as any, src.id));
}
} catch (e: any) {
if (e?.name === "AbortError") return;
}
}, signal);
}
async function enrichGenres(signal: AbortSignal) {
const unenriched = [...sourceCache.values()].filter((m) => !m.genreEnriched);
if (!unenriched.length) return;
sourceCacheEnriching = true;
await runConcurrent(unenriched, async (entry) => {
if (signal.aborted) return;
try {
const m = await getAdapter().getManga(String(entry.id));
if (signal.aborted) return;
const updated = sourceCache.get(entry.id);
if (updated) {
updated.genre = (m as any).genre ?? [];
updated.status = (m as any).status ?? updated.status;
updated.lowerGenres = updated.genre.map((g: string) => g.toLowerCase());
updated.genreEnriched = true;
}
} catch {
const updated = sourceCache.get(entry.id);
if (updated) updated.genreEnriched = true;
}
}, signal);
if (!signal.aborted) sourceCacheEnriching = false;
}
function startSourceCacheBuild() {
if (sourceCacheLoading || sourceCacheReady) return;
sourceCacheAbort?.abort();
const ctrl = new AbortController();
sourceCacheAbort = ctrl;
sourceCacheLoading = true;
sourceCache.clear();
buildSourceCache(allSources, ctrl.signal)
.then(() => {
if (ctrl.signal.aborted) return;
sourceCacheReady = true;
sourceCacheLoading = false;
enrichGenres(ctrl.signal);
})
.catch((e) => {
if (e?.name !== "AbortError") console.error(e);
sourceCacheLoading = false;
});
}
onDestroy(() => {
popular_abortCtrl?.abort();
sourceCacheAbort?.abort();
});
</script>
<div class="root">
<div class="header">
<span class="heading">Search</span>
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl}>
{#if anims && tabIndicator.width > 0}
<div class="tab-slide-indicator" style="left:{tabIndicator.left}px;width:{tabIndicator.width}px" aria-hidden="true"></div>
{/if}
<button class="tab" class:tabActive={urlTab === "keyword"} onclick={() => setTab("keyword")}>
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
</svg>
Keyword
</button>
<button class="tab" class:tabActive={urlTab === "tag"} onclick={() => setTab("tag")}>
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M224,104H200l8-48a8,8,0,0,0-15.79-2.67L183.79,104H136l8-48a8,8,0,0,0-15.79-2.67L119.79,104H72a8,8,0,0,0,0,16h45.33L105.6,200H56a8,8,0,0,0,0,16H103l-8,48a8,8,0,0,0,15.79,2.67L119.21,216H168l-8,48a8,8,0,0,0,15.79,2.67L184.21,216H232a8,8,0,0,0,0-16H186.67l11.73-80H224a8,8,0,0,0,0-16Zm-69.33,96H101.6L113.33,120h53.07Z"/>
</svg>
Tags
</button>
<button class="tab" class:tabActive={urlTab === "source"} onclick={() => setTab("source")}>
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"/>
</svg>
Sources
</button>
</div>
</div>
{#if urlTab === "keyword"}
<KeywordTab
{allSources}
{availableLangs}
{hasMultipleLangs}
{loadingSources}
popularResults={popular_results}
popularLoading={popular_loading}
{sourceCache}
query={urlQuery}
onQueryChange={setQuery}
onPreview={(m) => setPreviewManga(m)}
/>
{:else if urlTab === "tag"}
<TagTab
{allSources}
{sourceCache}
{sourceCacheReady}
{sourceCacheLoading}
{sourceCacheEnriching}
onPreview={(m) => setPreviewManga(m)}
onGenreDrill={(genre) => goto(`/browse?genre=${encodeURIComponent(genre)}`)}
/>
{:else}
<SourceTab
{allSources}
{availableLangs}
{loadingSources}
{localSource}
{preselectedSourceId}
onPreview={(m) => setPreviewManga(m)}
/>
{/if}
</div>
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); }
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
.tabs { margin-left: auto; display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; }
.tab-slide-indicator { position: absolute; top: 2px; bottom: 2px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); pointer-events: none; z-index: 0; transition: left 0.22s cubic-bezier(0.16,1,0.3,1), width 0.22s cubic-bezier(0.16,1,0.3,1); }
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); cursor: pointer; border: 1px solid transparent; }
.tab:hover { color: var(--text-muted); }
.tabActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.tabs-anims .tabActive { background: transparent; border-color: transparent; }
.tabActive:hover { color: var(--accent-fg); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
+376
View File
@@ -0,0 +1,376 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { getAdapter } from "$lib/request-manager";
import { settingsState } from "$lib/state/settings.svelte";
import { shouldHideNsfw, shouldHideSource } from "$lib/core/util";
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
import ContextMenu from "$lib/components/shared/ui/ContextMenu.svelte";
import { PushPin, PushPinSlash, ArrowRight } from "phosphor-svelte";
import type { Manga, Source } from "$lib/types";
interface Props {
allSources: Source[];
availableLangs: string[];
loadingSources: boolean;
localSource: Source | null;
onPreview: (m: Manga) => void;
preselectedSourceId?: string;
}
let { allSources, availableLangs, loadingSources, localSource, onPreview, preselectedSourceId }: Props = $props();
const preferredLang = $derived(settingsState.settings.preferredExtensionLang ?? "en");
let src_selectedLang = $state(preferredLang || "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;
let ctx_x = $state(0);
let ctx_y = $state(0);
let ctx_source: Source | null = $state(null);
const pinnedIds = $derived(settingsState.settings.pinnedSourceIds ?? []);
const pinnedSources = $derived(
pinnedIds
.map((id: string) => allSources.find((s) => s.id === id))
.filter((s: Source | undefined): s is Source => !!s)
);
$effect(() => {
if (!preselectedSourceId || !allSources.length || src_activeSource) return;
const target = allSources.find((s) => s.id === preselectedSourceId);
if (target) srcSelectSource(target);
});
$effect(() => {
if (!allSources.length) return;
const langs = new Set(allSources.map((s) => s.lang));
if (src_selectedLang !== "all" && !langs.has(src_selectedLang)) {
src_selectedLang = langs.has(preferredLang) ? preferredLang : "all";
}
});
const src_visibleSources = $derived.by(() => {
const hide = (s: Source) => shouldHideSource(s, settingsState.settings);
if (src_selectedLang !== "all") {
return allSources.filter((s) => s.lang === src_selectedLang && !hide(s));
}
const map = new Map<string, Source>();
for (const s of allSources) {
if (hide(s)) continue;
const existing = map.get(s.name);
if (!existing) { map.set(s.name, s); continue; }
const existingPref = existing.lang === preferredLang;
const newPref = s.lang === preferredLang;
if (newPref && !existingPref) map.set(s.name, s);
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
}
return Array.from(map.values());
});
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 {
let result: { items: Manga[]; hasNextPage: boolean };
if (type === "SEARCH" && q) {
result = await getAdapter().searchSource(src.id, q, page, ctrl.signal);
} else {
result = await getAdapter().browseSource(src.id, page);
}
if (ctrl.signal.aborted) return;
const incoming = result.items.filter((m) => !shouldHideNsfw(m as any, settingsState.settings));
src_browseResults = page === 1 ? incoming : [...src_browseResults, ...incoming];
src_hasNextPage = result.hasNextPage;
src_currentPage = page;
} catch (e: any) {
if (e?.name !== "AbortError") console.error(e);
} finally {
if (!ctrl.signal.aborted) src_loadingBrowse = false;
}
}
function srcSelectSource(src: Source) {
src_activeSource = src; src_browseQuery = ""; src_submitted = "";
srcFetchBrowse(src, "POPULAR");
}
function srcHandleSearch() {
if (!src_activeSource || !src_browseQuery.trim()) return;
src_submitted = src_browseQuery.trim();
srcFetchBrowse(src_activeSource, "SEARCH", src_browseQuery.trim());
}
function srcClearSearch() {
src_browseQuery = ""; src_submitted = "";
if (src_activeSource) srcFetchBrowse(src_activeSource, "POPULAR");
}
function openCtx(e: MouseEvent, src: Source) {
e.preventDefault();
ctx_x = e.clientX; ctx_y = e.clientY; ctx_source = src;
}
function closeCtx() { ctx_source = null; }
function togglePinnedSource(id: string) {
const current = settingsState.settings.pinnedSourceIds ?? [];
const next = current.includes(id) ? current.filter((x: string) => x !== id) : [...current, id];
settingsState.updateSettings({ pinnedSourceIds: next });
}
onDestroy(() => { src_abortCtrl?.abort(); });
</script>
<div class="splitRoot">
<div class="splitSidebar">
<div class="srcLangRow">
<span class="langPocketLabel">Language</span>
<select class="langSelect" bind:value={src_selectedLang}>
<option value="all">All</option>
{#each availableLangs as lang (lang)}
<option value={lang}>{lang.toUpperCase()}{lang === preferredLang ? " ★" : ""}</option>
{/each}
</select>
</div>
{#if loadingSources}
<div class="splitLoading">
<svg width="16" height="16" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" 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"/>
</svg>
</div>
{:else}
<div class="splitList">
{#if localSource}
<button
class="splitItem splitItemSource"
class:splitItemActive={src_activeSource?.id === localSource.id}
onclick={() => srcSelectSource(localSource)}
oncontextmenu={(e) => openCtx(e, localSource)}
>
<div class="localSourceIcon">
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" aria-hidden="true">
<path d="M128,20A108,108,0,1,0,236,128,108.12,108.12,0,0,0,128,20Zm0,192a84,84,0,1,1,84-84A84.09,84.09,0,0,1,128,212Zm44-84a44,44,0,1,1-44-44A44.05,44.05,0,0,1,172,128Z"/>
</svg>
</div>
<span class="splitItemLabel">Local Source</span>
</button>
<div class="localDivider"></div>
{/if}
{#if pinnedSources.length > 0}
<p class="sectionLabel">Pinned</p>
{#each pinnedSources as src (src.id)}
<button
class="splitItem splitItemSource"
class:splitItemActive={src_activeSource?.id === src.id}
onclick={() => srcSelectSource(src)}
oncontextmenu={(e) => openCtx(e, src)}
>
<Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="splitItemLabel">{src.name}</span>
<span class="pinIndicator" title="Pinned">
<PushPin size={9} weight="fill" />
</span>
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
</button>
{/each}
<div class="localDivider"></div>
<p class="sectionLabel">All Sources</p>
{/if}
{#each src_visibleSources as src (src.id)}
<button
class="splitItem splitItemSource"
class:splitItemActive={src_activeSource?.id === src.id}
onclick={() => srcSelectSource(src)}
oncontextmenu={(e) => openCtx(e, src)}
>
<Thumbnail src={src.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="splitItemLabel">{src.name}</span>
{#if src_selectedLang === "all"}
<span class="sourceLang">{src.lang.toUpperCase()}</span>
{/if}
{#if src.isNsfw}<span class="nsfwBadge">18+</span>{/if}
</button>
{/each}
{#if src_visibleSources.length === 0}
<p class="splitEmpty">No sources for this language</p>
{/if}
</div>
{/if}
</div>
<div class="splitContent">
{#if !src_activeSource}
<div class="empty">
<svg width="32" height="32" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
<path d="M224,128a8,8,0,0,1-8,8H40a8,8,0,0,1,0-16H216A8,8,0,0,1,224,128ZM40,72H216a8,8,0,0,0,0-16H40a8,8,0,0,0,0,16ZM216,184H40a8,8,0,0,0,0,16H216a8,8,0,0,0,0-16Z"/>
</svg>
<p class="emptyText">Browse a source</p>
<p class="emptyHint">Select a source to see its popular titles, or search within it.</p>
</div>
{:else}
<div class="splitContentHeader">
<div class="splitSourceTitle">
<Thumbnail src={src_activeSource.iconUrl} alt="" class="splitSourceIcon" onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span class="splitContentTitle">{src_activeSource.displayName}</span>
{#if src_loadingBrowse}
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" 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"/>
</svg>
{:else if src_browseResults.length > 0}
<span class="splitResultCount">{src_browseResults.length} results</span>
{/if}
</div>
</div>
<div class="sourceBrowseBar">
<div class="searchBar" style="flex:1">
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" class="searchIcon" aria-hidden="true">
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
</svg>
<input
bind:value={src_browseQuery}
class="searchInput"
placeholder="Search {src_activeSource.displayName}…"
onkeydown={(e) => e.key === "Enter" && srcHandleSearch()}
/>
{#if src_submitted}
<button class="clearBtn" title="Clear search" onclick={srcClearSearch}>×</button>
{/if}
</div>
<button class="searchBtn" onclick={srcHandleSearch} disabled={!src_browseQuery.trim() || src_loadingBrowse}>Search</button>
</div>
{#if src_loadingBrowse && src_browseResults.length === 0}
<div class="tagGrid">
{#each Array(18) as _, i (i)}
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
{/each}
</div>
{:else if src_browseResults.length > 0}
<div class="tagGrid">
{#each src_browseResults as m, i (m.id)}
<button class="card" onclick={() => onPreview(m)}>
<div class="coverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
</div>
<p class="cardTitle">{m.title}</p>
</button>
{/each}
{#if src_hasNextPage}
<div class="showMoreCell">
<button
class="showMoreBtn"
disabled={src_loadingBrowse}
onclick={() => src_activeSource && srcFetchBrowse(src_activeSource, src_submitted ? "SEARCH" : "POPULAR", src_submitted || undefined, src_currentPage + 1)}
>
{src_loadingBrowse ? "Loading…" : "Load more"}
</button>
</div>
{/if}
</div>
{:else if !src_loadingBrowse}
<div class="empty">
<p class="emptyText">No results</p>
<p class="emptyHint">Try a different search term.</p>
</div>
{/if}
{/if}
</div>
</div>
{#if ctx_source}
{@const isPinned = pinnedIds.includes(ctx_source.id)}
<ContextMenu
x={ctx_x}
y={ctx_y}
onClose={closeCtx}
items={[
{
label: isPinned ? "Unpin source" : "Pin source",
icon: isPinned ? PushPinSlash : PushPin,
onClick: () => { togglePinnedSource(ctx_source!.id); },
},
{ separator: true },
{
label: "Browse source",
icon: ArrowRight,
onClick: () => { srcSelectSource(ctx_source!); },
},
]}
/>
{/if}
<style>
.splitRoot { flex: 1; display: flex; overflow: hidden; }
.splitSidebar { width: 180px; flex-shrink: 0; border-right: 1px solid var(--border-dim); overflow: hidden; display: flex; flex-direction: column; }
.srcLangRow { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); }
.langPocketLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.langSelect { appearance: none; -webkit-appearance: none; background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 24px 4px 8px; cursor: pointer; max-width: 110px; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 256 256'%3E%3Cpath fill='%23888' d='M213.66,101.66l-80,80a8,8,0,0,1-11.32,0l-80-80A8,8,0,0,1,53.66,90.34L128,164.69l74.34-74.35a8,8,0,0,1,11.32,11.32Z'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 7px center; transition: border-color var(--t-base), background var(--t-base), color var(--t-base); }
.langSelect:hover { border-color: var(--border-strong); background-color: var(--bg-raised); color: var(--text-primary); }
.langSelect:focus { outline: none; border-color: var(--accent-dim); color: var(--text-primary); }
.langSelect option { background: var(--bg-surface); color: var(--text-secondary); }
.splitLoading { flex: 1; display: flex; align-items: center; justify-content: center; padding: var(--sp-6); }
.splitList { flex: 1; overflow-y: auto; padding: var(--sp-1); scrollbar-width: thin; scrollbar-color: var(--border-dim) transparent; }
.localSourceIcon { width: 20px; height: 20px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); display: flex; align-items: center; justify-content: center; color: var(--accent-fg); flex-shrink: 0; }
.localDivider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
.splitItem { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.splitItemActive { background: var(--accent-muted); border-color: var(--accent-dim); }
.splitItemActive:hover { background: var(--accent-muted); }
.splitItemSource { gap: var(--sp-2); }
.splitItemLabel { font-size: var(--text-xs); color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
.splitEmpty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-3); margin: 0; }
.sectionLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: var(--sp-2) var(--sp-3) var(--sp-1); margin: 0; }
.pinIndicator { display: flex; align-items: center; color: var(--accent-fg); opacity: 0.7; flex-shrink: 0; margin-left: auto; margin-right: 2px; }
.sourceLang { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin-left: auto; margin-right: 4px; }
.nsfwBadge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--color-error); background: var(--color-error-bg, rgba(180,60,60,0.08)); border: 1px solid rgba(180,60,60,0.25); border-radius: var(--radius-sm); padding: 1px 5px; margin-left: auto; flex-shrink: 0; }
.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.splitContentHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); }
.splitSourceTitle { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; }
.splitContentTitle { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: var(--tracking-tight); }
.splitResultCount { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
:global(.splitSourceIcon) { width: 20px; height: 20px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
.sourceBrowseBar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.searchBar { display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: var(--sp-2) var(--sp-3); transition: border-color var(--t-base); }
.searchBar:focus-within { border-color: var(--border-strong); }
.searchIcon { color: var(--text-faint); flex-shrink: 0; }
.searchInput { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
.searchInput::placeholder { color: var(--text-faint); }
.clearBtn { color: var(--text-faint); font-size: 16px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
.clearBtn:hover { color: var(--text-muted); }
.searchBtn { padding: 6px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); flex-shrink: 0; }
.searchBtn:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-secondary); border-color: var(--border-strong); }
.searchBtn:disabled { opacity: 0.4; cursor: default; }
.tagGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: var(--sp-4); padding: var(--sp-4); overflow-y: auto; flex: 1; align-content: start; }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; display: flex; flex-direction: column; gap: var(--sp-2); }
.coverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
.cardTitle { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
.showMoreCell { grid-column: 1 / -1; display: flex; justify-content: center; padding: var(--sp-2) 0; }
.showMoreBtn { display: inline-flex; align-items: center; gap: var(--sp-1); padding: 5px 12px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
.showMoreBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); border-color: var(--border-strong); }
.showMoreBtn:disabled { opacity: 0.4; cursor: default; }
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); }
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
.skCover { aspect-ratio: 2/3; width: 100%; border-radius: var(--radius-md); }
.skTitle { height: 10px; width: 80%; }
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
.emptyIcon { color: var(--text-faint); opacity: 0.5; }
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
.anim-spin { animation: anim-spin 0.8s linear infinite; }
</style>
+444
View File
@@ -0,0 +1,444 @@
<script lang="ts">
import { onDestroy, untrack } from "svelte";
import { getAdapter } from "$lib/request-manager";
import { settingsState } from "$lib/state/settings.svelte";
import { shouldHideNsfw, dedupeMangaById, dedupeMangaByTitle, normalizeTitle } from "$lib/core/util";
import { runConcurrent, filterSourceCache, buildTagFilter, COMMON_GENRES, MANGA_STATUSES, type TagMode, type CachedManga } from "$lib/components/browse/lib/searchFilter";
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
import type { Manga, Source } from "$lib/types";
interface Props {
allSources: Source[];
sourceCache: Map<number, CachedManga>;
sourceCacheReady: boolean;
sourceCacheLoading: boolean;
sourceCacheEnriching: boolean;
onPreview: (m: Manga) => void;
}
let {
allSources, sourceCache,
sourceCacheReady, sourceCacheLoading, sourceCacheEnriching,
onPreview,
}: Props = $props();
const SEARCH_LIMIT = 200;
const preferredLang = $derived(settingsState.settings.preferredExtensionLang ?? "en");
let tag_activeTags: string[] = $state([]);
let tag_activeStatuses: string[] = $state([]);
let tag_tagMode: TagMode = $state("AND");
let tag_tagFilter = $state("");
const tag_filteredGenres = $derived.by(() => {
const q = tag_tagFilter.trim().toLowerCase();
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : [...COMMON_GENRES];
});
const tag_hasActiveFilters = $derived(tag_activeTags.length > 0 || tag_activeStatuses.length > 0);
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;
const renderLimit = $derived(settingsState.settings.renderLimit ?? 48);
$effect(() => {
const _tags = tag_activeTags;
const _mode = tag_tagMode;
const _statuses = tag_activeStatuses;
untrack(() => tagFetchLocal(_tags, _mode, _statuses));
});
$effect(() => {
if (tag_localHasNext && !tag_loadingMoreLocal && !tag_loadingLocal) tagLoadMoreLocal();
});
async function tagFetchLocal(activeTags: string[], tagMode: TagMode, activeStatuses: string[]) {
if (activeTags.length === 0 && activeStatuses.length === 0) {
tag_localResults = []; tag_totalCount = 0; tag_localHasNext = false; tag_localOffset = 0;
return;
}
tag_abortLocal?.abort();
const ctrl = new AbortController();
tag_abortLocal = ctrl;
tag_localResults = []; tag_totalCount = 0; tag_localOffset = 0; tag_localHasNext = false;
tag_loadingLocal = true;
const limit = renderLimit;
try {
const d = await getAdapter().getMangasByGenre(
buildTagFilter(activeTags, tagMode, activeStatuses), limit, 0, ctrl.signal,
);
if (ctrl.signal.aborted) return;
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m as any, settingsState.settings);
tag_localResults = d.items.filter(nsfwFilter);
tag_totalCount = d.totalCount;
tag_localHasNext = d.hasNextPage;
tag_localOffset = limit;
} catch (e: any) {
if (e?.name !== "AbortError") console.error(e);
} finally {
if (!ctrl.signal.aborted) tag_loadingLocal = false;
}
}
async function tagLoadMoreLocal() {
if (tag_loadingMoreLocal || !tag_localHasNext) return;
tag_loadingMoreLocal = true;
tag_abortLocal?.abort();
const ctrl = new AbortController();
tag_abortLocal = ctrl;
const limit = renderLimit;
try {
const d = await getAdapter().getMangasByGenre(
buildTagFilter(tag_activeTags, tag_tagMode, tag_activeStatuses), limit, tag_localOffset, ctrl.signal,
);
if (ctrl.signal.aborted) return;
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m as any, settingsState.settings);
tag_localResults = [...tag_localResults, ...d.items.filter(nsfwFilter)];
tag_localHasNext = d.hasNextPage;
tag_localOffset += limit;
} catch (e: any) {
if (e?.name !== "AbortError") console.error(e);
} finally {
if (!ctrl.signal.aborted) tag_loadingMoreLocal = false;
}
}
let tag_searchSources = $state(false);
let tag_sourceFiltered: CachedManga[] = $state([]);
let tag_sourceFanOut: Manga[] = $state([]);
let tag_fanOutLoading = $state(false);
let tag_fanOutAbort: AbortController | null = null;
$effect(() => {
const _tags = tag_activeTags;
const _mode = tag_tagMode;
const _statuses = tag_activeStatuses;
const _ready = sourceCacheReady;
const _search = tag_searchSources;
untrack(() => {
if (_search && _ready && (_tags.length > 0 || _statuses.length > 0)) {
tag_sourceFiltered = filterSourceCache(sourceCache, _tags, _mode, _statuses, settingsState.settings);
} else {
tag_sourceFiltered = [];
}
});
});
$effect(() => {
const _tags = tag_activeTags;
const _search = tag_searchSources;
untrack(() => {
if (_search && _tags.length === 1 && tag_activeStatuses.length === 0) {
tagStartFanOut(_tags[0]);
} else {
tag_fanOutAbort?.abort();
tag_fanOutAbort = null;
tag_sourceFanOut = [];
tag_fanOutLoading = false;
}
});
});
async function tagStartFanOut(genre: string) {
tag_fanOutAbort?.abort();
const ctrl = new AbortController();
tag_fanOutAbort = ctrl;
tag_sourceFanOut = [];
tag_fanOutLoading = true;
const seenIds = new Set<number>();
const seenTitles = new Set<string>();
const genreLower = genre.toLowerCase();
const srcs = allSources.filter((s) => !shouldHideNsfw(s as any, settingsState.settings));
await runConcurrent(srcs, async (src) => {
for (let page = 1; page <= 2; page++) {
if (ctrl.signal.aborted) return;
let result: { items: Manga[]; hasNextPage: boolean } | null = null;
try {
result = await getAdapter().searchSource(src.id, genre, page, ctrl.signal);
} catch { return; }
if (!result || ctrl.signal.aborted) return;
const matching = result.items.filter((m) =>
((m as any).genre ?? []).some((g: string) => g.toLowerCase() === genreLower)
);
const candidates = (matching.length ? matching : result.items).filter(
(m) => !shouldHideNsfw(m as any, settingsState.settings)
);
const toAdd: Manga[] = [];
for (const m of candidates) {
if (seenIds.has(m.id)) continue;
const norm = normalizeTitle(m.title);
if (seenTitles.has(norm)) continue;
seenIds.add(m.id);
seenTitles.add(norm);
toAdd.push(m);
}
if (toAdd.length) {
tag_sourceFanOut = [...tag_sourceFanOut, ...toAdd].slice(0, SEARCH_LIMIT);
}
if (!result.hasNextPage) return;
}
}, ctrl.signal);
if (!ctrl.signal.aborted) tag_fanOutLoading = false;
}
let tag_autoSearchFired = $state(false);
$effect(() => {
tag_activeTags;
tag_activeStatuses;
untrack(() => { tag_autoSearchFired = false; });
if (!tag_loadingLocal && tag_hasActiveFilters && !tag_autoSearchFired && !tag_searchSources && sourceCacheReady) {
if (tag_localResults.length < 20) {
untrack(() => { tag_autoSearchFired = true; tag_searchSources = true; });
}
}
});
const tag_localIds = $derived(new Set(tag_localResults.map((m) => m.id)));
const tag_mergedResults = $derived.by(() => {
const fanOutMapped = tag_sourceFanOut.filter((m) => !tag_localIds.has(m.id));
const cacheMapped: Manga[] = tag_sourceFiltered
.filter((m) => !tag_localIds.has(m.id) && !fanOutMapped.some((f) => f.id === m.id))
.map((m) => ({ id: m.id, title: m.title, thumbnailUrl: m.thumbnailUrl, inLibrary: m.inLibrary, genre: m.genre, status: m.status } as Manga));
return dedupeMangaByTitle(
dedupeMangaById([...tag_localResults, ...fanOutMapped, ...cacheMapped]),
settingsState.settings.mangaLinks,
);
});
const tag_totalVisible = $derived(tag_mergedResults.length);
function tagToggleTag(tag: string) {
tag_activeTags = tag_activeTags.includes(tag)
? tag_activeTags.filter((t) => t !== tag)
: [...tag_activeTags, tag];
}
function tagToggleStatus(status: string) {
tag_activeStatuses = tag_activeStatuses.includes(status)
? tag_activeStatuses.filter((s) => s !== status)
: [...tag_activeStatuses, status];
}
onDestroy(() => {
tag_abortLocal?.abort();
tag_fanOutAbort?.abort();
});
</script>
<div class="splitRoot">
<div class="splitSidebar">
<div class="splitSearchWrap">
<svg width="12" height="12" viewBox="0 0 256 256" fill="currentColor" class="splitSearchIcon" aria-hidden="true">
<path d="M229.66,218.34l-50.07-50.07a88,88,0,1,0-11.31,11.31l50.06,50.07a8,8,0,0,0,11.32-11.31ZM40,112a72,72,0,1,1,72,72A72.08,72.08,0,0,1,40,112Z"/>
</svg>
<input bind:value={tag_tagFilter} class="splitSearchInput" placeholder="Filter genres…" />
{#if tag_tagFilter}
<button class="splitSearchClear" title="Clear" onclick={() => (tag_tagFilter = "")}>×</button>
{/if}
</div>
<div class="splitList">
<div class="splitSectionLabel">Status</div>
{#each MANGA_STATUSES as { value, label } (value)}
<button class="splitItem" class:splitItemActive={tag_activeStatuses.includes(value)} onclick={() => tagToggleStatus(value)}>
<span class="splitItemLabel">{label}</span>
{#if tag_activeStatuses.includes(value)}<span class="tagCheckMark"></span>{/if}
</button>
{/each}
<div class="splitSectionLabel splitSectionLabelSpaced">Genre</div>
{#each tag_filteredGenres as tag (tag)}
<button class="splitItem" class:splitItemActive={tag_activeTags.includes(tag)} onclick={() => tagToggleTag(tag)}>
<span class="splitItemLabel">{tag}</span>
{#if tag_activeTags.includes(tag)}<span class="tagCheckMark"></span>{/if}
</button>
{/each}
{#if tag_filteredGenres.length === 0}
<p class="splitEmpty">No matching genres</p>
{/if}
</div>
</div>
<div class="splitContent">
{#if !tag_hasActiveFilters}
<div class="empty">
<svg width="32" height="32" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
<path d="M224,104H200l8-48a8,8,0,0,0-15.79-2.67L183.79,104H136l8-48a8,8,0,0,0-15.79-2.67L119.79,104H72a8,8,0,0,0,0,16h45.33L105.6,200H56a8,8,0,0,0,0,16H103l-8,48a8,8,0,0,0,15.79,2.67L119.21,216H168l-8,48a8,8,0,0,0,15.79,2.67L184.21,216H232a8,8,0,0,0,0-16H186.67l11.73-80H224a8,8,0,0,0,0-16Zm-69.33,96H101.6L113.33,120h53.07Z"/>
</svg>
<p class="emptyText">Browse by tag</p>
<p class="emptyHint">Select a status or genre to find matching manga.</p>
</div>
{:else}
<div class="tagActiveBar">
<div class="tagPillRow">
{#each tag_activeStatuses as status (status)}
<span class="tagPill tagPillStatus">
{MANGA_STATUSES.find((s) => s.value === status)?.label ?? status}
<button class="tagPillRemove" title="Remove {status}" onclick={() => tagToggleStatus(status)}>×</button>
</span>
{/each}
{#each tag_activeTags as tag (tag)}
<span class="tagPill">
{tag}
<button class="tagPillRemove" title="Remove {tag}" onclick={() => tagToggleTag(tag)}>×</button>
</span>
{/each}
</div>
<div class="tagBarRight">
{#if tag_activeTags.length > 1}
<div class="tagModeToggle">
<button class="tagModeBtn" class:tagModeBtnActive={tag_tagMode === "AND"} title="Match ALL tags" onclick={() => (tag_tagMode = "AND")}>AND</button>
<button class="tagModeBtn" class:tagModeBtnActive={tag_tagMode === "OR"} title="Match ANY tag" onclick={() => (tag_tagMode = "OR")}>OR</button>
</div>
{/if}
<button
class="tagModeBtn"
class:tagModeBtnActive={tag_searchSources}
title={sourceCacheLoading ? "Building source cache…" : sourceCacheReady ? "Search across sources" : "Sources unavailable"}
disabled={!sourceCacheReady && !sourceCacheLoading}
onclick={() => (tag_searchSources = !tag_searchSources)}
>
{#if sourceCacheLoading || tag_fanOutLoading}
<svg width="11" height="11" 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"/>
</svg>
{:else}
<svg width="11" height="11" viewBox="0 0 256 256" fill="currentColor" style="margin-right:3px;vertical-align:middle" aria-hidden="true">
<path d="M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24ZM101.63,168h52.74C149,186.34,140,202.87,128,215.89,116,202.87,107,186.34,101.63,168ZM98,152a145.72,145.72,0,0,1,0-48h60a145.72,145.72,0,0,1,0,48ZM40,128a87.61,87.61,0,0,1,3.33-24H81.79a161.79,161.79,0,0,0,0,48H43.33A87.61,87.61,0,0,1,40,128ZM154.37,88H101.63C107,69.66,116,53.13,128,40.11,140,53.13,149,69.66,154.37,88ZM174.21,104h38.46a88.15,88.15,0,0,1,0,48H174.21a161.79,161.79,0,0,0,0-48Zm32.32-16H170.71a133.32,133.32,0,0,0-22.7-45.8A88.21,88.21,0,0,1,206.53,88ZM108,42.2A133.32,133.32,0,0,0,85.29,88H49.47A88.21,88.21,0,0,1,108,42.2ZM49.47,168H85.29A133.32,133.32,0,0,0,108,213.8,88.21,88.21,0,0,1,49.47,168Zm98.53,45.8A133.32,133.32,0,0,0,170.71,168h35.82A88.21,88.21,0,0,1,148,213.8Z"/>
</svg>
{/if}
Sources{sourceCacheEnriching ? " ·" : ""}
</button>
<button class="tagClearAll" onclick={() => { tag_activeTags = []; tag_activeStatuses = []; }}>Clear all</button>
</div>
</div>
<div class="splitContentHeader">
<span class="splitContentTitle">
{#if tag_activeStatuses.length > 0 && tag_activeTags.length === 0}
{tag_activeStatuses.map((s) => MANGA_STATUSES.find((x) => x.value === s)?.label ?? s).join(" · ")}
{:else if tag_activeTags.length === 1 && tag_activeStatuses.length === 0}
{tag_activeTags[0]}
{:else}
{[...tag_activeStatuses.map((s) => MANGA_STATUSES.find((x) => x.value === s)?.label ?? s), ...tag_activeTags].join(` ${tag_tagMode} `)}
{/if}
{#if tag_searchSources}
<span style="margin-left:6px;font-weight:400;opacity:0.55;font-size:0.9em">+ sources</span>
{/if}
</span>
{#if tag_loadingLocal}
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" style="color:var(--text-faint)" 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"/>
</svg>
{:else}
<span class="splitResultCount">
{tag_totalVisible}{tag_localHasNext ? "+" : ""} results
{#if tag_searchSources && sourceCacheReady}
· {sourceCache.size} cached
{/if}
</span>
{/if}
</div>
{#if tag_loadingLocal}
<div class="tagGrid">
{#each Array(48) as _, i (i)}
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
{/each}
</div>
{:else if tag_mergedResults.length > 0}
<div class="tagGrid">
{#each tag_mergedResults as m, i (m.id)}
<button class="card" onclick={() => onPreview(m)}>
<div class="coverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" priority={i < 12 ? 12 - i : 0} />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
</div>
<p class="cardTitle">{m.title}</p>
</button>
{/each}
{#if tag_loadingMoreLocal}
{#each Array(12) as _, i (i)}
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
{/each}
{/if}
</div>
{:else}
<div class="empty">
<p class="emptyText">No results</p>
<p class="emptyHint">
{#if tag_searchSources}Try OR mode or broader tags.
{:else}Try OR mode, enable Sources, or check your library.
{/if}
</p>
</div>
{/if}
{/if}
</div>
</div>
<style>
.splitRoot { flex: 1; display: flex; overflow: hidden; }
.splitSidebar { width: 180px; flex-shrink: 0; border-right: 1px solid var(--border-dim); overflow: hidden; display: flex; flex-direction: column; }
.splitSearchWrap { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.splitSearchIcon { color: var(--text-faint); flex-shrink: 0; }
.splitSearchInput { flex: 1; background: none; border: none; outline: none; font-size: var(--text-xs); color: var(--text-primary); font-family: var(--font-ui); min-width: 0; }
.splitSearchInput::placeholder { color: var(--text-faint); }
.splitSearchClear { color: var(--text-faint); font-size: 13px; line-height: 1; background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base); }
.splitSearchClear:hover { color: var(--text-muted); }
.splitList { flex: 1; overflow-y: auto; padding: var(--sp-1); scrollbar-width: thin; scrollbar-color: var(--border-dim) transparent; }
.splitSectionLabel { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: var(--sp-2) var(--sp-3) var(--sp-1); pointer-events: none; user-select: none; }
.splitSectionLabelSpaced { margin-top: var(--sp-2); border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
.splitItem { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
.splitItem:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.splitItemActive { background: var(--accent-muted); border-color: var(--accent-dim); }
.splitItemActive:hover { background: var(--accent-muted); }
.splitItemLabel { font-size: var(--text-xs); color: var(--text-muted); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.splitItemActive .splitItemLabel { color: var(--accent-fg); font-weight: var(--weight-medium); }
.splitEmpty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-3); margin: 0; }
.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.splitContentHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); }
.splitContentTitle { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; letter-spacing: var(--tracking-tight); }
.splitResultCount { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.tagActiveBar { display: flex; align-items: flex-start; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; flex-wrap: wrap; }
.tagPillRow { display: flex; flex-wrap: wrap; gap: var(--sp-1); flex: 1; min-width: 0; }
.tagPill { display: inline-flex; align-items: center; gap: 4px; padding: 2px 7px; background: var(--accent-muted); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
.tagPillStatus { background: color-mix(in srgb, var(--color-info, #4a90d9) 12%, transparent); border-color: color-mix(in srgb, var(--color-info, #4a90d9) 30%, transparent); color: var(--color-info, #4a90d9); }
.tagPillRemove { color: currentColor; opacity: 0.6; font-size: 13px; line-height: 1; background: none; border: none; cursor: pointer; padding: 0; transition: opacity var(--t-base); }
.tagPillRemove:hover { opacity: 1; }
.tagBarRight { display: flex; align-items: center; gap: 4px; flex-shrink: 0; }
.tagModeToggle { display: flex; border: 1px solid var(--border-dim); border-radius: var(--radius-md); overflow: hidden; }
.tagModeBtn { display: flex; align-items: center; gap: 4px; padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; border-right: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), background var(--t-base); }
.tagModeBtn:last-child { border-right: none; }
.tagModeBtn:hover { color: var(--text-muted); background: var(--bg-raised); }
.tagModeBtnActive { color: var(--accent-fg); background: var(--accent-muted); }
.tagModeBtnActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
.tagClearAll { display: flex; align-items: center; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); padding: 4px 8px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.tagClearAll:hover { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, transparent); background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent)); }
.tagCheckMark { font-size: var(--text-xs); color: var(--accent-fg); margin-left: auto; }
.tagGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: var(--sp-4); padding: var(--sp-4); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; display: flex; flex-direction: column; gap: var(--sp-2); }
.coverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
.cardTitle { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
.inLibBadge { position: absolute; top: var(--sp-2); left: var(--sp-2); font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 1px 5px; }
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); }
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
.skCover { aspect-ratio: 2/3; width: 100%; border-radius: var(--radius-md); }
.skTitle { height: 10px; width: 80%; }
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-8); }
.emptyIcon { color: var(--text-faint); opacity: 0.5; }
.emptyText { font-size: var(--text-sm); color: var(--text-muted); font-weight: var(--weight-medium); margin: 0; }
.emptyHint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); margin: 0; }
@keyframes anim-spin { from { transform: rotate(0deg) } to { transform: rotate(360deg) } }
.anim-spin { animation: anim-spin 0.8s linear infinite; }
</style>
@@ -0,0 +1,133 @@
import type { Settings } from "$lib/types/settings";
import { shouldHideNsfw } from "$lib/core/util";
export { shouldHideNsfw };
export const PAGE_SIZE = 50;
export const INITIAL_PAGES = 3;
export const MAX_SOURCES = 12;
export const CONCURRENCY = 4;
export function parseTags(f: string): string[] {
return f.split("+").map((t) => t.trim()).filter(Boolean);
}
export function tagsLabel(tags: string[]): string {
if (tags.length === 1) return tags[0];
return tags.slice(0, -1).join(", ") + " & " + tags[tags.length - 1];
}
export function matchesAllTags(m: { genre?: string[] }, tags: string[]): boolean {
const g = (m.genre ?? []).map((x) => x.toLowerCase());
return tags.every((t) => g.includes(t.toLowerCase()));
}
export 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) {
if (signal.aborted) return;
await fn(items[i++]).catch(() => {});
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
}
export type TagMode = "AND" | "OR";
export interface CachedManga {
id: number;
title: string;
thumbnailUrl: string;
inLibrary: boolean;
status: string;
genre: string[];
lowerGenres: string[];
sourceId: string;
genreEnriched: boolean;
}
export const COMMON_GENRES = [
"Action", "Adventure", "Comedy", "Drama", "Fantasy", "Romance",
"Sci-Fi", "Slice of Life", "Horror", "Mystery", "Thriller", "Sports",
"Supernatural", "Mecha", "Historical", "Psychological", "School Life",
"Shounen", "Seinen", "Josei", "Shoujo", "Isekai", "Martial Arts",
"Magic", "Music", "Cooking", "Medical", "Military", "Harem", "Ecchi",
] as const;
export const MANGA_STATUSES: { value: string; label: string }[] = [
{ value: "ONGOING", label: "Ongoing" },
{ value: "COMPLETED", label: "Completed" },
{ value: "HIATUS", label: "Hiatus" },
{ value: "ABANDONED", label: "Abandoned" },
{ value: "UNKNOWN", label: "Unknown" },
];
export function buildTagFilter(
tags: string[],
mode: TagMode,
statuses: string[],
): Record<string, unknown> {
const genrePart: Record<string, unknown> | null =
tags.length === 0 ? null :
mode === "AND"
? { and: tags.map((t) => ({ genre: { includesInsensitive: t } })) }
: { or: tags.map((t) => ({ genre: { includesInsensitive: t } })) };
const statusPart: Record<string, unknown> | null =
statuses.length === 0 ? null :
statuses.length === 1
? { status: { equalTo: statuses[0] } }
: { or: statuses.map((s) => ({ status: { equalTo: s } })) };
if (!genrePart && !statusPart) return {};
if (genrePart && !statusPart) return genrePart;
if (!genrePart && statusPart) return statusPart;
return { and: [genrePart, statusPart] };
}
export function filterSourceCache(
sourceCache: Map<number, CachedManga>,
tags: string[],
mode: TagMode,
statuses: string[],
settings: Pick<Settings, "contentLevel" | "sourceOverridesEnabled" | "nsfwAllowedSourceIds" | "nsfwBlockedSourceIds">,
): CachedManga[] {
return [...sourceCache.values()].filter((m) => {
if (shouldHideNsfw(m as any, settings)) return false;
const statusMatch = statuses.length === 0 || statuses.includes(m.status);
let genreMatch = true;
if (tags.length > 0) {
const lower = m.lowerGenres;
genreMatch = mode === "AND"
? tags.every((t) => lower.some((g) => g.includes(t.toLowerCase())))
: tags.some((t) => lower.some((g) => g.includes(t.toLowerCase())));
}
return statusMatch && genreMatch;
});
}
export function toCachedManga(
m: { id: number; title: string; thumbnailUrl: string; inLibrary: boolean; genre?: string[]; status?: string },
srcId: string,
): CachedManga {
const genre = m.genre ?? [];
return {
id: m.id,
title: m.title,
thumbnailUrl: m.thumbnailUrl,
inLibrary: m.inLibrary,
status: m.status ?? "UNKNOWN",
genre,
lowerGenres: genre.map((g) => g.toLowerCase()),
sourceId: srcId,
genreEnriched: genre.length > 0,
};
}
+9 -9
View File
@@ -1,6 +1,6 @@
<script lang="ts">
import { page } from '$app/stores'
import { goto } from '$app/navigation'
import { page } from '$app/stores'
import { app } from '$lib/state/app.svelte'
import {
House, Books, MagnifyingGlass, ClockCounterClockwise,
@@ -8,25 +8,25 @@
} from 'phosphor-svelte'
import logoUrl from '$lib/assets/moku-icon-wordmark.svg'
const TABS = [
const TABS: { path: string; label: string; icon: any }[] = [
{ path: '/', label: 'Home', icon: House },
{ path: '/library', label: 'Library', icon: Books },
{ path: '/browse', label: 'Browse', icon: MagnifyingGlass },
{ path: '/downloads', label: 'Downloads', icon: DownloadSimple },
{ path: '/extensions', label: 'Extensions', icon: PuzzlePiece },
{ path: '/tracking', label: 'Tracking', icon: ChartLineUp },
] as const
]
const TAB_SIZE = 36
const TAB_GAP = 4
const activeIndex = $derived(
TABS.findIndex(t => {
if (t.path === '/') return $page.url.pathname === '/'
return $page.url.pathname.startsWith(t.path)
})
)
function isActive(path: string): boolean {
const pathname = $page.url.pathname
if (path === '/') return pathname === '/'
return pathname.startsWith(path)
}
const activeIndex = $derived(TABS.findIndex(t => isActive(t.path)))
const indicatorY = $derived(activeIndex * (TAB_SIZE + TAB_GAP))
</script>
+24 -20
View File
@@ -1,4 +1,5 @@
<script lang="ts">
import { tick } from 'svelte'
import { dismissToast } from '$lib/state/notifications.svelte'
import type { Toast } from '$lib/state/notifications.svelte'
@@ -17,10 +18,11 @@
timers.set(t.id, setTimeout(() => dismiss(t.id), dur))
}
function dismiss(id: string) {
async function dismiss(id: string) {
if (leaving.has(id)) return
leaving.add(id)
if (timers.has(id)) { clearTimeout(timers.get(id)!); timers.delete(id) }
await tick()
const el = document.querySelector<HTMLElement>(`[data-toast-id="${id}"]`)
if (!el) { finalize(id); return }
el.style.setProperty('--exit-h', `${el.offsetHeight}px`)
@@ -30,6 +32,7 @@
function finalize(id: string) {
leaving.delete(id)
if (detail?.id === id) return
dismissToast(id)
}
@@ -39,8 +42,9 @@
if (timers.has(t.id)) { clearTimeout(timers.get(t.id)!); timers.delete(t.id) }
}
function onBackdropKey(e: KeyboardEvent) {
if (e.key === 'Escape') detail = null
function closeDetail() {
if (detail) dismiss(detail.id)
detail = null
}
$effect(() => {
@@ -66,7 +70,7 @@
<button
class="toast toast-{t.kind}"
data-toast-id={t.id}
aria-label="{t.message}{t.detail ? ': ' + t.detail : ''}"
aria-label="{t.title}{t.body ? ': ' + t.body : ''}"
onclick={() => dismiss(t.id)}
oncontextmenu={(e) => openDetail(e, t)}
>
@@ -77,8 +81,8 @@
</svg>
</span>
<div class="body">
<p class="message">{t.message}</p>
<p class="sub">{t.detail ?? '\u00a0'}</p>
<p class="message">{t.title}</p>
<p class="sub">{t.body ?? '\u00a0'}</p>
</div>
</button>
{/each}
@@ -89,14 +93,14 @@
<div
class="detail-backdrop"
role="presentation"
onclick={() => (detail = null)}
onkeydown={onBackdropKey}
onclick={() => closeDetail()}
onkeydown={(e) => { if (e.key === 'Escape') closeDetail() }}
>
<div
class="detail-panel detail-{detail.kind}"
role="dialog"
aria-modal="true"
aria-label={detail.message}
aria-label={detail.title}
tabindex="-1"
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
@@ -105,21 +109,21 @@
<div class="detail-body">
<div class="detail-header">
<span class="detail-kind">{detail.kind}</span>
<button class="detail-close" onclick={() => (detail = null)} aria-label="Close">
<button class="detail-close" onclick={closeDetail} aria-label="Close">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<p class="detail-message">{detail.message}</p>
{#if detail.detail}
<pre class="detail-text">{detail.detail}</pre>
<p class="detail-message">{detail.title}</p>
{#if detail.body}
<pre class="detail-text">{detail.body}</pre>
{/if}
<div class="detail-actions">
<button class="detail-copy" onclick={() => navigator.clipboard.writeText(`${detail!.message}${detail!.detail ? '\n' + detail!.detail : ''}`)}>
<button class="detail-copy" onclick={() => navigator.clipboard.writeText(`${detail!.title}${detail!.body ? '\n' + detail!.body : ''}`)}>
Copy
</button>
<button class="detail-dismiss" onclick={() => { dismiss(detail!.id); detail = null }}>
<button class="detail-dismiss" onclick={closeDetail}>
Dismiss
</button>
</div>
@@ -129,13 +133,13 @@
{/if}
<style>
.toaster { position:fixed; bottom:var(--sp-5); right:var(--sp-5); z-index:9999; display:flex; flex-direction:column; gap:5px; pointer-events:none; }
.toaster { position:fixed; bottom:var(--sp-5); right:var(--sp-5); z-index:9999; display:flex; flex-direction:column; gap:6px; pointer-events:none; }
.toast {
display:flex; align-items:center; gap:10px; padding:12px var(--sp-3) 12px 0;
display:flex; align-items:center; gap:12px; padding:14px var(--sp-4) 14px 0;
border-radius:var(--radius-md); background:var(--bg-raised); border:1px solid var(--border-dim);
box-shadow:0 8px 32px rgba(0,0,0,0.5), 0 1px 0 rgba(255,255,255,0.04) inset;
pointer-events:all; width:280px; overflow:hidden; cursor:pointer;
pointer-events:all; width:320px; overflow:hidden; cursor:pointer;
font-family:inherit; font-size:inherit; color:inherit; text-align:left;
will-change:transform, opacity;
animation:slideIn 0.35s cubic-bezier(0.16,1,0.3,1) both;
@@ -166,8 +170,8 @@
.toast-download .icon { color:var(--accent-fg); }
.body { flex:1; min-width:0; display:flex; flex-direction:column; gap:5px; }
.message { font-size:var(--text-xs); font-family:var(--font-ui); color:var(--text-secondary); font-weight:var(--weight-medium); letter-spacing:var(--tracking-wide); line-height:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.sub { font-family:var(--font-ui); font-size:var(--text-2xs); color:var(--text-faint); letter-spacing:var(--tracking-wide); line-height:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.message { font-size:var(--text-sm); font-family:var(--font-ui); color:var(--text-secondary); font-weight:var(--weight-medium); letter-spacing:var(--tracking-wide); line-height:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.sub { font-family:var(--font-ui); font-size:var(--text-xs); color:var(--text-faint); letter-spacing:var(--tracking-wide); line-height:1; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; }
.detail-backdrop { position:fixed; inset:0; z-index:10000; background:rgba(0,0,0,0.45); display:flex; align-items:center; justify-content:center; animation:fadeIn 0.15s ease both; }
@keyframes fadeIn { from { opacity:0 } to { opacity:1 } }
@@ -0,0 +1,132 @@
<script lang="ts">
import { CircleNotch, ArrowClockwise, X } from "phosphor-svelte";
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
import { longPress } from "$lib/core/ui/touchscreen";
import type { DownloadQueueItem } from "$lib/types/api";
import { pageProgress } from "$lib/components/downloads/lib/downloadQueue";
interface Props {
item: DownloadQueueItem;
isActive: boolean;
isRemoving: boolean;
isSelected: boolean;
onRemove: (chapterId: number) => void;
onRetry: (chapterId: number) => void;
onSelect: (chapterId: number, e: MouseEvent) => void;
}
const {
item, isActive, isRemoving, isSelected,
onRemove, onRetry, onSelect,
}: Props = $props();
const manga = $derived(item.chapter.manga);
const pages = $derived(item.chapter.pageCount ?? 0);
const prog = $derived(pageProgress(item.progress, pages));
const isError = $derived(item.state === "ERROR");
const pct = $derived(Math.round(item.progress * 100));
function rowLongPress(node: HTMLElement) {
return longPress(node, {
onLongPress() { onSelect(item.chapter.id, { shiftKey: false, ctrlKey: true, metaKey: false } as MouseEvent); },
});
}
</script>
<div
class="row"
class:row-active={isActive}
class:row-error={isError}
class:row-selected={isSelected}
class:row-removing={isRemoving}
role="option"
aria-selected={isSelected}
tabindex="0"
use:rowLongPress
onclick={(e) => { e.stopPropagation(); onSelect(item.chapter.id, e); }}
onkeydown={(e) => { if (e.key === ' ' || e.key === 'Enter') { e.preventDefault(); onSelect(item.chapter.id, e as unknown as MouseEvent); } }}
>
{#if manga?.thumbnailUrl}
<div class="thumb">
<Thumbnail src={manga.thumbnailUrl} alt={manga.title} class="thumb-img" />
</div>
{/if}
<div class="info">
{#if manga?.title}<span class="manga-title">{manga.title}</span>{/if}
<span class="chapter-name">{item.chapter.name}</span>
{#if pages > 0}
<div class="progress-row">
<div class="progress-wrap">
<div class="progress-bar" class:progress-error={isError} style="width:{pct}%"></div>
</div>
<span class="pages-label">
{#if isActive}
{prog.done}/{prog.total}
{:else if isError}
failed · {item.tries} {item.tries === 1 ? "try" : "tries"}
{:else}
{prog.total}p
{/if}
</span>
</div>
{/if}
</div>
<div class="row-right">
<span class="state-label" class:state-error={isError}>{item.state}</span>
<div class="actions">
{#if isError}
<button class="action-btn retry" onclick={(e) => { e.stopPropagation(); onRetry(item.chapter.id); }} disabled={isRemoving} title="Retry">
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<ArrowClockwise size={11} weight="bold" />{/if}
</button>
{/if}
{#if !isActive}
<button class="action-btn remove" onclick={(e) => { e.stopPropagation(); onRemove(item.chapter.id); }} disabled={isRemoving} title="Remove">
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
</button>
{/if}
</div>
</div>
</div>
<style>
.row {
display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3);
background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md);
transition: border-color var(--t-fast), opacity var(--t-base), background var(--t-fast);
cursor: default; user-select: none; -webkit-user-select: none; -webkit-touch-callout: none;
}
.row:hover:not(.row-active):not(.row-removing) { border-color: var(--border-strong); background: var(--bg-elevated); }
.row.row-active { background: color-mix(in srgb, var(--accent) 6%, var(--bg-raised)); border-color: var(--accent-dim); }
.row.row-error { border-color: color-mix(in srgb, var(--color-error) 30%, transparent); }
.row.row-selected { background: color-mix(in srgb, var(--accent) 8%, transparent); border-color: var(--accent-dim); }
.row.row-removing { opacity: 0.4; pointer-events: none; }
.thumb { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-overlay); flex-shrink: 0; border: 1px solid var(--border-dim); }
:global(.thumb-img) { width: 100%; height: 100%; object-fit: cover; }
.info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; }
.manga-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.chapter-name { font-size: var(--text-xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.progress-row { display: flex; align-items: center; gap: var(--sp-2); }
.progress-wrap { flex: 1; height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
.progress-bar { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; opacity: 0.35; }
.row-active .progress-bar { opacity: 1; }
.progress-bar.progress-error { background: var(--color-error); opacity: 0.7; }
.pages-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
.row-active .pages-label { color: var(--accent-fg); opacity: 0.8; }
.row-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
.state-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.row-active .state-label { color: var(--accent-fg); opacity: 0.8; }
.state-label.state-error { color: var(--color-error); opacity: 0.8; }
.actions { display: flex; align-items: center; gap: 2px; }
.action-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base), background var(--t-base); }
.action-btn:hover:not(:disabled) { color: var(--text-secondary); background: var(--bg-overlay); }
.action-btn:disabled { opacity: 0.25; cursor: default; }
.action-btn.remove:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
.action-btn.retry:hover:not(:disabled) { color: var(--accent-fg); background: var(--accent-muted); }
</style>
@@ -0,0 +1,94 @@
<script lang="ts">
import { CircleNotch } from "phosphor-svelte";
import DownloadItem from "$lib/components/downloads/DownloadItem.svelte";
import type { DownloadQueueItem } from "$lib/types/api";
interface Props {
queue: DownloadQueueItem[];
loading: boolean;
isRunning: boolean;
dequeueing: Set<number>;
selected: Set<number>;
onRemove: (chapterId: number) => void;
onRetry: (chapterId: number) => void;
onReorder: (chapterId: number, dir: "up" | "down") => void;
onReorderEdge: (chapterId: number, edge: "top" | "bottom") => void;
onSelect: (chapterId: number, e: MouseEvent) => void;
}
const {
queue, loading, isRunning, dequeueing, selected,
onRemove, onRetry, onReorder, onReorderEdge, onSelect,
}: Props = $props();
</script>
{#if loading}
<div class="list">
{#each Array(5) as _, i (i)}
<div class="sk-row">
<div class="sk-thumb skeleton"></div>
<div class="sk-info">
<div class="skeleton sk-title"></div>
<div class="skeleton sk-chapter"></div>
<div class="sk-progress-row">
<div class="skeleton sk-bar"></div>
<div class="skeleton sk-pages"></div>
</div>
</div>
<div class="sk-right">
<div class="skeleton sk-state"></div>
<div class="sk-actions"><div class="skeleton sk-btn"></div></div>
</div>
</div>
{/each}
</div>
{:else if queue.length === 0}
<div class="empty">Queue is empty.</div>
{:else}
<div class="list">
{#each queue as item, i (item.chapter.id)}
<DownloadItem
{item}
isActive={i === 0 && isRunning}
isRemoving={dequeueing.has(item.chapter.id)}
isSelected={selected.has(item.chapter.id)}
{onRemove}
{onRetry}
{onReorder}
{onReorderEdge}
{onSelect}
/>
{/each}
</div>
{/if}
<style>
.list { display: flex; flex-direction: column; gap: var(--sp-2); }
.empty { display: flex; align-items: center; justify-content: center; height: 160px; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
@keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
.skeleton {
border-radius: var(--radius-sm);
background: linear-gradient(
90deg,
color-mix(in srgb, var(--bg-overlay) 90%, var(--text-primary) 6%) 20%,
color-mix(in srgb, var(--bg-overlay) 76%, var(--text-primary) 16%) 50%,
color-mix(in srgb, var(--bg-overlay) 90%, var(--text-primary) 6%) 80%
);
background-size: 220% 100%;
animation: shimmer 1.45s ease-in-out infinite;
}
.sk-row { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); pointer-events: none; }
.sk-thumb { width: 36px; height: 54px; flex-shrink: 0; }
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 5px; overflow: hidden; min-width: 0; }
.sk-title { height: 12px; width: clamp(120px, 55%, 280px); }
.sk-chapter { height: 10px; width: clamp(80px, 35%, 200px); }
.sk-progress-row { display: flex; align-items: center; gap: var(--sp-2); }
.sk-bar { flex: 1; height: 2px; }
.sk-pages { width: 28px; height: 9px; }
.sk-right { display: flex; flex-direction: column; align-items: flex-end; gap: var(--sp-1); flex-shrink: 0; }
.sk-state { width: 54px; height: 9px; }
.sk-actions { display: flex; gap: 2px; }
.sk-btn { width: 20px; height: 20px; border-radius: var(--radius-sm); }
</style>
@@ -0,0 +1,228 @@
<script lang="ts">
import { Play, Pause, Trash, CircleNotch, ArrowClockwise, Bell, BellSlash, Repeat, Warning } from "phosphor-svelte";
import { ArrowLineUp, ArrowLineDown, X, CaretUp, CaretDown } from "phosphor-svelte";
import DownloadQueue from "$lib/components/downloads/DownloadQueue.svelte";
import { downloadStore } from "$lib/state/downloads.svelte";
import { formatEta } from "$lib/components/downloads/lib/downloadQueue";
let selectAnchor = $state<number | null>(null);
let moveBy = $state(1);
const selectedErrorCount = $derived(
downloadStore.queue.filter(i => downloadStore.selected.has(i.chapter.id) && i.state === "ERROR").length,
);
function handleSelect(chapterId: number, e: MouseEvent | { shiftKey: boolean; ctrlKey: boolean; metaKey: boolean }) {
const ctrl = e.ctrlKey || e.metaKey;
if (e.shiftKey && selectAnchor !== null) {
downloadStore.selectRange(selectAnchor, chapterId);
} else if (ctrl) {
downloadStore.toggleSelect(chapterId);
selectAnchor = chapterId;
} else {
if (downloadStore.selected.has(chapterId) && downloadStore.selected.size === 1) {
downloadStore.clearSelection();
selectAnchor = null;
} else {
downloadStore.selectOnly(chapterId);
selectAnchor = chapterId;
}
}
}
function handleClickOff() {
if (downloadStore.selected.size > 0) { downloadStore.clearSelection(); selectAnchor = null; }
}
</script>
<div class="root">
<div class="header">
<h1 class="heading">Downloads</h1>
<div class="header-actions">
{#if downloadStore.storageWarning}
<div class="storage-warning" title="Download queue may exceed available disk space">
<Warning size={14} weight="fill" />
</div>
{/if}
<button
class="icon-btn"
class:active={downloadStore.autoRetryEnabled}
onclick={() => downloadStore.toggleAutoRetry()}
title={downloadStore.autoRetryEnabled ? "Disable auto-retry" : "Enable auto-retry"}
>
<Repeat size={14} weight="regular" />
</button>
{#if downloadStore.hasErrored}
<button
class="icon-btn"
onclick={() => downloadStore.retryAllErrored()}
disabled={downloadStore.batchWorking}
title="Retry all errored"
>
{#if downloadStore.batchWorking}
<CircleNotch size={14} weight="light" class="anim-spin" />
{:else}
<ArrowClockwise size={14} weight="bold" />
{/if}
</button>
{/if}
<button
class="icon-btn"
class:active={downloadStore.toastsEnabled}
onclick={() => downloadStore.toggleToasts()}
title={downloadStore.toastsEnabled ? "Mute download notifications" : "Unmute download notifications"}
>
{#if downloadStore.toastsEnabled}
<Bell size={14} weight="regular" />
{:else}
<BellSlash size={14} weight="regular" />
{/if}
</button>
<button
class="icon-btn"
class:loading={downloadStore.togglingPlay}
onclick={() => downloadStore.togglePlay()}
disabled={downloadStore.togglingPlay || (downloadStore.queue.length === 0 && !downloadStore.isRunning)}
title={downloadStore.isRunning ? "Pause" : "Resume"}
>
{#if downloadStore.togglingPlay}<CircleNotch size={14} weight="light" class="anim-spin" />
{:else if downloadStore.isRunning}<Pause size={14} weight="fill" />
{:else}<Play size={14} weight="fill" />{/if}
</button>
<button
class="icon-btn"
class:loading={downloadStore.clearing}
onclick={() => downloadStore.clear()}
disabled={downloadStore.clearing || downloadStore.queue.length === 0}
title="Clear queue"
>
{#if downloadStore.clearing}<CircleNotch size={14} weight="light" class="anim-spin" />
{:else}<Trash size={14} weight="regular" />{/if}
</button>
</div>
</div>
<div class="bar-wrap">
<div class="status-bar" role="none">
<div class="status-dot" class:active={downloadStore.isRunning}></div>
<span class="status-text">
{downloadStore.togglingPlay
? (downloadStore.isRunning ? "Pausing…" : "Starting…")
: downloadStore.isRunning ? "Downloading" : "Paused"}
</span>
{#if downloadStore.selected.size > 0}
<div class="sel-controls">
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelectedToEdge("top"); }} title="Move to top">
<ArrowLineUp size={12} weight="bold" />
</button>
<div class="move-step" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()} role="none">
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelected("up", moveBy); }} title="Move up">
<CaretUp size={12} weight="bold" />
</button>
<input
class="move-input"
type="number"
min="1"
bind:value={moveBy}
onclick={(e) => e.stopPropagation()}
onkeydown={(e) => e.stopPropagation()}
/>
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelected("down", moveBy); }} title="Move down">
<CaretDown size={12} weight="bold" />
</button>
</div>
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.reorderSelectedToEdge("bottom"); }} title="Move to bottom">
<ArrowLineDown size={12} weight="bold" />
</button>
{#if selectedErrorCount > 0}
<button class="sel-action-btn" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.retrySelected(); }} title="Retry errors">
<ArrowClockwise size={12} weight="bold" />
</button>
{/if}
<button class="sel-action-btn sel-action-danger" disabled={downloadStore.batchWorking} onclick={(e) => { e.stopPropagation(); downloadStore.dequeueSelected(); }} title="Remove selected">
<X size={12} weight="bold" />
</button>
</div>
<div class="bar-sep"></div>
<span class="status-count">{downloadStore.selected.size} selected</span>
{:else}
<div class="status-right">
{#if downloadStore.isRunning && downloadStore.eta !== null && downloadStore.eta > 0}
<span class="status-eta">{formatEta(downloadStore.eta)}</span>
<span class="bar-sep"></span>
{/if}
<span class="status-count">{downloadStore.queue.length} {downloadStore.queue.length === 1 ? "chapter" : "chapters"}</span>
</div>
{/if}
</div>
</div>
<div class="content" role="none" onclick={handleClickOff} onkeydown={(e) => e.key === "Escape" && handleClickOff()}>
<DownloadQueue
queue={downloadStore.queue}
loading={downloadStore.loading}
isRunning={downloadStore.isRunning}
dequeueing={downloadStore.dequeueing}
selected={downloadStore.selected}
onRemove={(id) => downloadStore.dequeue(id)}
onRetry={(id) => downloadStore.retryOne(id)}
onReorder={(id, dir) => downloadStore.reorder(id, dir)}
onReorderEdge={(id, edge) => downloadStore.reorderToEdge(id, edge)}
onSelect={handleSelect}
/>
</div>
</div>
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.header-actions { display: flex; align-items: center; gap: var(--sp-2); }
.storage-warning {
display: flex; align-items: center; justify-content: center;
width: 30px; height: 30px;
border-radius: var(--radius-md);
border: 1px solid color-mix(in srgb, var(--color-warning) 40%, transparent);
background: color-mix(in srgb, var(--color-warning) 10%, transparent);
color: var(--color-warning);
flex-shrink: 0;
}
.bar-wrap { padding: var(--sp-4) var(--sp-6); flex-shrink: 0; }
.status-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--radius-md); box-shadow: 0 1px 4px rgba(0,0,0,0.25); cursor: default; }
.status-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--text-faint); flex-shrink: 0; transition: background var(--t-base); }
.status-dot.active { background: var(--accent); animation: pulse 1.6s ease infinite; }
.status-text { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); flex: 1; letter-spacing: var(--tracking-wide); }
.status-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
.status-eta { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
.status-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.sel-controls { display: flex; align-items: center; gap: var(--sp-2); }
.bar-sep { width: 1px; height: 12px; background: var(--border-dim); flex-shrink: 0; }
.sel-action-btn { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-xs); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
.sel-action-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
.sel-action-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.sel-action-danger:hover:not(:disabled) { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, transparent); background: color-mix(in srgb, var(--color-error) 8%, transparent); }
.content { flex: 1; overflow-y: auto; padding: 0 var(--sp-6) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); }
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.icon-btn:hover:not(:disabled):not(.active) { color: var(--text-primary); border-color: var(--border-strong); }
.icon-btn.active:hover:not(:disabled) { border-color: var(--accent); background: var(--accent-muted); }
.icon-btn:disabled { opacity: 0.3; cursor: default; }
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.icon-btn.active { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.move-step { display: flex; align-items: center; border: 1px solid var(--border-dim); border-radius: var(--radius-sm); overflow: hidden; }
.move-step .sel-action-btn { border: none; border-radius: 0; background: none; padding: 3px 6px; }
.move-step .sel-action-btn:hover:not(:disabled) { background: var(--bg-overlay); border-color: transparent; }
.move-input { width: 28px; background: none; border: none; border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); text-align: center; padding: 2px 0; outline: none; -moz-appearance: textfield; }
.move-input::-webkit-outer-spin-button, .move-input::-webkit-inner-spin-button { -webkit-appearance: none; }
.move-input:focus { color: var(--text-primary); }
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
@@ -0,0 +1,105 @@
<script lang="ts">
interface Props {
onConfirm: () => void;
onCancel: () => void;
}
const { onConfirm, onCancel }: Props = $props();
</script>
<div
class="backdrop"
role="dialog"
aria-modal="true"
aria-labelledby="storage-dialog-title"
onclick={(e) => { if (e.target === e.currentTarget) onCancel(); }}
onkeydown={(e) => { if (e.key === "Escape") onCancel(); }}
>
<div class="panel">
<div class="panel-header">
<p id="storage-dialog-title" class="panel-title">Low disk space</p>
</div>
<div class="panel-body">
<p class="panel-message">
The download queue is estimated to exceed 95% of your available storage. Download anyway?
</p>
</div>
<div class="panel-footer">
<button class="btn-cancel" onclick={onCancel}>Cancel</button>
<button class="btn-confirm" onclick={onConfirm}>Download anyway</button>
</div>
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0; z-index: 10000;
background: rgba(0, 0, 0, 0.5);
display: flex; align-items: center; justify-content: center;
animation: s-fade-in 0.15s ease both;
}
.panel {
background: var(--bg-surface);
border: 1px solid var(--border-base);
border-radius: var(--radius-2xl);
box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.04) inset;
width: min(380px, calc(100vw - 40px));
overflow: hidden;
animation: s-scale-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
}
.panel-header {
padding: var(--sp-4) var(--sp-5) var(--sp-3);
border-bottom: 1px solid var(--border-dim);
}
.panel-title {
margin: 0;
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--text-primary);
letter-spacing: 0.01em;
}
.panel-body { padding: var(--sp-4) var(--sp-5); }
.panel-message {
margin: 0;
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-muted);
letter-spacing: var(--tracking-wide);
line-height: var(--leading-snug);
}
.panel-footer {
padding: var(--sp-3) var(--sp-5);
border-top: 1px solid var(--border-dim);
display: flex; justify-content: flex-end; gap: var(--sp-2);
}
.btn-cancel, .btn-confirm {
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 5px var(--sp-3);
border-radius: var(--radius-sm);
cursor: pointer;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
}
.btn-cancel {
border: 1px solid var(--border-dim);
background: none;
color: var(--text-muted);
}
.btn-cancel:hover { color: var(--text-primary); border-color: var(--border-strong); }
.btn-confirm {
border: 1px solid color-mix(in srgb, var(--color-error) 40%, transparent);
background: color-mix(in srgb, var(--color-error) 10%, transparent);
color: var(--color-error);
}
.btn-confirm:hover {
background: color-mix(in srgb, var(--color-error) 18%, transparent);
border-color: color-mix(in srgb, var(--color-error) 60%, transparent);
}
</style>
@@ -0,0 +1,32 @@
import type { DownloadQueueItem } from "$lib/types/api";
const RETRY_DELAY_MS = 20_000;
export interface AutoRetryHandle {
stop: () => void;
}
export function startAutoRetry(
getQueue: () => DownloadQueueItem[],
isRunning: () => boolean,
retryErrored: () => Promise<void>,
): AutoRetryHandle {
let stopped = false;
let timer: ReturnType<typeof setTimeout> | null = null;
async function tick() {
if (stopped) return;
const queue = getQueue();
const errored = queue.filter(i => i.state === "ERROR");
const active = queue.filter(i => i.state !== "ERROR");
if (errored.length > 0 && active.length === 0 && !isRunning()) {
await retryErrored().catch(() => {});
}
if (!stopped) timer = setTimeout(tick, RETRY_DELAY_MS);
}
timer = setTimeout(tick, RETRY_DELAY_MS);
return {
stop() { stopped = true; if (timer !== null) { clearTimeout(timer); timer = null; } },
};
}
@@ -0,0 +1,55 @@
import type { DownloadQueueItem } from "$lib/types/api";
export function isRunning(state: string | undefined): boolean {
return state === "STARTED";
}
export function getErrored(queue: DownloadQueueItem[]): DownloadQueueItem[] {
return queue.filter(i => i.state === "ERROR");
}
export function pageProgress(progress: number, pageCount: number): { done: number; total: number } {
return { done: Math.round(progress * pageCount), total: pageCount };
}
export interface SpeedSample {
ts: number;
progress: number;
pages: number;
}
export function calcSpeed(prev: SpeedSample | null, current: SpeedSample): number | null {
if (!prev) return null;
const dt = (current.ts - prev.ts) / 1000;
if (dt <= 0) return null;
const delta = Math.round(current.progress * current.pages) - Math.round(prev.progress * prev.pages);
if (delta <= 0) return null;
return delta / dt;
}
export function estimateEta(pagesPerSec: number, queue: DownloadQueueItem[]): number | null {
if (pagesPerSec <= 0 || !queue.length) return null;
let remaining = 0;
for (const item of queue) {
const pages = item.chapter.pageCount ?? 0;
remaining += pages - Math.round(item.progress * pages);
}
const eta = remaining / pagesPerSec;
return eta > 0 ? eta : null;
}
export function estimateQueueBytes(queue: DownloadQueueItem[]): number {
const AVG = 1_500_000;
let total = 0;
for (const item of queue) {
const pages = item.chapter.pageCount ?? 0;
total += (pages - Math.round(item.progress * pages)) * AVG;
}
return total;
}
export function formatEta(seconds: number): string {
if (seconds < 60) return `~${Math.ceil(seconds)}s`;
if (seconds < 3600) return `~${Math.ceil(seconds / 60)}m`;
return `~${(seconds / 3600).toFixed(1)}h`;
}
@@ -0,0 +1,134 @@
<script lang="ts">
import { CircleNotch, CaretRight, CaretDown, Books } from "phosphor-svelte";
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
import type { Extension } from "$lib/types";
type SourceEntry = { id: string; displayName: string };
interface Props {
base: string;
primary: Extension;
variants: Extension[];
expanded: boolean;
working: Set<string>;
anims: boolean;
sources: SourceEntry[];
libraryCount: number;
onToggle: (base: string) => void;
onMutate: (pkgName: string, op: "install" | "update" | "uninstall") => void;
onLibrary: (pkgName: string, extensionName: string, iconUrl: string) => void;
}
let { base, primary, variants, expanded, working, anims, sources, libraryCount, onToggle, onMutate, onLibrary }: Props = $props();
const clickable = $derived(primary.isInstalled);
const hasVariants = $derived(variants.length > 0);
</script>
<div class="group">
<svelte:element
this={clickable ? "button" : "div"}
class="row"
class:row-clickable={clickable}
onclick={clickable ? () => onLibrary(primary.pkgName, base, primary.iconUrl) : undefined}
>
<Thumbnail
src={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>
{#if primary.isInstalled}
<span class="lib-badge" class:lib-badge-empty={libraryCount === 0}>
<Books size={10} weight={libraryCount > 0 ? "fill" : "regular"} />
{libraryCount > 0 ? libraryCount : 0}
</span>
{/if}
v{primary.versionName}
</span>
</div>
{#if working.has(primary.pkgName)}
<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" onclick={(e) => { e.stopPropagation(); onMutate(primary.pkgName, "update"); }}>Update</button>
<button class="action-btn-dim" onclick={(e) => { e.stopPropagation(); onMutate(primary.pkgName, "uninstall"); }}>Remove</button>
</div>
{:else if primary.isInstalled}
<div class="row-actions">
<button class="action-btn-dim" onclick={(e) => { e.stopPropagation(); onMutate(primary.pkgName, "uninstall"); }}>Remove</button>
</div>
{:else}
<button class="action-btn" onclick={() => onMutate(primary.pkgName, "install")}>Install</button>
{/if}
{#if hasVariants}
<button class="expand-btn" onclick={(e) => { e.stopPropagation(); onToggle(base); }} title="{variants.length + 1} languages">
{#if expanded}<CaretDown size={12} weight="light" />{:else}<CaretRight size={12} weight="light" />{/if}
<span class="expand-count">{variants.length + 1}</span>
</button>
{/if}
</svelte:element>
{#if expanded && hasVariants}
<div class="variants" class:variants-anim={anims}>
{#each variants as v}
<div class="variant-row">
<span class="lang-tag">{v.lang.toUpperCase()}</span>
<span class="variant-name">{v.name}</span>
<span class="variant-version">v{v.versionName}</span>
{#if v.hasUpdate}<span class="update-badge-small"></span>{/if}
<div class="variant-actions">
{#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" onclick={() => onMutate(v.pkgName, "update")}>Update</button>
{:else if v.isInstalled}
<button class="action-btn-dim" onclick={() => onMutate(v.pkgName, "uninstall")}>Remove</button>
{:else}
<button class="action-btn" onclick={() => onMutate(v.pkgName, "install")}>Install</button>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
<style>
.group { display: flex; flex-direction: column; }
.row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); width: 100%; text-align: left; background: none; }
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.row-clickable { cursor: pointer; }
:global(.icon) { width: 32px; height: 32px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.meta { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.lang-tag { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 1px 5px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); }
.lib-badge { display: inline-flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); flex-shrink: 0; }
.lib-badge-empty { border-color: var(--border-dim); background: var(--bg-overlay); color: var(--text-faint); }
.update-badge-small { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); flex-shrink: 0; }
.row-actions { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
.action-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base); }
.action-btn:hover { filter: brightness(1.1); }
.action-btn-dim { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-md); background: none; color: var(--text-faint); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base); }
.action-btn-dim:hover { color: var(--color-error); border-color: var(--color-error); }
.expand-btn { display: flex; align-items: center; gap: 3px; padding: 4px 6px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
.expand-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.expand-count { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); }
.variants { display: flex; flex-direction: column; gap: 1px; margin: 1px 0 2px calc(32px + var(--sp-3) + var(--sp-3)); padding-left: var(--sp-3); border-left: 1px solid var(--border-dim); }
.variants-anim { animation: slideDown 0.18s cubic-bezier(0.16,1,0.3,1) both; }
@keyframes slideDown { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: translateY(0); } }
.variant-row { display: flex; align-items: center; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); transition: background var(--t-fast); }
.variant-row:hover { background: var(--bg-raised); }
.variant-name { flex: 1; font-size: var(--text-sm); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.variant-version { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.variant-actions { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
</style>
@@ -0,0 +1,115 @@
<script lang="ts">
import { MagnifyingGlass, ArrowsClockwise, Plus, GitBranch, ArrowCircleUp, CheckCircle, Rows, Globe } from "phosphor-svelte";
import { FILTERS, type Filter, type Panel } from "$lib/components/extensions/lib/extensionHelpers";
interface Props {
filter: Filter;
search: string;
panel: Panel;
refreshing: boolean;
updateCount: number;
updatingAll: boolean;
availableLangs: string[];
langFilter: string | null;
anims: boolean;
tabIndicator: { left: number; width: number };
tabsEl: HTMLDivElement | undefined;
onFilter: (f: Filter) => void;
onSearch: (q: string) => void;
onLang: (lang: string | null) => void;
onPanel: (p: Panel) => void;
onRefresh: () => void;
onUpdateAll: () => void;
}
let {
filter, search, panel, refreshing, updateCount, updatingAll,
availableLangs, langFilter,
anims, tabIndicator,
tabsEl = $bindable(),
onFilter, onSearch, onLang, onPanel, onRefresh, onUpdateAll,
}: Props = $props();
</script>
<div class="header">
<h1 class="heading">Extensions</h1>
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl}>
{#if anims && tabIndicator.width > 0}
<div class="tab-slide-indicator" style="left:{tabIndicator.left}px;width:{tabIndicator.width}px" aria-hidden="true"></div>
{/if}
{#each FILTERS as f}
<button class="tab" class:active={filter === f.id} onclick={() => onFilter(f.id)}>
{#if f.id === "installed"}
<CheckCircle size={11} weight="bold" />
{:else if f.id === "available"}
<Globe size={11} weight="bold" />
{:else if f.id === "updates"}
<ArrowCircleUp size={11} weight="bold" />
{:else if f.id === "all"}
<Rows size={11} weight="bold" />
{/if}
{f.id === "updates" && updateCount > 0 ? `Updates (${updateCount})` : f.label}
</button>
{/each}
</div>
<div class="header-right">
<div class="search-wrap">
<MagnifyingGlass size={12} class="search-icon" weight="light" />
<input class="search" placeholder="Search" value={search} oninput={(e) => onSearch((e.target as HTMLInputElement).value)} />
</div>
<button class="icon-btn" class:active={panel === "repos"} onclick={() => onPanel("repos")} title="Manage repos">
<Plus size={14} weight="light" />
</button>
<button class="icon-btn" class:active={panel === "apk"} onclick={() => onPanel("apk")} title="Install from URL">
<GitBranch size={14} weight="light" />
</button>
<button class="icon-btn" onclick={onRefresh} disabled={refreshing} title="Refresh repo">
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
</button>
{#if updateCount > 0}
<button class="icon-btn update-badge" onclick={onUpdateAll} disabled={updatingAll} title="Update all ({updateCount})">
<ArrowCircleUp size={14} weight="fill" class={updatingAll ? "anim-spin" : ""} />
</button>
{/if}
</div>
</div>
{#if availableLangs.length > 1}
<div class="lang-bar">
<button class="lang-pill" class:active={langFilter === null} onclick={() => onLang(null)}>All</button>
{#each availableLangs as lang}
<button class="lang-pill" class:active={langFilter === lang} onclick={() => onLang(langFilter === lang ? null : lang)}>
{lang.toUpperCase()}
</button>
{/each}
</div>
{/if}
<style>
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.header { display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
.icon-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.4; }
.icon-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
.tabs { display: flex; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; }
.tab-slide-indicator { position: absolute; top: 2px; bottom: 2px; border-radius: var(--radius-sm); background: var(--accent-muted); border: 1px solid var(--accent-dim); pointer-events: none; z-index: 0; transition: left 0.22s cubic-bezier(0.16,1,0.3,1), width 0.22s cubic-bezier(0.16,1,0.3,1); }
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base); }
.tab:hover { color: var(--text-muted); }
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
.tabs-anims .tab.active { background: transparent; border-color: transparent; }
.search-wrap { position: relative; display: flex; align-items: center; }
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
.search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); }
.icon-btn.update-badge { color: var(--accent-fg); }
.icon-btn.update-badge:hover:not(:disabled) { background: var(--accent-muted); }
.lang-bar { display: flex; align-items: center; gap: 4px; padding: var(--sp-2) var(--sp-6); flex-shrink: 0; flex-wrap: wrap; border-bottom: 1px solid var(--border-dim); }
.lang-pill { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 3px 9px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast), border-color var(--t-fast); }
.lang-pill:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.lang-pill.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
</style>
@@ -0,0 +1,336 @@
<script lang="ts">
import { ArrowLeft, MagnifyingGlass, GearSix, Swap, Funnel, Check } from "phosphor-svelte";
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
import { resolvedCover } from "$lib/core/cover/coverResolver";
import { getAdapter } from "$lib/request-manager";
import { setPreviewManga } from "$lib/state/series.svelte";
import { libraryByExtension, type LibraryManga, type SourceNode, type SourceLibrary } from "$lib/components/extensions/lib/extensionLibrary";
import SourceMigrateModal from "$lib/components/extensions/panels/SourceMigrateModal.svelte";
type SourceEntry = { id: string; displayName: string };
interface Props {
pkgName: string;
extensionName: string;
iconUrl: string;
cols: number;
cropCovers: boolean;
statsAlways: boolean;
anims: boolean;
sources: SourceEntry[];
onBack: () => void;
onSettings: () => void;
}
let { pkgName, extensionName, iconUrl, cols, cropCovers, statsAlways, anims, sources, onBack, onSettings }: Props = $props();
let groups: SourceLibrary[] = $state([]);
let loading = $state(true);
let search = $state("");
type ContentFilter = "unread" | "downloaded";
let activeFilters = $state<Partial<Record<ContentFilter, boolean>>>({});
let filterOpen = $state(false);
const hasActiveFilters = $derived(Object.values(activeFilters).some(Boolean));
let migrateTarget: { sourceId: string; sourceName: string; iconUrl: string; manga: LibraryManga[] } | null = $state(null);
const allManga = $derived(groups.flatMap(g => g.manga));
const filtered = $derived((() => {
let items = allManga;
const q = search.trim().toLowerCase();
if (q) items = items.filter(m => m.title.toLowerCase().includes(q));
if (activeFilters.unread) items = items.filter(m => m.unreadCount > 0);
if (activeFilters.downloaded) items = items.filter(m => m.downloadCount > 0);
return items;
})());
let sourceNodes: SourceNode[] = $state([]);
$effect(() => { load(); });
async function load() {
loading = true;
try {
const [libData, srcData] = await Promise.all([
getAdapter().getMangaList({}).then(r => ({ mangas: { nodes: r.items as any } })),
getAdapter().getSources().then(nodes => ({ sources: { nodes } })),
]);
sourceNodes = srcData.sources.nodes;
groups = libraryByExtension(libData.mangas.nodes, srcData.sources.nodes, pkgName);
} finally {
loading = false;
}
}
function toggleFilter(f: ContentFilter) {
activeFilters = { ...activeFilters, [f]: !activeFilters[f] };
}
function clearFilters() {
activeFilters = {};
}
function openMigrate(group: SourceLibrary) {
const node = sourceNodes.find(s => s.id === group.sourceId);
migrateTarget = {
sourceId: group.sourceId,
sourceName: group.displayName,
iconUrl: (node as any)?.iconUrl ?? iconUrl,
manga: group.manga,
};
}
$effect(() => {
if (!filterOpen) return;
function onOutside(e: MouseEvent) {
if (!(e.target as HTMLElement).closest(".filter-wrap")) filterOpen = false;
}
setTimeout(() => document.addEventListener("mousedown", onOutside, true), 0);
return () => document.removeEventListener("mousedown", onOutside, true);
});
const CONTENT_FILTERS: [ContentFilter, string][] = [
["unread", "Unread"],
["downloaded", "Downloaded"],
];
</script>
<div class="root">
<div class="header">
<button class="header-btn" onclick={onBack}>
<ArrowLeft size={14} weight="bold" />
</button>
{#if iconUrl}
<Thumbnail src={iconUrl} alt={extensionName} class="header-icon" onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
{/if}
<div class="title-block">
<span class="eyebrow">In Library</span>
<span class="title">{extensionName}</span>
</div>
{#if !loading}
<span class="count-badge">{filtered.length}{filtered.length !== allManga.length ? ` / ${allManga.length}` : ""}</span>
{/if}
<div class="header-right">
<div class="search-wrap">
<MagnifyingGlass size={12} class="search-icon" weight="light" />
<input class="search" placeholder="Search" bind:value={search} autocomplete="off" />
</div>
<div class="filter-wrap">
<button
class="filter-btn"
class:filter-btn-active={hasActiveFilters}
title="Filter"
onclick={() => filterOpen = !filterOpen}
>
<Funnel size={13} weight={hasActiveFilters ? "fill" : "bold"} />
</button>
{#if filterOpen}
<div class="filter-panel" role="menu">
<div class="filter-panel-header">
<span class="panel-heading">Filter</span>
{#if hasActiveFilters}
<button class="panel-clear-btn" onclick={clearFilters}>Clear all</button>
{/if}
</div>
<div class="panel-divider"></div>
<p class="panel-label">Content</p>
{#each CONTENT_FILTERS as [f, label]}
<button
class="panel-item"
class:panel-item-active={activeFilters[f]}
role="menuitem"
onclick={() => toggleFilter(f)}
>
<span class="panel-check" class:panel-check-on={activeFilters[f]}>
{#if activeFilters[f]}<Check size={9} weight="bold" />{/if}
</span>
{label}
</button>
{/each}
</div>
{/if}
</div>
{#if sources.length > 0}
<button class="settings-btn" onclick={onSettings} title="Extension settings">
<GearSix size={14} weight="bold" />
</button>
{/if}
</div>
</div>
<div class="content">
{#if loading}
<div class="grid" style="--cols:{cols}">
{#each Array(12) as _}
<div class="card-skeleton">
<div class="cover-skeleton skeleton"></div>
<div class="title-skeleton skeleton"></div>
</div>
{/each}
</div>
{:else if filtered.length === 0}
<div class="empty">
{allManga.length === 0 ? "Nothing from this extension is in your library." : "No matches."}
</div>
{:else}
{#if groups.length > 1}
<div class="source-groups">
{#each groups as group}
<div class="source-group-header">
<span class="source-group-name">{group.displayName}</span>
<span class="source-group-count">{group.manga.length}</span>
<button class="migrate-btn" onclick={() => openMigrate(group)} title="Migrate this source">
<Swap size={12} weight="bold" />
Migrate source
</button>
</div>
{/each}
</div>
{:else if groups.length === 1}
<div class="single-source-bar">
<span class="source-group-name">{groups[0].displayName}</span>
<button class="migrate-btn" onclick={() => openMigrate(groups[0])} title="Migrate this source">
<Swap size={12} weight="bold" />
Migrate source
</button>
</div>
{/if}
<div class="grid" style="--cols:{cols}">
{#each filtered as m (m.id)}
{@const isCompleted = !m.unreadCount && m.downloadCount > 0}
<button class="card" class:anims onclick={() => setPreviewManga(m as any)}>
<div class="cover-wrap" class:completed={isCompleted}>
<Thumbnail
src={resolvedCover(m.id, m.thumbnailUrl)}
alt={m.title}
class="cover"
style="object-fit:{cropCovers ? 'cover' : 'contain'}"
draggable="false"
/>
<div class="card-info-overlay" class:anim={anims} class:instant={!anims} class:always={statsAlways}>
<div class="overlay-badges">
{#if isCompleted}
<span class="badge badge-done">✓ Done</span>
{:else if m.unreadCount}
<span class="badge badge-unread">{m.unreadCount} new</span>
{/if}
{#if m.downloadCount}
<span class="badge badge-dl">{m.downloadCount}</span>
{/if}
</div>
</div>
</div>
<p class="card-title">{m.title}</p>
</button>
{/each}
</div>
{/if}
</div>
</div>
{#if migrateTarget}
<SourceMigrateModal
sourceId={migrateTarget.sourceId}
sourceName={migrateTarget.sourceName}
sourceIconUrl={migrateTarget.iconUrl}
manga={migrateTarget.manga}
onClose={() => migrateTarget = null}
onDone={() => { migrateTarget = null; load(); }}
/>
{/if}
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
:global(.header-icon) { width: 24px; height: 24px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
.header { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
.header-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-md); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
.header-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
.title-block { display: flex; flex-direction: column; gap: 1px; }
.eyebrow { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); }
.count-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 8px; border-radius: var(--radius-sm); background: var(--bg-overlay); border: 1px solid var(--border-dim); color: var(--text-muted); flex-shrink: 0; }
.search-wrap { position: relative; display: flex; align-items: center; }
.search-wrap :global(.search-icon) { position: absolute; left: 9px; color: var(--text-faint); pointer-events: none; }
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 26px; color: var(--text-primary); font-size: var(--text-sm); width: 160px; outline: none; transition: border-color var(--t-base); }
.search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); }
.filter-wrap { position: relative; }
.filter-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.filter-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
.filter-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.settings-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.settings-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
.filter-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999; min-width: 200px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5); animation: fadeIn 0.1s ease both; }
.filter-panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px 4px; }
.panel-heading { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); font-weight: var(--weight-medium, 500); }
.panel-clear-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0; transition: color var(--t-base); }
.panel-clear-btn:hover { color: var(--color-error); }
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
.panel-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: 4px 8px 8px; }
.panel-item { display: flex; align-items: center; gap: var(--sp-2); width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); }
.panel-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.panel-item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
.panel-item-active:hover { background: var(--accent-dim); }
.panel-check { width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong); background: transparent; flex-shrink: 0; display: flex; align-items: center; justify-content: center; color: var(--bg-base); transition: background var(--t-base), border-color var(--t-base); }
.panel-check-on { background: var(--accent); border-color: var(--accent); }
.content { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6) var(--sp-6); will-change: scroll-position; display: flex; flex-direction: column; gap: var(--sp-3); }
.source-groups { display: flex; flex-direction: column; gap: var(--sp-1); }
.source-group-header { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) 0; border-bottom: 1px solid var(--border-dim); }
.single-source-bar { display: flex; align-items: center; gap: var(--sp-2); padding-bottom: var(--sp-2); border-bottom: 1px solid var(--border-dim); }
.source-group-name { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); font-weight: var(--weight-medium); }
.source-group-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 1px 6px; border-radius: var(--radius-sm); background: var(--bg-overlay); border: 1px solid var(--border-dim); }
.migrate-btn { display: flex; align-items: center; gap: 5px; margin-left: auto; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 9px; border-radius: var(--radius-sm); background: none; color: var(--text-faint); border: 1px solid var(--border-dim); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.migrate-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.grid { display: grid; grid-template-columns: repeat(var(--cols, auto-fill), minmax(130px, 1fr)); gap: var(--sp-4); }
.card { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.card.anims:hover .cover-wrap { transform: translateY(-3px); border-color: var(--border-strong); box-shadow: 0 6px 20px rgba(0,0,0,0.35); }
.card:hover .card-title { color: var(--text-primary); }
.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); will-change: transform; }
.card.anims .cover-wrap { transition: transform 0.18s cubic-bezier(0.16,1,0.3,1), border-color var(--t-base), box-shadow 0.18s cubic-bezier(0.16,1,0.3,1); }
.cover-wrap.completed { box-shadow: inset 0 -2px 0 0 var(--accent); }
.card-info-overlay { position: absolute; bottom: -4px; left: 0; right: 0; z-index: 2; padding: 32px 6px 10px; background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.5) 50%, transparent 100%); opacity: 0; pointer-events: none; }
.card-info-overlay.anim { transition: opacity 0.18s ease; }
.card-info-overlay.instant { transition: none; }
.card-info-overlay.always { opacity: 1; }
.card:hover .card-info-overlay { opacity: 1; }
.overlay-badges { display: flex; align-items: flex-end; justify-content: space-between; gap: 4px; flex-wrap: wrap; }
.badge { font-family: var(--font-ui); font-size: 9.5px; font-weight: 700; letter-spacing: 0.04em; line-height: 1; padding: 3px 7px; border-radius: 20px; white-space: nowrap; }
.badge-unread { background: var(--accent); color: #fff; box-shadow: 0 1px 8px rgba(0,0,0,0.5); }
.badge-done { background: rgba(255,255,255,0.18); color: rgba(255,255,255,0.9); border: 1px solid rgba(255,255,255,0.25); }
.badge-dl { background: rgba(0,0,0,0.55); color: rgba(255,255,255,0.8); border: 1px solid rgba(255,255,255,0.18); margin-left: auto; }
.card-title { margin-top: var(--sp-2); font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; height: 2lh; }
.card.anims .card-title { transition: color var(--t-base); }
.card-skeleton { padding: 0; }
.cover-skeleton { aspect-ratio: 2/3; border-radius: var(--radius-md); }
.title-skeleton { height: 12px; margin-top: var(--sp-2); width: 80%; border-radius: var(--radius-sm); }
.empty { display: flex; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
@@ -0,0 +1,415 @@
<script lang="ts">
import { untrack } from "svelte";
import { CircleNotch, X, Check, HardDrives } from "phosphor-svelte";
import { getAdapter } from "$lib/request-manager";
import { addToast } from "$lib/state/notifications.svelte";
import { settingsState } from "$lib/state/settings.svelte";
import type { Extension } from "$lib/types";
import { matchesFilter, groupExtensions, validateUrl, type Filter, type Panel } from "$lib/components/extensions/lib/extensionHelpers";
import { libraryCountByPkg, type LibraryManga, type SourceNode } from "$lib/components/extensions/lib/extensionLibrary";
import ExtensionFilters from "$lib/components/extensions/ExtensionFilters.svelte";
import ExtensionCard from "$lib/components/extensions/ExtensionCard.svelte";
import ExtensionSettingsPanel from "$lib/components/extensions/panels/ExtensionSettingsPanel.svelte";
import ExtensionLibrary from "$lib/components/extensions/ExtensionLibrary.svelte";
const anims = $derived(settingsState.settings.qolAnimations ?? true);
const cols = $derived(settingsState.settings.libraryCols ?? 5);
const cropCovers = $derived(settingsState.settings.cropCovers ?? true);
const statsAlways = $derived(settingsState.settings.statsAlways ?? false);
let tabsEl = $state<HTMLDivElement | undefined>(undefined);
let tabIndicator = $state({ left: 0, width: 0 });
function updateIndicator() {
if (!tabsEl) return;
const active = tabsEl.querySelector<HTMLElement>(".tab.active");
if (!active) return;
tabIndicator = { left: active.offsetLeft, width: active.offsetWidth };
}
let extensions: Extension[] = $state([]);
let localMangaCount = $state(0);
let loading = $state(true);
let refreshing = $state(false);
let filter = $state<Filter>("installed");
let search = $state("");
let langFilter = $state<string | null>(null);
let working = $state(new Set<string>());
let updatingAll = $state(false);
let expanded = $state(new Set<string>());
let panel = $state<Panel>(null);
type SourceEntry = { id: string; displayName: string };
type SettingsTarget = { extensionName: string; iconUrl: string; sources: SourceEntry[] };
type LibraryTarget = { pkgName: string; extensionName: string; iconUrl: string };
let settingsTarget = $state<SettingsTarget | null>(null);
let libraryTarget = $state<LibraryTarget | null>(null);
let sourcesByPkg = $state<Record<string, SourceEntry[]>>({});
let libCountByPkg = $state<Record<string, number>>({});
$effect(() => { filter; extensions; if (anims) requestAnimationFrame(() => requestAnimationFrame(updateIndicator)); });
let externalUrl = $state("");
let installing = $state(false);
let installError = $state<string | null>(null);
let installSuccess = $state(false);
let repos = $state<string[]>([]);
let reposLoading = $state(false);
let newRepoUrl = $state("");
let repoError = $state<string | null>(null);
let savingRepos = $state(false);
async function load() {
const [extData, srcData, libData] = await Promise.all([
getAdapter().getExtensions().then(nodes => ({ extensions: { nodes } })).catch(console.error),
getAdapter().getSources().then(nodes => ({ sources: { nodes } })).catch(console.error),
getAdapter().getMangaList({}).then(r => ({ mangas: { nodes: r.items as any } })).catch(console.error),
]);
if (extData) extensions = extData.extensions.nodes;
if (srcData) {
const map: Record<string, SourceEntry[]> = {};
for (const s of srcData.sources.nodes) {
if (!s.isConfigurable || !s.extension?.pkgName) continue;
const pkg = s.extension.pkgName;
if (!map[pkg]) map[pkg] = [];
map[pkg].push({ id: s.id, displayName: s.displayName });
}
sourcesByPkg = map;
}
if (libData && srcData) {
libCountByPkg = libraryCountByPkg(libData.mangas.nodes, srcData.sources.nodes);
}
}
async function loadLocalManga() {
const d = await Promise.resolve(null);
}
async function fetchFromRepo() {
refreshing = true;
const d = await getAdapter().getExtensions().then(nodes => ({ fetchExtensions: { extensions: nodes } }))
.catch(console.error)
.finally(() => refreshing = false);
if (d) {
extensions = d.fetchExtensions.extensions;
addToast({ kind: "success", title: "Extensions refreshed", body: "Extension list is up to date" });
}
}
async function loadRepos() {
reposLoading = true;
try {
const d = await (getAdapter() as any).gql<{ settings: { extensionRepos: string[] } }>(`query GetSettings { settings { extensionRepos } }`);
repos = d.settings.extensionRepos ?? [];
} catch (e) { console.error(e); }
finally { reposLoading = false; }
}
async function saveRepos(updated: string[], intent: "add" | "remove") {
savingRepos = true;
try {
const d = await (getAdapter() as any).gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(`mutation SetExtensionRepos($repos: [String!]!) { setSettings(input: { settings: { extensionRepos: $repos } }) { settings { extensionRepos } } }`, { repos: updated });
repos = d.setSettings.settings.extensionRepos;
addToast(intent === "add"
? { kind: "success", title: "Repo added", body: updated[updated.length - 1] }
: { kind: "info", title: "Repo removed", body: repos.find(r => !updated.includes(r)) ?? "" }
);
} catch (e: any) {
repoError = e instanceof Error ? e.message : "Failed to save";
} finally { savingRepos = false; }
}
function addRepo() {
const url = newRepoUrl.trim();
const err = validateUrl(url);
if (err) { repoError = err; return; }
if (repos.includes(url)) { repoError = "Repo already added"; return; }
repoError = null; newRepoUrl = "";
saveRepos([...repos, url], "add");
}
function removeRepo(url: string) { saveRepos(repos.filter((r) => r !== url), "remove"); }
async function mutate(pkgName: string, op: "install" | "update" | "uninstall") {
working = new Set(working).add(pkgName);
const label = extensions.find((e) => e.pkgName === pkgName)?.name ?? pkgName;
const gqlArgs = {
install: { id: pkgName, install: true },
update: { id: pkgName, update: true },
uninstall: { id: pkgName, uninstall: true },
}[op];
try {
await getAdapter()[{ install: 'installExtension', update: 'updateExtension', uninstall: 'uninstallExtension' }[op] as 'installExtension'](pkgName);
await load();
addToast({
install: { kind: "download" as const, title: "Extension installed", body: label },
update: { kind: "success" as const, title: "Extension updated", body: label },
uninstall: { kind: "info" as const, title: "Extension removed", body: label },
}[op]);
} catch (e: any) {
await load();
addToast({ kind: "error", title: "Extension error", body: e instanceof Error ? e.message : String(e) });
} finally {
working.delete(pkgName); working = new Set(working);
}
}
async function updateAll() {
const pending = extensions.filter((e) => e.hasUpdate);
if (!pending.length || updatingAll) return;
updatingAll = true;
for (const ext of pending) await mutate(ext.pkgName, "update");
updatingAll = false;
addToast({ kind: "success", title: "All extensions updated", body: `${pending.length} extension${pending.length === 1 ? "" : "s"} updated` });
}
async function installExternal() {
const url = externalUrl.trim();
const err = validateUrl(url, ".apk");
if (err) { installError = err; return; }
installing = true; installError = null; installSuccess = false;
try {
await getAdapter().installExternalExtension(url);
installSuccess = true; externalUrl = "";
await load();
addToast({ kind: "download", title: "Extension installed", body: url.split("/").pop() ?? url });
setTimeout(() => { panel = null; installSuccess = false; }, 1500);
} catch (e: any) {
installError = e instanceof Error ? e.message : "Install failed";
addToast({ kind: "error", title: "Install failed", body: installError });
} finally { installing = false; }
}
function openPanel(p: Panel) {
panel = panel === p ? null : p;
installError = null; installSuccess = false; externalUrl = "";
repoError = null; newRepoUrl = "";
if (p === "repos") loadRepos();
}
function toggleExpand(base: string) {
const next = new Set(expanded);
next.has(base) ? next.delete(base) : next.add(base);
expanded = next;
}
function setFilter(f: Filter) {
if (f === filter) return;
filter = f;
langFilter = null;
}
const showLocal = $derived(
(filter === "installed" || filter === "all") &&
(search === "" || "local source".includes(search.toLowerCase()))
);
const allGroups = $derived(groupExtensions(extensions, settingsState.settings.preferredExtensionLang));
const groups = $derived(allGroups.filter(({ primary, variants }) => {
const all = [primary, ...variants];
const q = search.toLowerCase();
const matchesSearch = all.some((e) => e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q));
const matchesTab = all.some((e) => matchesFilter(e, filter));
const matchesLang = langFilter === null || all.some((e) => e.lang === langFilter);
return matchesSearch && matchesTab && matchesLang;
}));
const availableLangs = $derived(
[...new Set(extensions.filter((e) => matchesFilter(e, filter)).map((e) => e.lang))].sort()
);
const updateCount = $derived(extensions.filter((e) => e.hasUpdate).length);
$effect(() => {
untrack(async () => {
loadLocalManga();
await load();
loading = false;
fetchFromRepo();
});
});
$effect(() => {
if (!panel) return;
function onMouseDown(e: MouseEvent) {
if (!(e.target as HTMLElement).closest(".ext-panel, .icon-btn")) panel = null;
}
document.addEventListener("mousedown", onMouseDown, true);
return () => document.removeEventListener("mousedown", onMouseDown, true);
});
function focusOnMount(node: HTMLElement) { node.focus(); }
</script>
{#if libraryTarget}
<ExtensionLibrary
pkgName={libraryTarget.pkgName}
extensionName={libraryTarget.extensionName}
iconUrl={libraryTarget.iconUrl}
{cols} {cropCovers} {statsAlways} {anims}
sources={sourcesByPkg[libraryTarget.pkgName] ?? []}
onBack={() => libraryTarget = null}
onSettings={() => { settingsTarget = { extensionName: libraryTarget!.extensionName, iconUrl: libraryTarget!.iconUrl, sources: sourcesByPkg[libraryTarget!.pkgName] ?? [] }; }}
/>
{:else}
<div class="root anim-fade-in">
<ExtensionFilters
{filter} {search} {panel} {refreshing} {updateCount} {availableLangs} {langFilter}
{anims} {tabIndicator} {updatingAll}
bind:tabsEl
onFilter={setFilter}
onSearch={(q) => search = q}
onLang={(l) => langFilter = l}
onPanel={openPanel}
onRefresh={fetchFromRepo}
onUpdateAll={updateAll}
/>
{#if panel === "apk"}
<div class="ext-panel" class:ext-panel-anim={anims}>
<div class="panel-header">
<span class="panel-title-wrap"><span class="panel-title">Install from APK URL</span></span>
</div>
<div class="ext-row">
<input
class="ext-input" class:error={installError}
placeholder="https://example.com/extension.apk"
bind:value={externalUrl} disabled={installing}
oninput={() => installError = null}
onkeydown={(e) => e.key === "Enter" && !installing && installExternal()}
use:focusOnMount
/>
<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}
</button>
</div>
{#if installError}<div class="panel-error">{installError}</div>{/if}
</div>
{/if}
{#if panel === "repos"}
<div class="ext-panel" class:ext-panel-anim={anims}>
<div class="panel-header">
<span class="panel-title-wrap"><span class="panel-title">Extension Repositories</span></span>
</div>
{#if reposLoading}
<div class="repo-loading"><CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
{:else}
{#if repos.length === 0}
<div class="repo-empty">No repos configured.</div>
{:else}
<div class="repo-list">
{#each repos as url}
<div class="repo-row">
<span class="repo-url">{url}</span>
<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>
{/each}
</div>
{/if}
<div class="ext-row">
<input
class="ext-input" class:error={repoError}
placeholder="https://example.com/index.min.json"
bind:value={newRepoUrl} disabled={savingRepos}
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>
{#if repoError}<div class="panel-error">{repoError}</div>{/if}
{/if}
</div>
{/if}
{#if loading}
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
{:else}
<div class="list">
{#if showLocal}
<div class="local-row">
<div class="local-icon"><HardDrives size={18} weight="bold" /></div>
<div class="info">
<span class="name">Local Source</span>
<span class="meta">Built-in · {localMangaCount} {localMangaCount === 1 ? "manga" : "manga"}</span>
</div>
<span class="local-badge">Built-in</span>
</div>
{/if}
{#each groups as { base, primary, variants }}
<ExtensionCard
{base} {primary} {variants} {working} {anims}
sources={sourcesByPkg[primary.pkgName] ?? []}
libraryCount={libCountByPkg[primary.pkgName] ?? 0}
expanded={expanded.has(base)}
onToggle={toggleExpand}
onMutate={mutate}
onLibrary={(pkgName, extensionName, iconUrl) => libraryTarget = { pkgName, extensionName, iconUrl }}
/>
{/each}
{#if !showLocal && groups.length === 0}
<div class="empty" style="flex:1">No extensions found.</div>
{/if}
</div>
{/if}
</div>
{/if}
{#if settingsTarget}
<ExtensionSettingsPanel
extensionName={settingsTarget.extensionName}
iconUrl={settingsTarget.iconUrl}
sources={settingsTarget.sources}
onClose={() => settingsTarget = null}
/>
{/if}
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.list { flex: 1; overflow-y: auto; padding: 0 var(--sp-4) var(--sp-4); display: flex; flex-direction: column; gap: 1px; }
.empty { display: flex; align-items: center; justify-content: center; flex: 1; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); }
:global(.icon-btn) { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
:global(.icon-btn:hover:not(:disabled)) { color: var(--text-primary); border-color: var(--border-strong); }
:global(.icon-btn-active) { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.ext-panel { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3) var(--sp-6); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); background: var(--bg-raised); opacity: 1; }
.ext-panel-anim { animation: panelSlide 0.18s cubic-bezier(0.16,1,0.3,1) both; }
.panel-header { display: flex; align-items: center; padding-bottom: var(--sp-1); }
.panel-title-wrap { display: inline-flex; align-items: center; background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 2px 8px; }
.panel-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.panel-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); padding: 0 2px; }
.ext-row { display: flex; gap: var(--sp-2); }
.ext-input { flex: 1; background: var(--bg-base); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 6px var(--sp-3); color: var(--text-primary); font-size: var(--text-sm); outline: none; transition: border-color var(--t-base); }
.ext-input:focus { border-color: var(--border-focus); }
.ext-input:disabled { opacity: 0.5; }
.ext-input.error { border-color: var(--color-error) !important; }
.install-btn { display: flex; align-items: center; gap: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 14px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; transition: filter var(--t-base), opacity var(--t-base); white-space: nowrap; }
.install-btn:hover:not(:disabled) { filter: brightness(1.15); }
.install-btn:disabled { opacity: 0.5; cursor: default; }
.install-btn.success { background: rgba(107,143,107,0.2); border-color: var(--accent-fg); color: var(--accent-fg); }
.repo-loading { display: flex; align-items: center; justify-content: center; padding: var(--sp-2); }
.repo-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: var(--sp-1) 2px; }
.repo-list { display: flex; flex-direction: column; gap: 2px; margin-bottom: var(--sp-2); }
.repo-row { display: flex; align-items: center; gap: var(--sp-2); padding: 6px var(--sp-3); border-radius: var(--radius-md); background: var(--bg-base); border: 1px solid var(--border-dim); }
.repo-url { flex: 1; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; letter-spacing: var(--tracking-wide); }
.repo-remove { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
.repo-remove:hover:not(:disabled) { color: var(--color-error); background: var(--bg-overlay); }
@keyframes panelSlide { from { opacity: 0; transform: translateY(-6px); } to { opacity: 1; transform: translateY(0); } }
.local-row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast), border-color var(--t-fast); margin-bottom: 1px; }
.local-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.local-icon { width: 32px; height: 32px; border-radius: var(--radius-md); background: var(--accent-muted); border: 1px solid var(--accent-dim); display: flex; align-items: center; justify-content: center; color: var(--accent-fg); flex-shrink: 0; }
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
.name { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.local-badge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); flex-shrink: 0; }
</style>
@@ -0,0 +1,55 @@
import type { Extension } from "$lib/types";
export type Filter = "installed" | "available" | "updates" | "all";
export type Panel = null | "apk" | "repos";
export function baseName(name: string): string {
return name.replace(/\s*\([A-Z0-9-]{2,10}\)\s*$/, "").trim();
}
export function matchesFilter(ext: Extension, filter: Filter): boolean {
if (filter === "installed") return ext.isInstalled;
if (filter === "available") return !ext.isInstalled;
if (filter === "updates") return ext.hasUpdate;
return true;
}
export interface ExtensionGroup {
base: string;
primary: Extension;
variants: Extension[];
}
export function groupExtensions(
extensions: Extension[],
preferredLang: string | undefined,
): ExtensionGroup[] {
const map = new Map<string, Extension[]>();
for (const ext of extensions) {
const key = baseName(ext.name);
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(ext);
}
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) };
});
}
export function validateUrl(url: string, ext?: string): string | null {
if (!url.startsWith("http://") && !url.startsWith("https://"))
return "URL must start with http:// or https://";
if (ext && !url.endsWith(ext))
return `URL must point to a ${ext} file`;
return null;
}
export const FILTERS: { id: Filter; label: string }[] = [
{ id: "installed", label: "Installed" },
{ id: "available", label: "Available" },
{ id: "updates", label: "Updates" },
{ id: "all", label: "All" },
];
@@ -0,0 +1,56 @@
export interface LibraryManga {
id: number;
title: string;
thumbnailUrl: string;
unreadCount: number;
downloadCount: number;
source: { id: string; displayName: string };
}
export interface SourceLibrary {
sourceId: string;
displayName: string;
manga: LibraryManga[];
}
export type SourceNode = {
id: string;
displayName: string;
isConfigurable: boolean;
extension: { pkgName: string };
};
export function libraryByExtension(
libraryManga: LibraryManga[],
sources: SourceNode[],
pkgName: string,
): SourceLibrary[] {
const pkgSources = sources.filter(s => s.extension?.pkgName === pkgName);
const sourceIds = new Set(pkgSources.map(s => s.id));
const bySource = new Map<string, LibraryManga[]>();
for (const src of pkgSources) bySource.set(src.id, []);
for (const m of libraryManga) {
if (sourceIds.has(m.source.id)) bySource.get(m.source.id)!.push(m);
}
return pkgSources
.map(src => ({ sourceId: src.id, displayName: src.displayName, manga: bySource.get(src.id)! }))
.filter(g => g.manga.length > 0);
}
export function libraryCountByPkg(
libraryManga: LibraryManga[],
sources: SourceNode[],
): Record<string, number> {
const sourceIdToPkg = new Map<string, string>();
for (const s of sources) {
if (s.extension?.pkgName) sourceIdToPkg.set(s.id, s.extension.pkgName);
}
const counts: Record<string, number> = {};
for (const m of libraryManga) {
const pkg = sourceIdToPkg.get(m.source.id);
if (pkg) counts[pkg] = (counts[pkg] ?? 0) + 1;
}
return counts;
}
@@ -0,0 +1,588 @@
<script lang="ts">
import { X, CircleNotch, CaretUpDown, Check, CaretLeft, CaretRight } from "phosphor-svelte";
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
import { getAdapter } from "$lib/request-manager";
import { addToast } from "$lib/state/notifications.svelte";
interface Preference {
type: string;
key: string;
CheckBoxTitle?: string;
CheckBoxSummary?: string;
CheckBoxDefault?: boolean;
CheckBoxCurrentValue?: boolean;
SwitchPreferenceTitle?: string;
SwitchPreferenceSummary?: string;
SwitchPreferenceDefault?: boolean;
SwitchPreferenceCurrentValue?: boolean;
ListPreferenceTitle?: string;
ListPreferenceSummary?: string;
ListPreferenceDefault?: string;
ListPreferenceCurrentValue?: string;
entries?: string[];
entryValues?: string[];
EditTextPreferenceTitle?: string;
EditTextPreferenceSummary?: string;
EditTextPreferenceDefault?: string;
EditTextPreferenceCurrentValue?: string;
dialogTitle?: string;
dialogMessage?: string;
MultiSelectListPreferenceTitle?: string;
MultiSelectListPreferenceSummary?: string;
MultiSelectListPreferenceDefault?: string[];
MultiSelectListPreferenceCurrentValue?: string[];
}
export type SourceEntry = { id: string; displayName: string };
interface Props {
extensionName: string;
iconUrl: string;
sources: SourceEntry[];
onClose: () => void;
}
let { extensionName, iconUrl, sources, onClose }: Props = $props();
let activeIndex = $state(sources.length === 1 ? 0 : -1);
let prefs = $state<Preference[]>([]);
let loading = $state(false);
let saving = $state<string | null>(null);
let editKey = $state<string | null>(null);
let editValue = $state("");
let listOpen = $state<string | null>(null);
let closing = $state(false);
const activeSource = $derived(activeIndex >= 0 ? sources[activeIndex] : null);
const inPicker = $derived(sources.length > 1 && activeIndex < 0);
$effect(() => {
if (activeSource) loadPrefs(activeSource);
});
async function loadPrefs(src: SourceEntry) {
loading = true;
prefs = [];
editKey = null;
listOpen = null;
try {
const d = await (getAdapter() as any).gql<{ source: { preferences: Preference[] } }>(
`query GetSourceSettings($id: LongString!) { source(id: $id) { preferences {
... on CheckBoxPreference { type: __typename CheckBoxTitle: title CheckBoxSummary: summary CheckBoxDefault: default CheckBoxCurrentValue: currentValue key }
... on SwitchPreference { type: __typename SwitchPreferenceTitle: title SwitchPreferenceSummary: summary SwitchPreferenceDefault: default SwitchPreferenceCurrentValue: currentValue key }
... on ListPreference { type: __typename ListPreferenceTitle: title ListPreferenceSummary: summary ListPreferenceDefault: default ListPreferenceCurrentValue: currentValue entries entryValues key }
... on EditTextPreference { type: __typename EditTextPreferenceTitle: title EditTextPreferenceSummary: summary EditTextPreferenceDefault: default EditTextPreferenceCurrentValue: currentValue dialogTitle dialogMessage key }
... on MultiSelectListPreference { type: __typename MultiSelectListPreferenceTitle: title MultiSelectListPreferenceSummary: summary MultiSelectListPreferenceDefault: default MultiSelectListPreferenceCurrentValue: currentValue entries entryValues key }
} } }`,
{ id: String(src.id) },
);
prefs = d.source.preferences ?? [];
} catch (e: any) {
addToast({ kind: "error", title: "Failed to load settings", body: e?.message ?? "" });
} finally {
loading = false;
}
}
async function save(position: number, changeType: string, value: unknown) {
if (!activeSource) return;
const pref = prefs[position];
saving = pref.key;
try {
await (getAdapter() as any).gql(
`mutation UpdateSourcePreference($source: LongString!, $change: SourcePreferenceChangeInput!) { updateSourcePreference(input: { source: $source, change: $change }) { source { id } } }`,
{ source: String(activeSource.id), change: { position, [changeType]: value } },
);
const d = await (getAdapter() as any).gql<{ source: { preferences: Preference[] } }>(
`query GetSourceSettings($id: LongString!) { source(id: $id) { preferences {
... on CheckBoxPreference { type: __typename CheckBoxTitle: title CheckBoxCurrentValue: currentValue key }
... on SwitchPreference { type: __typename SwitchPreferenceTitle: title SwitchPreferenceCurrentValue: currentValue key }
... on ListPreference { type: __typename ListPreferenceTitle: title ListPreferenceCurrentValue: currentValue entries entryValues key }
... on EditTextPreference { type: __typename EditTextPreferenceTitle: title EditTextPreferenceCurrentValue: currentValue key }
... on MultiSelectListPreference { type: __typename MultiSelectListPreferenceTitle: title MultiSelectListPreferenceCurrentValue: currentValue entries entryValues key }
} } }`,
{ id: String(activeSource.id) },
);
prefs = d.source.preferences ?? [];
} catch (e: any) {
addToast({ kind: "error", title: "Failed to save", body: e?.message ?? "" });
} finally {
saving = null;
}
}
function getTitle(p: Preference) {
return p.CheckBoxTitle ?? p.SwitchPreferenceTitle ?? p.ListPreferenceTitle
?? p.EditTextPreferenceTitle ?? p.MultiSelectListPreferenceTitle ?? p.key;
}
function getSummary(p: Preference) {
return p.CheckBoxSummary ?? p.SwitchPreferenceSummary ?? p.ListPreferenceSummary
?? p.EditTextPreferenceSummary ?? p.MultiSelectListPreferenceSummary ?? null;
}
function getBoolValue(p: Preference) {
return p.type === "CheckBoxPreference"
? (p.CheckBoxCurrentValue ?? p.CheckBoxDefault ?? false)
: (p.SwitchPreferenceCurrentValue ?? p.SwitchPreferenceDefault ?? false);
}
function getListValue(p: Preference) { return p.ListPreferenceCurrentValue ?? p.ListPreferenceDefault ?? ""; }
function getListLabel(p: Preference, val: string) {
const idx = p.entryValues?.indexOf(val) ?? -1;
return idx >= 0 ? (p.entries?.[idx] ?? val) : val;
}
function getMultiValue(p: Preference): string[] {
return p.MultiSelectListPreferenceCurrentValue ?? p.MultiSelectListPreferenceDefault ?? [];
}
function toggleMulti(pos: number, p: Preference, val: string) {
const curr = getMultiValue(p);
save(pos, "multiSelectState", curr.includes(val) ? curr.filter(v => v !== val) : [...curr, val]);
}
function openEdit(p: Preference) {
editKey = p.key;
editValue = p.EditTextPreferenceCurrentValue ?? p.EditTextPreferenceDefault ?? "";
}
function submitEdit(pos: number) { save(pos, "editTextState", editValue); editKey = null; }
function langTag(name: string) {
const m = name.match(/\(([^)]+)\)$/);
return m ? m[1].toUpperCase() : null;
}
function baseName(name: string) { return name.replace(/\s*\([^)]+\)$/, ""); }
function prevSource() { if (activeIndex > 0) activeIndex--; }
function nextSource() { if (activeIndex < sources.length - 1) activeIndex++; }
function dismiss() {
closing = true;
setTimeout(onClose, 200);
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "Escape") {
if (editKey) { editKey = null; return; }
if (listOpen) { listOpen = null; return; }
if (sources.length > 1 && activeIndex >= 0) { activeIndex = -1; return; }
dismiss();
}
}
function onBackdrop(e: MouseEvent) { if (e.target === e.currentTarget) dismiss(); }
</script>
<svelte:window onkeydown={onKeydown} />
<div class="backdrop" class:backdrop-out={closing} role="dialog" aria-modal="true" onmousedown={onBackdrop}>
<aside class="panel" class:panel-out={closing}>
<div class="panel-header">
<div class="ext-identity">
{#if iconUrl}
<Thumbnail src={iconUrl} alt={extensionName} class="ext-icon"
onerror={(e) => ((e.target as HTMLImageElement).style.display = "none")} />
{/if}
<div class="ext-titles">
<span class="ext-eyebrow">Extension Settings</span>
<span class="ext-name">{extensionName}</span>
</div>
</div>
<button class="close-btn" onclick={dismiss} aria-label="Close">
<X size={13} weight="bold" />
</button>
</div>
{#if sources.length > 1}
<div class="source-bar">
<button class="src-arrow" onclick={prevSource}
disabled={activeIndex <= 0} aria-label="Previous source">
<CaretLeft size={11} weight="bold" />
</button>
<div class="src-label">
{#if activeIndex >= 0}
<span class="src-name">{baseName(sources[activeIndex].displayName)}</span>
{#if langTag(sources[activeIndex].displayName)}
<span class="src-lang">{langTag(sources[activeIndex].displayName)}</span>
{/if}
{:else}
<span class="src-name src-dim">Choose a source</span>
{/if}
</div>
<button class="src-arrow" onclick={nextSource}
disabled={activeIndex >= sources.length - 1} aria-label="Next source">
<CaretRight size={11} weight="bold" />
</button>
</div>
{#if inPicker}
<div class="picker">
{#each sources as src, i}
{@const tag = langTag(src.displayName)}
<button class="picker-row" onclick={() => activeIndex = i}>
<span class="picker-name">{baseName(src.displayName)}</span>
{#if tag}<span class="src-lang">{tag}</span>{/if}
<CaretRight size={11} weight="bold" class="picker-caret" />
</button>
{/each}
</div>
{/if}
{/if}
{#if !inPicker}
<div class="prefs-body">
{#if loading}
<div class="center-state">
<CircleNotch size={15} weight="light" class="anim-spin" style="color:var(--text-faint)" />
</div>
{:else if prefs.length === 0}
<div class="center-state dim">No configurable settings.</div>
{:else}
<ul class="pref-list">
{#each prefs as pref, i}
{@const title = getTitle(pref)}
{@const summary = getSummary(pref)}
{@const isSaving = saving === pref.key}
{#if pref.type === "CheckBoxPreference" || pref.type === "SwitchPreference"}
{@const checked = getBoolValue(pref)}
<li class="pref-row">
<div class="pref-text">
<span class="pref-title">{title}</span>
{#if summary}<span class="pref-summary">{summary}</span>{/if}
</div>
<button
class="toggle" class:toggle-on={checked}
disabled={isSaving}
role="switch" aria-checked={checked}
onclick={() => save(i, pref.type === "CheckBoxPreference" ? "checkBoxState" : "switchState", !checked)}
>
{#if isSaving}
<CircleNotch size={9} weight="light" class="anim-spin" />
{:else}
<span class="toggle-thumb"></span>
{/if}
</button>
</li>
{:else if pref.type === "ListPreference"}
{@const current = getListValue(pref)}
<li class="pref-row pref-col">
<div class="pref-text">
<span class="pref-title">{title}</span>
{#if summary}<span class="pref-summary">{summary}</span>{/if}
</div>
<div class="select-wrap">
<button
class="select-btn" class:select-open={listOpen === pref.key}
disabled={isSaving}
onclick={() => listOpen = listOpen === pref.key ? null : pref.key}
>
<span class="select-val">{getListLabel(pref, current)}</span>
{#if isSaving}
<CircleNotch size={10} weight="light" class="anim-spin" />
{:else}
<CaretUpDown size={10} weight="bold" />
{/if}
</button>
{#if listOpen === pref.key}
<div class="dropdown">
{#each (pref.entries ?? []) as entry, j}
{@const val = pref.entryValues?.[j] ?? entry}
<button
class="dropdown-item" class:dropdown-item-active={val === current}
onclick={() => { save(i, "listState", val); listOpen = null; }}
>
{entry}
{#if val === current}<Check size={10} weight="bold" />{/if}
</button>
{/each}
</div>
{/if}
</div>
</li>
{:else if pref.type === "EditTextPreference"}
{#if editKey === pref.key}
<li class="pref-row pref-col edit-active">
<div class="pref-text">
{#if pref.dialogTitle}<span class="pref-title">{pref.dialogTitle}</span>{/if}
{#if pref.dialogMessage}<span class="pref-summary">{pref.dialogMessage}</span>{/if}
</div>
<div class="edit-row">
<input class="edit-input" bind:value={editValue} disabled={isSaving} autofocus
onkeydown={(e) => { if (e.key === "Enter") submitEdit(i); if (e.key === "Escape") editKey = null; }} />
<button class="action-dim" onclick={() => editKey = null}>Cancel</button>
<button class="action-btn" onclick={() => submitEdit(i)} disabled={isSaving}>
{#if isSaving}<CircleNotch size={10} weight="light" class="anim-spin" />{:else}Save{/if}
</button>
</div>
</li>
{:else}
<li>
<button class="pref-row pref-row-btn" onclick={() => openEdit(pref)}>
<div class="pref-text">
<span class="pref-title">{title}</span>
{#if summary}<span class="pref-summary">{summary}</span>{/if}
</div>
<span class="pref-value-hint">
{pref.EditTextPreferenceCurrentValue ?? pref.EditTextPreferenceDefault ?? "—"}
</span>
</button>
</li>
{/if}
{:else if pref.type === "MultiSelectListPreference"}
{@const selected = getMultiValue(pref)}
<li class="pref-row pref-col">
<div class="pref-text">
<span class="pref-title">{title}</span>
{#if summary}<span class="pref-summary">{summary}</span>{/if}
</div>
<div class="multi-list">
{#each (pref.entries ?? []) as entry, j}
{@const val = pref.entryValues?.[j] ?? entry}
{@const on = selected.includes(val)}
<button class="multi-item" class:multi-item-on={on}
disabled={isSaving} onclick={() => toggleMulti(i, pref, val)}>
<span class="multi-check">{#if on}<Check size={9} weight="bold" />{/if}</span>
{entry}
</button>
{/each}
</div>
</li>
{/if}
{/each}
</ul>
{/if}
</div>
{/if}
</aside>
</div>
<style>
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.28);
backdrop-filter: blur(2px);
z-index: var(--z-modal);
animation: fadeIn 0.15s ease both;
}
.backdrop-out { animation: fadeOut 0.2s ease forwards; pointer-events: none; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes fadeOut { from { opacity: 1 } to { opacity: 0 } }
.panel {
position: absolute; inset-block: 0; right: 0;
width: 320px; max-width: 100vw;
display: flex; flex-direction: column;
background: var(--bg-surface);
border-left: 1px solid var(--border-dim);
box-shadow: -12px 0 40px rgba(0,0,0,0.35);
animation: slideIn 0.2s cubic-bezier(0.16,1,0.3,1) both;
overflow: clip;
}
.panel-out { animation: slideOut 0.2s cubic-bezier(0.4,0,1,1) forwards; }
@keyframes slideIn { from { transform: translateX(100%) } to { transform: translateX(0) } }
@keyframes slideOut { from { transform: translateX(0) } to { transform: translateX(100%) } }
.panel-header {
display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-3);
padding: var(--sp-4) var(--sp-3) var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.ext-identity { display: flex; align-items: center; gap: var(--sp-3); min-width: 0; }
:global(.ext-icon) {
width: 26px; height: 26px;
border-radius: var(--radius-md); object-fit: cover;
flex-shrink: 0; background: var(--bg-raised);
}
.ext-titles { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.ext-eyebrow {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.ext-name {
font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.close-btn {
display: flex; align-items: center; justify-content: center;
width: 24px; height: 24px; flex-shrink: 0;
border-radius: var(--radius-md); color: var(--text-faint);
transition: color var(--t-base), background var(--t-base);
}
.close-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
.source-bar {
display: flex; align-items: center; gap: var(--sp-1);
padding: var(--sp-2) var(--sp-3);
border-bottom: 1px solid var(--border-dim);
background: var(--bg-raised);
flex-shrink: 0;
}
.src-arrow {
display: flex; align-items: center; justify-content: center;
width: 22px; height: 22px; flex-shrink: 0;
border-radius: var(--radius-sm); color: var(--text-faint);
transition: color var(--t-base), background var(--t-base);
}
.src-arrow:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); }
.src-arrow:disabled { opacity: 0.3; cursor: default; }
.src-label {
flex: 1; display: flex; align-items: center; justify-content: center;
gap: var(--sp-2); min-width: 0;
}
.src-name {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-secondary); font-weight: var(--weight-medium);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.src-dim { color: var(--text-faint); font-weight: normal; }
.src-lang {
font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
color: var(--text-faint); background: var(--bg-overlay);
border: 1px solid var(--border-dim); border-radius: var(--radius-sm);
padding: 1px 5px; flex-shrink: 0;
}
.picker { display: flex; flex-direction: column; padding: var(--sp-1) 0; flex: 1; overflow-y: auto; min-height: 0; }
.picker-row {
display: flex; align-items: center; gap: var(--sp-3);
padding: 9px var(--sp-5); text-align: left;
transition: background var(--t-fast);
}
.picker-row:hover { background: var(--bg-raised); }
.picker-name {
flex: 1; font-size: var(--text-sm); color: var(--text-secondary);
font-weight: var(--weight-medium);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
:global(.picker-caret) { color: var(--text-faint); flex-shrink: 0; }
.prefs-body { flex: 1; overflow-y: auto; }
.center-state {
display: flex; align-items: center; justify-content: center;
padding: var(--sp-10);
}
.dim { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.pref-list { list-style: none; padding: var(--sp-1) 0; margin: 0; display: flex; flex-direction: column; }
.pref-row {
display: flex; align-items: center; gap: var(--sp-4);
padding: 11px var(--sp-5);
border-bottom: 1px solid var(--border-dim);
}
.pref-row:last-child { border-bottom: none; }
.pref-col { flex-direction: column; align-items: stretch; gap: var(--sp-2); }
.pref-row-btn {
width: 100%; text-align: left;
display: flex; align-items: center; gap: var(--sp-4);
padding: 11px var(--sp-5);
border-bottom: 1px solid var(--border-dim);
transition: background var(--t-fast);
}
.pref-row-btn:hover { background: var(--bg-raised); }
.edit-active { background: var(--bg-raised); }
.pref-text { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.pref-title { font-size: var(--text-sm); color: var(--text-secondary); font-weight: var(--weight-medium); }
.pref-summary {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: 1.5;
}
.pref-value-hint {
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted);
letter-spacing: var(--tracking-wide); flex-shrink: 0;
max-width: 100px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.toggle {
position: relative; width: 30px; height: 17px; border-radius: 9px;
background: var(--bg-overlay); border: 1px solid var(--border-strong);
flex-shrink: 0;
display: flex; align-items: center; justify-content: center;
transition: background var(--t-base), border-color var(--t-base);
}
.toggle-on { background: var(--accent-muted); border-color: var(--accent-dim); }
.toggle-thumb {
position: absolute; left: 2px; width: 11px; height: 11px;
border-radius: 50%; background: var(--text-faint);
transition: left var(--t-base), background var(--t-base); pointer-events: none;
}
.toggle-on .toggle-thumb { left: 15px; background: var(--accent-fg); }
.toggle:disabled { opacity: 0.4; cursor: default; }
.select-wrap { position: relative; }
.select-btn {
display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2);
width: 100%; padding: 6px var(--sp-3);
background: var(--bg-base); border: 1px solid var(--border-strong);
border-radius: var(--radius-md); color: var(--text-secondary); font-size: var(--text-sm);
transition: border-color var(--t-base);
}
.select-btn:hover:not(:disabled) { border-color: var(--border-focus); }
.select-btn:disabled { opacity: 0.4; cursor: default; }
.select-open { border-color: var(--border-focus); }
.select-val { flex: 1; text-align: left; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dropdown {
position: absolute; top: calc(100% + 4px); left: 0; right: 0;
background: var(--bg-surface); border: 1px solid var(--border-strong);
border-radius: var(--radius-md); overflow: hidden;
box-shadow: var(--shadow-lg); z-index: 10;
animation: dropIn 0.1s cubic-bezier(0.16,1,0.3,1) both;
}
@keyframes dropIn { from { opacity: 0; transform: translateY(-4px); } to { opacity: 1; transform: none; } }
.dropdown-item {
display: flex; align-items: center; justify-content: space-between;
width: 100%; padding: 7px var(--sp-3);
font-size: var(--text-sm); color: var(--text-secondary);
transition: background var(--t-fast);
}
.dropdown-item:hover { background: var(--bg-raised); }
.dropdown-item-active { color: var(--accent-fg); }
.edit-row { display: flex; gap: var(--sp-2); }
.edit-input {
flex: 1; background: var(--bg-base); border: 1px solid var(--border-strong);
border-radius: var(--radius-md); padding: 6px var(--sp-3);
color: var(--text-primary); font-size: var(--text-sm);
outline: none; transition: border-color var(--t-base);
}
.edit-input:focus { border-color: var(--border-focus); }
.action-btn {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 5px 11px; border-radius: var(--radius-md);
background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim);
flex-shrink: 0; display: flex; align-items: center; gap: var(--sp-1);
transition: filter var(--t-base);
}
.action-btn:hover:not(:disabled) { filter: brightness(1.1); }
.action-btn:disabled { opacity: 0.4; cursor: default; }
.action-dim {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 5px 11px; border-radius: var(--radius-md);
background: none; color: var(--text-faint); border: 1px solid var(--border-dim);
flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base);
}
.action-dim:hover { color: var(--text-secondary); border-color: var(--border-strong); }
.multi-list { display: flex; flex-direction: column; gap: 1px; }
.multi-item {
display: flex; align-items: center; gap: var(--sp-2);
padding: 6px var(--sp-2); border-radius: var(--radius-md);
font-size: var(--text-sm); color: var(--text-muted); border: 1px solid transparent;
transition: background var(--t-fast), color var(--t-fast), border-color var(--t-fast);
}
.multi-item:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); }
.multi-item-on { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.multi-check {
width: 13px; height: 13px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); background: var(--bg-base);
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; color: var(--accent-fg);
transition: background var(--t-fast), border-color var(--t-fast);
}
.multi-item-on .multi-check { background: var(--accent-muted); border-color: var(--accent-dim); }
</style>
@@ -0,0 +1,445 @@
<script lang="ts">
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle, Swap } from "phosphor-svelte";
import { getAdapter } from "$lib/request-manager";
import Thumbnail from "$lib/components/shared/manga/Thumbnail.svelte";
import { resolvedCover } from "$lib/core/cover/coverResolver";
import { addToast } from "$lib/state/notifications.svelte";
import { settingsState } from "$lib/state/settings.svelte";
import type { Manga, Chapter, Source } from "$lib/types";
import type { LibraryManga } from "$lib/components/extensions/lib/extensionLibrary";
interface Props {
sourceId: string;
sourceName: string;
sourceIconUrl: string;
manga: LibraryManga[];
onClose: () => void;
onDone: () => void;
}
let { sourceId, sourceName, sourceIconUrl, manga, onClose, onDone }: Props = $props();
type Phase = "pick-target" | "review" | "migrating" | "done";
interface EntryResult {
manga: LibraryManga;
match: Manga | null;
chapters: Chapter[];
similarity: number;
status: "pending" | "searching" | "found" | "no-match" | "migrated" | "failed";
error?: string;
}
function titleSimilarity(a: string, b: string): number {
const norm = (s: string) =>
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
const wordsA = new Set(norm(a));
const wordsB = new Set(norm(b));
if (wordsA.size === 0 || wordsB.size === 0) return 0;
const intersection = [...wordsA].filter(w => wordsB.has(w)).length;
return intersection / new Set([...wordsA, ...wordsB]).size;
}
function onKey(e: KeyboardEvent) { if (e.key === "Escape" && phase !== "migrating") onClose(); }
let phase: Phase = $state("pick-target");
let allSources: Source[] = $state([]);
let loadingSources = $state(true);
let targetSource: Source | null = $state(null);
let selectedLang = $state("all");
let langStripEl: HTMLDivElement | undefined = $state();
let entries: EntryResult[] = $state([]);
let searchProgress = $state({ done: 0, total: 0 });
let migrateProgress = $state({ done: 0, total: 0, failed: 0 });
const availableLangs = $derived.by(() => {
const langs = Array.from(new Set<string>(allSources.map(s => s.lang))).sort();
const en = langs.indexOf("en");
if (en > 0) { langs.splice(en, 1); langs.unshift("en"); }
return langs;
});
const hasMultipleLangs = $derived(availableLangs.length > 1);
const visibleSources = $derived.by(() => {
if (selectedLang !== "all") return allSources.filter(s => s.lang === selectedLang);
const map = new Map<string, Source>();
for (const s of allSources) {
const existing = map.get(s.name);
if (!existing || s.lang < existing.lang) map.set(s.name, s);
}
return Array.from(map.values());
});
const foundCount = $derived(entries.filter(e => e.status === "found").length);
const noMatchCount = $derived(entries.filter(e => e.status === "no-match").length);
const migratedCount = $derived(entries.filter(e => e.status === "migrated").length);
const failedCount = $derived(entries.filter(e => e.status === "failed").length);
$effect(() => {
getAdapter().getSources().then(nodes => ({ sources: { nodes } }))
.then(d => {
allSources = d.sources.nodes.filter(s => s.id !== "0" && s.id !== sourceId);
const prefLang = settingsState.settings.preferredExtensionLang ?? "";
const langs = new Set(allSources.map(s => s.lang));
if (prefLang && langs.has(prefLang) && langs.size > 1) selectedLang = prefLang;
})
.catch(console.error)
.finally(() => { loadingSources = false; });
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
function scrollLangStrip(dir: -1 | 1) {
if (!langStripEl) return;
const chips = Array.from(langStripEl.children) as HTMLElement[];
const viewEnd = langStripEl.scrollLeft + langStripEl.clientWidth;
if (dir === 1) {
const next = chips.find(c => c.offsetLeft + c.offsetWidth > viewEnd + 2);
if (next) langStripEl.scrollTo({ left: next.offsetLeft, behavior: "smooth" });
} else {
const prev = [...chips].reverse().find(c => c.offsetLeft < langStripEl!.scrollLeft - 2);
if (prev) langStripEl.scrollTo({ left: prev.offsetLeft + prev.offsetWidth - langStripEl.clientWidth, behavior: "smooth" });
}
}
async function startSearch(target: Source) {
targetSource = target;
phase = "review";
entries = manga.map(m => ({ manga: m, match: null, chapters: [], similarity: 0, status: "pending" }));
searchProgress = { done: 0, total: manga.length };
for (let i = 0; i < entries.length; i++) {
entries[i] = { ...entries[i], status: "searching" };
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
source: target.id, type: "SEARCH", page: 1, query: entries[i].manga.title,
});
const results = d.fetchSourceManga.mangas
.map(m => ({ manga: m, similarity: titleSimilarity(entries[i].manga.title, m.title) }))
.sort((a, b) => b.similarity - a.similarity);
if (results.length > 0 && results[0].similarity > 0.3) {
entries[i] = { ...entries[i], match: results[0].manga, similarity: results[0].similarity, status: "found" };
} else {
entries[i] = { ...entries[i], status: "no-match" };
}
} catch (e: any) {
entries[i] = { ...entries[i], status: "no-match", error: e.message };
}
searchProgress = { done: i + 1, total: manga.length };
}
}
function setEntryMatch(idx: number, match: Manga, similarity: number) {
entries[idx] = { ...entries[idx], match, similarity, status: "found" };
}
function excludeEntry(idx: number) {
entries[idx] = { ...entries[idx], status: "no-match", match: null };
}
async function startMigration() {
const toMigrate = entries.filter(e => e.status === "found" && e.match);
migrateProgress = { done: 0, total: toMigrate.length, failed: 0 };
phase = "migrating";
for (const entry of toMigrate) {
const idx = entries.indexOf(entry);
try {
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: entry.match!.id });
const newChaps = d.fetchChapters.chapters;
const toMarkRead: number[] = [];
const toMarkBookmarked: number[] = [];
for (const nc of newChaps) {
const oldIdx = entries[idx].manga;
if (oldIdx) {
toMarkRead.push(nc.id);
}
}
if (toMarkRead.length)
await getAdapter().markChaptersRead(toMarkRead.map(String), true);
await getAdapter().addToLibrary(String(entry.match!.id));
await getAdapter().removeFromLibrary(String(entry.manga.id));
entries[idx] = { ...entries[idx], status: "migrated" };
migrateProgress = { ...migrateProgress, done: migrateProgress.done + 1 };
} catch (e: any) {
entries[idx] = { ...entries[idx], status: "failed", error: e.message };
migrateProgress = { ...migrateProgress, done: migrateProgress.done + 1, failed: migrateProgress.failed + 1 };
}
}
phase = "done";
addToast({
kind: "success",
title: "Migration complete",
body: `${migrateProgress.done - migrateProgress.failed} migrated, ${migrateProgress.failed} failed`,
});
}
</script>
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget && phase !== "migrating") onClose(); }}>
<div class="modal">
<div class="modal-header">
<div class="source-context">
<div class="source-icon-wrap">
<Thumbnail src={sourceIconUrl} alt={sourceName} class="src-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
</div>
<div class="source-context-info">
<span class="modal-eyebrow">Source migration</span>
<span class="modal-title">{sourceName}</span>
<span class="modal-sub">{manga.length} {manga.length === 1 ? "title" : "titles"} in library</span>
</div>
</div>
{#if phase !== "migrating"}
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
{/if}
</div>
<div class="body">
{#if phase === "pick-target"}
<div class="phase-label-row">
<span class="phase-label">Select destination source</span>
</div>
{#if loadingSources}
<div class="centered"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
{:else if allSources.length === 0}
<div class="centered"><span class="hint">No other sources installed.</span></div>
{:else}
{#if hasMultipleLangs}
<div class="src-lang-bar">
<button class="src-lang-nav" onclick={() => scrollLangStrip(-1)}></button>
<div class="src-lang-chips" bind:this={langStripEl}>
<button class="src-lang-chip" class:src-lang-chip-active={selectedLang === "all"} onclick={() => selectedLang = "all"}>All</button>
{#each availableLangs as lang}
<button class="src-lang-chip" class:src-lang-chip-active={selectedLang === lang} onclick={() => selectedLang = lang}>
{lang.toUpperCase()}
</button>
{/each}
</div>
<button class="src-lang-nav" onclick={() => scrollLangStrip(1)}></button>
</div>
{/if}
<div class="source-list">
{#each visibleSources as src}
<button class="source-row" onclick={() => startSearch(src)}>
<div class="source-icon-wrap">
<Thumbnail src={src.iconUrl} alt={src.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
</div>
<div class="source-info">
<span class="source-name">{src.displayName}</span>
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
</div>
<ArrowRight size={13} weight="light" class="source-arrow" />
</button>
{/each}
</div>
{/if}
{:else if phase === "review" || phase === "migrating" || phase === "done"}
<div class="review-header">
<div class="review-route">
<div class="review-source">
<div class="source-icon-wrap small">
<Thumbnail src={sourceIconUrl} alt={sourceName} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
</div>
<span class="review-source-name">{sourceName}</span>
</div>
<ArrowRight size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
{#if targetSource}
<div class="review-source">
<div class="source-icon-wrap small">
<Thumbnail src={targetSource.iconUrl} alt={targetSource.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
</div>
<span class="review-source-name">{targetSource.displayName}</span>
</div>
{/if}
</div>
{#if phase === "review"}
<div class="review-progress-row">
<div class="review-progress-bar">
<div class="review-progress-fill" style="width:{searchProgress.total ? (searchProgress.done / searchProgress.total) * 100 : 0}%"></div>
</div>
<span class="review-progress-label">
{#if searchProgress.done < searchProgress.total}
Searching {searchProgress.done + 1} / {searchProgress.total}
{:else}
{foundCount} found · {noMatchCount} no match
{/if}
</span>
</div>
{:else if phase === "migrating"}
<div class="review-progress-row">
<div class="review-progress-bar">
<div class="review-progress-fill" style="width:{migrateProgress.total ? (migrateProgress.done / migrateProgress.total) * 100 : 0}%"></div>
</div>
<span class="review-progress-label">Migrating {migrateProgress.done} / {migrateProgress.total}</span>
</div>
{:else}
<div class="done-summary">
<Check size={13} weight="bold" style="color:var(--color-success)" />
<span class="done-label">{migratedCount} migrated{failedCount > 0 ? ` · ${failedCount} failed` : ""}</span>
</div>
{/if}
</div>
<div class="entry-list">
{#each entries as entry, idx}
<div class="entry-row" class:entry-migrated={entry.status === "migrated"} class:entry-failed={entry.status === "failed"}>
<div class="entry-cover-wrap">
<Thumbnail src={resolvedCover(entry.manga.id, entry.manga.thumbnailUrl)} alt={entry.manga.title} class="entry-cover" />
</div>
<div class="entry-info">
<span class="entry-title">{entry.manga.title}</span>
{#if entry.status === "found" && entry.match}
<span class="entry-match">
<Sparkle size={9} weight="fill" style="color:var(--accent-fg);flex-shrink:0" />
{entry.match.title}
<span class="entry-sim">{Math.round(entry.similarity * 100)}%</span>
</span>
{:else if entry.status === "no-match"}
<span class="entry-no-match">No match found</span>
{:else if entry.status === "searching"}
<span class="entry-searching">Searching…</span>
{:else if entry.status === "migrated"}
<span class="entry-done">Migrated</span>
{:else if entry.status === "failed"}
<span class="entry-fail">{entry.error ?? "Failed"}</span>
{/if}
</div>
<div class="entry-status">
{#if entry.status === "searching"}
<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint)" />
{:else if entry.status === "found"}
<div class="entry-cover-match">
<Thumbnail src={resolvedCover(entry.match!.id, entry.match!.thumbnailUrl)} alt={entry.match!.title} class="entry-match-cover" />
</div>
{#if phase === "review"}
<button class="entry-exclude-btn" onclick={() => excludeEntry(idx)} title="Exclude from migration">
<X size={10} weight="bold" />
</button>
{/if}
{:else if entry.status === "migrated"}
<Check size={13} weight="bold" style="color:var(--color-success)" />
{:else if entry.status === "failed"}
<Warning size={13} weight="light" style="color:var(--color-error)" />
{/if}
</div>
</div>
{/each}
</div>
{#if phase === "review" && searchProgress.done === searchProgress.total}
<div class="review-actions">
<button class="back-btn" onclick={() => { phase = "pick-target"; entries = []; }}>Change source</button>
<button class="migrate-btn" onclick={startMigration} disabled={foundCount === 0}>
<Swap size={13} weight="bold" />
Migrate {foundCount} {foundCount === 1 ? "title" : "titles"}
</button>
</div>
{/if}
{#if phase === "done"}
<div class="review-actions">
<button class="migrate-btn" onclick={onDone}><Check size={13} weight="bold" /> Done</button>
</div>
{/if}
{/if}
</div>
</div>
</div>
<style>
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 200; animation: fadeIn 0.1s ease both; }
.modal { background: var(--bg-base); border: 1px solid var(--border-base); border-radius: var(--radius-xl); width: 560px; max-height: 84vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 8px 40px rgba(0,0,0,0.5); }
.modal-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.source-context { display: flex; align-items: center; gap: var(--sp-3); min-width: 0; }
.source-icon-wrap { width: 36px; height: 36px; border-radius: var(--radius-md); overflow: hidden; flex-shrink: 0; background: var(--bg-raised); border: 1px solid var(--border-dim); }
.source-icon-wrap.small { width: 20px; height: 20px; border-radius: var(--radius-sm); }
:global(.src-icon) { width: 100%; height: 100%; object-fit: cover; }
:global(.source-icon) { width: 100%; height: 100%; object-fit: cover; }
.source-context-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.modal-eyebrow { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
.modal-sub { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.close-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; margin-top: 2px; }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.body { flex: 1; overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
.phase-label-row { padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; }
.phase-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-widest); text-transform: uppercase; }
.centered { flex: 1; display: flex; align-items: center; justify-content: center; padding: var(--sp-8); }
.hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.src-lang-bar { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.src-lang-nav { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; flex-shrink: 0; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 15px; line-height: 1; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
.src-lang-nav:hover { color: var(--text-muted); background: var(--bg-raised); }
.src-lang-chips { display: flex; align-items: center; gap: var(--sp-1); flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; }
.src-lang-chips::-webkit-scrollbar { display: none; }
.src-lang-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.src-lang-chip:hover { color: var(--text-muted); background: var(--bg-raised); }
.src-lang-chip-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.source-list { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: 1px; }
.source-row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
.source-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.source-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
.source-name { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.source-meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
:global(.source-arrow) { color: var(--text-faint); opacity: 0; transition: opacity var(--t-base); flex-shrink: 0; }
.source-row:hover :global(.source-arrow) { opacity: 1; }
.review-header { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
.review-route { display: flex; align-items: center; gap: var(--sp-2); }
.review-source { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
.review-source-name { font-size: var(--text-xs); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; font-weight: var(--weight-medium); }
.review-progress-row { display: flex; align-items: center; gap: var(--sp-3); }
.review-progress-bar { flex: 1; height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; }
.review-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
.review-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; flex-shrink: 0; }
.done-summary { display: flex; align-items: center; gap: var(--sp-2); }
.done-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); }
.entry-list { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: 1px; }
.entry-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; transition: background var(--t-fast); }
.entry-row:hover { background: var(--bg-raised); }
.entry-migrated { opacity: 0.5; }
.entry-failed { border-color: rgba(180,60,60,0.15); background: rgba(180,60,60,0.04); }
.entry-cover-wrap { width: 28px; height: 42px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
:global(.entry-cover) { width: 100%; height: 100%; object-fit: cover; }
.entry-info { flex: 1; display: flex; flex-direction: column; gap: 3px; min-width: 0; overflow: hidden; }
.entry-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.entry-match { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.entry-sim { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 0 4px; font-size: 9px; flex-shrink: 0; }
.entry-no-match { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.entry-searching { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.entry-done { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--color-success); letter-spacing: var(--tracking-wide); }
.entry-fail { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--color-error); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.entry-status { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
.entry-cover-match { width: 24px; height: 36px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
:global(.entry-match-cover) { width: 100%; height: 100%; object-fit: cover; }
.entry-exclude-btn { display: flex; align-items: center; justify-content: center; width: 18px; height: 18px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.entry-exclude-btn:hover { color: var(--color-error); background: var(--bg-raised); }
.review-actions { display: flex; justify-content: flex-end; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.back-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 10px; border-radius: var(--radius-md); background: none; color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.back-btn:hover { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
.migrate-btn { 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-md); background: var(--accent-dim); border: 1px solid var(--accent); color: var(--accent-fg); cursor: pointer; transition: background var(--t-base), border-color var(--t-base); }
.migrate-btn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-bright); }
.migrate-btn:disabled { opacity: 0.4; cursor: default; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
+475
View File
@@ -0,0 +1,475 @@
<script lang="ts">
import { getAdapter } from '$lib/request-manager'
import { libraryState } from '$lib/state/library.svelte'
import type { LibrarySortOption, LibraryContentFilter, LibraryStatusFilter } from '$lib/state/library.svelte'
import { startLibraryUpdate } from '$lib/components/library/lib/libraryUpdater'
import { addToast } from '$lib/state/notifications.svelte'
import { updateSettings, settingsState } from '$lib/state/settings.svelte'
import { goto } from '$app/navigation'
import { invoke } from '@tauri-apps/api/core'
import LibraryToolbar from '$lib/components/library/LibraryToolbar.svelte'
import LibraryGrid from '$lib/components/library/LibraryGrid.svelte'
import ContextMenu from '$lib/components/shared/ui/ContextMenu.svelte'
import type { MenuEntry } from '$lib/components/shared/ui/ContextMenu.svelte'
import type { Manga, Category } from '$lib/types'
import {
Books, Folder, FolderSimple, FolderSimplePlus,
Trash, CheckSquare, ArrowSquareOut, ArrowsClockwise,
} from 'phosphor-svelte'
const SIDEBAR_W = 52
const TITLEBAR_H = 36
const CTX_FOLDER_CAP = 4
const DT_TAB = 'application/x-moku-tab'
const COMPLETED_NAME = 'Completed'
let cancelUpdate: (() => void) | null = null
let refreshDoneTimer: ReturnType<typeof setTimeout> | null = null
let ctx: { x: number; y: number; manga: Manga } | null = $state(null)
let emptyCtx: { x: number; y: number } | null = $state(null)
let bulkWorking: boolean = $state(false)
let activeDragKind: 'tab' | null = $state(null)
let dragInsertIdx = $state(-1)
let dragTabId: string|null = $state(null)
let dragOverTabId: string|null = $state(null)
$effect(() => {
libraryState.syncFromSettings(settingsState.settings)
loadLibrary()
})
$effect(() => { libraryState.syncFromSettings(settingsState.settings) })
$effect(() => { libraryState.tab; libraryState.exitSelect() })
$effect(() => { libraryState.guardTab() })
async function loadLibrary() {
libraryState.loading = true
libraryState.error = null
try {
const result = await getAdapter().getMangaList({ inLibrary: true })
libraryState.items = result.items
await loadCategories()
} catch (e) {
libraryState.error = String(e)
} finally {
libraryState.loading = false
}
}
async function loadCategories() {
try {
let cats = await getAdapter().getCategories()
if (!cats.some(c => c.name === COMPLETED_NAME)) {
try {
const created = await getAdapter().createCategory(COMPLETED_NAME)
cats = [...cats, created]
} catch {}
}
const needsPopulation = cats.every(c => !(c as any).mangas?.nodes?.length)
if (needsPopulation && libraryState.items.length > 0) {
cats = cats.map(c => {
const nodes = libraryState.items.filter(m =>
(m as any).categories?.nodes?.some((mc: any) => mc.id === c.id) ||
(m as any).categoryIds?.includes(c.id)
)
return { ...c, mangas: { nodes } }
})
}
libraryState.setCategories(cats)
} catch (e) {
libraryState.error = String(e)
}
}
function onCardClick(e: MouseEvent, m: Manga) {
if (libraryState.selectMode) { libraryState.toggleSelect(m.id); return }
if (e.metaKey || e.ctrlKey || e.shiftKey) { e.preventDefault(); libraryState.enterSelect(m.id); return }
goto(`/series/${m.id}`)
}
function openCtx(e: MouseEvent, m: Manga) {
if (libraryState.selectMode) { libraryState.toggleSelect(m.id); return }
e.preventDefault()
ctx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H, manga: m }
}
async function doRemove(m: Manga) {
await getAdapter().removeFromLibrary(String(m.id))
libraryState.items = libraryState.items.filter(x => x.id !== m.id)
await loadCategories()
}
async function doDeleteDownloads(m: Manga) {
try {
const chapters = await getAdapter().getChapters(String(m.id))
const downloaded = chapters.filter(c => c.downloaded).map(c => String(c.id))
if (!downloaded.length) return
await getAdapter().deleteDownloadedChapters(downloaded)
libraryState.items = libraryState.items.map(x =>
x.id === m.id ? { ...x, downloadCount: 0 } : x
)
} catch (e) { console.error(e) }
}
async function openMangaFolder(m: Manga) {
let base: string | undefined
try { base = await invoke<string>('get_default_downloads_path') } catch {}
if (!base) { addToast({ kind: 'error', title: 'No downloads path set', body: 'Configure it in Settings → Storage' }); return }
const sanitize = (s: string) => s.replace(/[\/\\?%*:|"<>]/g, '_')
const source = (m as any).source?.displayName ?? (m as any).source?.name ?? ''
const path = source
? `${base}/mangas/${sanitize(source)}/${sanitize(m.title)}`
: `${base}/mangas/${sanitize(m.title)}`
try { await invoke('open_path', { path }) }
catch (e: any) { addToast({ kind: 'error', title: 'Could not open folder', body: e?.toString?.() ?? path }) }
}
async function openDownloadsFolder() {
let path: string | undefined
try { path = await invoke<string>('get_default_downloads_path') } catch {}
if (!path) { addToast({ kind: 'error', title: 'No downloads path set', body: 'Configure it in Settings → Storage' }); return }
try { await invoke('open_path', { path }) }
catch (e: any) { addToast({ kind: 'error', title: 'Could not open folder', body: e?.toString?.() ?? path }) }
}
async function refreshSingleManga(m: Manga) {
if (libraryState.refreshingMangaId !== null) return
libraryState.refreshingMangaId = m.id
try {
await getAdapter().fetchManga(String(m.id))
await loadLibrary()
addToast({ kind: 'success', title: 'Manga refreshed', body: m.title })
} catch (e) {
addToast({ kind: 'error', title: 'Refresh failed', body: String(e) })
} finally {
libraryState.refreshingMangaId = null
}
}
export async function checkAndMarkCompleted(mangaId: number) {
const completedCat = libraryState.categories.find(c => c.name === COMPLETED_NAME)
if (!completedCat) return
const alreadyIn = (libraryState.categoryMangaMap.get(completedCat.id) ?? []).some(m => m.id === mangaId)
if (alreadyIn) return
try {
await getAdapter().updateMangaCategories(String(mangaId), [completedCat.id], [])
await loadCategories()
} catch (e) { console.error(e) }
}
async function toggleMangaCategory(manga: Manga, cat: Category) {
const nodes = (cat as any).mangas?.nodes ?? libraryState.categoryMangaMap.get(cat.id) ?? []
const inCat = nodes.some((m: Manga) => m.id === manga.id)
libraryState.setCategories(
libraryState.categories.map(c => {
if (c.id !== cat.id) return c
const existing = (c as any).mangas?.nodes ?? []
const updated = inCat
? existing.filter((m: Manga) => m.id !== manga.id)
: [...existing, manga]
return { ...c, mangas: { nodes: updated } }
})
)
if (!inCat) libraryState.bumpCategoryFrecency(cat.id)
try {
await getAdapter().updateMangaCategories(String(manga.id), inCat ? [] : [cat.id], inCat ? [cat.id] : [])
} catch {}
await loadCategories()
}
async function createAndAssign(manga: Manga) {
const name = prompt('Folder name:')
if (!name?.trim()) return
try {
const cat = await getAdapter().createCategory(name.trim())
libraryState.setCategories([...libraryState.categories, cat])
await getAdapter().updateMangaCategories(String(manga.id), [cat.id], [])
libraryState.bumpCategoryFrecency(cat.id)
await loadCategories()
} catch (e) { console.error(e) }
}
async function bulkMove(cat: Category) {
bulkWorking = true
try {
await getAdapter().updateMangasCategories(
[...libraryState.selected].map(String),
[cat.id],
[],
)
await loadCategories()
} catch (e) { console.error(e) }
finally { bulkWorking = false; libraryState.exitSelect() }
}
async function onBulkRemove() {
bulkWorking = true
try {
await Promise.allSettled(
[...libraryState.selected].map(id => getAdapter().removeFromLibrary(String(id)))
)
libraryState.items = libraryState.items.filter(m => !libraryState.selected.has(m.id))
libraryState.exitSelect()
} finally { bulkWorking = false }
}
async function startRefresh() {
if (libraryState.refreshing) return
libraryState.refreshing = true
libraryState.refreshProgress = { finished: 0, total: 0 }
cancelUpdate = startLibraryUpdate({
onProgress(p) { libraryState.refreshProgress = p },
async onDone({ newChapters, totalUpdated }) {
cancelUpdate = null
await loadLibrary()
libraryState.refreshing = false
libraryState.refreshDone = true
if (refreshDoneTimer) clearTimeout(refreshDoneTimer)
refreshDoneTimer = setTimeout(() => { libraryState.refreshDone = false }, 2500)
if (newChapters > 0) {
addToast({ kind: 'success', title: 'Library updated', body: `${newChapters} new chapter${newChapters !== 1 ? 's' : ''} across ${totalUpdated} series` })
} else {
addToast({ kind: 'info', title: 'Already up to date' })
}
},
onError() { libraryState.refreshing = false; cancelUpdate = null },
})
}
async function cancelRefresh() {
if (!libraryState.refreshing) return
cancelUpdate?.(); cancelUpdate = null
try { await getAdapter().stopLibraryUpdate() } catch {}
libraryState.refreshing = false
libraryState.refreshProgress = { finished: 0, total: 0 }
}
async function refreshCategory(catId: number) {
if (libraryState.refreshingCatId !== null || libraryState.refreshing) return
libraryState.refreshingCatId = catId
try {
await getAdapter().updateCategoryManga(catId)
await loadLibrary()
const cat = libraryState.categories.find(c => c.id === catId)
addToast({ kind: 'success', title: 'Folder refreshed', body: cat?.name ?? '' })
} catch (e) {
addToast({ kind: 'error', title: 'Refresh failed', body: String(e) })
} finally { libraryState.refreshingCatId = null }
}
function buildCtxItems(m: Manga): MenuEntry[] {
const sorted = [...libraryState.visibleCategories].sort(
(a, b) => (libraryState.categoryFrecency[b.id] ?? 0) - (libraryState.categoryFrecency[a.id] ?? 0)
)
const pinned = sorted.slice(0, CTX_FOLDER_CAP)
const overflow = sorted.slice(CTX_FOLDER_CAP)
const makeCatEntry = (cat: Category): MenuEntry => {
const inCat = (libraryState.categoryMangaMap.get(cat.id) ?? []).some(x => x.id === m.id)
return { label: inCat ? `Remove from ${cat.name}` : cat.name, icon: Folder, onClick: () => toggleMangaCategory(m, cat) }
}
return [
{ label: m.inLibrary ? 'Remove from library' : 'Add to library', icon: Books,
onClick: () => m.inLibrary
? doRemove(m)
: getAdapter().addToLibrary(String(m.id)).then(loadLibrary).catch(console.error) },
{ label: libraryState.refreshingMangaId === m.id ? 'Refreshing…' : 'Refresh manga', icon: ArrowsClockwise,
disabled: libraryState.refreshingMangaId !== null, onClick: () => refreshSingleManga(m) },
{ label: 'Open in file manager', icon: ArrowSquareOut,
disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => openMangaFolder(m) },
{ label: 'Delete all downloads', icon: Trash, danger: true,
disabled: !(m.downloadCount && m.downloadCount > 0), onClick: () => doDeleteDownloads(m) },
{ separator: true },
{ label: 'Select', icon: CheckSquare, onClick: () => libraryState.enterSelect(m.id) },
...(pinned.length ? [{ separator: true } as MenuEntry, ...pinned.map(makeCatEntry)] : []),
...(overflow.length ? [{ label: `More folders (${overflow.length})`, icon: FolderSimple, onClick: () => {}, children: overflow.map(makeCatEntry) } as MenuEntry] : []),
{ separator: true },
{ label: 'New folder', icon: FolderSimplePlus, onClick: () => createAndAssign(m) },
]
}
function buildEmptyCtx(): MenuEntry[] {
return [{
label: 'New folder', icon: FolderSimplePlus,
onClick: async () => {
const name = prompt('Folder name:')
if (!name?.trim()) return
try {
const cat = await getAdapter().createCategory(name.trim())
libraryState.setCategories([...libraryState.categories, cat])
} catch (e) { console.error(e) }
},
}]
}
function onTabDragStart(e: DragEvent, id: string) {
activeDragKind = 'tab'; dragTabId = id
e.dataTransfer!.effectAllowed = 'move'
e.dataTransfer!.setData(DT_TAB, id)
e.dataTransfer!.setData('text/plain', `tab:${id}`)
}
function onTabDragOver(e: DragEvent, id: string, idx: number) {
if (activeDragKind !== 'tab' || dragTabId === null || dragTabId === id) return
e.preventDefault(); e.dataTransfer!.dropEffect = 'move'
dragOverTabId = id
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
dragInsertIdx = e.clientX < rect.left + rect.width / 2 ? idx : idx + 1
}
function onTabDragLeave() { dragOverTabId = null }
async function onTabDrop(e: DragEvent, dropId: string) {
e.preventDefault(); dragOverTabId = null
const insertAt = dragInsertIdx; dragInsertIdx = -1
if (activeDragKind !== 'tab' || dragTabId === null || dragTabId === dropId) { dragTabId = null; return }
const dragStrId = dragTabId; dragTabId = null; activeDragKind = null
const tabs = [...libraryState.allTabIds]
const fromIdx = tabs.indexOf(dragStrId)
const dropIdx = tabs.indexOf(dropId)
if (fromIdx < 0 || dropIdx < 0) return
const visibleDrop = libraryState.visibleTabIds[insertAt] ?? null
const destIdx = visibleDrop ? tabs.indexOf(visibleDrop) : tabs.length
tabs.splice(fromIdx, 1)
const adjusted = Math.max(0, Math.min(destIdx > fromIdx ? destIdx - 1 : destIdx, tabs.length))
tabs.splice(adjusted, 0, dragStrId)
libraryState.pinnedTabOrder = tabs
updateSettings({ libraryPinnedTabOrder: tabs })
const catIds = tabs.filter(id => id !== 'library' && id !== 'downloaded')
const zeroCat = libraryState.categories.filter(c => c.id === 0)
const reordered = catIds.map((id, i) => {
const c = libraryState.categories.find(x => String(x.id) === id)!
return { ...c, order: i + 1 }
})
libraryState.setCategories([...zeroCat, ...reordered])
if (dragStrId !== 'library' && dragStrId !== 'downloaded') {
const serverPos = catIds.indexOf(dragStrId) + 1
try {
const cats = await getAdapter().updateCategoryOrder(Number(dragStrId), serverPos)
libraryState.setCategories(cats)
} catch { await loadCategories() }
}
}
function onTabDragEnd() { activeDragKind = null; dragTabId = null; dragOverTabId = null; dragInsertIdx = -1 }
</script>
<div
class="root"
role="presentation"
oncontextmenu={(e) => {
if ((e.target as HTMLElement).closest('button')) return
e.preventDefault()
emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H }
}}
>
{#if libraryState.error}
<div class="center">
<p class="error-msg">Could not load library</p>
<p class="error-detail">{libraryState.error}</p>
<button class="retry-btn" onclick={() => { loadLibrary(); loadCategories() }}>Retry</button>
</div>
{:else}
<LibraryToolbar
tab={libraryState.tab}
tabSortMode={libraryState.tabSort[libraryState.tab]?.mode ?? 'alphabetical'}
tabSortDir={libraryState.tabSort[libraryState.tab]?.dir ?? 'asc'}
tabStatus={libraryState.tabStatus[libraryState.tab] ?? 'ALL'}
tabFilters={libraryState.tabFilters[libraryState.tab] ?? {}}
hasActiveFilters={libraryState.hasActiveFilters}
visibleCategories={libraryState.visibleCategories}
visibleTabIds={libraryState.visibleTabIds}
counts={libraryState.counts}
query={libraryState.filter.query}
refreshing={libraryState.refreshing}
refreshProgress={libraryState.refreshProgress}
refreshDone={libraryState.refreshDone}
refreshingCatId={libraryState.refreshingCatId}
{activeDragKind}
{dragInsertIdx}
{dragTabId}
{dragOverTabId}
onTabChange={(t) => libraryState.tab = t}
onQuery={(q) => libraryState.filter.query = q}
onSortChange={(mode) => libraryState.setTabSort(libraryState.tab, mode)}
onSortDirToggle={() => libraryState.toggleTabSortDir(libraryState.tab)}
onStatusChange={(s) => libraryState.setTabStatus(libraryState.tab, s)}
onFilterToggle={(f) => libraryState.toggleTabFilter(libraryState.tab, f)}
onFiltersClear={() => libraryState.clearTabFilters(libraryState.tab)}
onRefresh={startRefresh}
onCancelRefresh={cancelRefresh}
onRefreshCategory={refreshCategory}
onOpenDownloadsFolder={openDownloadsFolder}
onTabDragStart={onTabDragStart}
onTabDragOver={onTabDragOver}
onTabDragLeave={onTabDragLeave}
onTabDrop={onTabDrop}
onTabDragEnd={onTabDragEnd}
/>
{#if libraryState.refreshing && libraryState.refreshProgress.total > 0}
{@const pct = Math.round((libraryState.refreshProgress.finished / libraryState.refreshProgress.total) * 100)}
<div class="refresh-bar-wrap" aria-hidden="true">
<div class="refresh-bar-fill" style="width:{pct}%"></div>
</div>
{/if}
<LibraryGrid
items={libraryState.filteredItems}
loading={libraryState.loading}
selectMode={libraryState.selectMode}
selected={libraryState.selected}
tab={libraryState.tab}
visibleCategories={libraryState.visibleCategories}
{bulkWorking}
onCardClick={onCardClick}
onCardContextMenu={openCtx}
onSelectAll={() => libraryState.selectAll(libraryState.filteredItems.map(m => m.id))}
onExitSelect={() => libraryState.exitSelect()}
onBulkRemove={onBulkRemove}
onBulkMove={bulkMove}
/>
{/if}
</div>
{#if ctx}
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
{/if}
{#if emptyCtx}
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtx()} onClose={() => emptyCtx = null} />
{/if}
<style>
.root {
position: relative; display: flex; flex-direction: column;
height: 100%; overflow: hidden;
animation: fadeIn 0.14s ease both;
}
.center {
display: flex; flex-direction: column; align-items: center;
justify-content: center; height: 60%; gap: var(--sp-2);
color: var(--text-muted); text-align: center;
}
.error-msg { color: var(--color-error); font-size: var(--text-base); }
.error-detail { color: var(--text-faint); font-size: var(--text-sm); }
.retry-btn {
margin-top: var(--sp-3); padding: 6px 16px;
border-radius: var(--radius-md); border: 1px solid var(--border-dim);
background: var(--bg-raised); color: var(--text-muted);
cursor: pointer; font-family: var(--font-ui);
font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
}
.refresh-bar-wrap { height: 2px; background: var(--border-dim); flex-shrink: 0; overflow: hidden; }
.refresh-bar-fill { height: 100%; background: var(--accent); border-radius: 0 2px 2px 0; transition: width 0.6s ease; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
@@ -115,7 +115,7 @@
<div class="grid">
{#each items as m (m.id)}
{@const isSelected = selected.has(m.id)}
{@const isCompleted = !m.unreadCount && (m.chapters?.totalCount ?? 0) > 0}
{@const isCompleted = m.status === 'COMPLETED' || (!m.unreadCount && (m.chapters?.totalCount ?? 0) > 0)}
<button
class="card"
class:card-selected={isSelected}
+194 -282
View File
@@ -1,352 +1,264 @@
<script lang="ts">
import {
MagnifyingGlass, Books, DownloadSimple, FolderSimple,
SortAscending, CaretUp, CaretDown, ArrowsClockwise, X,
} from 'phosphor-svelte'
import LibraryFilters from './LibraryFilters.svelte'
import type { LibrarySortOption, LibraryContentFilter, LibraryStatusFilter } from '$lib/state/library.svelte'
import type { Category } from '$lib/types'
MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple,
SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star, X, CheckSquare,
} from "phosphor-svelte";
import LibraryFilters from "./LibraryFilters.svelte";
import type { Category } from "@types";
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "@store/state.svelte";
interface Props {
tab: string
tabSortMode: LibrarySortOption
tabSortDir: 'asc' | 'desc'
tabStatus: LibraryStatusFilter
tabFilters: Partial<Record<LibraryContentFilter, boolean>>
hasActiveFilters: boolean
visibleCategories: Category[]
visibleTabIds: string[]
counts: Record<string, number>
query: string
refreshing: boolean
refreshProgress: { finished: number; total: number }
refreshDone: boolean
refreshingCatId: number | null
activeDragKind: 'tab' | null
dragInsertIdx: number
dragTabId: string | null
dragOverTabId: string | null
onTabChange: (t: string) => void
onQuery: (q: string) => void
onSortChange: (mode: LibrarySortOption) => void
onSortDirToggle: () => void
onStatusChange: (s: LibraryStatusFilter) => void
onFilterToggle: (f: LibraryContentFilter) => void
onFiltersClear: () => void
onRefresh: () => void
onCancelRefresh: () => void
onRefreshCategory: (catId: number) => void
onOpenDownloadsFolder: () => void
onTabDragStart: (e: DragEvent, id: string) => void
onTabDragOver: (e: DragEvent, id: string, idx: number) => void
onTabDragLeave: () => void
onTabDrop: (e: DragEvent, id: string) => void
onTabDragEnd: () => void
tab: string;
tabSortMode: LibrarySortMode;
tabSortDir: LibrarySortDir;
tabStatus: LibraryStatusFilter;
tabFilters: Partial<Record<LibraryContentFilter, boolean>>;
hasActiveFilters: boolean;
anims: boolean;
visibleCategories: Category[];
visibleTabIds: string[];
virtualTabIds: string[];
folderTabIds: string[];
completedCatId: number | null;
counts: Record<string, number>;
search: string;
refreshing: boolean;
refreshProgress: { finished: number; total: number };
refreshDone: boolean;
refreshingCatId: number | null;
activeDragKind: "tab" | null;
dragInsertIdx: number;
dragTabId: string | null;
dragOverTabId: string | null;
sortPanelOpen: boolean;
filterPanelOpen: boolean;
tabsEl: HTMLDivElement;
onSearchChange: (v: string) => void;
onTabChange: (f: string) => void;
onSortChange: (mode: LibrarySortMode) => void;
onSortDirToggle: () => void;
onStatusChange: (s: LibraryStatusFilter) => void;
onFilterToggle: (f: LibraryContentFilter) => void;
onFiltersClear: () => void;
onSortPanelToggle: () => void;
onFilterPanelToggle: () => void;
onRefresh: () => void;
onCancelRefresh: () => void;
onRefreshCategory: (catId: number) => void;
onOpenDownloadsFolder: () => void;
onTabDragStart: (e: DragEvent, id: string) => void;
onTabDragOver: (e: DragEvent, id: string, idx: number) => void;
onTabDragLeave: () => void;
onTabDrop: (e: DragEvent, id: string) => void;
onTabDragEnd: () => void;
}
let {
tab, tabSortMode, tabSortDir, tabStatus, tabFilters,
hasActiveFilters, visibleCategories, visibleTabIds, counts, query,
refreshing, refreshProgress, refreshDone, refreshingCatId,
activeDragKind, dragInsertIdx, dragTabId, dragOverTabId,
onTabChange, onQuery, onSortChange, onSortDirToggle,
onStatusChange, onFilterToggle, onFiltersClear,
onRefresh, onCancelRefresh, onOpenDownloadsFolder,
tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters,
anims, visibleCategories, visibleTabIds, virtualTabIds, folderTabIds, completedCatId,
counts, search, refreshing, refreshProgress, refreshDone, refreshingCatId,
activeDragKind, dragInsertIdx, dragTabId, dragOverTabId, sortPanelOpen, filterPanelOpen,
tabsEl = $bindable(),
onSearchChange, onTabChange, onSortChange, onSortDirToggle, onStatusChange,
onFilterToggle, onFiltersClear, onSortPanelToggle, onFilterPanelToggle,
onRefresh, onCancelRefresh, onRefreshCategory, onOpenDownloadsFolder,
onTabDragStart, onTabDragOver, onTabDragLeave, onTabDrop, onTabDragEnd,
}: Props = $props()
}: Props = $props();
let sortOpen = $state(false)
let filterOpen = $state(false)
const SORT_LABELS: Record<LibrarySortOption, string> = {
alphabetical: 'AZ',
unread: 'Unread chapters',
lastRead: 'Recently read',
dateAdded: 'Date added',
totalChapters: 'Total chapters',
latestFetched: 'Latest fetched',
latestUploaded: 'Latest uploaded',
}
function catById(id: string): Category | undefined {
return visibleCategories.find(c => String(c.id) === id)
}
function onDocDown(e: MouseEvent) {
const t = e.target as HTMLElement
if (sortOpen && !t.closest('.sort-wrap')) sortOpen = false
if (filterOpen && !t.closest('.filter-wrap')) filterOpen = false
function onTabsWheel(e: WheelEvent) {
const ids = visibleTabIds.filter(id => id === "library" || id === "downloaded" || visibleCategories.some(c => String(c.id) === id));
const idx = ids.indexOf(tab);
if (e.deltaY > 0 && idx < ids.length - 1) onTabChange(ids[idx + 1]);
else if (e.deltaY < 0 && idx > 0) onTabChange(ids[idx - 1]);
}
$effect(() => {
document.addEventListener('mousedown', onDocDown, true)
return () => document.removeEventListener('mousedown', onDocDown, true)
})
tab;
if (!tabsEl) return;
const active = tabsEl.querySelector<HTMLElement>(".tab.active");
if (!active) return;
const pl = tabsEl.scrollLeft;
const cw = tabsEl.clientWidth;
const ol = active.offsetLeft;
const ow = active.offsetWidth;
if (ol < pl) tabsEl.scrollTo({ left: ol, behavior: "smooth" });
else if (ol + ow > pl + cw) tabsEl.scrollTo({ left: ol + ow - cw, behavior: "smooth" });
});
const SORT_LABELS: Record<LibrarySortMode, string> = {
az: "AZ",
unreadCount: "Unread chapters",
totalChapters: "Total chapters",
recentlyAdded: "Recently added",
recentlyRead: "Recently read",
latestFetched: "Latest fetched chapter",
latestUploaded: "Latest uploaded chapter",
};
const ALL_SORT_MODES: LibrarySortMode[] = [
"az", "unreadCount", "totalChapters", "recentlyAdded", "recentlyRead", "latestFetched", "latestUploaded",
];
</script>
<div class="toolbar">
<div class="header">
<span class="heading">Library</span>
<div class="tabs" role="tablist">
<div class="tabs" class:tabs-anims={anims} bind:this={tabsEl} onwheel={onTabsWheel}>
{#each visibleTabIds as id, idx}
{@const isActive = tab === id}
{@const isDragOver = dragOverTabId === id}
{@const showInsertBefore = activeDragKind === 'tab' && dragInsertIdx === idx}
{@const showInsertAfter = activeDragKind === 'tab' && dragInsertIdx === idx + 1 && idx === visibleTabIds.length - 1}
{#if showInsertBefore}
<div class="drop-indicator" aria-hidden="true"></div>
{@const cat = visibleCategories.find(c => String(c.id) === id)}
{#if id === "library" || id === "downloaded" || cat}
{@const isBuiltin = id === "library" || id === "downloaded"}
{@const isCompleted = cat && id === String(completedCatId)}
{@const isDraggable = true}
{#if activeDragKind === "tab" && dragInsertIdx === idx}
<div class="tab-insert-bar" aria-hidden="true"></div>
{/if}
<button
class="tab"
class:active={isActive}
class:drag-over={isDragOver}
role="tab"
aria-selected={isActive}
draggable="true"
ondragstart={(e) => onTabDragStart(e, id)}
ondragover={(e) => onTabDragOver(e, id, idx)}
ondragleave={onTabDragLeave}
ondrop={(e) => onTabDrop(e, id)}
ondragend={onTabDragEnd}
class:active={tab === id}
class:tab-dragging={isDraggable && dragTabId === id}
draggable={isDraggable}
onclick={() => onTabChange(id)}
ondragstart={isDraggable ? (e) => onTabDragStart(e, id) : undefined}
ondragover={isDraggable ? (e) => onTabDragOver(e, id, idx) : undefined}
ondragleave={isDraggable ? onTabDragLeave : undefined}
ondrop={isDraggable ? (e) => onTabDrop(e, id) : undefined}
ondragend={isDraggable ? onTabDragEnd : undefined}
>
{#if id === 'library'}
<Books size={11} weight="bold" />
Library
{:else if id === 'downloaded'}
<DownloadSimple size={11} weight="bold" />
Downloaded
{:else}
{@const cat = catById(id)}
<FolderSimple size={11} weight="bold" />
{cat?.name ?? id}
{#if id === "library"}<Books size={11} weight="bold" />
{:else if id === "downloaded"}<DownloadSimple size={11} weight="bold" />
{:else if cat && id === String(completedCatId)}<CheckSquare size={11} weight="bold" />
{:else if cat}<Folder size={11} weight="bold" />
{/if}
<span class="count">{counts[id] ?? 0}</span>
{id === "library" ? "Saved" : id === "downloaded" ? "Downloaded" : (cat?.name ?? id)}
<span class="tab-count">{counts[id] ?? 0}</span>
</button>
{#if showInsertAfter}
<div class="drop-indicator" aria-hidden="true"></div>
{#if activeDragKind === "tab" && dragInsertIdx === idx + 1}
<div class="tab-insert-bar" aria-hidden="true"></div>
{/if}
{/if}
{/each}
</div>
<div class="right">
<div class="header-right">
<div class="search-wrap">
<MagnifyingGlass size={13} class="search-icon" weight="light" />
<input
class="search"
placeholder="Search"
value={query}
oninput={(e) => onQuery((e.target as HTMLInputElement).value)}
/>
<input class="search" placeholder="Search" value={search} oninput={(e) => onSearchChange((e.target as HTMLInputElement).value)} />
</div>
{#if tab === 'downloaded'}
{#if refreshing}
<button
class="icon-btn refresh-btn icon-btn-active"
title={`Checking… ${refreshProgress.finished}/${refreshProgress.total}`}
onclick={onCancelRefresh}
>
<ArrowsClockwise size={15} weight="bold" class="anim-spin" />
{#if refreshProgress.total > 0}
<span class="refresh-progress">{refreshProgress.finished}/{refreshProgress.total}</span>
{/if}
</button>
{:else}
<button
class="icon-btn refresh-btn"
class:refresh-btn-done={refreshDone}
title={refreshDone ? "Library updated" : "Check for updates"}
onclick={onRefresh}
>
<ArrowsClockwise size={15} weight="bold" />
</button>
{/if}
<button class="icon-btn" title="Open downloads folder" onclick={onOpenDownloadsFolder}>
<FolderSimple size={15} weight="bold" />
</button>
{/if}
<div class="sort-panel-wrap">
<button
class="icon-btn"
class:spinning={refreshing}
class:done={refreshDone}
title={refreshing ? 'Cancel update' : 'Check for updates'}
onclick={refreshing ? onCancelRefresh : onRefresh}
>
{#if refreshing}
<X size={15} weight="bold" />
{:else}
<ArrowsClockwise size={15} weight="bold" />
{/if}
</button>
{#if refreshing && refreshProgress.total > 0}
<span class="refresh-label">
{refreshProgress.finished}/{refreshProgress.total}
</span>
{/if}
<div class="sort-wrap">
<button
class="icon-btn"
class:active={tabSortMode !== 'alphabetical' || tabSortDir !== 'asc'}
class:icon-btn-active={tabSortMode !== "az" || tabSortDir !== "asc"}
title="Sort"
onclick={() => { sortOpen = !sortOpen; filterOpen = false }}
onclick={onSortPanelToggle}
>
<SortAscending size={15} weight="bold" />
</button>
{#if sortOpen}
<div class="panel sort-panel" role="menu">
<div class="panel-head">
<span class="panel-title">Sort</span>
{#if sortPanelOpen}
<div class="dropdown-panel sort-panel anim-fade-in" role="menu">
<div class="panel-header">
<span class="panel-heading">Sort</span>
</div>
<div class="divider"></div>
<p class="section-label">Order by</p>
{#each Object.entries(SORT_LABELS) as [s, label]}
<div class="panel-divider"></div>
<p class="panel-label">Order by</p>
{#each ALL_SORT_MODES as m}
<button
class="item"
class:item-active={tabSortMode === s}
class="panel-item"
class:panel-item-active={tabSortMode === m}
role="menuitem"
onclick={() => { onSortChange(s as LibrarySortOption); sortOpen = false }}
onclick={() => onSortChange(m)}
>
{label}
{#if tabSortMode === s}
{#if tabSortDir === 'desc'}<CaretDown size={11} weight="bold" />
{:else}<CaretUp size={11} weight="bold" />
{/if}
{SORT_LABELS[m]}
{#if tabSortMode === m}
{#if tabSortDir === "asc"}<CaretUp size={11} weight="bold" class="sort-caret" />
{:else}<CaretDown size={11} weight="bold" class="sort-caret" />{/if}
{/if}
</button>
{/each}
<button class="item dir-toggle" role="menuitem" onclick={onSortDirToggle}>
{tabSortDir === 'desc' ? 'Descending' : 'Ascending'}
{#if tabSortDir === 'desc'}<CaretDown size={11} weight="bold" />
{:else}<CaretUp size={11} weight="bold" />
{/if}
<button class="panel-item dir-toggle" role="menuitem" onclick={onSortDirToggle}>
{tabSortDir === "asc" ? "Ascending" : "Descending"}
{#if tabSortDir === "asc"}<CaretUp size={11} weight="bold" />
{:else}<CaretDown size={11} weight="bold" />{/if}
</button>
</div>
{/if}
</div>
<div class="filter-wrap">
<LibraryFilters
status={tabStatus}
filters={tabFilters}
hasActive={hasActiveFilters}
open={filterOpen}
onToggle={() => { filterOpen = !filterOpen; sortOpen = false }}
{tabStatus}
{tabFilters}
{hasActiveFilters}
{filterPanelOpen}
{onStatusChange}
{onFilterToggle}
onClear={onFiltersClear}
{onFiltersClear}
{onFilterPanelToggle}
/>
</div>
</div>
</div>
<style>
.toolbar {
position: relative; z-index: 100;
display: flex; align-items: center; gap: var(--sp-3);
padding: var(--sp-4) var(--sp-6);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0; min-width: 0; overflow-x: auto;
scrollbar-width: none;
}
.toolbar::-webkit-scrollbar { display: none; }
.heading {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider);
text-transform: uppercase; flex-shrink: 0;
}
.tabs {
display: flex; align-items: center; gap: 2px;
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: 2px;
flex-shrink: 0;
}
.tab {
display: flex; align-items: center; gap: 5px;
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); text-transform: uppercase;
padding: 4px 10px; border-radius: var(--radius-sm);
border: 1px solid transparent; color: var(--text-faint);
white-space: nowrap; cursor: pointer;
transition: background var(--t-base), color var(--t-base), border-color var(--t-base);
user-select: none;
}
.header { position: relative; z-index: 100; display: flex; align-items: center; gap: var(--sp-4); padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; min-width: 0; }
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; flex-shrink: 0; }
.heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; }
.tabs { display: flex; align-items: center; gap: 2px; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 2px; position: relative; flex-shrink: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; overscroll-behavior-x: contain; }
.tabs::-webkit-scrollbar { display: none; }
.tab { position: relative; z-index: 1; display: flex; align-items: center; gap: 5px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid transparent; color: var(--text-faint); white-space: nowrap; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); cursor: grab; flex-shrink: 0; }
.tab:hover { color: var(--text-muted); }
.tab.active { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
.tab.drag-over { border-color: var(--accent); }
.count { font-size: var(--text-2xs); opacity: 0.6; }
.drop-indicator {
width: 2px; height: 20px; background: var(--accent);
border-radius: 1px; flex-shrink: 0;
animation: fadeIn 0.1s ease both;
}
.right {
display: flex; align-items: center; gap: var(--sp-2);
margin-left: auto; flex-shrink: 0;
}
.tab-dragging { opacity: 0.4; cursor: grabbing; }
.tab-insert-bar { width: 2px; height: 22px; background: var(--accent); border-radius: 2px; flex-shrink: 0; box-shadow: 0 0 6px var(--accent); pointer-events: none; }
.tab-count { font-size: var(--text-2xs); opacity: 0.6; }
.search-wrap { position: relative; display: flex; align-items: center; }
.search-wrap :global(.search-icon) { position: absolute; left: 10px; color: var(--text-faint); pointer-events: none; }
.search {
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: 5px 10px 5px 28px;
color: var(--text-primary); font-size: var(--text-sm); width: 180px;
outline: none; transition: border-color var(--t-base);
}
.search { background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px 5px 28px; color: var(--text-primary); font-size: var(--text-sm); width: 180px; outline: none; transition: border-color var(--t-base); }
.search::placeholder { color: var(--text-faint); }
.search:focus { border-color: var(--border-strong); }
.icon-btn { display: flex; align-items: center; justify-content: center; width: 30px; height: 30px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); cursor: pointer; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.icon-btn:hover { color: var(--text-primary); border-color: var(--border-strong); }
.icon-btn-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.refresh-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide);
white-space: nowrap;
}
.icon-btn {
display: flex; align-items: center; justify-content: center;
width: 30px; height: 30px;
border-radius: var(--radius-md); border: 1px solid var(--border-dim);
background: var(--bg-raised); color: var(--text-faint);
cursor: pointer; flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.icon-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.icon-btn:disabled { opacity: 0.5; cursor: default; }
.icon-btn.done { color: var(--color-success, #4caf50); border-color: color-mix(in srgb, var(--color-success, #4caf50) 40%, transparent); }
.icon-btn.spinning :global(svg) { animation: spin 1s linear infinite; }
.sort-wrap, .filter-wrap { position: relative; }
.panel {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999;
min-width: 220px; background: var(--bg-raised);
border: 1px solid var(--border-base); border-radius: var(--radius-lg);
padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5);
animation: fadeIn 0.1s ease both;
}
.panel-head { display: flex; align-items: center; padding: 6px 10px 4px; }
.panel-title {
font-family: var(--font-ui); font-size: var(--text-xs);
letter-spacing: var(--tracking-wide); color: var(--text-secondary);
font-weight: var(--weight-medium, 500);
}
.divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
.section-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
color: var(--text-faint); padding: 4px 8px 8px;
}
.item {
display: flex; align-items: center; justify-content: space-between;
width: 100%; padding: 7px 10px; border-radius: var(--radius-sm);
border: none; background: transparent; color: var(--text-muted);
font-family: var(--font-ui); font-size: var(--text-xs);
cursor: pointer; text-align: left; gap: var(--sp-2);
transition: background var(--t-base), color var(--t-base);
}
.item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
.item-active:hover { background: var(--accent-dim); }
.dir-toggle {
justify-content: flex-start; color: var(--text-secondary);
border-top: 1px solid var(--border-dim);
border-radius: 0 0 var(--radius-sm) var(--radius-sm);
margin-top: 2px; padding-top: 9px;
}
@keyframes spin { to { transform: rotate(360deg); } }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
.refresh-btn { gap: var(--sp-1); width: auto; padding: 0 8px; }
.refresh-btn:disabled { cursor: default; }
.refresh-progress { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); }
.refresh-btn-done { color: var(--color-success, #5cae6e) !important; border-color: color-mix(in srgb, var(--color-success, #5cae6e) 40%, transparent) !important; background: color-mix(in srgb, var(--color-success, #5cae6e) 10%, transparent) !important; }
.sort-panel-wrap { position: relative; }
.dropdown-panel { position: absolute; top: calc(100% + 6px); right: 0; z-index: 9999; min-width: 220px; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.5); }
.panel-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); text-transform: uppercase; color: var(--text-faint); padding: 4px 8px 8px; }
.panel-item { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer; text-align: left; transition: background var(--t-base), color var(--t-base); gap: var(--sp-2); }
.panel-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.panel-item-active { color: var(--accent-fg); background: var(--accent-muted); font-weight: var(--weight-medium, 500); }
.panel-item-active:hover { background: var(--accent-dim); }
.panel-divider { height: 1px; background: var(--border-dim); margin: 4px 2px; }
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: 6px 10px 4px; }
.panel-heading { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); font-weight: var(--weight-medium, 500); }
.dir-toggle { color: var(--text-secondary); justify-content: flex-start; gap: var(--sp-2); border-top: 1px solid var(--border-dim); border-radius: 0 0 var(--radius-sm) var(--radius-sm); margin-top: 2px; padding-top: 9px; }
:global(.sort-caret) { flex-shrink: 0; }
</style>
+9 -9
View File
@@ -65,11 +65,11 @@
{:else if viewMode === 'grid'}
{#each sortedChapters as ch, i}
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
{@const inProgress = !ch.read && (ch.lastPageRead ?? 0) > 0}
{@const isGridSelected = selectedIds.has(ch.id)}
<button
class="grid-cell"
class:read={ch.isRead}
class:read={ch.read}
class:in-progress={inProgress}
class:grid-selected={isGridSelected}
use:chapterLongPress={[ch, i]}
@@ -79,8 +79,8 @@
>
{#if isGridSelected}<span class="grid-cell-check"></span>{/if}
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
{#if ch.isDownloaded}<span class="grid-cell-dl" title="Downloaded"></span>{/if}
{#if ch.isRead}<span class="grid-cell-dot"></span>{/if}
{#if ch.downloaded}<span class="grid-cell-dl" title="Downloaded"></span>{/if}
{#if ch.read}<span class="grid-cell-dot"></span>{/if}
{#if enqueueing.has(ch.id)}<span class="grid-cell-spinner"><CircleNotch size={10} weight="light" class="anim-spin" /></span>{/if}
</button>
{/each}
@@ -89,12 +89,12 @@
{#each pageChapters as ch}
{@const idxInSorted = sortedChapters.indexOf(ch)}
{@const isSelected = selectedIds.has(ch.id)}
{@const chInProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
{@const chInProgress = !ch.read && (ch.lastPageRead ?? 0) > 0}
<div
role="button"
tabindex="0"
class="ch-row"
class:read={ch.isRead}
class:read={ch.read}
class:ch-selected={isSelected}
use:chapterLongPress={[ch, idxInSorted]}
onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, chInProgress)}
@@ -109,12 +109,12 @@
<div class="ch-meta">
{#if ch.scanlator}<span class="ch-meta-item">{ch.scanlator}</span>{/if}
{#if ch.uploadDate}<span class="ch-meta-item">{formatDate(ch.uploadDate)}</span>{/if}
{#if ch.lastPageRead && ch.lastPageRead > 0 && !ch.isRead}<span class="ch-meta-item">p.{ch.lastPageRead}</span>{/if}
{#if ch.lastPageRead && ch.lastPageRead > 0 && !ch.read}<span class="ch-meta-item">p.{ch.lastPageRead}</span>{/if}
</div>
</div>
<div class="ch-right">
{#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
{#if ch.isDownloaded}
{#if ch.read}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
{#if ch.downloaded}
<div class="ch-dl-wrap">
<Download size={13} weight="fill" class="ch-dl-icon" />
<button class="dl-btn dl-btn-delete" onclick={(e) => { e.stopPropagation(); onDeleteDownload(ch.id) }} title="Delete download">
+14 -8
View File
@@ -2,7 +2,7 @@
import {
Download, CheckCircle, Circle, SortAscending, SortDescending,
CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus,
Trash, DownloadSimple, X, MagnifyingGlass, Funnel, Check,
Trash, DownloadSimple, X, MagnifyingGlass, Funnel, Check, FolderOpen,
} from 'phosphor-svelte'
import type { Chapter, Category } from '$lib/types'
import type { ChapterSortMode, ChapterSortDir } from './lib/chapterList'
@@ -51,6 +51,7 @@
onSetScanlatorFilter: (v: string[]) => void
onSetScanlatorBlacklist: (v: string[]) => void
onSetScanlatorForce: (v: boolean) => void
onOpenFolder: () => void
}
let {
@@ -63,6 +64,7 @@
onMarkSelectedRead, onClearSelection, onEnqueueNext, onEnqueueMultiple,
onDeleteAll, onRefresh, onToggleCategory, onCreateCategory,
onSetScanlatorFilter, onSetScanlatorBlacklist, onSetScanlatorForce,
onOpenFolder,
}: Props = $props()
let sortMenuOpen: boolean = $state(false)
@@ -103,7 +105,7 @@
const from = parseFloat(rangeFrom), to = parseFloat(rangeTo)
if (isNaN(from) || isNaN(to)) return
const lo = Math.min(from, to), hi = Math.max(from, to)
onEnqueueMultiple(sortedChapters.filter(c => c.chapterNumber >= lo && c.chapterNumber <= hi && !c.isDownloaded).map(c => c.id))
onEnqueueMultiple(sortedChapters.filter(c => c.chapterNumber >= lo && c.chapterNumber <= hi && !c.downloaded).map(c => c.id))
}
function submitNewFolder() {
@@ -270,10 +272,14 @@
</div>
{/if}
<button class="icon-btn" onclick={onRefresh} disabled={refreshing}>
<button class="icon-btn" onclick={onRefresh} disabled={refreshing} title="Refresh chapters">
<ArrowsClockwise size={14} weight="light" class={refreshing ? 'anim-spin' : ''} />
</button>
<button class="icon-btn" onclick={onOpenFolder} title="Open manga folder">
<FolderOpen size={14} weight="light" />
</button>
<div class="fp-wrap" bind:this={folderPickerRef}>
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
<FolderSimplePlus size={14} weight={hasFolders ? 'fill' : 'light'} />
@@ -329,7 +335,7 @@
<p class="dl-section-label">From Ch.{continueChapter.chapter.chapterNumber}</p>
<div class="dl-next-row">
{#each [5, 10, 25] as n}
{@const avail = sortedChapters.slice(contIdx, contIdx + n).filter(c => !c.isDownloaded).length}
{@const avail = sortedChapters.slice(contIdx, contIdx + n).filter(c => !c.downloaded).length}
<button class="dl-next-btn" disabled={avail === 0} onclick={() => { onEnqueueNext(n); dlOpen = false }}>
<span>Next {n}</span><span class="dl-next-sub">{avail} new</span>
</button>
@@ -352,11 +358,11 @@
</div>
{/if}
<div class="dl-divider"></div>
<button class="dl-item" onclick={() => { onEnqueueMultiple(sortedChapters.filter(c => !c.isRead && !c.isDownloaded).map(c => c.id)); dlOpen = false }}>
<span>Unread chapters</span><span class="dl-item-sub">{sortedChapters.filter(c => !c.isRead && !c.isDownloaded).length} remaining</span>
<button class="dl-item" onclick={() => { onEnqueueMultiple(sortedChapters.filter(c => !c.read && !c.downloaded).map(c => c.id)); dlOpen = false }}>
<span>Unread chapters</span><span class="dl-item-sub">{sortedChapters.filter(c => !c.read && !c.downloaded).length} remaining</span>
</button>
<button class="dl-item" onclick={() => { onEnqueueMultiple(sortedChapters.filter(c => !c.isDownloaded).map(c => c.id)); dlOpen = false }}>
<span>Download all</span><span class="dl-item-sub">{sortedChapters.filter(c => !c.isDownloaded).length} not downloaded</span>
<button class="dl-item" onclick={() => { onEnqueueMultiple(sortedChapters.filter(c => !c.downloaded).map(c => c.id)); dlOpen = false }}>
<span>Download all</span><span class="dl-item-sub">{sortedChapters.filter(c => !c.downloaded).length} not downloaded</span>
</button>
{#if downloadedCount > 0}
<div class="dl-divider"></div>
+68 -112
View File
@@ -1,5 +1,6 @@
<script lang="ts">
import { untrack } from 'svelte'
import { goto } from '$app/navigation'
import SeriesHeader from '$lib/components/series/SeriesHeader.svelte'
import SeriesActions from '$lib/components/series/SeriesActions.svelte'
import ChapterList from '$lib/components/series/ChapterList.svelte'
@@ -10,12 +11,11 @@
import type { MenuEntry } from '$lib/components/shared/ui/ContextMenu.svelte'
import { getManga, getMangaList } from '$lib/request-manager/manga'
import { getChapters, fetchChapters, markChapterRead, markChaptersRead, deleteDownloadedChapters } from '$lib/request-manager/chapters'
import { enqueueDownload } from '$lib/request-manager/downloads'
import { downloadStore } from '$lib/state/downloads.svelte'
import { getCategories, updateMangaCategories, createCategory as createCategoryReq, updateManga } from '$lib/request-manager/manga'
import { saveScroll, getScroll } from '$lib/state/app.svelte'
import { seriesState, openReader, setActiveManga, addBookmark,
acknowledgeUpdate, clearMarkersForManga,
setPreviewManga } from '$lib/state/series.svelte'
import { seriesState, openReader, addBookmark,
acknowledgeUpdate, clearMarkersForManga } from '$lib/state/series.svelte'
import { DEFAULT_MANGA_PREFS } from '$lib/types/settings'
import type { MangaPrefs } from '$lib/types/settings'
import { addToast } from '$lib/state/notifications.svelte'
@@ -23,6 +23,7 @@
import { autoLinkLibrary } from '$lib/core/cover/autoLink'
import { buildChapterList } from '$lib/components/series/lib/chapterList'
import { getPref, setPref } from '$lib/components/series/lib/mangaPrefs'
import { openMangaFolder } from '$lib/core/filesystem'
import type { Manga, Chapter, Category } from '$lib/types'
import AutomationPanel from '$lib/components/series/panels/AutomationPanel.svelte'
import CoverPickerPanel from '$lib/components/series/panels/CoverPickerPanel.svelte'
@@ -30,7 +31,10 @@
import MigrateModal from '$lib/components/shared/manga/MigrateModal.svelte'
import SeriesLinkPanel from '$lib/components/shared/manga/SeriesLinkPanel.svelte'
import TrackingPanel from '$lib/components/tracking/TrackingPanel.svelte'
import MangaPreview from '$lib/components/shared/manga/MangaPreview.svelte'
interface Props { mangaId: number }
let { mangaId }: Props = $props()
const CHAPTERS_PER_PAGE = 25
const MANGA_TTL_MS = 5 * 60 * 1000
const CHAPTER_TTL_MS = 2 * 60 * 1000
@@ -69,9 +73,9 @@
let prevMangaId: number | null = null
const get = <K extends keyof MangaPrefs>(key: K) =>
seriesState.activeManga ? getPref(seriesState.activeManga.id, key) : DEFAULT_MANGA_PREFS[key]
mangaId ? getPref(mangaId, key) : DEFAULT_MANGA_PREFS[key]
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => {
if (seriesState.activeManga) setPref(seriesState.activeManga.id, key, value)
if (mangaId) setPref(mangaId, key, value)
}
const hasSelection = $derived(selectedIds.size > 0)
@@ -107,8 +111,8 @@
if (!sortedChapters.length) return null
const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
const anyRead = asc.some(c => c.read)
const bookmark = seriesState.activeManga
? seriesState.bookmarks.find(b => b.mangaId === seriesState.activeManga!.id)
const bookmark = mangaId
? seriesState.bookmarks.find(b => b.mangaId === mangaId)
: null
const bookmarkedCh = bookmark ? asc.find(c => c.id === bookmark.chapterId) : null
if (bookmarkedCh && !bookmarkedCh.read) {
@@ -131,9 +135,7 @@
!!(get('preferredScanlator') as string)
)
const linkedIds = $derived(
seriesState.activeManga ? (seriesState.settings.mangaLinks?.[seriesState.activeManga.id] ?? []) : []
)
const linkedIds = $derived(seriesState.settings.mangaLinks?.[mangaId] ?? [])
function clearSelection() { selectedIds = new Set() }
@@ -152,21 +154,21 @@
}
prevChapterIds = new Set(nodes.map(c => c.id))
chapters = nodes
if (seriesState.activeManga && nodes.length > 0) checkAndMarkCompleted(seriesState.activeManga.id, nodes)
if (mangaId && nodes.length > 0) checkAndMarkCompleted(mangaId, nodes)
}
function loadCategories(mangaId: number) {
function loadCategories(id: number) {
catsLoading = true
getCategories()
.then(d => {
allCategories = d.filter(c => c.id !== 0)
mangaCategories = allCategories.filter(c => c.mangas?.some((m: Manga) => m.id === mangaId))
mangaCategories = allCategories.filter(c => c.mangas?.nodes?.some((m: Manga) => m.id === id))
})
.catch(console.error)
.finally(() => { catsLoading = false })
}
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
async function checkAndMarkCompleted(id: number, chaps: Chapter[]) {
if (chaps.length && manga?.status !== 'ONGOING') {
const allRead = chaps.every(c => c.read)
const completed = allCategories.find(c => c.name === 'Completed')
@@ -232,42 +234,41 @@
}).catch(() => { if (!ctrl.signal.aborted) loadingChapters = false })
}
async function syncTrackersIntoChapters(mangaId: number, chaps: Chapter[]) {
async function syncTrackersIntoChapters(id: number, chaps: Chapter[]) {
if (!seriesState.settings.trackerSyncBack) return
const records = trackingState.recordsFor(mangaId)
const records = trackingState.recordsFor(id)
if (!records.length) return
for (const record of records) {
try {
const { markedIds } = await trackingState.syncFromRemote(mangaId, record, chaps, currentPrefs)
const { markedIds } = await trackingState.syncFromRemote(id, record, chaps, currentPrefs)
if (markedIds.length > 0) {
const idSet = new Set(markedIds)
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, read: true } : c)
if (seriesState.activeManga) chapterCache.set(seriesState.activeManga.id, { data: chapters, fetchedAt: Date.now() })
chapterCache.set(id, { data: chapters, fetchedAt: Date.now() })
}
} catch {}
}
}
$effect(() => {
const id = seriesState.activeMangaId
const m = seriesState.activeManga
const id = mangaId
const shouldAutoLink = seriesState.settings.autoLinkOnOpen
if (id) untrack(() => {
if (m) acknowledgeUpdate(m.id)
acknowledgeUpdate(id)
loadMangaData(id)
loadChaptersData(id)
loadCategories(id)
trackingState.loadForManga(id).then(() => syncTrackersIntoChapters(id, chapters))
if (shouldAutoLink) {
if (allMangaForLink.length) {
autoLinkLibrary(m, allMangaForLink)
autoLinkLibrary(manga, allMangaForLink)
.then(n => { if (n > 0) addToast({ kind: 'success', title: 'Series linked', body: `${n} new link${n === 1 ? '' : 's'} found` }) })
} else {
loadingLinkList = true
getMangaList()
.then(list => {
allMangaForLink = list
return autoLinkLibrary(m, list)
return autoLinkLibrary(manga, list)
})
.then(n => { if (n > 0) addToast({ kind: 'success', title: 'Series linked', body: `${n} new link${n === 1 ? '' : 's'} found` }) })
.catch(console.error)
@@ -281,19 +282,18 @@
$effect(() => {
const wasOpen = prevChapterId !== null
prevChapterId = seriesState.activeChapter?.id ?? null
if (wasOpen && !seriesState.activeChapter && seriesState.activeManga) {
const id = seriesState.activeManga.id
untrack(() => { reloadChapters(id) })
if (wasOpen && !seriesState.activeChapter) {
untrack(() => { reloadChapters(mangaId) })
}
})
$effect(() => {
const mangaId = seriesState.activeManga?.id ?? null
if (mangaId === prevMangaId) return
const id = mangaId
if (id === prevMangaId) return
if (chapterListEl && prevMangaId !== null) saveScroll(`series:${prevMangaId}`, chapterListEl.scrollTop)
prevMangaId = mangaId
if (chapterListEl && mangaId !== null) {
chapterListEl.scrollTo({ top: getScroll(`series:${mangaId}`) })
prevMangaId = id
if (chapterListEl && id !== null) {
chapterListEl.scrollTo({ top: getScroll(`series:${id}`) })
}
})
@@ -318,27 +318,25 @@
async function enqueue(ch: Chapter, e: MouseEvent) {
e.stopPropagation()
enqueueing = new Set(enqueueing).add(ch.id)
await enqueueDownload(ch.id)
addToast({ kind: 'download', title: 'Download queued', body: ch.name })
const allowed = await downloadStore.enqueue(ch.id)
if (allowed) addToast({ kind: 'download', title: 'Download queued', body: ch.name })
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing)
if (seriesState.activeManga) reloadChapters(seriesState.activeManga.id)
reloadChapters(mangaId)
}
async function enqueueMultiple(chapterIds: number[]) {
if (!chapterIds.length) return
for (const id of chapterIds) {
const allowed = await enqueueDownload(id)
const allowed = await downloadStore.enqueue(id)
if (!allowed) return
}
addToast({ kind: 'download', title: 'Download queued', body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? 's' : ''} added` })
if (seriesState.activeManga) reloadChapters(seriesState.activeManga.id)
reloadChapters(mangaId)
}
async function markRead(chapterId: number, isRead: boolean) {
const mangaId = seriesState.activeManga?.id
await markChapterRead(chapterId, isRead).catch(console.error)
chapters = chapters.map(c => c.id === chapterId ? { ...c, read } : c)
if (mangaId) {
chapters = chapters.map(c => c.id === chapterId ? { ...c, read: isRead } : c)
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
checkAndMarkCompleted(mangaId, chapters)
const ch = chapters.find(c => c.id === chapterId)
@@ -346,10 +344,8 @@
if (isRead) await trackingState.updateFromRead(mangaId, ch, chapters, currentPrefs)
else await trackingState.updateFromUnread(mangaId, chapters, currentPrefs)
}
}
if (isRead) {
if (get('deleteOnRead')) {
const ch = chapters.find(c => c.id === chapterId)
if (ch?.downloaded) {
const delayMs = (get('deleteDelayHours') as number) * 60 * 60 * 1000
if (delayMs === 0) deleteDownloaded(chapterId)
@@ -369,11 +365,9 @@
async function markBulk(ids: number[], isRead: boolean) {
if (!ids.length) return
const mangaId = seriesState.activeManga?.id
await markChaptersRead(ids, isRead).catch(console.error)
const idSet = new Set(ids)
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, read } : c)
if (mangaId) {
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, read: isRead } : c)
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
checkAndMarkCompleted(mangaId, chapters)
if (isRead) {
@@ -383,7 +377,6 @@
} else {
await trackingState.updateFromUnread(mangaId, chapters, currentPrefs)
}
}
if (isRead && get('deleteOnRead')) {
const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.downloaded)
if (toDelete.length) {
@@ -391,7 +384,7 @@
const doDelete = async () => {
await deleteDownloadedChapters(toDelete).catch(console.error)
chapters = chapters.map(c => toDelete.includes(c.id) ? { ...c, downloaded: false } : c)
if (mangaId) chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
}
if (delayMs === 0) doDelete(); else setTimeout(doDelete, delayMs)
}
@@ -403,7 +396,7 @@
if (ids.length) {
await deleteDownloadedChapters(ids).catch(console.error)
chapters = chapters.map(c => ids.includes(c.id) ? { ...c, downloaded: false } : c)
if (seriesState.activeManga) chapterCache.set(seriesState.activeManga.id, { data: chapters, fetchedAt: Date.now() })
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
}
clearSelection()
}
@@ -426,7 +419,7 @@
async function deleteDownloaded(chapterId: number) {
await deleteDownloadedChapters([chapterId]).catch(console.error)
chapters = chapters.map(c => c.id === chapterId ? { ...c, downloaded: false } : c)
if (seriesState.activeManga) chapterCache.set(seriesState.activeManga.id, { data: chapters, fetchedAt: Date.now() })
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
}
async function deleteAllDownloads() {
@@ -435,16 +428,16 @@
deletingAll = true
await deleteDownloadedChapters(ids).catch(console.error)
chapters = chapters.map(c => ({ ...c, downloaded: false }))
if (seriesState.activeManga) chapterCache.set(seriesState.activeManga.id, { data: chapters, fetchedAt: Date.now() })
chapterCache.set(mangaId, { data: chapters, fetchedAt: Date.now() })
deletingAll = false
}
async function refreshChapters() {
if (!seriesState.activeManga || refreshing) return
if (refreshing) return
refreshing = true
chapterCache.delete(seriesState.activeManga.id)
fetchChapters(seriesState.activeManga.id)
.then(() => reloadChapters(seriesState.activeManga!.id))
chapterCache.delete(mangaId)
fetchChapters(mangaId)
.then(() => reloadChapters(mangaId))
.then(() => addToast({ kind: 'success', title: 'Chapters refreshed', body: `${chapters.length} chapter${chapters.length !== 1 ? 's' : ''} available` }))
.catch(e => addToast({ kind: 'error', title: 'Refresh failed', body: e?.message }))
.finally(() => refreshing = false)
@@ -464,7 +457,7 @@
{ label: 'Mark below as read', icon: ArrowFatLinesDown, onClick: () => markBelowRead(idx), disabled: idx === last || below.filter(c => !c.read).length === 0 },
{ label: 'Mark below as unread', icon: ArrowFatLineDown, onClick: () => markBelowUnread(idx), disabled: idx === last || below.filter(c => c.read).length === 0 },
{ separator: true },
{ label: ch.downloaded ? 'Delete download' : 'Download', icon: ch.downloaded ? Trash : Download, danger: ch.downloaded, onClick: () => ch.downloaded ? deleteDownloaded(ch.id) : enqueueDownload(ch.id) },
{ label: ch.downloaded ? 'Delete download' : 'Download', icon: ch.downloaded ? Trash : Download, danger: ch.downloaded, onClick: () => ch.downloaded ? deleteDownloaded(ch.id) : downloadStore.enqueue(ch.id) },
{ separator: true },
{ label: 'Download next 5 from here', icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx, idx + 5).filter(c => !c.downloaded).map(c => c.id)) },
{ label: 'Download all from here', icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx).filter(c => !c.downloaded).map(c => c.id)) },
@@ -493,9 +486,9 @@
const existing = seriesState.bookmarks.find(b => b.chapterId === ch.id)
if (!existing || existing.pageNumber < resumePage) {
addBookmark({
mangaId: seriesState.activeManga!.id,
mangaTitle: seriesState.activeManga!.title,
thumbnailUrl: seriesState.activeManga!.thumbnailUrl,
mangaId,
mangaTitle: manga!.title,
thumbnailUrl: manga!.thumbnailUrl,
chapterId: ch.id,
chapterName: ch.name,
pageNumber: resumePage,
@@ -520,9 +513,9 @@
const existing = seriesState.bookmarks.find(b => b.chapterId === cc.chapter.id)
if (!existing || existing.pageNumber < cc.resumePage) {
addBookmark({
mangaId: seriesState.activeManga!.id,
mangaTitle: seriesState.activeManga!.title,
thumbnailUrl: seriesState.activeManga!.thumbnailUrl,
mangaId,
mangaTitle: manga!.title,
thumbnailUrl: manga!.thumbnailUrl,
chapterId: cc.chapter.id,
chapterName: cc.chapter.name,
pageNumber: cc.resumePage,
@@ -553,12 +546,11 @@
}
async function toggleCategory(cat: Category) {
if (!seriesState.activeManga) return
const inCat = mangaCategories.some(c => c.id === cat.id)
try {
await updateMangaCategories(seriesState.activeManga.id, inCat ? [] : [cat.id], inCat ? [cat.id] : [])
await updateMangaCategories(mangaId, inCat ? [] : [cat.id], inCat ? [cat.id] : [])
if (!inCat && !manga?.inLibrary) {
await updateManga(seriesState.activeManga.id, { inLibrary: true }).catch(console.error)
await updateManga(mangaId, { inLibrary: true }).catch(console.error)
if (manga) manga = { ...manga, inLibrary: true }
}
mangaCategories = inCat ? mangaCategories.filter(c => c.id !== cat.id) : [...mangaCategories, cat]
@@ -566,12 +558,12 @@
}
async function createNewCategory(name: string) {
if (!name || !seriesState.activeManga) return
if (!name) return
try {
const cat = await createCategoryReq(name)
await updateMangaCategories(seriesState.activeManga.id, [cat.id], [])
await updateMangaCategories(mangaId, [cat.id], [])
if (!manga?.inLibrary) {
await updateManga(seriesState.activeManga.id, { inLibrary: true }).catch(console.error)
await updateManga(mangaId, { inLibrary: true }).catch(console.error)
if (manga) manga = { ...manga, inLibrary: true }
}
allCategories = [...allCategories, cat]
@@ -580,7 +572,6 @@
}
</script>
{#if seriesState.activeMangaId}
<div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}>
<SeriesHeader
@@ -608,6 +599,7 @@
onMarkersToggle={() => markersOpen = !markersOpen}
onLinkPickerOpen={openLinkPicker}
onCoverPickerOpen={openCoverPicker}
onGenreClick={(genre) => goto(`/browse?genre=${encodeURIComponent(genre)}`)}
/>
<div class="list-wrap">
@@ -648,6 +640,7 @@
onSetScanlatorFilter={(v) => set('scanlatorFilter', v)}
onSetScanlatorBlacklist={(v) => set('scanlatorBlacklist', v)}
onSetScanlatorForce={(v) => set('scanlatorForce', v)}
onOpenFolder={() => manga && openMangaFolder(manga)}
/>
<ChapterList
@@ -711,55 +704,18 @@
{manga}
currentChapters={chapters}
onClose={() => migrateOpen = false}
onMigrated={(newManga) => { setActiveManga(newManga); migrateOpen = false }}
onMigrated={(newManga) => { goto(`/series/${newManga.id}`); migrateOpen = false }}
/>
{/if}
{/if}
<MangaPreview />
<style>
.root {
display: flex; height: 100%; overflow: hidden;
animation: fadeIn 0.14s ease both;
}
.root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
.list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.markers-panel-overlay,
.panel-overlay {
position: fixed; inset: 0; z-index: var(--z-settings);
display: flex; align-items: stretch; justify-content: flex-start;
animation: fadeIn 0.12s ease both;
}
.markers-panel-drawer,
.panel-drawer {
width: 280px; max-width: 90vw;
background: var(--bg-surface); border-right: 1px solid var(--border-base);
box-shadow: 4px 0 24px rgba(0,0,0,0.4);
display: flex; flex-direction: column;
animation: drawerIn 0.18s cubic-bezier(0.16,1,0.3,1) both;
}
.panel-overlay { position: fixed; inset: 0; z-index: var(--z-settings); display: flex; align-items: stretch; justify-content: flex-start; animation: fadeIn 0.12s ease both; }
.panel-drawer { width: 280px; max-width: 90vw; background: var(--bg-surface); border-right: 1px solid var(--border-base); box-shadow: 4px 0 24px rgba(0,0,0,0.4); display: flex; flex-direction: column; animation: drawerIn 0.18s cubic-bezier(0.16,1,0.3,1) both; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes drawerIn { from { opacity: 0; transform: translateX(-12px) } to { opacity: 1; transform: translateX(0) } }
.modal-overlay {
position: fixed; inset: 0; z-index: var(--z-settings);
display: flex; align-items: center; justify-content: center;
background: rgba(0,0,0,0.5);
animation: fadeIn 0.12s ease both;
}
.modal-dialog {
width: 480px; max-width: 90vw; max-height: 80vh;
background: var(--bg-surface); border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
box-shadow: 0 8px 48px rgba(0,0,0,0.5);
display: flex; flex-direction: column;
animation: modalIn 0.18s cubic-bezier(0.16,1,0.3,1) both;
}
.modal-overlay { position: fixed; inset: 0; z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.5); animation: fadeIn 0.12s ease both; }
.modal-dialog { width: 480px; max-width: 90vw; max-height: 80vh; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-lg); box-shadow: 0 8px 48px rgba(0,0,0,0.5); display: flex; flex-direction: column; animation: modalIn 0.18s cubic-bezier(0.16,1,0.3,1) both; }
@keyframes modalIn { from { opacity: 0; transform: scale(0.96) translateY(8px) } to { opacity: 1; transform: scale(1) translateY(0) } }
</style>
+19 -12
View File
@@ -1,7 +1,7 @@
<script lang="ts">
import {
ArrowLeft, BookmarkSimple, ArrowSquareOut, Play, CaretDown,
Eye, ArrowsClockwise, LinkSimpleHorizontalBreak, ChartLineUp,
ArrowsClockwise, LinkSimpleHorizontalBreak, ChartLineUp,
MapPin, Gear, Trash, Image,
} from 'phosphor-svelte'
import { goto } from '$app/navigation'
@@ -9,9 +9,9 @@
import { get } from 'svelte/store'
import Thumbnail from '$lib/components/shared/manga/Thumbnail.svelte'
import { resolvedCover } from '$lib/core/cover/coverResolver'
import type { MangaPrefs } from '$lib/state/series.svelte'
import { setGenreFilter, setNavPage } from '$lib/state/app.svelte'
import { seriesState, setActiveManga, setPreviewManga } from '$lib/state/series.svelte'
import type { MangaPrefs } from '$lib/types/settings'
import { seriesState } from '$lib/state/series.svelte'
import { setPreviewManga } from '$lib/state/series.svelte'
interface ContinueChapter {
chapter: Chapter
@@ -44,6 +44,7 @@
onMarkersToggle: () => void
onLinkPickerOpen: () => void
onCoverPickerOpen:() => void
onGenreClick: (genre: string) => void
}
let {
@@ -53,6 +54,7 @@
mangaCategories, togglingLibrary,
onRead, onToggleLibrary, onDeleteAll, onMigrateOpen,
onTrackingOpen, onAutoOpen, onMarkersToggle, onLinkPickerOpen, onCoverPickerOpen,
onGenreClick,
}: Props = $props()
let manageOpen: boolean = $state(false)
@@ -89,9 +91,11 @@
<ArrowLeft size={13} weight="light" /> Back
</button>
<button class="cover-wrap" onclick={() => setPreviewManga(manga)}>
<div class="cover-wrap">
<button class="cover-btn" onclick={() => manga && setPreviewManga(manga)} title="Quick preview" disabled={!manga}>
<Thumbnail src={resolvedCover(seriesState.activeManga?.id ?? manga?.id ?? 0, seriesState.activeManga?.thumbnailUrl ?? manga?.thumbnailUrl ?? "")} alt={seriesState.activeManga?.title ?? manga?.title ?? ""} class="cover" />
</button>
</div>
{#if loadingManga}
<div class="meta-skeleton">
@@ -132,7 +136,7 @@
{#if manga?.genre?.length}
<div class="genres">
{#each (genresExpanded ? manga.genre : manga.genre.slice(0, 3)) as g}
<button class="genre" onclick={() => { setGenreFilter(g); setNavPage('search'); setActiveManga(null) }}>{g}</button>
<button class="genre" onclick={() => onGenreClick(g)}>{g}</button>
{/each}
{#if manga.genre.length > 3}
<button class="genre-toggle" onclick={() => genresExpanded = !genresExpanded}>
@@ -145,7 +149,7 @@
{#if manga?.description}
<div class="desc-wrap">
<p class="desc">{manga.description}</p>
<button class="expand-toggle" onclick={() => setPreviewManga(manga)}>Read more</button>
<button class="expand-toggle" onclick={() => genresExpanded = !genresExpanded}>Read more</button>
</div>
{/if}
</div>
@@ -192,9 +196,6 @@
{#if manageOpen}
<div class="details-body">
<div class="detail-actions">
<button class="detail-action-btn" onclick={() => setPreviewManga(manga)}>
<Eye size={12} weight="light" /> Preview
</button>
<button class="detail-action-btn" onclick={onMigrateOpen}>
<ArrowsClockwise size={12} weight="light" /> Switch Source
</button>
@@ -251,10 +252,16 @@
width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md);
overflow: hidden; background: var(--bg-raised);
border: 1px solid var(--border-dim); flex-shrink: 0;
cursor: pointer; transition: opacity var(--t-base); padding: 0;
position: relative;
}
.cover-wrap:hover { opacity: 0.88; }
.cover-btn {
display: block; position: absolute; inset: 0;
width: 100%; height: 100%;
background: none; border: none; padding: 0; cursor: pointer;
transition: filter var(--t-base);
}
.cover-btn:hover:not(:disabled) { filter: brightness(0.85); }
.cover-btn:disabled { cursor: default; }
:global(.cover) {
display: block;
position: absolute; inset: 0;
@@ -8,6 +8,7 @@
import CoverPickerPanel from "$lib/components/series/panels/CoverPickerPanel.svelte";
import SeriesLinkPanel from "$lib/components/shared/manga/SeriesLinkPanel.svelte";
import { getAdapter } from "$lib/request-manager";
import { goto } from "$app/navigation";
import { cache, CACHE_KEYS } from "$lib/core/cache/queryCache";
import { resolvedCover } from "$lib/core/cover/coverResolver";
import { autoLinkLibrary } from "$lib/core/cover/autoLink";
@@ -17,7 +18,7 @@
seriesState,
setPreviewManga, setActiveManga, openReader, addBookmark,
} from "$lib/state/series.svelte";
import { app, setNavPage, setGenreFilter } from "$lib/state/app.svelte";
import { app } from "$lib/state/app.svelte";
import type { Manga, Chapter, Category } from "$lib/types";
@@ -266,7 +267,7 @@
getAdapter().getCategories()
.then((cats) => {
allCategories = cats.filter((c) => c.id !== 0);
mangaCategories = allCategories.filter((c) => c.mangas?.some((m) => m.id === mangaId));
mangaCategories = allCategories.filter((c) => c.mangas?.nodes?.some((m) => m.id === mangaId));
})
.catch(console.error)
.finally(() => { catsLoading = false; });
@@ -593,12 +594,12 @@
</div>
{/if}
{#if !loadingDetail && displayManga?.genre?.length}
{#if !loadingDetail && displayManga?.tags?.length}
<div class="genres">
{#each displayManga.genre as g}
{#each displayManga.tags as g}
<button
class="genre-tag"
onclick={() => { setGenreFilter(g); setNavPage("search"); close(); }}
onclick={() => { close(); goto(`/browse?genre=${encodeURIComponent(g)}`); }}
>{g}</button>
{/each}
</div>
+48
View File
@@ -0,0 +1,48 @@
import { platformService } from '$lib/platform-service'
import { seriesState } from '$lib/state/series.svelte'
import { addToast } from '$lib/state/notifications.svelte'
import type { Manga } from '$lib/types'
function sanitizeTitle(title: string): string {
return title.replace(/[\\/:*?"<>|]/g, '').replace(/\s+/g, ' ').trim()
}
async function getDownloadsRoot(): Promise<string> {
let root = (seriesState.settings as any).downloadsPath?.trim() ?? ''
if (!root) root = await platformService.getDefaultDownloadsPath().catch(() => '')
return root
}
function join(root: string, ...parts: string[]): string {
const sep = root.includes('\\') ? '\\' : '/'
return [root.replace(/[/\\]$/, ''), ...parts].join(sep)
}
export async function openMangaFolder(manga: Manga): Promise<void> {
if (!platformService.isSupported('filesystem')) {
addToast({ kind: 'info', title: 'Desktop only', body: 'Opening folders requires the desktop app.' })
return
}
const root = await getDownloadsRoot()
if (!root) return
await platformService.openPath(join(root, 'mangas', sanitizeTitle(manga.title))).catch(console.error)
}
export async function openCustomFolder(path: string): Promise<void> {
if (!platformService.isSupported('filesystem')) {
addToast({ kind: 'info', title: 'Desktop only', body: 'Opening folders requires the desktop app.' })
return
}
if (!path?.trim()) return
await platformService.openPath(path).catch(console.error)
}
export async function openDownloadsFolder(): Promise<void> {
if (!platformService.isSupported('filesystem')) {
addToast({ kind: 'info', title: 'Desktop only', body: 'Opening folders requires the desktop app.' })
return
}
const root = await getDownloadsRoot()
if (!root) return
await platformService.openPath(join(root, 'mangas')).catch(console.error)
}
+36 -20
View File
@@ -1,44 +1,60 @@
import { getAdapter } from "$lib/request-manager";
import { downloadsState } from "$lib/state/downloads.svelte";
import { invoke } from "@tauri-apps/api/core";
import type { DownloadStatus } from "$lib/types/api";
export async function loadDownloads() {
export async function loadDownloadStatus(): Promise<DownloadStatus | null> {
try {
downloadsState.items = await getAdapter().getDownloads();
} catch (e) {
downloadsState.error = String(e);
return await getAdapter().getDownloadStatus();
} catch {
return null;
}
}
export async function enqueueDownload(chapterId: string) {
export async function enqueueDownload(chapterId: string): Promise<void> {
await getAdapter().enqueueDownload(chapterId);
await loadDownloads();
}
export async function enqueueDownloads(chapterIds: string[]) {
export async function enqueueDownloads(chapterIds: string[]): Promise<void> {
await getAdapter().enqueueDownloads(chapterIds);
await loadDownloads();
}
export async function dequeueDownload(chapterId: string) {
export async function dequeueDownload(chapterId: string): Promise<void> {
await getAdapter().dequeueDownload(chapterId);
downloadsState.items = downloadsState.items.filter(d => d.chapterId !== chapterId);
}
export async function dequeueDownloads(chapterIds: string[]) {
const ids = new Set(chapterIds);
export async function dequeueDownloads(chapterIds: string[]): Promise<void> {
await getAdapter().dequeueDownloads(chapterIds);
downloadsState.items = downloadsState.items.filter(d => !ids.has(d.chapterId));
}
export async function clearDownloads() {
export async function reorderDownload(chapterId: number, to: number): Promise<DownloadStatus | null> {
try {
return await getAdapter().reorderDownload(String(chapterId), to);
} catch {
return null;
}
}
export async function clearDownloads(): Promise<void> {
await getAdapter().clearDownloads();
downloadsState.items = [];
}
export async function startDownloader() {
await getAdapter().startDownloader();
export async function startDownloader(): Promise<DownloadStatus | null> {
try {
return await getAdapter().startDownloader();
} catch {
return null;
}
}
export async function stopDownloader() {
await getAdapter().stopDownloader();
export async function stopDownloader(): Promise<DownloadStatus | null> {
try {
return await getAdapter().stopDownloader();
} catch {
return null;
}
}
export async function getStorageInfo(downloadsPath: string): Promise<{ freeBytes: number }> {
const info = await invoke<{ free_bytes: number }>("get_storage_info", { downloadsPath });
return { freeBytes: info.free_bytes };
}
+112 -8
View File
@@ -14,6 +14,7 @@ import type {
SetSocksProxyInput,
SetFlareSolverrInput,
} from '$lib/server-adapters/types'
import type { DownloadStatus } from '$lib/types/api'
import type { Manga, Chapter, Extension, Source, Tracker, Category } from '$lib/types'
import {
GET_LIBRARY,
@@ -35,6 +36,7 @@ import {
DELETE_MANGA_META,
FETCH_SOURCE_MANGA,
LIBRARY_UPDATE_STATUS,
MANGAS_BY_GENRE,
} from './manga'
import {
GET_CHAPTERS,
@@ -55,6 +57,7 @@ import {
ENQUEUE_CHAPTERS_DOWNLOAD,
DEQUEUE_DOWNLOAD,
DEQUEUE_CHAPTERS_DOWNLOAD,
REORDER_DOWNLOAD,
START_DOWNLOADER,
STOP_DOWNLOADER,
CLEAR_DOWNLOADER,
@@ -132,6 +135,20 @@ const SET_FLARE_SOLVERR = `
}
`
type RawQueueItem = Record<string, unknown>
function mapDownloadStatus(raw: { state: string; queue: RawQueueItem[] }): DownloadStatus {
return {
state: raw.state,
queue: raw.queue.map(item => ({
progress: (item.progress as number) ?? 0,
state: item.state as string,
tries: (item.tries as number) ?? 0,
chapter: item.chapter as DownloadStatus['queue'][number]['chapter'],
})),
}
}
export class SuwayomiAdapter implements ServerAdapter {
private baseUrl = 'http://127.0.0.1:4567'
private authHeader: string | null = null
@@ -300,11 +317,29 @@ export class SuwayomiAdapter implements ServerAdapter {
await this.gql(DELETE_CHAPTER_META, { chapterId: Number(chapterId), key })
}
// ── Downloads ──────────────────────────────────────────────────────────────
/** @deprecated Use getDownloadStatus() — kept for any legacy callers. */
async getDownloads(): Promise<DownloadItem[]> {
const data = await this.gql<{ downloadStatus: { queue: Record<string, unknown>[] } }>(
GET_DOWNLOAD_STATUS
)
return data.downloadStatus.queue.map(mapDownloadItem)
const status = await this.getDownloadStatus()
return status.queue.map(item => ({
chapterId: String(item.chapter.id),
mangaId: String(item.chapter.mangaId ?? item.chapter.manga?.id),
chapterName: item.chapter.name,
mangaTitle: item.chapter.manga?.title ?? '',
progress: item.progress,
state: item.state === 'DOWNLOADING' ? 'downloading'
: item.state === 'FINISHED' ? 'finished'
: item.state === 'ERROR' ? 'error'
: 'queued',
}))
}
async getDownloadStatus(): Promise<DownloadStatus> {
const data = await this.gql<{
downloadStatus: { state: string; queue: RawQueueItem[] }
}>(GET_DOWNLOAD_STATUS)
return mapDownloadStatus(data.downloadStatus)
}
async enqueueDownload(chapterId: string): Promise<void> {
@@ -323,18 +358,41 @@ export class SuwayomiAdapter implements ServerAdapter {
await this.gql(DEQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: chapterIds.map(Number) })
}
async reorderDownload(chapterId: string, to: number): Promise<DownloadStatus | null> {
try {
const data = await this.gql<{
reorderChapterDownload: { downloadStatus: { state: string; queue: RawQueueItem[] } }
}>(REORDER_DOWNLOAD, { chapterId: Number(chapterId), to })
return mapDownloadStatus(data.reorderChapterDownload.downloadStatus)
} catch {
return null
}
}
async clearDownloads(): Promise<void> {
await this.gql(CLEAR_DOWNLOADER)
}
async startDownloader(): Promise<void> {
await this.gql(START_DOWNLOADER)
async startDownloader(): Promise<DownloadStatus | null> {
try {
const data = await this.gql<{
startDownloader: { downloadStatus: { state: string; queue: RawQueueItem[] } }
}>(START_DOWNLOADER)
return mapDownloadStatus(data.startDownloader.downloadStatus)
} catch { return null }
}
async stopDownloader(): Promise<void> {
await this.gql(STOP_DOWNLOADER)
async stopDownloader(): Promise<DownloadStatus | null> {
try {
const data = await this.gql<{
stopDownloader: { downloadStatus: { state: string; queue: RawQueueItem[] } }
}>(STOP_DOWNLOADER)
return mapDownloadStatus(data.stopDownloader.downloadStatus)
} catch { return null }
}
// ── Extensions & Sources ───────────────────────────────────────────────────
async getExtensions(): Promise<Extension[]> {
await this.gql(FETCH_EXTENSIONS)
const data = await this.gql<{ extensions: { nodes: Record<string, unknown>[] } }>(GET_EXTENSIONS)
@@ -376,6 +434,8 @@ export class SuwayomiAdapter implements ServerAdapter {
}
}
// ── Categories ─────────────────────────────────────────────────────────────
async getCategories(): Promise<Category[]> {
const data = await this.gql<{ categories: { nodes: Record<string, unknown>[] } }>(GET_CATEGORIES)
return data.categories.nodes.map(mapCategory)
@@ -411,6 +471,8 @@ export class SuwayomiAdapter implements ServerAdapter {
await this.gql(UPDATE_CATEGORY_MANGA, { categoryId })
}
// ── Tracking ───────────────────────────────────────────────────────────────
async getTrackers(): Promise<Tracker[]> {
const data = await this.gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS)
return data.trackers.nodes
@@ -450,6 +512,8 @@ export class SuwayomiAdapter implements ServerAdapter {
await this.gql(TRACK_PROGRESS, { mangaId: Number(mangaId) })
}
// ── Security ───────────────────────────────────────────────────────────────
async getServerSecurity(): Promise<ServerSecurity> {
const data = await this.gql<{ settings: ServerSecurity }>(GET_SERVER_SECURITY)
return data.settings
@@ -471,6 +535,45 @@ export class SuwayomiAdapter implements ServerAdapter {
await this.gql(SET_FLARE_SOLVERR, input)
}
// ── Browse / Search ────────────────────────────────────────────────────────
async searchSource(
sourceId: string,
query: string,
page = 1,
signal?: AbortSignal,
): Promise<PaginatedResult<Manga>> {
const data = await this.gql<{
fetchSourceManga: { mangas: Record<string, unknown>[]; hasNextPage: boolean }
}>(FETCH_SOURCE_MANGA, { source: sourceId, type: 'SEARCH', page, query }, signal)
return {
items: data.fetchSourceManga.mangas.map(mapManga),
hasNextPage: data.fetchSourceManga.hasNextPage,
}
}
async getMangasByGenre(
filter: Record<string, unknown>,
first: number,
offset: number,
signal?: AbortSignal,
): Promise<{ items: Manga[]; hasNextPage: boolean; totalCount: number }> {
const data = await this.gql<{
mangas: {
nodes: Record<string, unknown>[];
pageInfo: { hasNextPage: boolean };
totalCount: number;
}
}>(MANGAS_BY_GENRE, { filter, first, offset }, signal)
return {
items: data.mangas.nodes.map(mapManga),
hasNextPage: data.mangas.pageInfo.hasNextPage,
totalCount: data.mangas.totalCount,
}
}
// ── Library updates ────────────────────────────────────────────────────────
async checkForUpdates(mangaIds?: string[]): Promise<UpdateResult[]> {
if (mangaIds?.length) {
const results: UpdateResult[] = []
@@ -504,3 +607,4 @@ export class SuwayomiAdapter implements ServerAdapter {
_clearPageCache(chapterId)
}
}
+2 -1
View File
@@ -80,6 +80,7 @@ function mapDownloadState(state: string): DownloadItem['state'] {
}
export function mapCategory(raw: Record<string, unknown>): Category {
const mangaNodes = (raw.mangas as { nodes: Record<string, unknown>[] })?.nodes ?? []
return {
id: raw.id as number,
name: raw.name as string,
@@ -87,6 +88,6 @@ export function mapCategory(raw: Record<string, unknown>): Category {
default: raw.default as boolean,
includeInUpdate: raw.includeInUpdate as boolean,
includeInDownload: raw.includeInDownload as boolean,
mangas: (raw.mangas as { nodes: Record<string, unknown>[] })?.nodes?.map(mapManga) ?? [],
mangas: { nodes: mangaNodes.map(mapManga) },
}
}
+5 -2
View File
@@ -1,3 +1,4 @@
import type { DownloadStatus } from '$lib/types/api'
import type { Manga, Chapter, Extension, Source, Tracker, Category } from '$lib/types'
export interface ServerConfig {
@@ -131,13 +132,15 @@ export interface ServerAdapter {
deleteChapterMeta(chapterId: string, key: string): Promise<void>
getDownloads(): Promise<DownloadItem[]>
getDownloadStatus(): Promise<DownloadStatus>
enqueueDownload(chapterId: string): Promise<void>
enqueueDownloads(chapterIds: string[]): Promise<void>
dequeueDownload(chapterId: string): Promise<void>
dequeueDownloads(chapterIds: string[]): Promise<void>
reorderDownload(chapterId: string, to: number): Promise<DownloadStatus | null>
clearDownloads(): Promise<void>
startDownloader(): Promise<void>
stopDownloader(): Promise<void>
startDownloader(): Promise<DownloadStatus | null>
stopDownloader(): Promise<DownloadStatus | null>
getExtensions(): Promise<Extension[]>
installExtension(id: string): Promise<void>
+6 -16
View File
@@ -1,22 +1,13 @@
export type NavPage =
| 'home' | 'library' | 'sources' | 'explore'
| 'downloads' | 'extensions' | 'history' | 'search' | 'tracking'
export type AppStatus = 'booting' | 'not-configured' | 'auth' | 'ready' | 'error'
class AppStore {
navPage: NavPage = $state('home')
settingsOpen: boolean = $state(false)
searchPrefill: string = $state('')
searchQuery: string = $state('')
genreFilter: string = $state('')
navPage: string = $state('')
scrollPositions: Map<string, number> = $state(new Map())
setNavPage(next: NavPage) { this.navPage = next }
setSettingsOpen(next: boolean) { this.settingsOpen = next }
setSearchPrefill(next: string) { this.searchPrefill = next }
setSearchQuery(next: string) { this.searchQuery = next }
setGenreFilter(next: string) { this.genreFilter = next }
setNavPage(next: string) { this.navPage = next }
saveScroll(key: string, top: number) {
const m = new Map(this.scrollPositions)
m.set(key, top)
@@ -36,15 +27,14 @@ export const appState = $state({
platform: 'web' as 'web' | 'tauri' | 'capacitor',
version: '',
libraryFilter: '',
navPage: '',
categories: [] as { id: number; name: string }[],
history: [] as unknown[],
toasts: [] as unknown[],
})
export function setNavPage(next: NavPage) { app.setNavPage(next) }
export function setSettingsOpen(next: boolean) { app.setSettingsOpen(next) }
export function setSearchPrefill(next: string) { app.setSearchPrefill(next) }
export function setSearchQuery(next: string) { app.setSearchQuery(next) }
export function setGenreFilter(next: string) { app.setGenreFilter(next) }
export function saveScroll(key: string, top: number) { app.saveScroll(key, top) }
export function getScroll(key: string): number { return app.getScroll(key) }
export function setGenreFilter(genre: string) { appState.libraryFilter = genre }
export function setNavPage(page: string) { app.setNavPage(page); appState.navPage = page }
+338 -14
View File
@@ -1,18 +1,342 @@
import type { DownloadItem } from "$lib/server-adapters/types";
import type { DownloadStatus, DownloadQueueItem } from "$lib/types/api";
import {
loadDownloadStatus, dequeueDownload, dequeueDownloads,
reorderDownload, clearDownloads, startDownloader, stopDownloader, enqueueDownload,
getStorageInfo,
} from "$lib/request-manager/downloads";
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
import { addToast } from "$lib/state/notifications.svelte";
import {
isRunning, getErrored, calcSpeed, estimateEta, estimateQueueBytes,
type SpeedSample,
} from "$lib/components/downloads/lib/downloadQueue";
import { startAutoRetry, type AutoRetryHandle } from "$lib/components/downloads/lib/autoRetry";
import { mount, unmount } from "svelte";
import StorageWarningDialog from "$lib/components/downloads/StorageWarningDialog.svelte";
export const downloadsState = $state({
items: [] as DownloadItem[],
error: null as string | null,
});
class DownloadStore {
status: DownloadStatus | null = $state(null);
loading = $state(true);
togglingPlay = $state(false);
clearing = $state(false);
dequeueing = $state(new Set<number>());
selected = $state(new Set<number>());
batchWorking = $state(false);
pagesPerSec: number | null = $state(null);
eta: number | null = $state(null);
storageWarning = $state(false);
export function activeDownloads() {
return downloadsState.items.filter(d => d.state === "downloading");
private freeBytes: number | null = null;
private lastSample: SpeedSample | null = null;
private prevQueue: DownloadQueueItem[] = [];
private autoRetryHnd: AutoRetryHandle | null = null;
get queue() { return this.status?.queue ?? []; }
get isRunning() { return isRunning(this.status?.state); }
get erroredIds() { return new Set(getErrored(this.queue).map(i => i.chapter.id)); }
get hasErrored() { return this.erroredIds.size > 0; }
get toastsEnabled() { return settingsState.settings.downloadToastsEnabled ?? true; }
get autoRetryEnabled() { return settingsState.settings.downloadAutoRetry ?? false; }
private applyStatus(ds: DownloadStatus) {
this.detectTransitions(ds.queue);
this.status = ds;
this.updateSpeed(ds);
this.syncFreeBytes(ds);
}
private updateSpeed(ds: DownloadStatus) {
const active = ds.queue[0];
if (!active || active.state !== "DOWNLOADING") {
this.lastSample = null; this.pagesPerSec = null; this.eta = null;
return;
}
const sample: SpeedSample = { ts: Date.now(), progress: active.progress, pages: active.chapter.pageCount ?? 0 };
const speed = calcSpeed(this.lastSample, sample);
this.lastSample = sample;
if (speed !== null) { this.pagesPerSec = speed; this.eta = estimateEta(speed, ds.queue); }
}
private async syncFreeBytes(ds: DownloadStatus) {
const path = settingsState.settings.serverDownloadsPath ?? "";
if (!path) return;
try {
const info = await getStorageInfo(path);
this.freeBytes = info.freeBytes;
this.storageWarning = estimateQueueBytes(ds.queue) > info.freeBytes * 0.95;
} catch { }
}
private confirmStorageOverrun(): Promise<boolean> {
return new Promise(resolve => {
const target = document.createElement("div");
document.body.appendChild(target);
const instance = mount(StorageWarningDialog, {
target,
props: {
onConfirm: () => { unmount(instance); target.remove(); resolve(true); },
onCancel: () => { unmount(instance); target.remove(); resolve(false); },
},
});
});
}
private async guardStorage(queueAfter: DownloadQueueItem[]): Promise<boolean> {
if (this.freeBytes === null) return true;
if (estimateQueueBytes(queueAfter) <= this.freeBytes * 0.95) return true;
return this.confirmStorageOverrun();
}
detectTransitions(next: DownloadQueueItem[]) {
if (!this.toastsEnabled) return;
const nextMap = new Map(next.map(i => [i.chapter.id, i]));
for (const item of this.prevQueue) {
if (item.state !== "DOWNLOADING") continue;
const nextItem = nextMap.get(item.chapter.id);
const label = item.chapter.manga
? `${item.chapter.manga.title}${item.chapter.name}`
: item.chapter.name;
if (!nextItem) addToast({ kind: "download", title: "Chapter downloaded", body: label, duration: 4000 });
else if (nextItem.state === "ERROR") addToast({ kind: "error", title: "Download failed", body: label, duration: 5000 });
}
this.prevQueue = next.slice();
}
async poll() {
try {
const ds = await loadDownloadStatus();
if (ds) this.applyStatus(ds);
} catch { } finally {
this.loading = false;
}
}
async enqueue(chapterId: number): Promise<boolean> {
const projected = [...this.queue, { chapter: { id: chapterId, pageCount: 0 }, progress: 0, state: "QUEUED" } as DownloadQueueItem];
if (!(await this.guardStorage(projected))) return false;
try { await enqueueDownload(String(chapterId)); await this.poll(); } catch { }
return true;
}
toggleToasts() {
const next = !this.toastsEnabled;
updateSettings({ downloadToastsEnabled: next });
addToast({ kind: "info", title: next ? "Notifications enabled" : "Notifications muted", duration: 2500 });
}
toggleAutoRetry() {
if (this.autoRetryEnabled) {
this.autoRetryHnd?.stop();
this.autoRetryHnd = null;
updateSettings({ downloadAutoRetry: false });
addToast({ kind: "info", title: "Auto-retry disabled", duration: 2500 });
} else {
updateSettings({ downloadAutoRetry: true });
this.autoRetryHnd = startAutoRetry(
() => this.queue,
() => this.isRunning,
() => this.retryAllErrored(),
);
addToast({ kind: "info", title: "Auto-retry enabled", duration: 3000 });
}
}
async togglePlay() {
if (this.togglingPlay) return;
this.togglingPlay = true;
const wasRunning = this.isRunning;
if (this.status) this.status = { ...this.status, state: wasRunning ? "STOPPED" : "STARTED" };
try {
const ds = wasRunning ? await stopDownloader() : await startDownloader();
if (ds) this.applyStatus(ds); else await this.poll();
} catch { await this.poll(); }
finally { this.togglingPlay = false; }
}
async clear() {
if (this.clearing) return;
this.clearing = true;
this.selected = new Set();
if (this.status) this.status = { ...this.status, queue: [] };
try {
await clearDownloads();
addToast({ kind: "info", title: "Queue cleared", duration: 2500 });
} catch { await this.poll(); }
finally { this.clearing = false; }
}
async dequeue(chapterId: number) {
if (this.dequeueing.has(chapterId)) return;
this.dequeueing = new Set(this.dequeueing).add(chapterId);
if (this.status) this.status = { ...this.status, queue: this.queue.filter(i => i.chapter.id !== chapterId) };
const next = new Set(this.selected); next.delete(chapterId); this.selected = next;
try { await dequeueDownload(String(chapterId)); await this.poll(); }
catch { await this.poll(); }
finally { const s = new Set(this.dequeueing); s.delete(chapterId); this.dequeueing = s; }
}
async dequeueSelected() {
if (this.batchWorking || !this.selected.size) return;
this.batchWorking = true;
const ids = [...this.selected];
const idSet = new Set(ids);
this.selected = new Set();
if (this.status) this.status = { ...this.status, queue: this.queue.filter(i => !idSet.has(i.chapter.id)) };
try {
await dequeueDownloads(ids.map(String));
addToast({ kind: "info", title: `Removed ${ids.length} download${ids.length !== 1 ? "s" : ""}`, duration: 2500 });
await this.poll();
} catch { await this.poll(); }
finally { this.batchWorking = false; }
}
async retryOne(chapterId: number) {
if (this.dequeueing.has(chapterId)) return;
this.dequeueing = new Set(this.dequeueing).add(chapterId);
try {
await dequeueDownload(String(chapterId));
const projected = this.queue.filter(i => i.chapter.id !== chapterId);
if (!(await this.guardStorage(projected))) { await this.poll(); return; }
await enqueueDownload(String(chapterId));
await this.poll();
} catch { await this.poll(); }
finally { const s = new Set(this.dequeueing); s.delete(chapterId); this.dequeueing = s; }
}
async retryAllErrored() {
if (this.batchWorking || !this.hasErrored) return;
this.batchWorking = true;
const ids = [...this.erroredIds];
try {
await dequeueDownloads(ids.map(String));
const projected = this.queue.filter(i => !this.erroredIds.has(i.chapter.id));
if (!(await this.guardStorage(projected))) { await this.poll(); return; }
for (const id of ids) await enqueueDownload(String(id));
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
await this.poll();
} catch { await this.poll(); }
finally { this.batchWorking = false; }
}
async retrySelected() {
if (this.batchWorking || !this.selected.size) return;
this.batchWorking = true;
const ids = [...this.selected].filter(id => this.erroredIds.has(id));
this.selected = new Set();
try {
if (ids.length) {
await dequeueDownloads(ids.map(String));
const projected = this.queue.filter(i => !new Set(ids).has(i.chapter.id));
if (!(await this.guardStorage(projected))) { await this.poll(); return; }
for (const id of ids) await enqueueDownload(String(id));
addToast({ kind: "info", title: `Retrying ${ids.length} failed download${ids.length !== 1 ? "s" : ""}`, duration: 3000 });
}
await this.poll();
} catch { await this.poll(); }
finally { this.batchWorking = false; }
}
async reorder(chapterId: number, direction: "up" | "down") {
const idx = this.queue.findIndex(i => i.chapter.id === chapterId);
if (idx === -1) return;
const to = direction === "up" ? idx - 1 : idx + 1;
if (to < 0 || to >= this.queue.length) return;
const newQueue = [...this.queue];
[newQueue[idx], newQueue[to]] = [newQueue[to], newQueue[idx]];
if (this.status) this.status = { ...this.status, queue: newQueue };
try {
const ds = await reorderDownload(chapterId, to);
if (ds) this.applyStatus(ds); else await this.poll();
} catch { await this.poll(); }
}
async reorderSelected(direction: "up" | "down", step: number = 1) {
if (this.batchWorking || !this.selected.size) return;
this.batchWorking = true;
const queue = [...this.queue];
const selectedIndices = queue
.map((item, i) => ({ id: item.chapter.id, i }))
.filter(({ id }) => this.selected.has(id))
.map(({ i }) => i)
.sort((a, b) => direction === "up" ? a - b : b - a);
if (direction === "up" && selectedIndices[0] === 0) { this.batchWorking = false; return; }
if (direction === "down" && selectedIndices[0] === queue.length - 1) { this.batchWorking = false; return; }
const newQueue = [...queue];
for (const idx of selectedIndices) {
const to = direction === "up" ? Math.max(0, idx - step) : Math.min(newQueue.length - 1, idx + step);
[newQueue[idx], newQueue[to]] = [newQueue[to], newQueue[idx]];
}
if (this.status) this.status = { ...this.status, queue: newQueue };
try {
for (const idx of selectedIndices) {
const to = direction === "up" ? Math.max(0, idx - step) : Math.min(queue.length - 1, idx + step);
await reorderDownload(queue[idx].chapter.id, to);
}
await this.poll();
} catch { await this.poll(); }
finally { this.batchWorking = false; }
}
async reorderToEdge(chapterId: number, edge: "top" | "bottom") {
const idx = this.queue.findIndex(i => i.chapter.id === chapterId);
if (idx === -1) return;
const first = this.isRunning ? 1 : 0;
const last = this.queue.length - 1;
const to = edge === "top" ? first : last;
if (idx === to) return;
const newQueue = [...this.queue];
newQueue.splice(idx, 1);
newQueue.splice(to, 0, this.queue[idx]);
if (this.status) this.status = { ...this.status, queue: newQueue };
try {
const ds = await reorderDownload(chapterId, to);
if (ds) this.applyStatus(ds); else await this.poll();
} catch { await this.poll(); }
}
async reorderSelectedToEdge(edge: "top" | "bottom") {
if (this.batchWorking || !this.selected.size) return;
this.batchWorking = true;
const first = this.isRunning ? 1 : 0;
const active = this.queue.slice(0, first);
const moveable = this.queue.slice(first);
const pinned = moveable.filter(i => this.selected.has(i.chapter.id));
const rest = moveable.filter(i => !this.selected.has(i.chapter.id));
const newQueue = edge === "top" ? [...active, ...pinned, ...rest] : [...active, ...rest, ...pinned];
if (this.status) this.status = { ...this.status, queue: newQueue };
const last = this.queue.length - 1;
try {
if (edge === "top") {
for (let i = 0; i < pinned.length; i++)
await reorderDownload(pinned[i].chapter.id, first + i);
} else {
for (let i = 0; i < pinned.length; i++)
await reorderDownload(pinned[i].chapter.id, last - (pinned.length - 1 - i));
}
await this.poll();
} catch { await this.poll(); }
finally { this.batchWorking = false; }
}
selectOnly(chapterId: number) { this.selected = new Set([chapterId]); }
toggleSelect(chapterId: number) {
const next = new Set(this.selected);
next.has(chapterId) ? next.delete(chapterId) : next.add(chapterId);
this.selected = next;
}
selectRange(fromId: number, toId: number) {
const ids = this.queue.map(i => i.chapter.id);
const a = ids.indexOf(fromId), b = ids.indexOf(toId);
if (a === -1 || b === -1) return;
const [lo, hi] = a < b ? [a, b] : [b, a];
const next = new Set(this.selected);
for (let i = lo; i <= hi; i++) next.add(ids[i]);
this.selected = next;
}
selectAll() { this.selected = new Set(this.queue.map(i => i.chapter.id)); }
clearSelection() { this.selected = new Set(); }
}
export function queuedDownloads() {
return downloadsState.items.filter(d => d.state === "queued");
}
export function downloadCount() {
return downloadsState.items.length;
}
export const downloadStore = new DownloadStore();
+7
View File
@@ -1,6 +1,7 @@
import type { Manga } from "$lib/types";
import type { MangaStatus } from "$lib/server-adapters/types";
import type { Category } from "$lib/types";
import { settingsState, updateSettings } from "$lib/state/settings.svelte";
export type LibrarySortOption =
| "alphabetical"
@@ -203,6 +204,12 @@ class LibraryState {
this.tabFilters = { ...this.tabFilters, [tab]: {} };
}
syncFromSettings(s: { hiddenLibraryTabs?: string[]; libraryPinnedTabOrder?: string[]; defaultLibraryCategoryId?: number | null }) {
if (s.hiddenLibraryTabs) this.hiddenTabs = new Set(s.hiddenLibraryTabs);
if (s.libraryPinnedTabOrder) this.pinnedTabOrder = s.libraryPinnedTabOrder;
if (s.defaultLibraryCategoryId !== undefined) this.defaultCategoryId = s.defaultLibraryCategoryId ?? null;
}
setCategories(cats: Category[]) {
this.categories = cats;
}
+23
View File
@@ -11,10 +11,23 @@
import Toaster from '$lib/components/chrome/Toaster.svelte'
import Settings from '$lib/components/settings/Settings.svelte'
import ThemeEditor from '$lib/components/settings/ThemeEditor.svelte'
import { downloadStore } from '$lib/state/downloads.svelte'
import { seriesState } from '$lib/state/series.svelte'
import MangaPreview from '$lib/components/shared/manga/MangaPreview.svelte'
import '../app.css'
let { children } = $props()
const POLL_MS = 1500
let pollTimer: ReturnType<typeof setTimeout> | null = null
let polling = false
async function pollLoop() {
if (!polling) return
await downloadStore.poll()
if (polling) pollTimer = setTimeout(pollLoop, POLL_MS)
}
const isTauri = typeof window !== 'undefined' && '__TAURI_INTERNALS__' in window
const ringFull = $derived(appState.status !== 'booting')
@@ -31,6 +44,8 @@
// Apply theme immediately on mount (before first paint if possible)
onMount(() => {
polling = true
pollLoop()
applyTheme(
settingsState.settings.theme ?? 'dark',
settingsState.settings.customThemes ?? []
@@ -55,6 +70,11 @@
mountSystemThemeSync(enabled, darkTheme, lightTheme, (id) => updateSettings({ theme: id }))
})
$effect(() => () => {
polling = false
if (pollTimer !== null) { clearTimeout(pollTimer); pollTimer = null }
})
function onSplashReady() { splashVisible = false }
function onSplashBypass() { bypassed = true; splashVisible = false }
@@ -107,6 +127,9 @@
<AuthGate />
<Toaster toasts={notifications.toasts} />
{#if seriesState.previewManga}
<MangaPreview />
{/if}
<style>
.frame {
+17 -1
View File
@@ -1 +1,17 @@
<p>browse</p>
<script lang="ts">
import { page } from '$app/stores'
import { goto } from '$app/navigation'
import Search from '$lib/components/browse/Search.svelte'
import GenreDrillPage from '$lib/components/browse/GenreDrillPage.svelte'
const genre = $derived($page.url.searchParams.get('genre') ?? '')
</script>
{#if genre}
<GenreDrillPage
{genre}
onBack={() => goto('/browse')}
/>
{:else}
<Search />
{/if}
+5 -4
View File
@@ -1,7 +1,8 @@
<script lang="ts">
import { onMount } from 'svelte'
import { loadSources } from '$lib/request-manager/extensions'
import { extensionsState } from '$lib/state/extensions.svelte'
import { page } from '$app/stores'
import Search from '$lib/components/browse/Search.svelte'
const sourceId = $derived($page.params.sourceId)
</script>
<p>Browse — stub</p>
<Search initialTab="source" preselectedSourceId={sourceId} />
+2 -4
View File
@@ -1,7 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte'
import { loadDownloads, startDownloader, stopDownloader, clearDownloads } from '$lib/request-manager/downloads'
import { downloadsState } from '$lib/state/downloads.svelte'
import Downloads from '$lib/components/downloads/Downloads.svelte'
</script>
<p>Downloads — stub</p>
<Downloads />
+2 -4
View File
@@ -1,7 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte'
import { loadExtensions, loadSources } from '$lib/request-manager/extensions'
import { extensionsState } from '$lib/state/extensions.svelte'
import Extensions from '$lib/components/extensions/Extensions.svelte';
</script>
<p>Extensions — stub</p>
<Extensions />
+2 -433
View File
@@ -1,436 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation'
import { invoke } from '@tauri-apps/api/core'
import { libraryState } from '$lib/state/library.svelte'
import type { LibrarySortOption, LibraryContentFilter, LibraryStatusFilter } from '$lib/state/library.svelte'
import {
loadLibrary, refreshLibrary, removeFromLibrary,
bulkRemoveFromLibrary, loadCategories, createCategory,
updateMangaCategories, updateCategoryOrder,
} from '$lib/request-manager/manga'
import { startLibraryUpdate } from '$lib/components/library/lib/libraryUpdater'
import { toast } from '$lib/state/notifications.svelte'
import LibraryToolbar from '$lib/components/library/LibraryToolbar.svelte'
import LibraryGrid from '$lib/components/library/LibraryGrid.svelte'
import ContextMenu from '$lib/components/shared/ui/ContextMenu.svelte'
import type { MenuEntry } from '$lib/components/shared/ui/ContextMenu.svelte'
import type { Manga, Category } from '$lib/types'
import {
Books, Folder, FolderSimple, FolderSimplePlus,
Trash, CheckSquare, ArrowSquareOut, ArrowsClockwise,
} from 'phosphor-svelte'
const SIDEBAR_W = 52
const TITLEBAR_H = 36
const CTX_FOLDER_CAP = 4
const DT_TAB = 'application/x-moku-tab'
let cancelUpdate: (() => void) | null = null
let refreshDoneTimer: ReturnType<typeof setTimeout> | null = null
let ctx: { x: number; y: number; manga: Manga } | null = $state(null)
let emptyCtx: { x: number; y: number } | null = $state(null)
let bulkWorking = $state(false)
let activeDragKind: 'tab' | null = $state(null)
let dragInsertIdx = $state(-1)
let dragTabId: string | null = $state(null)
let dragOverTabId: string | null = $state(null)
$effect(() => {
loadLibrary()
loadCategories()
})
$effect(() => {
libraryState.tab
libraryState.exitSelect()
})
$effect(() => {
libraryState.guardTab()
})
function onCardClick(e: MouseEvent, m: Manga) {
if (libraryState.selectMode) { libraryState.toggleSelect(m.id); return }
if (e.metaKey || e.ctrlKey || e.shiftKey) { e.preventDefault(); libraryState.enterSelect(m.id); return }
goto(`/series/${m.id}`)
}
function openCtx(e: MouseEvent, m: Manga) {
if (libraryState.selectMode) { libraryState.toggleSelect(m.id); return }
e.preventDefault()
ctx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H, manga: m }
}
async function doRemove(m: Manga) {
await removeFromLibrary(String(m.id))
await loadCategories()
}
async function doDeleteDownloads(_m: Manga) {}
async function openMangaFolder(m: Manga) {
let base: string | undefined
try { base = await invoke<string>('get_default_downloads_path') } catch {}
if (!base) { toast({ kind: 'error', message: 'No downloads path set' }); return }
const source = (m as any).source?.displayName ?? (m as any).source?.name ?? ''
const sanitize = (s: string) => s.replace(/[\/\\?%*:|"<>]/g, '_')
const path = source
? `${base}/mangas/${sanitize(source)}/${sanitize(m.title)}`
: `${base}/mangas/${sanitize(m.title)}`
try { await invoke('open_path', { path }) }
catch (e: any) { toast({ kind: 'error', message: 'Could not open folder', detail: e?.toString?.() ?? path }) }
}
async function openDownloadsFolder() {
let path: string | undefined
try { path = await invoke<string>('get_default_downloads_path') } catch {}
if (!path) { toast({ kind: 'error', message: 'No downloads path set' }); return }
try { await invoke('open_path', { path }) }
catch (e: any) { toast({ kind: 'error', message: 'Could not open folder', detail: e?.toString?.() ?? path }) }
}
async function toggleMangaCategory(manga: Manga, cat: Category) {
const nodes = (cat as any).mangas?.nodes ?? libraryState.categoryMangaMap.get(cat.id) ?? []
const inCat = nodes.some((m: Manga) => m.id === manga.id)
libraryState.setCategories(
libraryState.categories.map(c => {
if (c.id !== cat.id) return c
const existing = (c as any).mangas?.nodes ?? []
const updated = inCat ? existing.filter((m: Manga) => m.id !== manga.id) : [...existing, manga]
return { ...c, mangas: { nodes: updated } }
})
)
if (!inCat) libraryState.bumpCategoryFrecency(cat.id)
try {
await updateMangaCategories(String(manga.id), inCat ? [] : [cat.id], inCat ? [cat.id] : [])
} catch { await loadCategories() }
await loadCategories()
}
async function createAndAssign(manga: Manga) {
const name = prompt('Folder name:')
if (!name?.trim()) return
try {
const cat = await createCategory(name.trim())
await updateMangaCategories(String(manga.id), [cat.id], [])
libraryState.bumpCategoryFrecency(cat.id)
} catch (e) { console.error(e) }
}
function buildCtxItems(m: Manga): MenuEntry[] {
const sorted = [...libraryState.visibleCategories].sort(
(a, b) => (libraryState.categoryFrecency[b.id] ?? 0) - (libraryState.categoryFrecency[a.id] ?? 0)
)
const pinned = sorted.slice(0, CTX_FOLDER_CAP)
const overflow = sorted.slice(CTX_FOLDER_CAP)
const makeCatEntry = (cat: Category): MenuEntry => {
const inCat = (libraryState.categoryMangaMap.get(cat.id) ?? []).some(x => x.id === m.id)
return {
label: inCat ? `Remove from ${cat.name}` : cat.name,
icon: Folder,
onClick: () => toggleMangaCategory(m, cat),
}
}
return [
{
label: m.inLibrary ? 'Remove from library' : 'Add to library',
icon: Books,
onClick: () => m.inLibrary ? doRemove(m) : loadLibrary(),
},
{
label: libraryState.refreshingMangaId === m.id ? 'Refreshing…' : 'Refresh manga',
icon: ArrowsClockwise,
disabled: libraryState.refreshingMangaId !== null,
onClick: () => refreshSingleManga(m),
},
{
label: 'Open in file manager',
icon: ArrowSquareOut,
disabled: !(m.downloadCount && m.downloadCount > 0),
onClick: () => openMangaFolder(m),
},
{
label: 'Delete all downloads',
icon: Trash,
danger: true,
disabled: !(m.downloadCount && m.downloadCount > 0),
onClick: () => doDeleteDownloads(m),
},
{ separator: true },
{ label: 'Select', icon: CheckSquare, onClick: () => libraryState.enterSelect(m.id) },
...(pinned.length ? [{ separator: true } as MenuEntry, ...pinned.map(makeCatEntry)] : []),
...(overflow.length ? [{
label: `More folders (${overflow.length})`,
icon: FolderSimple,
onClick: () => {},
children: overflow.map(makeCatEntry),
} as MenuEntry] : []),
{ separator: true },
{ label: 'New folder', icon: FolderSimplePlus, onClick: () => createAndAssign(m) },
]
}
function buildEmptyCtx(): MenuEntry[] {
return [{
label: 'New folder',
icon: FolderSimplePlus,
onClick: async () => {
const name = prompt('Folder name:')
if (!name?.trim()) return
try { await createCategory(name.trim()) }
catch (e) { console.error(e) }
},
}]
}
async function refreshSingleManga(m: Manga) {
if (libraryState.refreshingMangaId !== null) return
libraryState.refreshingMangaId = m.id
try {
await refreshLibrary()
toast({ kind: 'success', message: 'Manga refreshed', detail: m.title })
} finally {
libraryState.refreshingMangaId = null
}
}
async function startRefresh() {
if (libraryState.refreshing) return
libraryState.refreshing = true
libraryState.refreshProgress = { finished: 0, total: 0 }
cancelUpdate = startLibraryUpdate({
onProgress(p) { libraryState.refreshProgress = p },
async onDone({ newChapters, totalUpdated }) {
libraryState.refreshing = false
cancelUpdate = null
await loadLibrary()
libraryState.refreshDone = true
if (refreshDoneTimer) clearTimeout(refreshDoneTimer)
refreshDoneTimer = setTimeout(() => { libraryState.refreshDone = false }, 2500)
if (newChapters > 0) {
toast({ kind: 'success', message: 'Library updated', detail: `${newChapters} new chapter${newChapters !== 1 ? 's' : ''} across ${totalUpdated} series` })
} else {
toast({ kind: 'info', message: 'Already up to date' })
}
},
onError() { libraryState.refreshing = false; cancelUpdate = null },
})
}
async function cancelRefresh() {
if (!libraryState.refreshing) return
cancelUpdate?.()
cancelUpdate = null
libraryState.refreshing = false
libraryState.refreshProgress = { finished: 0, total: 0 }
}
async function refreshCategory(catId: number) {
if (libraryState.refreshingCatId !== null || libraryState.refreshing) return
libraryState.refreshingCatId = catId
try {
await loadLibrary()
const cat = libraryState.categories.find(c => c.id === catId)
toast({ kind: 'success', message: 'Folder refreshed', detail: cat?.name ?? '' })
} finally {
libraryState.refreshingCatId = null
}
}
async function bulkMove(cat: Category) {
bulkWorking = true
try {
await Promise.all(
[...libraryState.selected].map(id => {
const m = libraryState.items.find(x => x.id === id)
return m ? toggleMangaCategory(m, cat) : Promise.resolve()
})
)
} finally {
bulkWorking = false
libraryState.exitSelect()
}
}
async function onBulkRemove() {
bulkWorking = true
try { await bulkRemoveFromLibrary(libraryState.selected) }
finally { bulkWorking = false }
}
function onTabDragStart(e: DragEvent, id: string) {
activeDragKind = 'tab'; dragTabId = id
e.dataTransfer!.effectAllowed = 'move'
e.dataTransfer!.setData(DT_TAB, id)
e.dataTransfer!.setData('text/plain', `tab:${id}`)
}
function onTabDragOver(e: DragEvent, id: string, idx: number) {
if (activeDragKind !== 'tab' || dragTabId === null || dragTabId === id) return
e.preventDefault(); e.dataTransfer!.dropEffect = 'move'
dragOverTabId = id
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect()
dragInsertIdx = e.clientX < rect.left + rect.width / 2 ? idx : idx + 1
}
function onTabDragLeave() { dragOverTabId = null }
async function onTabDrop(e: DragEvent, dropId: string) {
e.preventDefault(); dragOverTabId = null
const insertAt = dragInsertIdx; dragInsertIdx = -1
if (activeDragKind !== 'tab' || dragTabId === null || dragTabId === dropId) { dragTabId = null; return }
const dragStrId = dragTabId; dragTabId = null; activeDragKind = null
const tabs = [...libraryState.allTabIds]
const fromIdx = tabs.indexOf(dragStrId)
const dropIdx = tabs.indexOf(dropId)
if (fromIdx < 0 || dropIdx < 0) return
const visibleDrop = libraryState.visibleTabIds[insertAt] ?? null
const destIdx = visibleDrop ? tabs.indexOf(visibleDrop) : tabs.length
tabs.splice(fromIdx, 1)
const adjusted = Math.max(0, Math.min(destIdx > fromIdx ? destIdx - 1 : destIdx, tabs.length))
tabs.splice(adjusted, 0, dragStrId)
libraryState.pinnedTabOrder = tabs
const catIds = tabs.filter(id => id !== 'library' && id !== 'downloaded')
const zeroCat = libraryState.categories.filter(c => c.id === 0)
const reordered = catIds.map((id, i) => {
const c = libraryState.categories.find(x => String(x.id) === id)!
return { ...c, order: i + 1 }
})
libraryState.setCategories([...zeroCat, ...reordered])
const dragIsBuiltin = dragStrId === 'library' || dragStrId === 'downloaded'
if (!dragIsBuiltin) {
const serverPos = catIds.indexOf(dragStrId) + 1
try { await updateCategoryOrder(Number(dragStrId), serverPos) }
catch { await loadCategories() }
}
}
function onTabDragEnd() {
activeDragKind = null; dragTabId = null; dragOverTabId = null; dragInsertIdx = -1
}
import Library from '$lib/components/library/Library.svelte'
</script>
<div
class="root"
role="presentation"
oncontextmenu={(e) => {
if ((e.target as HTMLElement).closest('button')) return
e.preventDefault()
emptyCtx = { x: e.clientX - SIDEBAR_W, y: e.clientY - TITLEBAR_H }
}}
>
{#if libraryState.error}
<div class="center">
<p class="error-msg">Could not load library</p>
<p class="error-detail">{libraryState.error}</p>
<button class="retry-btn" onclick={() => { loadLibrary(); loadCategories() }}>Retry</button>
</div>
{:else}
<LibraryToolbar
tab={libraryState.tab}
tabSortMode={libraryState.tabSort[libraryState.tab]?.mode ?? 'alphabetical'}
tabSortDir={libraryState.tabSort[libraryState.tab]?.dir ?? 'asc'}
tabStatus={libraryState.tabStatus[libraryState.tab] ?? 'ALL'}
tabFilters={libraryState.tabFilters[libraryState.tab] ?? {}}
hasActiveFilters={libraryState.hasActiveFilters}
visibleCategories={libraryState.visibleCategories}
visibleTabIds={libraryState.visibleTabIds}
counts={libraryState.counts}
query={libraryState.filter.query}
refreshing={libraryState.refreshing}
refreshProgress={libraryState.refreshProgress}
refreshDone={libraryState.refreshDone}
refreshingCatId={libraryState.refreshingCatId}
{activeDragKind}
{dragInsertIdx}
{dragTabId}
{dragOverTabId}
onTabChange={(t) => libraryState.tab = t}
onQuery={(q) => libraryState.filter.query = q}
onSortChange={(mode) => libraryState.setTabSort(libraryState.tab, mode)}
onSortDirToggle={() => libraryState.toggleTabSortDir(libraryState.tab)}
onStatusChange={(s) => libraryState.setTabStatus(libraryState.tab, s)}
onFilterToggle={(f) => libraryState.toggleTabFilter(libraryState.tab, f)}
onFiltersClear={() => libraryState.clearTabFilters(libraryState.tab)}
onRefresh={startRefresh}
onCancelRefresh={cancelRefresh}
onRefreshCategory={refreshCategory}
onOpenDownloadsFolder={openDownloadsFolder}
onTabDragStart={onTabDragStart}
onTabDragOver={onTabDragOver}
onTabDragLeave={onTabDragLeave}
onTabDrop={onTabDrop}
onTabDragEnd={onTabDragEnd}
/>
{#if libraryState.refreshing && libraryState.refreshProgress.total > 0}
{@const pct = Math.round((libraryState.refreshProgress.finished / libraryState.refreshProgress.total) * 100)}
<div class="refresh-bar-wrap" aria-hidden="true">
<div class="refresh-bar-fill" style="width:{pct}%"></div>
</div>
{/if}
<LibraryGrid
items={libraryState.filteredItems}
loading={libraryState.loading}
selectMode={libraryState.selectMode}
selected={libraryState.selected}
tab={libraryState.tab}
visibleCategories={libraryState.visibleCategories}
{bulkWorking}
{onCardClick}
onCardContextMenu={openCtx}
onSelectAll={() => libraryState.selectAll(libraryState.filteredItems.map(m => m.id))}
onExitSelect={() => libraryState.exitSelect()}
onBulkRemove={onBulkRemove}
onBulkMove={bulkMove}
/>
{/if}
</div>
{#if ctx}
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
{/if}
{#if emptyCtx}
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtx()} onClose={() => emptyCtx = null} />
{/if}
<style>
.root {
position: relative; display: flex; flex-direction: column;
height: 100%; overflow: hidden;
animation: fadeIn 0.14s ease both;
}
.center {
display: flex; flex-direction: column; align-items: center;
justify-content: center; height: 60%; gap: var(--sp-2);
color: var(--text-muted); text-align: center;
}
.error-msg { color: var(--color-error); font-size: var(--text-base); }
.error-detail { color: var(--text-faint); font-size: var(--text-sm); }
.retry-btn {
margin-top: var(--sp-3); padding: 6px 16px;
border-radius: var(--radius-md); border: 1px solid var(--border-dim);
background: var(--bg-raised); color: var(--text-muted);
cursor: pointer; font-family: var(--font-ui);
font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
}
.refresh-bar-wrap { height: 2px; background: var(--border-dim); flex-shrink: 0; overflow: hidden; }
.refresh-bar-fill { height: 100%; background: var(--accent); border-radius: 0 2px 2px 0; transition: width 0.6s ease; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
<Library />
+2 -6
View File
@@ -1,12 +1,8 @@
<script lang="ts">
import { page } from '$app/stores'
import { setActiveMangaId } from '$lib/state/series.svelte'
import SeriesDetail from '$lib/components/series/SeriesDetail.svelte'
$effect(() => {
setActiveMangaId(Number($page.params.mangaid) || null)
return () => setActiveMangaId(null)
})
const mangaId = $derived(Number($page.params.mangaid))
</script>
<SeriesDetail />
<SeriesDetail {mangaId} />