Feat: Reworked ENTIRE Project for Readability

This commit is contained in:
Youwes09
2026-04-20 00:19:22 -05:00
parent 005680394e
commit 4b97f4a6c9
191 changed files with 19210 additions and 15915 deletions
@@ -0,0 +1,275 @@
<script lang="ts">
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "phosphor-svelte";
import { untrack } from "svelte";
import { gql } from "@api/client";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, GET_CATEGORIES } from "@api/queries";
import { FETCH_SOURCE_MANGA, UPDATE_MANGA, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "@api/mutations";
import { cache, CACHE_KEYS, getPageSet } from "@core/cache";
import { dedupeSources, dedupeMangaById, shouldHideNsfw } from "@core/util";
import { store, setGenreFilter, setPreviewManga, setNavPage } from "@store/state.svelte";
import type { Manga, Source, Category } from "@types/index";
import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte";
import {
PAGE_SIZE, INITIAL_PAGES, MAX_SOURCES,
parseTags, tagsLabel, matchesAllTags, runConcurrent,
} from "@features/discover/lib/searchFilter";
const prevNavPage = store.navPage;
const tags = $derived(parseTags(store.genreFilter));
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, store.settings));
const libIds = new Set(libMatches.map((m) => m.id));
return dedupeMangaById([...libMatches, ...sourceManga.filter((m) => !libIds.has(m.id) && !shouldHideNsfw(m, store.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 = store.genreFilter; 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 preferredLang = store.settings.preferredExtensionLang || "en";
const t = parseTags(filter);
const pt = t[0] ?? "";
cache.get(CACHE_KEYS.LIBRARY, () =>
Promise.all([
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
]).then(([all, lib]) => {
const m = new Map(lib.mangas.nodes.map((x) => [x.id, x]));
return all.mangas.nodes.map((x) => m.get(x.id) ?? x);
}),
).then((manga) => { if (!ctrl.signal.aborted) libraryManga = manga; }).catch(() => {});
cache.get(
CACHE_KEYS.SOURCES,
() => gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => dedupeSources(d.sources.nodes.filter((s) => s.id !== "0"), preferredLang)),
Infinity,
).then(async (allSources) => {
const srcs = allSources.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 ps = getPageSet(src.id, "SEARCH", t);
const pageItems: Manga[] = [];
for (let page = 1; page <= INITIAL_PAGES; page++) {
if (ctrl.signal.aborted) return;
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, t);
const result = await cache.get<{ mangas: Manga[]; hasNextPage: boolean }>(
pageKey,
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page, query: pt }, ctrl.signal,
).then((d) => d.fetchSourceManga),
).catch(() => null);
if (!result || ctrl.signal.aborted) break;
ps.add(page);
const matching = t.length > 1 ? result.mangas.filter((m) => matchesAllTags(m, t)) : result.mangas;
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;
const ps = getPageSet(src.id, "SEARCH", tags);
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
const result = await cache.get<{ mangas: Manga[]; hasNextPage: boolean }>(
pageKey,
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page, query: primaryTag }, ctrl.signal,
).then((d) => d.fetchSourceManga),
).catch(() => { nextPageMap.set(src.id, -1); return null; });
if (!result || ctrl.signal.aborted) return;
ps.add(page);
nextPageMap.set(src.id, result.hasNextPage ? page + 1 : -1);
const matching = tags.length > 1 ? result.mangas.filter((m) => matchesAllTags(m, tags)) : result.mangas;
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;
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
.then((d) => { categories = d.categories.nodes.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: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
.then(() => {
sourceManga = sourceManga.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x);
cache.clear(CACHE_KEYS.LIBRARY);
})
.catch(console.error),
},
...(categories.length > 0 ? [
{ separator: true } as MenuEntry,
...categories.map((cat): MenuEntry => ({
label: (cat.mangas?.nodes ?? []).some((x) => x.id === m.id) ? `✓ ${cat.name}` : cat.name,
icon: Folder,
onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error),
})),
] : []),
{ separator: true },
{
label: "New folder & add",
icon: FolderSimplePlus,
onClick: async () => {
const name = prompt("Folder name:");
if (!name?.trim()) return;
const res = await gql<{ createCategory: { category: Category } }>(
CREATE_CATEGORY, { name: name.trim() },
).catch(console.error);
if (res) {
const cat = res.createCategory.category;
categories = [...categories, cat];
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error);
}
},
},
];
}
$effect(() => () => { abortCtrl?.abort(); });
</script>
<div class="root">
<div class="header">
<button class="back" onclick={() => { setGenreFilter(""); setNavPage(prevNavPage); }}>
<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%; }
.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>
@@ -0,0 +1,330 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { gql } from "@api/client";
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
import { runConcurrent } from "@core/async/batchRequests";
import { shouldHideNsfw, shouldHideSource, dedupeMangaById, dedupeMangaByTitle } from "@core/util";
import { store } from "@store/state.svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Manga, Source } from "@types";
interface Props {
allSources: Source[];
availableLangs: string[];
hasMultipleLangs: boolean;
loadingSources: boolean;
pendingPrefill: string;
popularResults: (Manga & { _priority: number })[];
popularLoading: boolean;
onPrefillConsumed: () => void;
onPreview: (m: Manga) => void;
}
let {
allSources, availableLangs, hasMultipleLangs, loadingSources,
pendingPrefill, popularResults, popularLoading,
onPrefillConsumed, onPreview,
}: Props = $props();
const preferredLang = store.settings?.preferredExtensionLang ?? "en";
let kw_query = $state("");
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) {
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();
kw_query = q;
kwDoSearch(q);
}
});
$effect(() => {
const q = kw_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 filtered = allSources;
if (kw_selectedLangs.size > 0)
filtered = filtered.filter((s) => kw_selectedLangs.has(s.lang));
if (!store.settings.showNsfw)
filtered = filtered.filter((s) => !shouldHideSource(s, store.settings));
return filtered;
}
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;
const initial: SourceResult[] = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null }));
kw_results = initial;
const indexBySrcId = new Map(visible.map((src, i) => [src.id, i]));
await runConcurrent(visible, async (src) => {
if (ctrl.signal.aborted) return;
const idx = indexBySrcId.get(src.id)!;
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page: 1, query: trimmed },
ctrl.signal,
);
if (ctrl.signal.aborted) return;
const mangas = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
const next = [...kw_results];
next[idx] = { ...next[idx], mangas, loading: false };
kw_results = next;
} catch (e: any) {
if (ctrl.signal.aborted || e?.name === "AbortError") return;
const next = [...kw_results];
next[idx] = { ...next[idx], loading: false, error: (e as any).message ?? "Error" };
kw_results = next;
}
}, ctrl.signal);
}
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_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0));
const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading));
const kw_anyLoading = $derived(kw_results.some((r) => r.loading));
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), store.settings.mangaLinks) as (Manga & { _sourceName?: string; _priority: number })[];
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}
bind:value={kw_query}
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 kw_query}
<button class="clearBtn" title="Clear" onclick={() => { kw_query = ""; 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 hasMultipleLangs && kw_showAdvanced}
<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 !kw_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 "{kw_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); contain: layout style; }
.srchGradient { position: absolute; inset: 0; 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; 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); 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>
@@ -0,0 +1,298 @@
<script lang="ts">
import { onDestroy, untrack } from "svelte";
import { gql } from "@api/client";
import { GET_SOURCES } from "@api/queries/extensions";
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
import { FETCH_MANGA } from "@api/mutations/manga";
import { runConcurrent } from "@core/async/batchRequests";
import { deprioritizeQueue } from "@core/cache/imageCache";
import { dedupeSourcesByLang }from "@core/algorithms/filter";
import { shouldHideNsfw } from "@core/util";
import { store, setSearchPrefill, setPreviewManga } from "@store/state.svelte";
import {
toCachedManga,
type CachedManga,
} from "@features/discover/lib/searchFilter";
import type { Manga, Source } from "@types";
import KeywordTab from "./KeywordTab.svelte";
import TagTab from "./TagTab.svelte";
import SourceTab from "./SourceTab.svelte";
const SEARCH_PAGES = 3;
const SEARCH_LIMIT = 200;
const SEARCH_BATCH = 20;
const POPULAR_CACHE_PAGES = 3;
type SearchTab = "keyword" | "tag" | "source";
let tab: SearchTab = $state("keyword");
let pendingPrefill = $state("");
$effect(() => {
if (store.searchPrefill) {
const prefill = store.searchPrefill;
untrack(() => {
pendingPrefill = prefill;
tab = "keyword";
setSearchPrefill("");
});
}
});
let allSources: Source[] = $state([]);
let loadingSources = $state(false);
const preferredLang = store.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;
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => {
allSources = d.sources.nodes.filter((src: Source) => src.id !== "0");
startSourceCacheBuild();
popularStart(allSources);
})
.catch(console.error)
.finally(() => { loadingSources = false; });
let popular_raw: Manga[] = $state([]);
let popular_loading = $state(false);
let popular_moreLoading = $state(false);
let popular_abortCtrl: AbortController | null = null;
let popular_sourcePool: Source[] = $state([]);
let popular_sourceCursor = $state(0);
let popular_hasMore = $state(false);
let popular_seenIds = new Set<number>();
let popular_seenTitles = new Set<string>();
const popular_results: (Manga & { _priority: number })[] = $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, store.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) { popular_hasMore = false; return; }
await runConcurrent(batch, async (src) => {
for (let page = 1; page <= SEARCH_PAGES; page++) {
if (signal.aborted) return;
const key = `${src.id}|POPULAR|All:p${page}`;
let mangas: Manga[];
if (store.searchCache?.has(key)) {
mangas = store.searchCache.get(key)!;
} else {
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "POPULAR", page, query: null },
signal,
).then((d) => d.fetchSourceManga).catch(() => null);
if (!result || signal.aborted) break;
mangas = result.mangas;
store.searchCache?.set(key, mangas);
if (!result.hasNextPage) { popular_push(mangas); break; }
}
popular_push(mangas);
}
}, signal);
popular_sourceCursor += batch.length;
popular_hasMore = popular_sourceCursor < popular_sourcePool.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 = dedupeSourcesByLang(sources, preferredLang, store.settings, true);
popular_sourceCursor = 0;
popular_hasMore = false;
popular_moreLoading = false;
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 }) => {
if (signal.aborted) return;
try {
const cacheKey = `${src.id}|POPULAR|All:p${page}`;
let mangas: Manga[];
if (store.searchCache?.has(cacheKey)) {
mangas = store.searchCache.get(cacheKey)!;
} else {
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "POPULAR", page },
signal,
);
if (signal.aborted) return;
mangas = d.fetchSourceManga.mangas;
store.searchCache?.set(cacheKey, mangas);
}
for (const m of mangas) {
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 d = await gql<{ fetchManga: { manga: Manga & { genre: string[]; status: string } } }>(
FETCH_MANGA, { id: entry.id }, signal,
);
if (signal.aborted) return;
const updated = sourceCache.get(entry.id);
if (updated) {
updated.genre = d.fetchManga.manga.genre ?? [];
updated.status = d.fetchManga.manga.status ?? updated.status;
updated.lowerGenres = updated.genre.map((g) => g.toLowerCase());
updated.genreEnriched = true;
}
} catch (e: any) {
if (e?.name === "AbortError") return;
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();
const dedupedSources = dedupeSourcesByLang(allSources, preferredLang, store.settings, true);
buildSourceCache(dedupedSources, 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">
<h1 class="heading">Search</h1>
<div class="tabs">
<button class="tab" class:tabActive={tab === "keyword"} onclick={() => { deprioritizeQueue(); tab = "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={tab === "tag"} onclick={() => { deprioritizeQueue(); tab = "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={tab === "source"} onclick={() => { deprioritizeQueue(); tab = "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 tab === "keyword"}
<KeywordTab
{allSources}
{availableLangs}
{hasMultipleLangs}
{loadingSources}
{pendingPrefill}
popularResults={popular_results}
popularLoading={popular_loading}
onPrefillConsumed={() => (pendingPrefill = "")}
onPreview={setPreviewManga}
/>
{:else if tab === "tag"}
<TagTab
{allSources}
{sourceCache}
{sourceCacheReady}
{sourceCacheLoading}
{sourceCacheEnriching}
onPreview={setPreviewManga}
/>
{:else}
<SourceTab
{allSources}
{availableLangs}
{loadingSources}
onPreview={setPreviewManga}
/>
{/if}
</div>
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-2); flex-shrink: 0; border-bottom: 1px solid var(--border-dim); gap: var(--sp-3); }
.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; gap: var(--sp-1); }
.tab { display: flex; align-items: center; gap: var(--sp-1); padding: 5px 10px; border-radius: var(--radius-md); border: 1px solid transparent; background: none; font-family: var(--font-ui); font-size: var(--text-xs); 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); }
.tab:hover { color: var(--text-muted); background: var(--bg-raised); }
.tabActive { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.tabActive:hover { color: var(--accent-fg); background: var(--accent-muted); }
</style>
@@ -0,0 +1,285 @@
<script lang="ts">
import { onDestroy } from "svelte";
import { gql } from "@api/client";
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
import { shouldHideNsfw, shouldHideSource } from "@core/util";
import { store } from "@store/state.svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Manga, Source } from "@types";
interface Props {
allSources: Source[];
availableLangs: string[];
loadingSources: boolean;
onPreview: (m: Manga) => void;
}
let { allSources, availableLangs, loadingSources, onPreview }: Props = $props();
const preferredLang = store.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;
$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, store.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 {
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type, page, query: q ?? null },
ctrl.signal,
);
if (ctrl.signal.aborted) return;
const incoming = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
src_browseResults = page === 1 ? incoming : [...src_browseResults, ...incoming];
src_hasNextPage = d.fetchSourceManga.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");
}
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">
{#each src_visibleSources as src (src.id)}
<button
class="splitItem splitItemSource"
class:splitItemActive={src_activeSource?.id === src.id}
onclick={() => srcSelectSource(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>
<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; }
.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; }
.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>
@@ -0,0 +1,474 @@
<script lang="ts">
import { onDestroy, untrack } from "svelte";
import { gql } from "@api/client";
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
import { MANGAS_BY_GENRE } from "@api/queries/manga";
import { runConcurrent } from "@core/async/batchRequests";
import { dedupeSourcesByLang }from "@core/algorithms/filter";
import { shouldHideNsfw, dedupeMangaById, dedupeMangaByTitle, normalizeTitle } from "@core/util";
import { store } from "@store/state.svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import {
buildTagFilter,
filterSourceCache,
COMMON_GENRES,
MANGA_STATUSES,
type TagMode,
type CachedManga,
} from "@features/discover/lib/searchFilter";
import type { Manga, Source } from "@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 = store.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;
$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;
gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean }; totalCount: number } }>(
MANGAS_BY_GENRE,
{ filter: buildTagFilter(activeTags, tagMode, activeStatuses), first: (store.settings.renderLimit ?? 48), offset: 0 },
ctrl.signal,
).then((d) => {
if (ctrl.signal.aborted) return;
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings);
tag_localResults = d.mangas.nodes.filter(nsfwFilter);
tag_totalCount = d.mangas.totalCount;
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
tag_localOffset = (store.settings.renderLimit ?? 48);
}).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;
try {
const d = await gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean } } }>(
MANGAS_BY_GENRE,
{ filter: buildTagFilter(tag_activeTags, tag_tagMode, tag_activeStatuses), first: (store.settings.renderLimit ?? 48), offset: tag_localOffset },
ctrl.signal,
);
if (ctrl.signal.aborted) return;
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings);
tag_localResults = [...tag_localResults, ...d.mangas.nodes.filter(nsfwFilter)];
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
tag_localOffset += (store.settings.renderLimit ?? 48);
} 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, store.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 = dedupeSourcesByLang(allSources, preferredLang, store.settings, true);
await runConcurrent(srcs, async (src) => {
for (let page = 1; page <= 2; page++) {
if (ctrl.signal.aborted) return;
const cacheKey = `${src.id}|SEARCH|${genre}:p${page}`;
let mangas: Manga[];
let hasNextPage = false;
if (store.searchCache?.has(cacheKey)) {
mangas = store.searchCache.get(cacheKey)!;
} else {
const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: genre },
ctrl.signal,
).then((d) => d.fetchSourceManga).catch(() => null);
if (!result || ctrl.signal.aborted) return;
mangas = result.mangas;
hasNextPage = result.hasNextPage;
store.searchCache?.set(cacheKey, mangas);
}
if (ctrl.signal.aborted) return;
const matching = mangas.filter((m) =>
((m as any).genre ?? []).some((g: string) => g.toLowerCase() === genreLower)
);
const candidates = (matching.length ? matching : mangas).filter(
(m) => !shouldHideNsfw(m, store.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 (!hasNextPage) return;
}
}, ctrl.signal);
if (!ctrl.signal.aborted) tag_fanOutLoading = false;
}
let tag_autoSearchFired = $state(false);
$effect(() => {
const _tags = tag_activeTags;
const _statuses = 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]),
store.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>
+2
View File
@@ -0,0 +1,2 @@
export { default as Search } from "./components/Search.svelte";
export * from "./lib/searchFilter";
+138
View File
@@ -0,0 +1,138 @@
import type { Settings } from "@types";
import { shouldHideNsfw } from "@core/util";
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, "showNsfw" | "nsfwFilteredTags" | "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;
if (mode === "AND") {
genreMatch = tags.every((t) => lower.some((g) => g.includes(t.toLowerCase())));
} else {
genreMatch = 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,
};
}
@@ -0,0 +1,145 @@
<script lang="ts">
import { CircleNotch, X } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { DownloadQueueItem } from "@types/index";
import { pageProgress } from "../lib/downloadQueue";
interface Props {
item: DownloadQueueItem;
isActive: boolean;
isRemoving: boolean;
onRemove: (chapterId: number) => void;
}
const { item, isActive, isRemoving, onRemove }: Props = $props();
const manga = $derived(item.chapter.manga);
const pages = $derived(item.chapter.pageCount ?? 0);
const prog = $derived(pageProgress(item.progress, pages));
</script>
<div class="row" class:row-active={isActive} class:row-removing={isRemoving}>
{#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}
<span class="pages-label">{isActive ? `${prog.done} / ${prog.total} pages` : `${prog.total} pages`}</span>
{/if}
{#if isActive}
<div class="progress-wrap">
<div class="progress-bar" style="width:{Math.round(item.progress * 100)}%"></div>
</div>
{/if}
</div>
<div class="row-right">
<span class="state-label">{item.state}</span>
{#if !isActive}
<button class="remove-btn" onclick={() => onRemove(item.chapter.id)} disabled={isRemoving} title="Remove from queue">
{#if isRemoving}<CircleNotch size={11} weight="light" class="anim-spin" />{:else}<X size={12} weight="light" />{/if}
</button>
{/if}
</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);
}
.row.row-active { 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: 3px;
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;
}
.pages-label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
.progress-wrap {
height: 2px;
background: var(--border-base);
border-radius: var(--radius-full);
overflow: hidden;
margin-top: 4px;
}
.progress-bar {
height: 100%;
background: var(--accent);
border-radius: var(--radius-full);
transition: width 0.4s ease;
}
.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;
}
.remove-btn {
display: flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: var(--radius-sm);
color: var(--text-faint);
transition: color var(--t-base), background var(--t-base);
}
.remove-btn:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
.remove-btn:disabled { opacity: 0.5; cursor: default; }
</style>
@@ -0,0 +1,51 @@
<script lang="ts">
import { CircleNotch } from "phosphor-svelte";
import DownloadItem from "./DownloadItem.svelte";
import type { DownloadQueueItem } from "@types/index";
interface Props {
queue: DownloadQueueItem[];
loading: boolean;
isRunning: boolean;
dequeueing: Set<number>;
onRemove: (chapterId: number) => void;
}
const { queue, loading, isRunning, dequeueing, onRemove }: Props = $props();
</script>
{#if loading}
<div class="empty"><CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" /></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)}
{onRemove}
/>
{/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);
}
</style>
@@ -0,0 +1,142 @@
<script lang="ts">
import { Play, Pause, Trash, CircleNotch } from "phosphor-svelte";
import DownloadQueue from "./DownloadQueue.svelte";
import { downloadStore } from "../store/downloadState.svelte";
$effect(() => {
downloadStore.poll();
const interval = setInterval(() => downloadStore.poll(), 2000);
return () => clearInterval(interval);
});
</script>
<div class="root">
<div class="header">
<h1 class="heading">Downloads</h1>
<div class="header-actions">
<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="content">
<div class="status-bar">
<div class="status-dot" class:active={downloadStore.isRunning}></div>
<span class="status-text">
{downloadStore.togglingPlay
? (downloadStore.isRunning ? "Pausing…" : "Starting…")
: downloadStore.isRunning ? "Downloading" : "Paused"}
</span>
<span class="status-count">{downloadStore.queue.length} queued</span>
</div>
<DownloadQueue
queue={downloadStore.queue}
loading={downloadStore.loading}
isRunning={downloadStore.isRunning}
dequeueing={downloadStore.dequeueing}
onRemove={(id) => downloadStore.dequeue(id)}
/>
</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; gap: var(--sp-2); }
.content {
flex: 1;
overflow-y: auto;
padding: var(--sp-5) 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: 28px;
height: 28px;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
color: var(--text-muted);
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.3; cursor: default; }
.icon-btn.loading { border-color: var(--accent-dim); color: var(--accent-fg); background: var(--accent-muted); }
.status-bar {
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);
}
.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-count {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
@keyframes pulse { 0%, 100% { opacity: 1 } 50% { opacity: 0.4 } }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
+2
View File
@@ -0,0 +1,2 @@
export { downloadStore } from "./store/downloadState.svelte";
export { toActiveDownloads, optimisticRemove, isRunning, pageProgress } from "./lib/downloadQueue";
@@ -0,0 +1,61 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { gql } from "@api/client";
import { GET_DOWNLOAD_STATUS } from "@api/queries/downloads";
import { addToast, setActiveDownloads } from "@store/state.svelte";
import type { DownloadStatus, DownloadQueueItem } from "@types/index";
let prevQueue: DownloadQueueItem[] = [];
function detectCompletions(prev: DownloadQueueItem[], next: DownloadQueueItem[]) {
for (const item of prev) {
if (item.state !== "DOWNLOADING") continue;
if (!next.some(q => q.chapter.id === item.chapter.id)) {
const manga = item.chapter.manga;
addToast({
kind: "success",
title: "Chapter downloaded",
body: manga ? `${manga.title}${item.chapter.name}` : item.chapter.name,
duration: 4000,
});
}
}
}
function applyQueue(next: DownloadQueueItem[]) {
detectCompletions(prevQueue, next);
prevQueue = next;
setActiveDownloads(next.map(item => ({
chapterId: item.chapter.id,
mangaId: item.chapter.mangaId,
progress: item.progress,
})));
}
export async function mountDownloadPoller(): Promise<() => void> {
const win = getCurrentWindow();
let paused = false;
let interval: ReturnType<typeof setInterval>;
const poll = () => {
if (paused) return;
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then(d => applyQueue(d.downloadStatus.queue))
.catch(console.error);
};
poll();
interval = setInterval(poll, 2000);
const onVisibility = () => { paused = document.hidden; };
document.addEventListener("visibilitychange", onVisibility);
const unlistenFocus = await win.onFocusChanged(({ payload: focused }) => {
paused = !focused;
});
return () => {
clearInterval(interval);
document.removeEventListener("visibilitychange", onVisibility);
unlistenFocus();
};
}
@@ -0,0 +1,25 @@
import type { DownloadQueueItem, ActiveDownload } from "@types/index";
export function toActiveDownloads(queue: DownloadQueueItem[]): ActiveDownload[] {
return queue.map((item) => ({
chapterId: item.chapter.id,
mangaId: item.chapter.mangaId,
progress: item.progress,
}));
}
export function optimisticRemove(queue: DownloadQueueItem[], chapterId: number): DownloadQueueItem[] {
return queue.filter((i) => i.chapter.id !== chapterId);
}
export function optimisticToggle(state: string, wasRunning: boolean): string {
return wasRunning ? "STOPPED" : "STARTED";
}
export function isRunning(state: string | undefined): boolean {
return state === "STARTED";
}
export function pageProgress(progress: number, pageCount: number): { done: number; total: number } {
return { done: Math.round(progress * pageCount), total: pageCount };
}
@@ -0,0 +1,69 @@
import { gql } from "@api/client";
import { GET_DOWNLOAD_STATUS } from "@api/queries";
import { START_DOWNLOADER, STOP_DOWNLOADER, CLEAR_DOWNLOADER, DEQUEUE_DOWNLOAD } from "@api/mutations";
import { setActiveDownloads } from "@store/state.svelte";
import type { DownloadStatus } from "@types/index";
import { toActiveDownloads, optimisticRemove, isRunning } from "../lib/downloadQueue";
class DownloadStore {
status: DownloadStatus | null = $state(null);
loading = $state(true);
togglingPlay = $state(false);
clearing = $state(false);
dequeueing = $state(new Set<number>());
get queue() { return this.status?.queue ?? []; }
get isRunning() { return isRunning(this.status?.state); }
applyStatus(ds: DownloadStatus) {
this.status = ds;
setActiveDownloads(toActiveDownloads(ds.queue));
}
async poll() {
gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS)
.then((d) => this.applyStatus(d.downloadStatus))
.catch(console.error)
.finally(() => this.loading = false);
}
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 {
if (wasRunning) {
const d = await gql<{ stopDownloader: { downloadStatus: DownloadStatus } }>(STOP_DOWNLOADER);
this.applyStatus(d.stopDownloader.downloadStatus);
} else {
const d = await gql<{ startDownloader: { downloadStatus: DownloadStatus } }>(START_DOWNLOADER);
this.applyStatus(d.startDownloader.downloadStatus);
}
} catch (e) { console.error(e); this.poll(); }
finally { this.togglingPlay = false; }
}
async clear() {
if (this.clearing) return;
this.clearing = true;
if (this.status) this.status = { ...this.status, queue: [] };
setActiveDownloads([]);
try {
const d = await gql<{ clearDownloader: { downloadStatus: DownloadStatus } }>(CLEAR_DOWNLOADER);
this.applyStatus(d.clearDownloader.downloadStatus);
} catch (e) { console.error(e); 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: optimisticRemove(this.status.queue, chapterId) };
try { await gql(DEQUEUE_DOWNLOAD, { chapterId }); this.poll(); }
catch (e) { console.error(e); this.poll(); }
finally { this.dequeueing.delete(chapterId); this.dequeueing = new Set(this.dequeueing); }
}
}
export const downloadStore = new DownloadStore();
@@ -0,0 +1,107 @@
<script lang="ts">
import { CircleNotch, CaretRight, CaretDown } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Extension } from "@types/index";
interface Props {
base: string;
primary: Extension;
variants: Extension[];
expanded: boolean;
working: Set<string>;
onToggle: (base: string) => void;
onMutate: (pkgName: string, op: "install" | "update" | "uninstall") => void;
}
let { base, primary, variants, expanded, working, onToggle, onMutate }: Props = $props();
const hasVariants = $derived(variants.length > 0);
</script>
<div class="group">
<div class="row">
<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>
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={() => onMutate(primary.pkgName, "update")}>Update</button>
<button class="action-btn-dim" onclick={() => onMutate(primary.pkgName, "uninstall")}>Remove</button>
</div>
{:else if primary.isInstalled}
<button class="action-btn-dim" onclick={() => onMutate(primary.pkgName, "uninstall")}>Remove</button>
{:else}
<button class="action-btn" onclick={() => onMutate(primary.pkgName, "install")}>Install</button>
{/if}
{#if hasVariants}
<button class="expand-btn" onclick={() => 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}
</div>
{#if expanded && hasVariants}
<div class="variants">
{#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); }
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
: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); }
.update-badge-small { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); flex-shrink: 0; }
.row-actions { display: flex; 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); animation: fadeIn 0.1s ease both; }
.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 { flex-shrink: 0; }
</style>
@@ -0,0 +1,87 @@
<script lang="ts">
import { MagnifyingGlass, ArrowsClockwise, Plus, GitBranch } from "phosphor-svelte";
import { FILTERS, type Filter, type Panel } from "../lib/extensionHelpers";
interface Props {
filter: Filter;
search: string;
panel: Panel;
refreshing: boolean;
updateCount: number;
availableLangs: string[];
langFilter: string | null;
onFilter: (f: Filter) => void;
onSearch: (q: string) => void;
onLang: (lang: string | null) => void;
onPanel: (p: Panel) => void;
onRefresh: () => void;
}
let {
filter, search, panel, refreshing, updateCount,
availableLangs, langFilter,
onFilter, onSearch, onLang, onPanel, onRefresh,
}: Props = $props();
</script>
<div class="header">
<h1 class="heading">Extensions</h1>
<div class="tabs">
{#each FILTERS as f}
<button class="tab" class:active={filter === f.id} onclick={() => onFilter(f.id)}>
{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>
</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; }
.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); 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); }
.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); }
.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-full); 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,272 @@
<script lang="ts">
import { untrack } from "svelte";
import { CircleNotch, X, Check } from "phosphor-svelte";
import { gql } from "@api/client";
import { store, addToast } from "@store/state.svelte";
import { GET_EXTENSIONS, GET_SETTINGS } from "@api/queries";
import { SET_EXTENSION_REPOS, INSTALL_EXTERNAL_EXTENSION, FETCH_EXTENSIONS, UPDATE_EXTENSION } from "@api/mutations";
import type { Extension } from "@types/index";
import { matchesFilter, groupExtensions, validateUrl, type Filter, type Panel } from "../lib/extensionHelpers";
import ExtensionFilters from "./ExtensionFilters.svelte";
import ExtensionCard from "./ExtensionCard.svelte";
let extensions: Extension[] = $state([]);
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 expanded = $state(new Set<string>());
let panel = $state<Panel>(null);
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 d = await gql<{ extensions: { nodes: Extension[] } }>(GET_EXTENSIONS).catch(console.error);
if (d) extensions = d.extensions.nodes;
}
async function fetchFromRepo() {
refreshing = true;
const d = await gql<{ fetchExtensions: { extensions: Extension[] } }>(FETCH_EXTENSIONS)
.catch(console.error)
.finally(() => refreshing = false);
if (d) extensions = d.fetchExtensions.extensions;
}
async function loadRepos() {
reposLoading = true;
try {
const d = await gql<{ settings: { extensionRepos: string[] } }>(GET_SETTINGS);
repos = d.settings.extensionRepos ?? [];
} catch (e) { console.error(e); }
finally { reposLoading = false; }
}
async function saveRepos(updated: string[]) {
savingRepos = true;
try {
const d = await gql<{ setSettings: { settings: { extensionRepos: string[] } } }>(SET_EXTENSION_REPOS, { repos: updated });
repos = d.setSettings.settings.extensionRepos;
} 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]);
}
function removeRepo(url: string) { saveRepos(repos.filter((r) => r !== url)); }
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 gql(UPDATE_EXTENSION, gqlArgs);
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 installExternal() {
const url = externalUrl.trim();
const err = validateUrl(url, ".apk");
if (err) { installError = err; return; }
installing = true; installError = null; installSuccess = false;
try {
await gql(INSTALL_EXTERNAL_EXTENSION, { 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) { filter = f; langFilter = null; }
const filtered = $derived(extensions.filter((e) => {
const q = search.toLowerCase();
return (e.name.toLowerCase().includes(q) || e.lang.toLowerCase().includes(q))
&& matchesFilter(e, filter)
&& (langFilter === null || e.lang === langFilter);
}));
const availableLangs = $derived(
[...new Set(extensions.filter((e) => matchesFilter(e, filter)).map((e) => e.lang))].sort()
);
const groups = $derived(groupExtensions(filtered, store.settings.preferredExtensionLang));
const updateCount = $derived(extensions.filter((e) => e.hasUpdate).length);
$effect(() => { untrack(() => fetchFromRepo().finally(() => { loading = false; })); });
function focusOnMount(node: HTMLElement) { node.focus(); }
</script>
<div class="root">
<ExtensionFilters
{filter} {search} {panel} {refreshing} {updateCount} {availableLangs} {langFilter}
onFilter={setFilter}
onSearch={(q) => search = q}
onLang={(l) => langFilter = l}
onPanel={openPanel}
onRefresh={fetchFromRepo}
/>
{#if panel === "apk"}
<div class="ext-panel">
<div class="panel-header">
<span class="panel-title">Install from APK URL</span>
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
</div>
<div class="ext-row">
<input
class="ext-input" class:error={installError}
placeholder="https://example.com/extension.apk"
bind:value={externalUrl} disabled={installing}
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">
<div class="panel-header">
<span class="panel-title">Extension Repositories</span>
<button class="icon-btn" onclick={() => panel = null}><X size={14} weight="light" /></button>
</div>
{#if reposLoading}
<div class="repo-loading"><CircleNotch size={14} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
{: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" style="margin-top:var(--sp-2)">
<input
class="ext-input" class:error={repoError}
placeholder="https://example.com/index.min.json"
bind:value={newRepoUrl} disabled={savingRepos}
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 if groups.length === 0}
<div class="empty">No extensions found.</div>
{:else}
<div class="list">
{#each groups as { base, primary, variants }}
<ExtensionCard
{base} {primary} {variants} {working}
expanded={expanded.has(base)}
onToggle={toggleExpand}
onMutate={mutate}
/>
{/each}
</div>
{/if}
</div>
<style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
.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); }
.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); }
.ext-panel { display: flex; flex-direction: column; gap: var(--sp-2); padding: 0 var(--sp-6) var(--sp-3); flex-shrink: 0; animation: fadeIn 0.1s ease both; }
.panel-header { display: flex; align-items: center; justify-content: space-between; }
.panel-title { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.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-raised); 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.1); }
.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-3); }
.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; }
.repo-row { display: flex; align-items: center; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); }
.repo-url { flex: 1; font-size: var(--text-2xs); color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.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); }
</style>
+2
View File
@@ -0,0 +1,2 @@
export { default as Extensions } from "./components/Extensions.svelte";
export * from "./lib/extensionHelpers";
@@ -0,0 +1,55 @@
import type { Extension } from "@types/index";
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,194 @@
<script lang="ts">
import { Play, ArrowRight, BookOpen, Clock } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { HistoryEntry } from "@store/state.svelte";
import { timeAgo } from "../lib/homeHelpers";
let {
entries,
onresume,
onviewhistory,
onopenlibrary,
}: {
entries: HistoryEntry[];
onresume: (entry: HistoryEntry) => void;
onviewhistory: () => void;
onopenlibrary: () => void;
} = $props();
</script>
<div class="section">
<div class="section-header">
<span class="section-title"><Clock size={10} weight="bold" /> Recent Activity</span>
{#if entries.length > 0}
<button class="see-all" onclick={onviewhistory}>
Full History <ArrowRight size={9} weight="bold" />
</button>
{/if}
</div>
<div class="list">
{#if entries.length > 0}
{#each entries as entry (entry.chapterId)}
<button class="row" onclick={() => onresume(entry)}>
<Thumbnail src={entry.thumbnailUrl} alt={entry.mangaTitle} class="row-thumb" />
<div class="row-info">
<span class="row-title">{entry.mangaTitle}</span>
<span class="row-sub">
{entry.chapterName}{entry.pageNumber > 1 ? ` · p.${entry.pageNumber}` : ""}
</span>
</div>
<span class="row-time">{timeAgo(entry.readAt)}</span>
<span class="row-play"><Play size={10} weight="fill" /></span>
</button>
{/each}
{:else}
<div class="placeholder">
{#each Array(5) as _, i}
<div class="row row-sk">
<div class="sk-thumb"></div>
<div class="row-info">
<div class="sk sk-title" style="width: {55 + (i * 7) % 30}%"></div>
<div class="sk sk-sub" style="width: {30 + (i * 11) % 25}%"></div>
</div>
<div class="sk sk-time"></div>
</div>
{/each}
<div class="placeholder-overlay">
<button class="placeholder-cta" onclick={onopenlibrary}>
<BookOpen size={12} weight="light" /> Start reading
</button>
</div>
</div>
{/if}
</div>
</div>
<style>
.section { border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-3) var(--sp-4) var(--sp-2);
}
.section-title {
display: inline-flex;
align-items: center;
gap: var(--sp-2);
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.see-all {
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--text-faint);
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color var(--t-base);
}
.see-all:hover { color: var(--accent-fg); }
.list { display: flex; flex-direction: column; padding: 0 var(--sp-3); overflow: hidden; }
.row {
display: flex;
align-items: center;
gap: var(--sp-3);
padding: 7px var(--sp-2);
border-radius: var(--radius-md);
border: 1px solid transparent;
background: none;
text-align: left;
cursor: pointer;
width: 100%;
transition: background var(--t-fast), border-color var(--t-fast);
}
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.row:hover .row-play { opacity: 1; }
:global(.row-thumb) {
width: 33px;
height: 48px;
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0;
border: 1px solid var(--border-dim);
}
.row-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.row-title {
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-sub {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--text-muted);
letter-spacing: var(--tracking-wide);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-time {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
flex-shrink: 0;
}
.row-play { color: var(--accent-fg); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
.row-sk { cursor: default; pointer-events: none; }
.sk-thumb { width: 33px; height: 48px; border-radius: var(--radius-sm); background: rgba(255,255,255,0.06); flex-shrink: 0; }
.sk { background: var(--bg-raised); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
.sk-title { height: 11px; margin-bottom: 5px; }
.sk-sub { height: 9px; }
.sk-time { width: 32px; height: 9px; flex-shrink: 0; background: rgba(255,255,255,0.06); border-radius: var(--radius-sm); }
.placeholder { position: relative; }
.placeholder-overlay {
position: absolute;
left: 0; right: 0; top: 0; bottom: -1px;
display: flex;
align-items: flex-end;
justify-content: center;
padding-bottom: var(--sp-4);
pointer-events: none;
background: linear-gradient(to bottom, transparent 0%, rgba(0,0,0,0.6) 100%);
}
.placeholder-cta {
pointer-events: all;
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 7px 16px;
border-radius: var(--radius-full);
background: rgba(255,255,255,0.08);
border: 1px solid rgba(255,255,255,0.13);
color: rgba(255,255,255,0.62);
cursor: pointer;
transition: background var(--t-base), color var(--t-base);
}
.placeholder-cta:hover { background: rgba(255,255,255,0.14); color: rgba(255,255,255,0.9); }
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.7 } }
</style>
@@ -0,0 +1,194 @@
<script lang="ts">
import { MagnifyingGlass, X as XIcon } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Manga } from "@types";
let {
slotIndex,
libraryManga,
loading,
onpin,
onclose,
}: {
slotIndex: 1 | 2 | 3;
libraryManga: Manga[];
loading: boolean;
onpin: (m: Manga) => void;
onclose: () => void;
} = $props();
let search = $state("");
function focusEl(node: HTMLElement) { node.focus(); }
const results = $derived(
search.trim()
? libraryManga.filter(m => m.title.toLowerCase().includes(search.toLowerCase())).slice(0, 20)
: libraryManga.slice(0, 20)
);
</script>
<div
class="backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onclose(); }}
onkeydown={(e) => { if (e.key === "Escape") onclose(); }}
>
<div class="modal">
<div class="modal-header">
<span class="modal-title">Pin manga — slot {slotIndex + 1}</span>
<button class="modal-close" onclick={onclose}><XIcon size={13} weight="light" /></button>
</div>
<div class="search-wrap">
<MagnifyingGlass size={12} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<input class="search-input" placeholder="Search library…" bind:value={search} use:focusEl />
</div>
<div class="list">
{#if loading}
<p class="empty-msg">Loading…</p>
{:else if results.length === 0}
<p class="empty-msg">No results</p>
{:else}
{#each results as m (m.id)}
<button class="list-row" onclick={() => onpin(m)}>
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="row-thumb" />
<div class="row-info">
<span class="row-title">{m.title}</span>
{#if m.source?.displayName}<span class="row-source">{m.source.displayName}</span>{/if}
</div>
</button>
{/each}
{/if}
</div>
</div>
</div>
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.62);
z-index: var(--z-settings);
display: flex;
align-items: center;
justify-content: center;
animation: fadeIn 0.1s ease both;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
}
.modal {
width: min(460px, calc(100vw - 48px));
max-height: 68vh;
display: flex;
flex-direction: column;
background: var(--bg-surface);
border: 1px solid var(--border-base);
border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.6);
animation: scaleIn 0.14s ease both;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.modal-title {
font-size: var(--text-sm);
font-weight: var(--weight-medium);
color: var(--text-secondary);
}
.modal-close {
display: flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: var(--radius-sm);
color: var(--text-faint);
background: none;
border: none;
cursor: pointer;
transition: background var(--t-fast), color var(--t-fast);
}
.modal-close:hover { color: var(--text-muted); background: var(--bg-raised); }
.search-wrap {
display: flex;
align-items: center;
gap: var(--sp-2);
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.search-input {
flex: 1;
background: none;
border: none;
outline: none;
color: var(--text-primary);
font-size: var(--text-sm);
}
.search-input::placeholder { color: var(--text-faint); }
.list {
flex: 1;
overflow-y: auto;
padding: var(--sp-2);
scrollbar-width: none;
}
.list::-webkit-scrollbar { display: none; }
.empty-msg {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: var(--text-faint);
padding: var(--sp-4) var(--sp-3);
text-align: center;
}
.list-row {
display: flex;
align-items: center;
gap: var(--sp-3);
width: 100%;
padding: 8px var(--sp-3);
border-radius: var(--radius-md);
border: none;
background: none;
text-align: left;
cursor: pointer;
transition: background var(--t-fast);
}
.list-row:hover { background: var(--bg-raised); }
:global(.row-thumb) {
height: 50px;
width: 35px;
aspect-ratio: 1 / 1.42;
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0;
border: 1px solid var(--border-dim);
background: var(--bg-raised);
display: block;
}
.row-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.row-title {
font-size: var(--text-sm);
color: var(--text-secondary);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.row-source {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
}
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
@@ -0,0 +1,581 @@
<script lang="ts">
import { Play, ArrowRight, ArrowLeft, BookOpen, Clock, ListBullets, PushPin, X as XIcon } from "phosphor-svelte";
import type { Manga, Chapter } from "@types";
import type { HistoryEntry } from "@store/state.svelte";
import { store, setGenreFilter, setNavPage } from "@store/state.svelte";
import { timeAgo } from "../lib/homeHelpers";
interface HeroSlot {
kind: "continue" | "pinned" | "empty";
entry?: HistoryEntry;
manga?: Manga;
slotIndex: number;
}
let {
resolvedSlots,
activeIdx = $bindable(),
heroThumb,
heroTitle,
heroManga,
heroEntry,
heroMangaId,
heroChapters,
loadingHeroChapters,
resuming,
onresume,
onopenchapter,
oncyclenext,
oncycleprev,
ongotoslot,
onopenpicker,
onunpin,
onviewall,
}: {
resolvedSlots: HeroSlot[];
activeIdx: number;
heroThumb: string;
heroTitle: string;
heroManga: Manga | null | undefined;
heroEntry: HistoryEntry | null;
heroMangaId: number | null;
heroChapters: Chapter[];
loadingHeroChapters: boolean;
resuming: boolean;
onresume: () => void;
onopenchapter: (ch: Chapter) => void;
oncyclenext: () => void;
oncycleprev: () => void;
ongotoslot: (i: number) => void;
onopenpicker: (i: 1 | 2 | 3) => void;
onunpin: (i: 1 | 2 | 3) => void;
onviewall: () => void;
} = $props();
const activeSlot = $derived(resolvedSlots[activeIdx]);
const TOTAL_SLOTS = 4;
</script>
<div class="hero-stage">
{#if heroThumb}
<div class="hero-backdrop" style="background-image:url({heroThumb})"></div>
{:else}
<div class="hero-backdrop hero-bd-empty"></div>
{/if}
<div class="hero-scrim"></div>
<button
class="hero-cover-col"
onclick={onresume}
disabled={resuming || activeSlot?.kind === "empty"}
aria-label={heroTitle ? `Resume ${heroTitle}` : "No manga selected"}
>
{#if heroThumb}
<img src={heroThumb} alt={heroTitle} class="hero-cover" loading="eager" decoding="async" />
{#if activeSlot?.kind === "continue"}
<div class="cover-resume-hint"><Play size={20} weight="fill" /></div>
{/if}
{:else}
<div class="hero-cover-empty"><BookOpen size={28} weight="light" /></div>
{/if}
</button>
<div class="hero-details">
{#if activeSlot?.kind === "empty"}
<p class="hero-empty-title">Nothing here yet</p>
<p class="hero-empty-sub">
{activeSlot.slotIndex === 0
? "Read a manga to see it here"
: "Pin a manga or keep reading to fill this slot"}
</p>
{#if activeSlot.slotIndex !== 0}
<button class="hero-cta" onclick={() => onopenpicker(activeSlot.slotIndex as 1 | 2 | 3)}>
<PushPin size={11} weight="fill" /> Pin manga
</button>
{/if}
{:else}
<div class="hero-tags">
{#if activeSlot?.kind === "continue"}
<span class="hero-tag hero-tag-reading"><Play size={8} weight="fill" /> Reading</span>
{:else}
<span class="hero-tag hero-tag-pinned"><PushPin size={8} weight="fill" /> Pinned</span>
{/if}
{#each (heroManga?.genre ?? []).slice(0, 3) as g}
<button
class="hero-tag hero-tag-genre"
onclick={() => { setGenreFilter(g); setNavPage("explore"); }}
>{g}</button>
{/each}
</div>
<h2 class="hero-title">{heroTitle}</h2>
{#if heroManga?.author}<p class="hero-author">{heroManga.author}</p>{/if}
{#if heroEntry}
<p class="hero-progress">
<Clock size={10} weight="light" />
{heroEntry.chapterName}
{#if heroEntry.pageNumber > 1}<span class="hero-prog-page"> · p.{heroEntry.pageNumber}</span>{/if}
<span class="hero-prog-time">{timeAgo(heroEntry.readAt)}</span>
</p>
{/if}
{#if heroManga?.description}
<p class="hero-desc">{heroManga.description}</p>
{/if}
<div class="hero-actions">
{#if activeSlot?.kind === "continue"}
<button class="hero-cta" onclick={onresume} disabled={resuming}>
<Play size={11} weight="fill" />{resuming ? "Loading…" : "Resume"}
</button>
{:else if heroManga}
<button class="hero-cta" onclick={() => store.previewManga = heroManga!}>
<BookOpen size={11} weight="light" /> View manga
</button>
{/if}
{#if activeSlot?.slotIndex !== 0}
{#if activeSlot?.kind === "pinned"}
<button class="hero-cta-ghost" onclick={() => onunpin(activeSlot.slotIndex as 1 | 2 | 3)}>
<XIcon size={10} weight="bold" /> Unpin
</button>
{:else}
<button class="hero-cta-ghost" onclick={() => onopenpicker(activeSlot!.slotIndex as 1 | 2 | 3)}>
<PushPin size={10} weight="light" /> Pin
</button>
{/if}
{/if}
</div>
{/if}
<div class="hero-nav-row">
<button class="hero-nav-btn" onclick={oncycleprev} aria-label="Previous">
<ArrowLeft size={12} weight="bold" />
</button>
<div class="hero-dots">
{#each resolvedSlots as slot, i}
<button
class="hero-dot"
class:active={activeIdx === i}
class:pinned={slot.kind === "pinned"}
onclick={() => ongotoslot(i)}
aria-label="Slot {i + 1}"
></button>
{/each}
</div>
<button class="hero-nav-btn" onclick={oncyclenext} aria-label="Next">
<ArrowRight size={12} weight="bold" />
</button>
<span class="hero-counter">{activeIdx + 1}/{TOTAL_SLOTS}</span>
</div>
</div>
<div class="hero-chapters">
<div class="hero-chapters-header">
<ListBullets size={11} weight="bold" /><span>Up Next</span>
</div>
{#if activeSlot?.kind === "empty"}
<p class="hero-chapters-empty">No chapters to show</p>
{:else if loadingHeroChapters}
{#each Array(4) as _}
<div class="chapter-row-sk">
<div class="sk sk-num"></div>
<div class="sk-info">
<div class="sk sk-name"></div>
<div class="sk sk-meta"></div>
</div>
</div>
{/each}
{:else if heroChapters.length === 0}
<p class="hero-chapters-empty">No chapters available</p>
{:else}
{#each heroChapters as ch (ch.id)}
{@const isCurrent = heroEntry?.chapterId === ch.id}
<button
class="chapter-row"
class:chapter-row-current={isCurrent}
class:chapter-row-read={ch.isRead && !isCurrent}
onclick={() => onopenchapter(ch)}
>
<span class="ch-num">Ch.{ch.chapterNumber % 1 === 0 ? Math.floor(ch.chapterNumber) : ch.chapterNumber}</span>
<div class="ch-info">
<span class="ch-name">{ch.name}</span>
{#if isCurrent && heroEntry && heroEntry.pageNumber > 1}
<span class="ch-meta">p.{heroEntry.pageNumber} · in progress</span>
{:else if ch.isRead}
<span class="ch-meta ch-read">Read</span>
{:else if ch.uploadDate}
<span class="ch-meta">
{new Date(Number(ch.uploadDate) > 1e10 ? Number(ch.uploadDate) : Number(ch.uploadDate) * 1000)
.toLocaleDateString("en-US", { month: "short", day: "numeric" })}
</span>
{/if}
</div>
{#if isCurrent}<Play size={10} weight="fill" class="ch-play-icon" />{/if}
</button>
{/each}
{#if heroManga}
<button class="ch-view-all" onclick={onviewall}>
All chapters <ArrowRight size={9} weight="bold" />
</button>
{/if}
{/if}
</div>
</div>
<style>
.hero-stage {
position: relative;
display: flex;
align-items: stretch;
height: 374px;
border-radius: var(--radius-xl);
overflow: hidden;
background: var(--bg-raised);
border: 1px solid var(--border-dim);
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.32);
}
.hero-backdrop {
position: absolute;
inset: -14px;
background-size: cover;
background-position: center 25%;
filter: blur(22px) saturate(2.4) brightness(0.4);
transform: scale(1.07);
pointer-events: none;
z-index: 0;
transition: background-image 0.3s ease;
}
.hero-bd-empty { background: var(--bg-void); filter: none; }
.hero-scrim {
position: absolute;
inset: 0;
z-index: 1;
pointer-events: none;
background: linear-gradient(110deg, rgba(0,0,0,0.0) 0%, rgba(0,0,0,0.6) 100%);
}
.hero-cover-col {
position: relative;
z-index: 2;
flex-shrink: 0;
width: 256px;
height: 374px;
overflow: hidden;
cursor: pointer;
border-right: 1px solid rgba(255, 255, 255, 0.07);
background: var(--bg-raised);
padding: 0;
border-top: none;
border-bottom: none;
border-left: none;
}
.hero-cover-col:hover .hero-cover { filter: brightness(1.1) saturate(1.05); }
.hero-cover-col:hover .cover-resume-hint { opacity: 1; }
.hero-cover { width: 100%; height: 100%; object-fit: cover; display: block; transition: filter 0.22s ease; }
.hero-cover-empty {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: var(--bg-overlay);
color: var(--text-faint);
}
.cover-resume-hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
background: rgba(0, 0, 0, 0.38);
opacity: 0;
transition: opacity 0.18s ease;
pointer-events: none;
}
.hero-details {
position: relative;
z-index: 2;
flex: 1;
min-width: 0;
padding: var(--sp-5) var(--sp-5) var(--sp-4);
display: flex;
flex-direction: column;
gap: var(--sp-2);
overflow: hidden;
border-right: 1px solid rgba(255, 255, 255, 0.05);
}
.hero-tags { display: flex; flex-wrap: wrap; gap: 5px; flex-shrink: 0; }
.hero-tag {
display: inline-flex;
align-items: center;
gap: 5px;
font-family: var(--font-ui);
font-size: 9px;
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
padding: 3px 8px;
border-radius: var(--radius-sm);
background: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.6);
border: 1px solid rgba(255, 255, 255, 0.13);
}
.hero-tag-reading { background: var(--accent-muted); color: var(--accent-fg); border-color: var(--accent-dim); }
.hero-tag-pinned { background: rgba(168, 132, 232, 0.18); color: #c4a8f0; border-color: rgba(168, 132, 232, 0.28); }
.hero-tag-genre { cursor: pointer; transition: background 0.15s, color 0.15s; }
.hero-tag-genre:hover { background: rgba(255, 255, 255, 0.18); color: rgba(255, 255, 255, 0.9); }
.hero-title {
font-size: var(--text-xl);
font-weight: var(--weight-semibold);
color: #fff;
line-height: var(--leading-tight);
margin: 0;
flex-shrink: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.55);
letter-spacing: -0.01em;
}
.hero-author {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.45);
letter-spacing: var(--tracking-wide);
flex-shrink: 0;
}
.hero-progress {
display: flex;
align-items: center;
gap: 5px;
flex-shrink: 0;
font-family: var(--font-ui);
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.55);
letter-spacing: var(--tracking-wide);
}
.hero-prog-page { color: rgba(255, 255, 255, 0.35); }
.hero-prog-time { margin-left: auto; color: rgba(255, 255, 255, 0.3); }
.hero-desc {
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.38);
line-height: 1.6;
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
flex-shrink: 0;
}
.hero-empty-title {
font-size: var(--text-base);
font-weight: var(--weight-medium);
color: rgba(255, 255, 255, 0.48);
flex-shrink: 0;
}
.hero-empty-sub {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.26);
letter-spacing: var(--tracking-wide);
line-height: var(--leading-snug);
}
.hero-actions { display: flex; gap: var(--sp-2); flex-shrink: 0; flex-wrap: wrap; margin-top: var(--sp-1); }
.hero-cta {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 7px 18px;
border-radius: var(--radius-md);
background: var(--accent-muted);
border: 1px solid var(--accent-dim);
color: var(--accent-fg);
cursor: pointer;
transition: filter var(--t-base);
white-space: nowrap;
}
.hero-cta:hover:not(:disabled) { filter: brightness(1.18); }
.hero-cta:disabled { opacity: 0.5; cursor: default; }
.hero-cta-ghost {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
padding: 7px 14px;
border-radius: var(--radius-md);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.11);
color: rgba(255, 255, 255, 0.48);
cursor: pointer;
transition: background var(--t-base), color var(--t-base);
white-space: nowrap;
}
.hero-cta-ghost:hover { background: rgba(255, 255, 255, 0.12); color: rgba(255, 255, 255, 0.82); }
.hero-nav-row {
display: flex;
align-items: center;
gap: var(--sp-2);
flex-shrink: 0;
margin-top: auto;
padding-top: var(--sp-3);
border-top: 1px solid rgba(255, 255, 255, 0.07);
}
.hero-nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 22px;
height: 22px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.11);
color: rgba(255, 255, 255, 0.55);
cursor: pointer;
flex-shrink: 0;
transition: background var(--t-base), color var(--t-base);
}
.hero-nav-btn:hover { background: rgba(255, 255, 255, 0.18); color: #fff; }
.hero-dots { display: flex; gap: 5px; align-items: center; }
.hero-dot {
width: 5px;
height: 5px;
border-radius: 50%;
background: rgba(255, 255, 255, 0.2);
border: none;
cursor: pointer;
padding: 0;
transition: background var(--t-base), transform var(--t-base), width var(--t-base);
}
.hero-dot:hover { background: rgba(255, 255, 255, 0.48); }
.hero-dot.active { background: #fff; width: 14px; border-radius: 3px; }
.hero-dot.pinned { background: rgba(168, 132, 232, 0.5); }
.hero-dot.pinned.active { background: #c4a8f0; }
.hero-counter {
font-family: var(--font-ui);
font-size: 10px;
color: rgba(255, 255, 255, 0.28);
letter-spacing: var(--tracking-wide);
margin-left: auto;
}
.hero-chapters {
position: relative;
z-index: 2;
width: clamp(180px, 30%, 232px);
flex-shrink: 0;
display: flex;
flex-direction: column;
padding: var(--sp-4) var(--sp-3);
gap: 1px;
overflow: hidden;
}
.hero-chapters-header {
display: flex;
align-items: center;
gap: var(--sp-2);
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: rgba(255, 255, 255, 0.35);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
padding-bottom: var(--sp-2);
margin-bottom: var(--sp-1);
border-bottom: 1px solid rgba(255, 255, 255, 0.07);
flex-shrink: 0;
}
.hero-chapters-empty {
font-family: var(--font-ui);
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.22);
letter-spacing: var(--tracking-wide);
padding: var(--sp-3) 0;
}
.chapter-row {
display: flex;
align-items: center;
gap: var(--sp-2);
width: 100%;
padding: 7px var(--sp-2);
border-radius: var(--radius-sm);
background: none;
border: none;
text-align: left;
cursor: pointer;
transition: background var(--t-fast);
}
.chapter-row:hover { background: rgba(255, 255, 255, 0.07); }
.chapter-row-current { background: rgba(255, 255, 255, 0.1) !important; }
.ch-num {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: rgba(255, 255, 255, 0.32);
letter-spacing: var(--tracking-wide);
flex-shrink: 0;
min-width: 36px;
}
.chapter-row-current .ch-num { color: var(--accent-fg); }
.ch-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.ch-name {
font-size: var(--text-xs);
color: rgba(255, 255, 255, 0.72);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chapter-row-read .ch-name { color: rgba(255, 255, 255, 0.32); }
.chapter-row-current .ch-name { color: #fff; font-weight: var(--weight-medium); }
.ch-meta {
font-family: var(--font-ui);
font-size: 9px;
color: rgba(255, 255, 255, 0.26);
letter-spacing: var(--tracking-wide);
}
.ch-read { color: rgba(255, 255, 255, 0.18); }
:global(.ch-play-icon) { color: var(--accent-fg); flex-shrink: 0; }
.chapter-row-sk { display: flex; gap: var(--sp-2); padding: 7px var(--sp-2); align-items: center; }
.sk-info { flex: 1; display: flex; flex-direction: column; gap: 4px; }
.sk { background: rgba(255, 255, 255, 0.06); border-radius: var(--radius-sm); animation: pulse 1.6s ease-in-out infinite; }
.sk-num { width: 32px; height: 10px; flex-shrink: 0; }
.sk-name { height: 11px; width: 85%; }
.sk-meta { height: 9px; width: 50%; }
.ch-view-all {
display: flex;
align-items: center;
gap: 4px;
margin-top: auto;
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: rgba(255, 255, 255, 0.28);
letter-spacing: var(--tracking-wide);
background: none;
border: none;
cursor: pointer;
padding: var(--sp-2) var(--sp-2) 0;
transition: color var(--t-base);
}
.ch-view-all:hover { color: var(--accent-fg); }
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 0.7 } }
</style>
+312
View File
@@ -0,0 +1,312 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { gql, thumbUrl } from "@api/client";
import { getBlobUrl } from "@core/cache/imageCache";
import { GET_CHAPTERS } from "@api/queries/chapters";
import { GET_LIBRARY } from "@api/queries/manga";
import { cache, CACHE_KEYS } from "@core/cache";
import { store, openReader, setHeroSlot, setNavPage, setLibraryFilter, clearLibraryUpdates } from "@store/state.svelte";
import type { HistoryEntry } from "@store/state.svelte";
import type { Manga, Chapter } from "@types";
import { buildReaderChapterList } from "@features/series/lib/chapterList";
import HeroStage from "./HeroStage.svelte";
import HeroSlotPicker from "./HeroSlotPicker.svelte";
import ActivityFeed from "./ActivityFeed.svelte";
import UpdatesRow from "./UpdatesRow.svelte";
import StatsGrid from "./StatsGrid.svelte";
let libraryManga: Manga[] = $state([]);
let loadingLibrary: boolean = $state(true);
onMount(() => {
loadLibrary();
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
function loadLibrary() {
cache.get(CACHE_KEYS.LIBRARY, () =>
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes)
)
.then(m => { libraryManga = m; })
.catch(console.error)
.finally(() => loadingLibrary = false);
}
function resetAndReload() {
cache.clear(CACHE_KEYS.LIBRARY);
loadingLibrary = true;
heroChapters = [];
heroAllChapters = [];
heroChaptersFor = null;
loadLibrary();
}
$effect(() => {
if (store.navPage === "home") untrack(() => resetAndReload());
});
$effect(() => {
const sessionId = store.readerSessionId;
if (sessionId === 0) return;
untrack(() => resetAndReload());
});
const continueReading = $derived((() => {
const seen = new Set<number>();
const out: HistoryEntry[] = [];
for (const e of store.history) {
if (seen.has(e.mangaId)) continue;
seen.add(e.mangaId);
out.push(e);
if (out.length >= 10) break;
}
return out;
})());
const TOTAL_SLOTS = 4;
interface HeroSlot { kind: "continue" | "pinned" | "empty"; entry?: HistoryEntry; manga?: Manga; slotIndex: number; }
const resolvedSlots = $derived((() => {
const pins = store.settings.heroSlots ?? [null, null, null, null];
const slots: HeroSlot[] = [];
const first = continueReading[0];
slots.push(first ? { kind: "continue", entry: first, slotIndex: 0 } : { kind: "empty", slotIndex: 0 });
let hi = 1;
for (let i = 1; i < TOTAL_SLOTS; i++) {
const pinId = pins[i];
if (pinId != null) {
const manga = libraryManga.find(m => m.id === pinId);
if (manga) { slots.push({ kind: "pinned", manga, slotIndex: i }); continue; }
}
const entry = continueReading[hi++];
slots.push(entry ? { kind: "continue", entry, slotIndex: i } : { kind: "empty", slotIndex: i });
}
return slots;
})());
let activeIdx = $state(0);
const activeSlot = $derived(resolvedSlots[activeIdx]);
const heroThumbSrc = $derived(
activeSlot?.kind === "pinned" ? (activeSlot.manga?.thumbnailUrl ?? "") :
activeSlot?.kind === "continue" ? (activeSlot.entry?.thumbnailUrl ?? "") : ""
);
let heroThumb = $state("");
$effect(() => {
const path = heroThumbSrc;
const mode = store.settings.serverAuthMode ?? "NONE";
if (!path) { heroThumb = ""; return; }
if (mode !== "BASIC_AUTH") { heroThumb = thumbUrl(path); return; }
getBlobUrl(thumbUrl(path))
.then(url => { heroThumb = url; })
.catch(() => { heroThumb = ""; });
});
const heroTitle = $derived(
activeSlot?.kind === "pinned" ? (activeSlot.manga?.title ?? "") :
activeSlot?.kind === "continue" ? (activeSlot.entry?.mangaTitle ?? "") : ""
);
const heroManga = $derived(
activeSlot?.kind === "pinned" ? activeSlot.manga :
activeSlot?.kind === "continue" ? libraryManga.find(m => m.id === activeSlot.entry?.mangaId) : null
);
const heroEntry = $derived(activeSlot?.kind === "continue" ? activeSlot.entry : null);
const heroMangaId = $derived(heroEntry?.mangaId ?? heroManga?.id ?? null);
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; heroChapters = []; heroAllChapters = []; }
function goToSlot(i: number) { if (i !== activeIdx) { activeIdx = i; heroChapters = []; heroAllChapters = []; } }
function onKey(e: KeyboardEvent) {
if (e.target !== document.body && !(e.target instanceof HTMLElement && e.target.closest(".hero-stage"))) return;
if (e.key === "ArrowRight") cycleNext();
if (e.key === "ArrowLeft") cyclePrev();
}
let heroChapters: Chapter[] = $state([]);
let heroAllChapters: Chapter[] = $state([]);
let loadingHeroChapters = $state(false);
let heroChaptersFor: number | null = null;
$effect(() => {
const id = heroMangaId;
void store.settings.mangaPrefs?.[id!];
if (id) untrack(() => loadHeroChapters(id));
});
async function loadHeroChapters(mangaId: number) {
heroChaptersFor = mangaId;
loadingHeroChapters = true;
heroChapters = [];
heroAllChapters = [];
try {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
if (heroChaptersFor !== mangaId) return;
const all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
heroAllChapters = all;
const filtered = buildReaderChapterList(all, store.settings.mangaPrefs?.[mangaId]);
const lastReadIdx = heroEntry ? filtered.findIndex(c => c.id === heroEntry!.chapterId) : filtered.findLastIndex(c => c.isRead);
const startIdx = Math.max(0, lastReadIdx);
heroChapters = filtered.slice(startIdx, startIdx + 5);
} catch { heroChapters = []; heroAllChapters = []; }
finally { loadingHeroChapters = false; }
}
let resuming = $state(false);
async function openChapter(chapter: Chapter) {
if (!heroMangaId) return;
resuming = true;
try {
let all = heroAllChapters;
if (!all.length) {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroMangaId });
all = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
}
if (all.length) {
const manga = heroManga ?? { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any;
store.activeManga = manga;
const list = buildReaderChapterList(all, store.settings.mangaPrefs?.[heroMangaId]);
const target = list.find(c => c.id === chapter.id) ?? list[0];
if (target) openReader(target, list);
}
} catch { store.activeManga = { id: heroMangaId, title: heroTitle, thumbnailUrl: heroManga?.thumbnailUrl ?? "" } as any; }
finally { resuming = false; }
}
async function resumeActive() {
if (!heroEntry && heroManga) { store.activeManga = heroManga; return; }
if (!heroEntry) return;
const target = heroAllChapters.find(c => c.id === heroEntry!.chapterId) ?? heroAllChapters[0];
if (target && heroAllChapters.length) { await openChapter(target); return; }
resuming = true;
try {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: heroEntry.mangaId });
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[heroEntry.mangaId]);
const ch = list.find(c => c.id === heroEntry!.chapterId) ?? list[0];
if (ch) {
store.activeManga = heroManga ?? { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any;
openReader(ch, list);
}
} catch { store.activeManga = { id: heroEntry.mangaId, title: heroEntry.mangaTitle, thumbnailUrl: heroEntry.thumbnailUrl } as any; }
finally { resuming = false; }
}
async function resumeEntry(entry: HistoryEntry) {
try {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: entry.mangaId });
const raw = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder);
const list = buildReaderChapterList(raw, store.settings.mangaPrefs?.[entry.mangaId]);
const ch = list.find(c => c.id === entry.chapterId) ?? list[0];
if (ch) {
store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
openReader(ch, list);
} else store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any;
} catch { store.activeManga = { id: entry.mangaId, title: entry.mangaTitle, thumbnailUrl: entry.thumbnailUrl } as any; }
}
let pickerOpen = $state(false);
let pickerSlotIndex: 1 | 2 | 3 | null = $state(null);
function openPicker(i: 1 | 2 | 3) { pickerSlotIndex = i; pickerOpen = true; }
function closePicker() { pickerOpen = false; pickerSlotIndex = null; }
function pinManga(m: Manga) { if (pickerSlotIndex !== null) { setHeroSlot(pickerSlotIndex, m.id); closePicker(); } }
function unpinSlot(i: 1 | 2 | 3) { setHeroSlot(i, null); }
const recentHistory = $derived(store.history.slice(0, 6));
const stats = $derived(store.readingStats);
const libraryUpdates = $derived(store.libraryUpdates.slice(0, 7));
const lastRefresh = $derived(store.lastLibraryRefresh);
</script>
<div class="root">
<div class="body">
<div class="hero-section">
<HeroStage
{resolvedSlots}
bind:activeIdx
{heroThumb}
{heroTitle}
{heroManga}
{heroEntry}
{heroMangaId}
{heroChapters}
{loadingHeroChapters}
{resuming}
onresume={resumeActive}
onopenchapter={openChapter}
oncyclenext={cycleNext}
oncycleprev={cyclePrev}
ongotoslot={goToSlot}
onopenpicker={openPicker}
onunpin={unpinSlot}
onviewall={() => { if (heroManga) store.activeManga = heroManga; }}
/>
</div>
<ActivityFeed
entries={recentHistory}
onresume={resumeEntry}
onviewhistory={() => setNavPage("history")}
onopenlibrary={() => setNavPage("library")}
/>
<div class="bottom-row">
<UpdatesRow
updates={libraryUpdates}
{libraryManga}
{lastRefresh}
onopen={(m) => { if (m) store.previewManga = m; }}
onclear={() => { clearLibraryUpdates(); setLibraryFilter("all"); setNavPage("library"); }}
/>
<div class="bottom-divider"></div>
<StatsGrid {stats} updateCount={libraryUpdates.length} />
</div>
</div>
</div>
{#if pickerOpen && pickerSlotIndex !== null}
<HeroSlotPicker
slotIndex={pickerSlotIndex}
{libraryManga}
loading={loadingLibrary}
onpin={pinManga}
onclose={closePicker}
/>
{/if}
<style>
.root {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
animation: fadeIn 0.14s ease both;
}
.body {
flex: 1;
display: flex;
flex-direction: column;
overflow-y: auto;
overflow-x: hidden;
min-height: 0;
}
.hero-section {
padding: var(--sp-3) var(--sp-4) var(--sp-2);
flex-shrink: 0;
}
.bottom-row {
display: grid;
grid-template-columns: 1fr 1px 1fr;
padding: var(--sp-4) var(--sp-4) var(--sp-5);
border-top: 1px solid var(--border-dim);
gap: var(--sp-4);
flex-shrink: 0;
}
.bottom-divider { background: var(--border-dim); align-self: stretch; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
@@ -0,0 +1,132 @@
<script lang="ts">
import { Fire, BookOpen, Clock, TrendUp, Bell, CalendarBlank } from "phosphor-svelte";
import { formatReadTime } from "../lib/homeHelpers";
let {
stats,
updateCount,
}: {
stats: {
currentStreakDays: number;
totalChaptersRead: number;
totalMinutesRead: number;
totalMangaRead: number;
longestStreakDays: number;
};
updateCount: number;
} = $props();
</script>
<div class="col">
<div class="col-header">
<span class="col-title"><TrendUp size={10} weight="bold" /> Your Stats</span>
</div>
<div class="grid">
<div class="card">
<div class="icon-wrap fire"><Fire size={15} weight="fill" /></div>
<div class="body">
<span class="val">{stats.currentStreakDays}</span>
<span class="label">Day streak</span>
</div>
</div>
<div class="card">
<div class="icon-wrap accent"><BookOpen size={15} weight="light" /></div>
<div class="body">
<span class="val">{stats.totalChaptersRead}</span>
<span class="label">Chapters read</span>
</div>
</div>
<div class="card">
<div class="icon-wrap neutral"><Clock size={15} weight="light" /></div>
<div class="body">
<span class="val">{formatReadTime(stats.totalMinutesRead)}</span>
<span class="label">Read time</span>
</div>
</div>
<div class="card">
<div class="icon-wrap neutral"><TrendUp size={15} weight="light" /></div>
<div class="body">
<span class="val">{stats.totalMangaRead}</span>
<span class="label">Series started</span>
</div>
</div>
<div class="card">
<div class="icon-wrap green"><Bell size={15} weight="light" /></div>
<div class="body">
<span class="val">{updateCount}</span>
<span class="label">New updates</span>
</div>
</div>
<div class="card">
<div class="icon-wrap neutral"><CalendarBlank size={15} weight="light" /></div>
<div class="body">
<span class="val">{stats.longestStreakDays}d</span>
<span class="label">Best streak</span>
</div>
</div>
</div>
</div>
<style>
.col { display: flex; flex-direction: column; min-width: 0; }
.col-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--sp-2);
}
.col-title {
display: inline-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-wider);
text-transform: uppercase;
}
.grid { display: grid; grid-template-columns: 1fr 1fr; gap: var(--sp-2); }
.card {
display: flex;
align-items: center;
gap: var(--sp-3);
background: var(--bg-raised);
border: 1px solid var(--border-dim);
border-radius: var(--radius-md);
padding: var(--sp-3);
transition: border-color var(--t-fast);
}
.card:hover { border-color: var(--border-base); }
.icon-wrap {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: var(--radius-sm);
flex-shrink: 0;
}
.fire { background: rgba(251, 146, 60, 0.15); color: #fb923c; }
.accent { background: var(--accent-muted); color: var(--accent-fg); }
.neutral { background: var(--bg-overlay); color: var(--text-faint); }
.green { background: rgba(34, 197, 94, 0.12); color: #22c55e; }
.body { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.val {
font-family: var(--font-ui);
font-size: var(--text-lg, 1.05rem);
font-weight: var(--weight-medium);
color: var(--text-secondary);
line-height: 1;
}
.label {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
white-space: nowrap;
}
</style>
@@ -0,0 +1,187 @@
<script lang="ts">
import { Bell, ArrowRight } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Manga } from "@types";
import { timeAgoRefresh, handleRowWheel } from "../lib/homeHelpers";
interface LibraryUpdate {
mangaId: number;
mangaTitle: string;
thumbnailUrl: string;
newChapters: number;
}
let {
updates,
libraryManga,
lastRefresh,
onopen,
onclear,
}: {
updates: LibraryUpdate[];
libraryManga: Manga[];
lastRefresh: number;
onopen: (m: Manga | undefined) => void;
onclear: () => void;
} = $props();
</script>
<div class="col">
<div class="col-header">
<span class="col-title">
<Bell size={10} weight="bold" /> Updates
{#if lastRefresh}<span class="refresh-age">{timeAgoRefresh(lastRefresh)}</span>{/if}
</span>
{#if updates.length > 0}
<button class="action-btn" onclick={onclear}>
Clear <ArrowRight size={9} weight="bold" />
</button>
{/if}
</div>
{#if updates.length > 0}
<div class="scroll-row" onwheel={(e) => { e.preventDefault(); handleRowWheel(e); }}>
{#each updates as u (u.mangaId)}
{@const m = libraryManga.find(x => x.id === u.mangaId)}
<button class="card" onclick={() => onopen(m)}>
<div class="card-cover-wrap">
<Thumbnail src={u.thumbnailUrl} alt={u.mangaTitle} class="card-cover" />
<div class="card-gradient"></div>
<div class="card-footer">
<p class="card-title">{u.mangaTitle}</p>
<p class="card-badge">+{u.newChapters} chapter{u.newChapters !== 1 ? "s" : ""}</p>
</div>
</div>
</button>
{/each}
</div>
{:else}
<p class="empty-msg">{lastRefresh ? "No new chapters found" : "Check for updates in the library"}</p>
{/if}
</div>
<style>
.col { display: flex; flex-direction: column; min-width: 0; }
.col-header {
display: flex;
align-items: center;
justify-content: space-between;
padding-bottom: var(--sp-2);
}
.col-title {
display: inline-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-wider);
text-transform: uppercase;
}
.refresh-age {
font-family: var(--font-ui);
font-size: var(--text-2xs);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
margin-left: var(--sp-2);
}
.action-btn {
display: flex;
align-items: center;
gap: 4px;
font-family: var(--font-ui);
font-size: var(--text-xs);
letter-spacing: var(--tracking-wide);
text-transform: uppercase;
color: var(--text-faint);
background: none;
border: none;
cursor: pointer;
padding: 0;
transition: color var(--t-base);
}
.action-btn:hover { color: var(--accent-fg); }
.scroll-row {
display: flex;
flex-direction: row;
gap: var(--sp-3);
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: none;
padding-bottom: var(--sp-1);
}
.scroll-row::-webkit-scrollbar { display: none; }
.card {
flex: 0 0 112px;
width: 112px;
background: none;
border: none;
padding: 0;
cursor: pointer;
text-align: left;
}
.card:hover :global(.card-cover) { filter: brightness(1.1) saturate(1.05); transform: scale(1.02); }
.card-cover-wrap {
position: relative;
aspect-ratio: 2 / 3;
overflow: hidden;
border-radius: var(--radius-md);
background: var(--bg-raised);
border: 1px solid var(--border-dim);
box-shadow: 0 2px 14px rgba(0, 0, 0, 0.38);
}
:global(.card-cover) {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
transition: filter 0.15s ease, transform 0.15s ease;
}
.card-gradient {
position: absolute;
inset: 0;
background: linear-gradient(to top, rgba(0,0,0,0.88) 0%, rgba(0,0,0,0.08) 55%, transparent 75%);
pointer-events: none;
}
.card-footer {
position: absolute;
bottom: 0;
left: 0;
right: 0;
padding: var(--sp-2);
pointer-events: none;
}
.card-title {
font-size: var(--text-xs);
font-weight: var(--weight-medium);
color: rgba(255,255,255,0.92);
line-height: var(--leading-snug);
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
text-shadow: 0 1px 4px rgba(0,0,0,0.7);
}
.card-badge {
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;
}
.empty-msg {
font-family: var(--font-ui);
font-size: var(--text-sm);
color: var(--text-faint);
letter-spacing: var(--tracking-wide);
padding: var(--sp-1) 0;
}
</style>
View File
+35
View File
@@ -0,0 +1,35 @@
export function timeAgo(ts: number): string {
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
if (m < 1) return "Just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
const d = Math.floor(h / 24);
if (d < 7) return `${d}d ago`;
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
export function formatReadTime(mins: number): string {
if (mins < 1) return `${Math.round(mins * 60)}s`;
if (mins < 60) return `${Math.round(mins)}m`;
const h = Math.floor(mins / 60), r = Math.round(mins % 60);
if (h < 24) return r === 0 ? `${h}h` : `${h}h ${r}m`;
const d = Math.floor(h / 24), rh = h % 24;
return rh === 0 ? `${d}d` : `${d}d ${rh}h`;
}
export function timeAgoRefresh(ts: number): string {
if (!ts) return "";
const diff = Date.now() - ts, m = Math.floor(diff / 60000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h ago`;
return `${Math.floor(h / 24)}d ago`;
}
export function handleRowWheel(e: WheelEvent) {
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
(e.currentTarget as HTMLElement).scrollLeft += e.deltaY;
e.stopPropagation();
}
@@ -0,0 +1,628 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { invoke } from "@tauri-apps/api/core";
import { gql } from "@api/client";
import {
GET_CATEGORIES, GET_LIBRARY, UPDATE_MANGA, UPDATE_MANGAS,
GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD,
CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES, UPDATE_LIBRARY, LIBRARY_UPDATE_STATUS,
UPDATE_CATEGORY_ORDER,
} from "@api";
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "@core/cache/queryCache";
import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw } from "@core/util";
import { sortLibrary } from "../lib/librarySort";
import { createPaginator } from "@core/algorithms/paginate";
import {
store, setCategories, setLibraryUpdates, addToast,
setTabSort, toggleTabSortDir, setTabStatus, toggleTabFilter, clearTabFilters,
} from "../store/libraryState.svelte";
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter, LibraryUpdateEntry } from "@store/state.svelte";
import type { Manga, Category, Chapter } from "@types";
import { checkAndMarkCompleted as storeCheckAndMarkCompleted } from "@store/state.svelte";
import LibraryToolbar from "./LibraryToolbar.svelte";
import LibraryGrid from "./LibraryGrid.svelte";
import LibraryFilters from "./LibraryFilters.svelte";
import ContextMenu, { type MenuEntry } from "@shared/ui/ContextMenu.svelte";
import { Books, DownloadSimple, Folder, FolderSimple, FolderSimplePlus, Trash, Star, CheckSquare, ArrowSquareOut } from "phosphor-svelte";
const CARD_MIN_W = 130;
const CARD_GAP = 16;
const COMPLETED_NAME = "Completed";
const paginator = createPaginator<Manga>(store.settings.renderLimit ?? 48);
let allManga: Manga[] = $state([]);
let loading: boolean = $state(true);
let error: string|null = $state(null);
let retryCount: number = $state(0);
let search: string = $state("");
let renderVisible: number = $state(store.settings.renderLimit ?? 48);
let scrollEl: HTMLDivElement;
let tabsEl: HTMLDivElement;
let containerWidth: number = $state(800);
let ctx: { x: number; y: number; manga: Manga } | null = $state(null);
let emptyCtx: { x: number; y: number } | null = $state(null);
let tabIndicator: { left: number; width: number } = $state({ left: 0, width: 0 });
let selectedIds: Set<number> = $state(new Set());
let selectMode: boolean = $state(false);
let bulkWorking: boolean = $state(false);
let bulkMoveOpen: boolean = $state(false);
let sortPanelOpen: boolean = $state(false);
let filterPanelOpen: boolean = $state(false);
let refreshing: boolean = $state(false);
let refreshProgress = $state({ finished: 0, total: 0 });
let pollTimer: ReturnType<typeof setTimeout> | null = null;
let refreshDone: boolean = $state(false);
let refreshDoneTimer: ReturnType<typeof setTimeout> | null = null;
let activeDragKind: "tab" | null = $state(null);
let dragInsertIdx: number = $state(-1);
let dragTabId: number | null = $state(null);
let dragOverTabId: number | null = $state(null);
let dropTargetTabId: number | null = $state(null);
const DT_TAB = "application/x-moku-tab";
const anims = $derived(store.settings.qolAnimations ?? true);
const tab = $derived(store.libraryFilter);
const tabSortMode = $derived(store.settings.libraryTabSort[tab]?.mode ?? "az" as LibrarySortMode);
const tabSortDir = $derived(store.settings.libraryTabSort[tab]?.dir ?? "asc" as LibrarySortDir);
const tabStatus = $derived(store.settings.libraryTabStatus[tab] ?? "ALL" as LibraryStatusFilter);
const tabFilters = $derived(store.settings.libraryTabFilters?.[tab] ?? {} as Partial<Record<LibraryContentFilter, boolean>>);
const hasActiveFilters = $derived(tabStatus !== "ALL" || Object.values(tabFilters).some(Boolean));
const cols = $derived(Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP))));
const visibleCategories = $derived((() => {
const defaultId = store.settings.defaultLibraryCategoryId ?? null;
return store.categories
.filter(c => c.id !== 0 && !(store.settings.hiddenCategoryIds ?? []).includes(c.id))
.sort((a, b) => {
if (a.id === defaultId) return -1;
if (b.id === defaultId) return 1;
return a.order - b.order;
});
})());
const categoryMangaMap = $derived((() => {
const map = new Map<number, Manga[]>();
for (const cat of store.categories) {
map.set(cat.id, cat.mangas?.nodes ?? []);
}
return map;
})());
const filtered = $derived((() => {
let items: Manga[];
if (tab === "library") {
items = (store.settings.libraryShowAllInSaved ?? true)
? allManga.filter(m => m.inLibrary)
: (categoryMangaMap.get(0) ?? []);
} else if (tab === "downloaded") {
items = allManga.filter(m => (m.downloadCount ?? 0) > 0);
} else {
items = categoryMangaMap.get(Number(tab)) ?? [];
}
items = items.filter(m => !shouldHideNsfw(m, store.settings));
const q = search.trim().toLowerCase();
if (q) items = items.filter(m => m.title.toLowerCase().includes(q));
if (tabStatus !== "ALL") {
items = items.filter(m => {
const s = m.status?.toUpperCase().replace(/\s+/g, "_") ?? "UNKNOWN";
return s === tabStatus;
});
}
const f = store.settings.libraryTabFilters?.[tab] ?? {};
if (f.unread) items = items.filter(m => (m.unreadCount ?? 0) > 0);
if (f.started) items = items.filter(m => (m.unreadCount ?? 0) > 0 && (m.chapterCount ?? 0) > (m.unreadCount ?? 0));
if (f.downloaded) items = items.filter(m => (m.downloadCount ?? 0) > 0);
if (f.bookmarked) items = items.filter(m => !!(m as any).hasBookmark);
const recentlyReadMap = new Map<number, number>();
if (tabSortMode === "recentlyRead") {
for (const h of store.history) {
if (!recentlyReadMap.has(h.mangaId)) recentlyReadMap.set(h.mangaId, h.readAt);
}
}
return sortLibrary(items, tabSortMode, tabSortDir, recentlyReadMap.size ? recentlyReadMap : undefined);
})());
const { items: visibleManga, hasMore, remaining: remainingCount } = $derived(
paginator.slice(filtered, renderVisible)
);
const counts = $derived((() => {
const m: Record<string, number> = {
library: (store.settings.libraryShowAllInSaved ?? true)
? allManga.filter(x => x.inLibrary).length
: (categoryMangaMap.get(0) ?? []).length,
downloaded: allManga.filter(m => (m.downloadCount ?? 0) > 0).length,
};
for (const cat of visibleCategories) {
m[String(cat.id)] = (categoryMangaMap.get(cat.id) ?? []).length;
}
return m;
})());
$effect(() => { filtered; untrack(() => { renderVisible = paginator.reset(); }); });
$effect(() => { retryCount; loading = true; error = null; if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); });
$effect(() => { if (scrollEl) scrollEl.scrollTo({ top: 0 }); });
$effect(() => {
const f = tab;
if (f === "library" || f === "downloaded") return;
const id = Number(f);
if (!store.categories.some(c => c.id === id)) untrack(() => { store.libraryFilter = "library"; });
});
$effect(() => { tab; untrack(() => exitSelectMode()); });
$effect(() => { tab; setTimeout(updateTabIndicator); });
let prevChapterId: number | null = null;
$effect(() => {
const wasOpen = prevChapterId !== null;
prevChapterId = store.activeChapter?.id ?? null;
if (wasOpen && !store.activeChapter) { cache.clear(CACHE_KEYS.LIBRARY); untrack(() => loadData()); }
});
function updateTabIndicator() {
if (!tabsEl) return;
const active = tabsEl.querySelector<HTMLElement>(".tab.active");
if (!active) return;
const parent = tabsEl.getBoundingClientRect();
const rect = active.getBoundingClientRect();
tabIndicator = { left: rect.left - parent.left, width: rect.width };
}
function enterSelectMode(id?: number) { selectMode = true; if (id !== undefined) selectedIds = new Set([id]); }
function exitSelectMode() { selectMode = false; selectedIds = new Set(); bulkMoveOpen = false; }
function toggleSelect(id: number) { const next = new Set(selectedIds); if (next.has(id)) next.delete(id); else next.add(id); selectedIds = next; if (next.size === 0) exitSelectMode(); }
function selectAll() { selectedIds = new Set(visibleManga.map(m => m.id)); }
function loadMore() { renderVisible = paginator.nextVisible(renderVisible); }
let longPressTimer: ReturnType<typeof setTimeout> | null = null;
function onCardPointerDown(e: PointerEvent, m: Manga) {
if (e.button !== 0) return;
longPressTimer = setTimeout(() => { longPressTimer = null; enterSelectMode(m.id); }, 500);
}
function onCardPointerUp() { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } }
function onCardPointerLeave() { if (longPressTimer) { clearTimeout(longPressTimer); longPressTimer = null; } }
function onCardClick(e: MouseEvent, m: Manga) {
if (selectMode) { toggleSelect(m.id); return; }
if (e.metaKey || e.ctrlKey || e.shiftKey) { e.preventDefault(); enterSelectMode(m.id); return; }
store.activeManga = m;
}
async function ensureCompletedCategory(cats: Category[]): Promise<Category[]> {
if (cats.some(c => c.name === COMPLETED_NAME)) return cats;
try {
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: COMPLETED_NAME });
return [...cats, res.createCategory.category];
} catch { return cats; }
}
async function reloadCategories() {
try {
const d = await gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES);
const cats = await ensureCompletedCategory(d.categories.nodes);
setCategories(cats);
} catch (e) { console.error(e); }
}
async function loadData() {
try {
const [nodes] = await Promise.all([
cache.get(CACHE_KEYS.LIBRARY, () => gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then(d => d.mangas.nodes), DEFAULT_TTL_MS, CACHE_GROUPS.LIBRARY),
reloadCategories(),
]);
const mapped = nodes.map((m: any) => ({ ...m, chapterCount: m.chapters?.totalCount ?? m.chapterCount ?? 0 }));
allManga = dedupeMangaByTitle(dedupeMangaById(mapped), store.settings.mangaLinks);
error = null;
await migrateCategorizedToLibrary();
} catch (e: any) {
error = e.message;
} finally {
loading = false;
}
}
async function migrateCategorizedToLibrary() {
const allCatManga = store.categories.flatMap(c => c.mangas?.nodes ?? []);
const orphanIds = [...new Set(allCatManga.filter(m => !m.inLibrary).map(m => m.id))];
if (!orphanIds.length) return;
await gql(UPDATE_MANGAS, { ids: orphanIds, inLibrary: true }).catch(console.error);
allManga = allManga.map(m => orphanIds.includes(m.id) ? { ...m, inLibrary: true } : m);
cache.clearGroup(CACHE_GROUPS.LIBRARY);
}
async function removeFromLibrary(manga: Manga) {
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
allManga = allManga.filter(m => m.id !== manga.id);
cache.clearGroup(CACHE_GROUPS.LIBRARY);
await reloadCategories();
}
async function deleteAllDownloads(manga: Manga) {
try {
const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id });
const ids = data.chapters.nodes.filter(c => c.isDownloaded).map(c => c.id);
if (!ids.length) return;
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
await Promise.allSettled(ids.map(id => gql(DEQUEUE_DOWNLOAD, { chapterId: id })));
allManga = allManga.map(m => m.id === manga.id ? { ...m, downloadCount: 0 } : m);
} catch (e) { console.error(e); }
}
async function toggleMangaCategory(manga: Manga, cat: Category) {
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(m => m.id === manga.id);
setCategories(store.categories.map(c => {
if (c.id !== cat.id || !c.mangas) return c;
const nodes = inCat ? c.mangas.nodes.filter(m => m.id !== manga.id) : [...c.mangas.nodes, manga];
return { ...c, mangas: { nodes } };
}));
try {
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: inCat ? [] : [cat.id], removeFrom: inCat ? [cat.id] : [] });
if (!inCat && !manga.inLibrary) {
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: true });
allManga = allManga.map(m => m.id === manga.id ? { ...m, inLibrary: true } : m);
cache.clearGroup(CACHE_GROUPS.LIBRARY);
}
await reloadCategories();
} catch (e) { console.error(e); await reloadCategories(); }
}
async function createAndAssign(manga: Manga) {
const name = prompt("Folder name:");
if (!name?.trim()) return;
try {
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: name.trim() });
const cat = res.createCategory.category;
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: manga.id, addTo: [cat.id], removeFrom: [] });
if (!manga.inLibrary) {
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: true });
allManga = allManga.map(m => m.id === manga.id ? { ...m, inLibrary: true } : m);
cache.clearGroup(CACHE_GROUPS.LIBRARY);
}
await reloadCategories();
} catch (e) { console.error(e); }
}
async function bulkMoveToCategory(cat: Category) {
bulkWorking = true; bulkMoveOpen = false;
try { await Promise.all([...selectedIds].map(id => { const m = allManga.find(x => x.id === id); return m ? toggleMangaCategory(m, cat) : Promise.resolve(); })); }
finally { bulkWorking = false; exitSelectMode(); }
}
async function bulkRemoveFromLibrary() {
bulkWorking = true;
try { await Promise.all([...selectedIds].map(id => { const m = allManga.find(x => x.id === id); return m ? removeFromLibrary(m) : Promise.resolve(); })); }
finally { bulkWorking = false; exitSelectMode(); }
}
function sanitize(s: string) { return s.replace(/[\/\\?%*:|"<>]/g, "_"); }
async function openMangaFolder(m: Manga) {
let base = store.settings.serverDownloadsPath?.trim();
if (!base) { 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 source = m.source?.displayName ?? m.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 = store.settings.serverDownloadsPath?.trim();
if (!path) { 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 }); }
}
function openCtx(e: MouseEvent, m: Manga) {
if (selectMode) { toggleSelect(m.id); return; }
e.preventDefault();
ctx = { x: e.clientX, y: e.clientY, manga: m };
}
function buildCtxItems(m: Manga): MenuEntry[] {
const catEntries: MenuEntry[] = visibleCategories.map(cat => {
const inCat = (categoryMangaMap.get(cat.id) ?? []).some(x => x.id === m.id);
return { label: inCat ? `Remove from ${cat.name}` : `Add to ${cat.name}`, icon: Folder, onClick: () => toggleMangaCategory(m, cat) };
});
return [
{ label: m.inLibrary ? "Remove from library" : "Add to library", icon: Books, onClick: () => m.inLibrary ? removeFromLibrary(m) : gql(UPDATE_MANGA, { id: m.id, inLibrary: true }).then(() => { allManga = allManga.map(x => x.id === m.id ? { ...x, inLibrary: true } : x); cache.clear(CACHE_KEYS.LIBRARY); }).catch(console.error) },
{ 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: () => deleteAllDownloads(m) },
{ separator: true },
{ label: "Select this manga", icon: CheckSquare, onClick: () => enterSelectMode(m.id) },
...(catEntries.length ? [{ separator: true } as MenuEntry, ...catEntries] : []),
{ 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 gql(CREATE_CATEGORY, { name: name.trim() }); await reloadCategories(); }
catch (e) { console.error(e); }
}}];
}
export async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
await storeCheckAndMarkCompleted(mangaId, chaps, store.categories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
await reloadCategories();
}
function showToast(newChapters: number, totalUpdated: number) {
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", body: "No new chapters found" });
}
}
async function startLibraryRefresh() {
if (refreshing) return;
refreshing = true;
refreshProgress = { finished: 0, total: 0 };
const prevCounts = new Map(allManga.map(m => [m.id, m.unreadCount ?? 0]));
let seenWork = false;
try {
const res = await gql<{ updateLibrary: { updateStatus: { jobsInfo: { isRunning: boolean; totalJobs: number } } } }>(UPDATE_LIBRARY, {});
seenWork = res.updateLibrary.updateStatus.jobsInfo.totalJobs > 0;
} catch { refreshing = false; return; }
pollTimer = setTimeout(function poll() {
gql<{ libraryUpdateStatus: {
jobsInfo: { isRunning: boolean; finishedJobs: number; totalJobs: number };
mangaUpdates: { status: string; manga: { id: number; title: string; thumbnailUrl: string; unreadCount: number } }[];
} }>(LIBRARY_UPDATE_STATUS, {})
.then(d => {
const { jobsInfo, mangaUpdates } = d.libraryUpdateStatus;
refreshProgress = { finished: jobsInfo.finishedJobs, total: jobsInfo.totalJobs };
if (jobsInfo.totalJobs > 0) seenWork = true;
if (!jobsInfo.isRunning && seenWork) {
refreshing = false;
pollTimer = null;
const entries: LibraryUpdateEntry[] = mangaUpdates
.filter(u => u.status === "FINISHED")
.reduce<LibraryUpdateEntry[]>((acc, u) => {
const newChapters = Math.max(0, (u.manga.unreadCount ?? 0) - (prevCounts.get(u.manga.id) ?? 0));
if (newChapters > 0) acc.push({ mangaId: u.manga.id, mangaTitle: u.manga.title, thumbnailUrl: u.manga.thumbnailUrl, newChapters, checkedAt: Date.now() });
return acc;
}, []);
setLibraryUpdates(entries);
cache.clearGroup(CACHE_GROUPS.LIBRARY);
loadData();
refreshDone = true;
if (refreshDoneTimer) clearTimeout(refreshDoneTimer);
refreshDoneTimer = setTimeout(() => { refreshDone = false; }, 2500);
showToast(entries.reduce((s, e) => s + e.newChapters, 0), entries.length);
return;
}
pollTimer = setTimeout(poll, 3000);
})
.catch(() => { refreshing = false; pollTimer = null; });
}, 2000);
}
function onTabDragStart(e: DragEvent, cat: Category) {
activeDragKind = "tab"; dragTabId = cat.id;
e.dataTransfer!.effectAllowed = "move";
e.dataTransfer!.setData(DT_TAB, String(cat.id));
e.dataTransfer!.setData("text/plain", `tab:${cat.id}`);
}
function onTabDragOver(e: DragEvent, cat: Category, idx: number) {
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === cat.id) return;
e.preventDefault(); e.dataTransfer!.dropEffect = "move";
dragOverTabId = cat.id; dragInsertIdx = idx;
}
function onTabDragLeave() { dragOverTabId = null; }
async function onTabDrop(e: DragEvent, dropCat: Category) {
e.preventDefault(); dragOverTabId = null; dragInsertIdx = -1;
if (activeDragKind !== "tab" || dragTabId === null || dragTabId === dropCat.id) { dragTabId = null; return; }
const dragId = dragTabId; dragTabId = null; activeDragKind = null;
const sorted = [...store.categories].filter(c => c.id !== 0).sort((a, b) => a.order - b.order);
const fromIdx = sorted.findIndex(c => c.id === dragId);
const toIdx = sorted.findIndex(c => c.id === dropCat.id);
if (fromIdx < 0 || toIdx < 0) return;
const reordered = [...sorted];
const [moved] = reordered.splice(fromIdx, 1);
reordered.splice(toIdx, 0, moved);
const withNewOrder = reordered.map((c, i) => ({ ...c, order: i + 1 }));
setCategories(store.categories.map(c => withNewOrder.find(u => u.id === c.id) ?? c));
try {
await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id: dragId, position: toIdx + 1 });
} catch (err) { console.error("Tab reorder failed:", err); await reloadCategories(); }
}
function onTabDragEnd() { activeDragKind = null; dragTabId = null; dragOverTabId = null; dragInsertIdx = -1; }
onMount(() => {
const ro = new ResizeObserver(([e]) => containerWidth = e.contentRect.width);
ro.observe(scrollEl);
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, () => loadData());
const defaultId = store.settings.defaultLibraryCategoryId;
if (defaultId && store.libraryFilter === "library") store.libraryFilter = String(defaultId);
function onKeyDown(e: KeyboardEvent) {
if (e.key === "Escape" && (sortPanelOpen || filterPanelOpen)) { sortPanelOpen = false; filterPanelOpen = false; return; }
if (e.key === "Escape" && selectMode) exitSelectMode();
if ((e.key === "a" && (e.metaKey || e.ctrlKey)) && selectMode) { e.preventDefault(); selectAll(); }
}
function onDocMouseDown(e: MouseEvent) {
const t = e.target as HTMLElement;
if (sortPanelOpen && !t.closest(".sort-panel-wrap, .sort-panel")) sortPanelOpen = false;
if (filterPanelOpen && !t.closest(".filter-panel-wrap, .filter-panel")) filterPanelOpen = false;
}
window.addEventListener("keydown", onKeyDown);
document.addEventListener("mousedown", onDocMouseDown, true);
updateTabIndicator();
return () => {
ro.disconnect(); unsub();
if (pollTimer) clearTimeout(pollTimer);
window.removeEventListener("keydown", onKeyDown);
document.removeEventListener("mousedown", onDocMouseDown, true);
};
});
</script>
<div
class="root"
role="presentation"
bind:this={scrollEl}
oncontextmenu={(e) => {
if ((e.target as HTMLElement).closest("button")) return;
e.preventDefault();
emptyCtx = { x: e.clientX, y: e.clientY };
}}
>
{#if store.settings.libraryBranches ?? true}
<svg class="branches" viewBox="0 0 400 600" preserveAspectRatio="xMaxYMid slice" aria-hidden="true">
<g stroke="var(--accent)" stroke-width="0.6" fill="none" opacity="0.13">
<path d="M380 600 C380 500 340 460 310 400 C280 340 300 280 270 220"/>
<path d="M270 220 C255 190 230 175 210 150"/>
<path d="M270 220 C290 195 310 185 330 165"/>
<path d="M310 400 C290 375 265 368 245 350"/>
<path d="M310 400 C330 370 355 362 370 340"/>
<path d="M210 150 C195 128 185 108 175 80"/>
<path d="M210 150 C225 130 240 122 258 105"/>
<path d="M245 350 C228 330 215 315 205 290"/>
<path d="M175 80 C168 60 162 42 158 20"/>
<path d="M175 80 C185 62 195 50 208 35"/>
<path d="M205 290 C196 268 190 250 186 225"/>
<path d="M258 105 C268 88 278 72 292 52"/>
<path class="anim-branch" d="M186 225 C180 205 176 185 174 160"/>
<path class="anim-branch" d="M292 52 C300 36 308 20 318 0"/>
</g>
</svg>
{/if}
{#if error}
<div class="center">
<p class="error-msg">Could not reach Suwayomi</p>
<p class="error-detail">Make sure the server is running, then retry.</p>
<button class="retry-btn" onclick={() => retryCount++}>Retry</button>
</div>
{:else}
<LibraryToolbar
{tab}
{tabSortMode}
{tabSortDir}
{tabStatus}
{tabFilters}
{hasActiveFilters}
{anims}
{tabIndicator}
{visibleCategories}
{counts}
{search}
{refreshing}
{refreshProgress}
{refreshDone}
{activeDragKind}
{dragInsertIdx}
{dragTabId}
{dragOverTabId}
{sortPanelOpen}
{filterPanelOpen}
bind:tabsEl
onSearchChange={(v) => search = v}
onTabChange={(f) => store.libraryFilter = f}
onSortChange={(mode) => { setTabSort(tab, mode); sortPanelOpen = false; }}
onSortDirToggle={() => toggleTabSortDir(tab)}
onStatusChange={(s) => setTabStatus(tab, s)}
onFilterToggle={(f) => toggleTabFilter(tab, f)}
onFiltersClear={() => clearTabFilters(tab)}
onSortPanelToggle={() => { sortPanelOpen = !sortPanelOpen; filterPanelOpen = false; }}
onFilterPanelToggle={() => { filterPanelOpen = !filterPanelOpen; sortPanelOpen = false; }}
onRefresh={startLibraryRefresh}
onOpenDownloadsFolder={openDownloadsFolder}
onTabDragStart={onTabDragStart}
onTabDragOver={onTabDragOver}
onTabDragLeave={onTabDragLeave}
onTabDrop={onTabDrop}
onTabDragEnd={onTabDragEnd}
/>
{#if refreshing && refreshProgress.total > 0}
{@const pct = Math.round((refreshProgress.finished / refreshProgress.total) * 100)}
<div class="refresh-bar-wrap" aria-hidden="true">
<div class="refresh-bar-fill" style="width:{pct}%"></div>
</div>
{/if}
<LibraryGrid
{visibleManga}
{filtered}
{loading}
{cols}
{anims}
{selectMode}
{selectedIds}
{hasMore}
{remainingCount}
renderLimit={store.settings.renderLimit ?? 48}
cropCovers={store.settings.libraryCropCovers}
libraryFilter={tab}
onCardClick={onCardClick}
onCardContextMenu={openCtx}
onCardPointerDown={onCardPointerDown}
onCardPointerUp={onCardPointerUp}
onCardPointerLeave={onCardPointerLeave}
onLoadMore={loadMore}
onRetry={() => retryCount++}
onExitSelectMode={exitSelectMode}
onSelectAll={selectAll}
onBulkMove={(cat) => { bulkMoveOpen = !bulkMoveOpen; }}
onBulkRemove={bulkRemoveFromLibrary}
{bulkWorking}
{bulkMoveOpen}
{visibleCategories}
onCategoryMove={bulkMoveToCategory}
/>
{/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: visible; animation: fadeIn 0.14s ease both; }
.branches { position: absolute; top: 0; right: 0; width: 400px; height: 600px; pointer-events: none; z-index: 0; }
.branches :global(.anim-branch) { stroke-dasharray: 60; stroke-dashoffset: 60; animation: branchGrow 2.4s ease forwards; }
.center { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); gap: var(--sp-2); text-align: center; line-height: var(--leading-base); }
.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 } }
@keyframes branchGrow { to { stroke-dashoffset: 0; } }
</style>
@@ -0,0 +1,113 @@
<script lang="ts">
import { Check, Funnel } from "phosphor-svelte";
import type { LibraryStatusFilter, LibraryContentFilter } from "@store/state.svelte";
interface Props {
tabStatus: LibraryStatusFilter;
tabFilters: Partial<Record<LibraryContentFilter, boolean>>;
hasActiveFilters: boolean;
filterPanelOpen: boolean;
onStatusChange: (s: LibraryStatusFilter) => void;
onFilterToggle: (f: LibraryContentFilter) => void;
onFiltersClear: () => void;
onFilterPanelToggle: () => void;
}
let {
tabStatus, tabFilters, hasActiveFilters, filterPanelOpen,
onStatusChange, onFilterToggle, onFiltersClear, onFilterPanelToggle,
}: Props = $props();
const STATUS_LABELS: Record<LibraryStatusFilter, string> = {
ALL: "All statuses", ONGOING: "Ongoing", COMPLETED: "Completed",
CANCELLED: "Cancelled", HIATUS: "Hiatus", UNKNOWN: "Unknown",
};
const ALL_STATUS_FILTERS: LibraryStatusFilter[] = [
"ALL", "ONGOING", "COMPLETED", "CANCELLED", "HIATUS", "UNKNOWN",
];
const CONTENT_FILTERS: [LibraryContentFilter, string][] = [
["unread", "Unread"],
["started", "Started"],
["downloaded", "Downloaded"],
["bookmarked", "Bookmarked"],
];
</script>
<div class="filter-panel-wrap">
<button
class="icon-btn"
class:icon-btn-active={hasActiveFilters}
title="Filter"
onclick={onFilterPanelToggle}
>
<Funnel size={15} weight={hasActiveFilters ? "fill" : "bold"} />
</button>
{#if filterPanelOpen}
<div class="dropdown-panel filter-panel" role="menu">
<div class="panel-header">
<span class="panel-heading">Filter</span>
{#if hasActiveFilters}
<button class="panel-clear-btn" onclick={onFiltersClear}>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 panel-item-check"
class:panel-item-active={tabFilters[f]}
role="menuitem"
onclick={() => onFilterToggle(f)}
>
<span class="panel-check" class:panel-check-on={tabFilters[f]}>
{#if tabFilters[f]}<Check size={9} weight="bold" />{/if}
</span>
{label}
</button>
{/each}
<div class="panel-divider"></div>
<p class="panel-label">Status</p>
{#each ALL_STATUS_FILTERS.filter(s => s !== "ALL") as s}
<button
class="panel-item panel-item-check"
class:panel-item-active={tabStatus === s}
role="menuitem"
onclick={() => onStatusChange(tabStatus === s ? "ALL" : s)}
>
<span class="panel-check" class:panel-check-on={tabStatus === s}>
{#if tabStatus === s}<Check size={9} weight="bold" />{/if}
</span>
{STATUS_LABELS[s]}
</button>
{/each}
</div>
{/if}
</div>
<style>
.filter-panel-wrap { position: relative; }
.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); }
.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); animation: fadeIn 0.1s ease both; }
.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; justify-content: flex-start; 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-item-check { justify-content: flex-start; }
.panel-check { width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong); background: transparent; flex-shrink: 0; transition: background var(--t-base), border-color var(--t-base); display: flex; align-items: center; justify-content: center; color: var(--bg-base); }
.panel-check-on { background: var(--accent); border-color: var(--accent); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
@@ -0,0 +1,220 @@
<script lang="ts">
import { Folder, Trash, CheckSquare, X } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Manga, Category } from "@types";
interface Props {
visibleManga: Manga[];
filtered: Manga[];
loading: boolean;
cols: number;
anims: boolean;
selectMode: boolean;
selectedIds: Set<number>;
hasMore: boolean;
remainingCount: number;
renderLimit: number;
cropCovers: boolean;
libraryFilter: string;
bulkWorking: boolean;
bulkMoveOpen: boolean;
visibleCategories: Category[];
onCardClick: (e: MouseEvent, m: Manga) => void;
onCardContextMenu: (e: MouseEvent, m: Manga) => void;
onCardPointerDown: (e: PointerEvent, m: Manga) => void;
onCardPointerUp: () => void;
onCardPointerLeave: () => void;
onLoadMore: () => void;
onRetry: () => void;
onExitSelectMode: () => void;
onSelectAll: () => void;
onBulkMove: (cat: Category) => void;
onBulkRemove: () => void;
onCategoryMove: (cat: Category) => void;
}
let {
visibleManga, filtered, loading, cols, anims, selectMode, selectedIds,
hasMore, remainingCount, renderLimit, cropCovers, libraryFilter,
bulkWorking, bulkMoveOpen, visibleCategories,
onCardClick, onCardContextMenu, onCardPointerDown, onCardPointerUp, onCardPointerLeave,
onLoadMore, onRetry, onExitSelectMode, onSelectAll, onBulkMove, onBulkRemove, onCategoryMove,
}: Props = $props();
</script>
{#if selectMode}
<div class="select-bar">
<div class="select-bar-left">
<button class="sel-btn sel-cancel" onclick={onExitSelectMode} title="Cancel (Esc)">
<X size={13} weight="bold" />
</button>
<span class="sel-count">{selectedIds.size} selected</span>
<button class="sel-btn sel-all" onclick={onSelectAll} title="Select all (⌘A)">Select all</button>
</div>
<div class="select-bar-right">
{#if visibleCategories.length}
<div class="bulk-move-wrap">
<button
class="sel-btn sel-move"
disabled={selectedIds.size === 0 || bulkWorking}
onclick={() => onBulkMove(visibleCategories[0])}
>
<Folder size={13} weight="bold" />
Move to folder
</button>
{#if bulkMoveOpen}
<div class="bulk-folder-list">
{#each visibleCategories as cat}
<button class="bulk-folder-item" onclick={() => onCategoryMove(cat)}>
<Folder size={11} weight="bold" />
{cat.name}
</button>
{/each}
</div>
{/if}
</div>
{/if}
<button class="sel-btn sel-remove" disabled={selectedIds.size === 0 || bulkWorking} onclick={onBulkRemove}>
<Trash size={13} weight="bold" />
Remove
</button>
</div>
</div>
{/if}
<div class="content">
{#if loading}
<div class="grid">
{#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="center">
{libraryFilter === "library" ? "No manga saved to library — browse sources to add some."
: libraryFilter === "downloaded" ? "No downloaded manga."
: "No manga in this folder yet. Right-click manga anywhere to assign them."}
</div>
{:else}
<div class="grid" style="--cols:{cols}">
{#each visibleManga as m (m.id)}
{@const isSelected = selectedIds.has(m.id)}
{@const isCompleted = !m.unreadCount && (m.chapterCount ?? 0) > 0}
<button
class="card"
class:card-selected={isSelected}
class:select-mode={selectMode}
class:anims={anims}
onclick={(e) => onCardClick(e, m)}
oncontextmenu={(e) => onCardContextMenu(e, m)}
onpointerdown={(e) => onCardPointerDown(e, m)}
onpointerup={onCardPointerUp}
onpointerleave={onCardPointerLeave}
>
<div class="cover-wrap" class:completed={isCompleted}>
<Thumbnail src={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}>
{#if isCompleted}
<span class="info-chip info-chip-done">✓ complete</span>
{:else if m.unreadCount}
<span class="info-chip info-chip-unread">
<span class="info-chip-dot"></span>
{m.unreadCount} unread
</span>
{:else}
<span></span>
{/if}
{#if m.downloadCount}
<span class="info-chip info-chip-dl">
<span class="info-chip-dot"></span>
{m.downloadCount}
</span>
{/if}
</div>
{#if selectMode}
<div class="select-overlay" aria-hidden="true">
<div class="select-check" class:checked={isSelected}>
{#if isSelected}
<CheckSquare size={20} weight="fill" />
{:else}
<div class="select-check-empty"></div>
{/if}
</div>
</div>
{/if}
</div>
<p class="title">{m.title}</p>
</button>
{/each}
</div>
{#if hasMore}
<div class="load-more-row">
<button class="load-more-btn" onclick={onLoadMore}>
Show {Math.min(remainingCount, renderLimit)} more
<span class="load-more-count">({remainingCount} remaining)</span>
</button>
</div>
{/if}
{/if}
</div>
<style>
.content { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6) var(--sp-6); will-change: scroll-position; -webkit-overflow-scrolling: touch; }
.select-bar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-2) var(--sp-6); background: var(--bg-raised); border-bottom: 1px solid var(--accent-dim); flex-shrink: 0; animation: fadeIn 0.1s ease both; }
.select-bar-left { display: flex; align-items: center; gap: var(--sp-3); }
.select-bar-right { display: flex; align-items: center; gap: var(--sp-2); position: relative; }
.sel-count { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
.sel-btn { 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 var(--border-dim); background: var(--bg-base); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; }
.sel-btn:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); }
.sel-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.sel-cancel { border-color: transparent; background: transparent; }
.sel-cancel:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.sel-move { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.sel-move:hover:not(:disabled) { background: var(--accent-dim); }
.sel-remove { color: var(--color-error, #e05c5c); border-color: color-mix(in srgb, var(--color-error, #e05c5c) 30%, transparent); }
.sel-remove:hover:not(:disabled) { background: color-mix(in srgb, var(--color-error, #e05c5c) 12%, transparent); }
.sel-all { border-color: transparent; background: transparent; }
.bulk-move-wrap { position: relative; }
.bulk-folder-list { position: absolute; top: calc(100% + 4px); right: 0; z-index: 200; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 4px; min-width: 160px; box-shadow: 0 8px 24px rgba(0,0,0,0.35); animation: fadeIn 0.1s ease both; }
.bulk-folder-item { display: flex; align-items: center; gap: 6px; width: 100%; padding: 6px 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); }
.bulk-folder-item:hover { background: var(--bg-hover, var(--bg-base)); color: var(--text-primary); }
.grid { position: relative; z-index: 1; isolation: isolate; 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:not(.select-mode):hover .cover-wrap { transform: translateY(-3px); border-color: var(--border-strong); box-shadow: 0 6px 20px rgba(0,0,0,0.35); }
.card.anims:not(.select-mode):hover .cover { filter: brightness(1.1); }
.card:not(.select-mode):hover .title { color: var(--text-primary); }
.card.select-mode { cursor: default; }
.card.card-selected .cover-wrap { outline: 2px solid var(--accent); outline-offset: 2px; border-radius: var(--radius-md); }
.card.card-selected .title { color: var(--accent-fg); }
.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.anims .cover { transition: filter var(--t-base); }
.card-info-overlay { position: absolute; bottom: 0; left: 0; right: 0; display: flex; align-items: flex-end; justify-content: space-between; padding: 20px 5px 5px; background: linear-gradient(to top, rgba(0,0,0,0.72) 0%, rgba(0,0,0,0.3) 55%, transparent 100%); opacity: 0; transform: translateY(3px); pointer-events: none; }
.card-info-overlay.anim { transition: opacity 0.18s ease, transform 0.18s cubic-bezier(0.16,1,0.3,1); }
.card-info-overlay.instant { transition: none; }
.card:not(.select-mode):hover .card-info-overlay { opacity: 1; transform: translateY(0); }
.info-chip { display: flex; align-items: center; gap: 4px; font-size: 10px; font-weight: 700; letter-spacing: 0.03em; line-height: 1; padding: 3px 6px; border-radius: 4px; background: rgba(0,0,0,0.52); backdrop-filter: blur(6px); }
.info-chip-unread { color: #fff; }
.info-chip-done { color: var(--accent-fg); font-size: 9px; letter-spacing: 0.06em; text-transform: uppercase; }
.info-chip-dl { color: var(--accent-fg); }
.info-chip-dot { width: 4px; height: 4px; border-radius: 50%; background: currentColor; flex-shrink: 0; }
.select-overlay { position: absolute; inset: 0; background: rgba(0,0,0,0.18); display: flex; align-items: flex-start; justify-content: flex-end; padding: 6px; pointer-events: none; }
.select-check { color: var(--text-faint); opacity: 0.7; transition: color var(--t-base), opacity var(--t-base); }
.select-check.checked { color: var(--accent-fg); opacity: 1; }
.select-check-empty { width: 20px; height: 20px; border-radius: 4px; border: 2px solid var(--text-faint); background: rgba(0,0,0,0.3); }
.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 .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); }
.load-more-row { display: flex; justify-content: center; padding: var(--sp-5) 0 var(--sp-2); position: relative; z-index: 1; }
.load-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: 8px 20px; border-radius: var(--radius-full); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.load-more-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.load-more-count { color: var(--text-faint); font-size: var(--text-2xs); }
.center { position: relative; z-index: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; height: 60%; color: var(--text-muted); font-size: var(--text-sm); gap: var(--sp-2); text-align: center; line-height: var(--leading-base); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
@@ -0,0 +1,241 @@
<script lang="ts">
import {
MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimple,
SortAscending, CaretUp, CaretDown, ArrowsClockwise, Star,
} 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: LibrarySortMode;
tabSortDir: LibrarySortDir;
tabStatus: LibraryStatusFilter;
tabFilters: Partial<Record<LibraryContentFilter, boolean>>;
hasActiveFilters: boolean;
anims: boolean;
tabIndicator: { left: number; width: number };
visibleCategories: Category[];
counts: Record<string, number>;
search: string;
refreshing: boolean;
refreshProgress: { finished: number; total: number };
refreshDone: boolean;
activeDragKind: "tab" | null;
dragInsertIdx: number;
dragTabId: number | null;
dragOverTabId: number | 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;
onOpenDownloadsFolder: () => void;
onTabDragStart: (e: DragEvent, cat: Category) => void;
onTabDragOver: (e: DragEvent, cat: Category, idx: number) => void;
onTabDragLeave: () => void;
onTabDrop: (e: DragEvent, cat: Category) => void;
onTabDragEnd: () => void;
}
let {
tab, tabSortMode, tabSortDir, tabStatus, tabFilters, hasActiveFilters,
anims, tabIndicator, visibleCategories, counts, search, refreshing,
refreshProgress, refreshDone, activeDragKind, dragInsertIdx, dragTabId,
dragOverTabId, sortPanelOpen, filterPanelOpen,
tabsEl = $bindable(),
onSearchChange, onTabChange, onSortChange, onSortDirToggle, onStatusChange,
onFilterToggle, onFiltersClear, onSortPanelToggle, onFilterPanelToggle,
onRefresh, onOpenDownloadsFolder,
onTabDragStart, onTabDragOver, onTabDragLeave, onTabDrop, onTabDragEnd,
}: Props = $props();
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="header">
<span class="heading">Library</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}
{#each [["library", "Saved"], ["downloaded", "Downloaded"]] as [f, label]}
<button class="tab" class:active={tab === f} onclick={() => onTabChange(f)}>
{#if f === "library"}<Books size={11} weight="bold" />
{:else if f === "downloaded"}<DownloadSimple size={11} weight="bold" />{/if}
{label}
<span class="tab-count">{counts[f] ?? 0}</span>
</button>
{/each}
{#each visibleCategories as cat, idx}
{#if dragInsertIdx === idx && activeDragKind === "tab"}
<div class="tab-insert-bar" aria-hidden="true"></div>
{/if}
<button
class="tab"
class:active={tab === String(cat.id)}
class:tab-dragging={dragTabId === cat.id}
class:tab-drop-target={dragOverTabId === cat.id}
draggable="true"
onclick={() => onTabChange(String(cat.id))}
ondragstart={(e) => onTabDragStart(e, cat)}
ondragover={(e) => onTabDragOver(e, cat, idx)}
ondragleave={onTabDragLeave}
ondrop={(e) => onTabDrop(e, cat)}
ondragend={onTabDragEnd}
>
<Folder size={11} weight="bold" />
{cat.name}
<span class="tab-count">{counts[String(cat.id)] ?? 0}</span>
</button>
{#if dragInsertIdx === idx + 1 && activeDragKind === "tab" && idx === visibleCategories.length - 1}
<div class="tab-insert-bar" aria-hidden="true"></div>
{/if}
{/each}
</div>
<div class="header-right">
<div class="search-wrap">
<MagnifyingGlass size={13} class="search-icon" weight="light" />
<input class="search" placeholder="Search" value={search} oninput={(e) => onSearchChange((e.target as HTMLInputElement).value)} />
</div>
<button
class="icon-btn refresh-btn"
class:icon-btn-active={refreshing}
class:refresh-btn-done={refreshDone}
title={refreshing ? `Checking… ${refreshProgress.finished}/${refreshProgress.total}` : refreshDone ? "Library updated" : "Check for updates"}
disabled={refreshing}
onclick={onRefresh}
>
<ArrowsClockwise size={15} weight="bold" class={refreshing ? "anim-spin" : ""} />
{#if refreshing && refreshProgress.total > 0}
<span class="refresh-progress">{refreshProgress.finished}/{refreshProgress.total}</span>
{/if}
</button>
<button class="icon-btn" title="Open downloads folder" onclick={onOpenDownloadsFolder}>
<FolderSimple size={15} weight="bold" />
</button>
<div class="sort-panel-wrap">
<button
class="icon-btn"
class:icon-btn-active={tabSortMode !== "az" || tabSortDir !== "asc"}
title="Sort"
onclick={onSortPanelToggle}
>
<SortAscending size={15} weight="bold" />
</button>
{#if sortPanelOpen}
<div class="dropdown-panel sort-panel" role="menu">
<div class="panel-header">
<span class="panel-heading">Sort</span>
</div>
<div class="panel-divider"></div>
<p class="panel-label">Order by</p>
{#each ALL_SORT_MODES as m}
<button
class="panel-item"
class:panel-item-active={tabSortMode === m}
role="menuitem"
onclick={() => onSortChange(m)}
>
{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="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>
<LibraryFilters
{tabStatus}
{tabFilters}
{hasActiveFilters}
{filterPanelOpen}
{onStatusChange}
{onFilterToggle}
{onFiltersClear}
{onFilterPanelToggle}
/>
</div>
</div>
<style>
.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; }
.header-right { display: flex; align-items: center; gap: var(--sp-2); margin-left: auto; }
.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; 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: grab; }
.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; }
.tab-dragging { opacity: 0.4; cursor: grabbing; }
.tab-drop-target { background: var(--accent-muted) !important; color: var(--accent-fg) !important; outline: 1px dashed var(--accent); }
.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::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-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); animation: fadeIn 0.1s ease both; }
.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); }
.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-item-check { justify-content: flex-start; gap: var(--sp-2); }
.panel-check { width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong); background: transparent; flex-shrink: 0; transition: background var(--t-base), border-color var(--t-base); display: flex; align-items: center; justify-content: center; color: var(--bg-base); }
.panel-check-on { background: var(--accent); border-color: var(--accent); }
.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; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
+3
View File
@@ -0,0 +1,3 @@
export { default as Library } from "./components/Library.svelte";
export { sortLibrary, librarySorter } from "./lib/librarySort";
export * from "./store/libraryState.svelte";
+52
View File
@@ -0,0 +1,52 @@
import { createSorter } from "@core/algorithms/sort";
import type { Manga } from "@types";
import type { LibrarySortMode, LibrarySortDir } from "@store/state.svelte";
export const librarySorter = createSorter<Manga>({
defaultField: "az",
defaultDir: "asc",
fields: [
{
key: "az",
comparator: (a, b) => a.title.localeCompare(b.title, undefined, { sensitivity: "base" }),
},
{
key: "unreadCount",
comparator: (a, b) => (a.unreadCount ?? 0) - (b.unreadCount ?? 0),
},
{
key: "totalChapters",
comparator: (a, b) => (a.chapterCount ?? 0) - (b.chapterCount ?? 0),
},
{
key: "recentlyAdded",
comparator: (a, b) => a.id - b.id,
},
{
key: "recentlyRead",
comparator: (a, b, ctx) => {
const map = ctx?.recentlyReadMap as Map<number, number> | undefined;
const ra = map?.get(a.id) ?? 0;
const rb = map?.get(b.id) ?? 0;
return ra - rb;
},
},
{
key: "latestFetched",
comparator: (a, b) => a.id - b.id,
},
{
key: "latestUploaded",
comparator: (a, b) => a.id - b.id,
},
],
});
export function sortLibrary(
items: Manga[],
mode: LibrarySortMode,
dir: LibrarySortDir,
recentlyReadMap?: Map<number, number>,
): Manga[] {
return librarySorter.sort(items, mode, dir, recentlyReadMap ? { recentlyReadMap } : undefined);
}
@@ -0,0 +1,45 @@
import { store, updateSettings, setCategories, setLibraryUpdates, addToast } from "@store/state.svelte";
import type { LibrarySortMode, LibrarySortDir, LibraryStatusFilter, LibraryContentFilter } from "@store/state.svelte";
import type { Category } from "@types";
export { store };
export function setTabSort(tab: string, mode: LibrarySortMode, dir?: LibrarySortDir) {
const prev = store.settings.libraryTabSort[tab];
const newDir = dir ?? prev?.dir ?? "asc";
updateSettings({
libraryTabSort: { ...store.settings.libraryTabSort, [tab]: { mode, dir: newDir } },
});
}
export function toggleTabSortDir(tab: string) {
const prev = store.settings.libraryTabSort[tab];
const mode = prev?.mode ?? "az";
const dir = prev?.dir === "asc" ? "desc" : "asc";
setTabSort(tab, mode, dir);
}
export function setTabStatus(tab: string, status: LibraryStatusFilter) {
updateSettings({
libraryTabStatus: { ...store.settings.libraryTabStatus, [tab]: status },
});
}
export function toggleTabFilter(tab: string, filter: LibraryContentFilter) {
const current = store.settings.libraryTabFilters?.[tab] ?? {};
updateSettings({
libraryTabFilters: {
...(store.settings.libraryTabFilters ?? {}),
[tab]: { ...current, [filter]: !current[filter] },
},
});
}
export function clearTabFilters(tab: string) {
updateSettings({
libraryTabStatus: { ...store.settings.libraryTabStatus, [tab]: "ALL" },
libraryTabFilters: { ...(store.settings.libraryTabFilters ?? {}), [tab]: {} },
});
}
export { setCategories, setLibraryUpdates, addToast };
@@ -0,0 +1,215 @@
<script lang="ts">
import { CircleNotch } from "phosphor-svelte";
import { store } from "@store/state.svelte";
import { readerState } from "../store/readerState.svelte";
import type { StripChapter } from "../lib/scrollHandler";
interface Props {
style: string;
imgCls: string;
effectiveWidth: number | undefined;
loading: boolean;
error: string | null;
pageReady: boolean;
pageGroups: number[][];
currentGroup: number[];
stripToRender: StripChapter[];
fadingOut: boolean;
tapToToggleBar: boolean;
resolveUrl: (url: string, priority?: number) => Promise<string>;
onTap: (e: MouseEvent) => void;
onWheel: (e: WheelEvent) => void;
onToggleUi: () => void;
bindContainer: (el: HTMLDivElement) => void;
}
const {
style, imgCls, effectiveWidth, loading, error, pageReady,
pageGroups, currentGroup, stripToRender, fadingOut,
tapToToggleBar, resolveUrl, onTap, onWheel, onToggleUi, bindContainer,
}: Props = $props();
const INSPECT_ZOOM_STEP = 0.15;
const INSPECT_ZOOM_MAX = 8;
let containerEl: HTMLDivElement;
function getInspectImageEl(): HTMLElement | null {
if (!containerEl) return null;
return (
containerEl.querySelector<HTMLElement>(".inspect-wrap .double-wrap") ??
containerEl.querySelector<HTMLElement>(".inspect-wrap img")
);
}
function clampInspectPan(scale: number, px: number, py: number): [number, number] {
const img = getInspectImageEl();
if (!img) return [px, py];
const maxX = Math.max(0, (img.offsetWidth * (scale - 1)) / 2);
const maxY = Math.max(0, (img.offsetHeight * (scale - 1)) / 2);
return [Math.max(-maxX, Math.min(maxX, px)), Math.max(-maxY, Math.min(maxY, py))];
}
let inspectDragging = false;
let inspectDragMoved = false;
let inspectDragStartX = 0;
let inspectDragStartY = 0;
let inspectPanStartX = 0;
let inspectPanStartY = 0;
export function onInspectMouseDown(e: MouseEvent) {
if (style === "longstrip" || readerState.inspectScale <= 1) return;
inspectDragging = true;
inspectDragMoved = false;
inspectDragStartX = e.clientX;
inspectDragStartY = e.clientY;
inspectPanStartX = readerState.inspectPanX;
inspectPanStartY = readerState.inspectPanY;
e.preventDefault();
}
export function onInspectMouseMove(e: MouseEvent) {
if (!inspectDragging) return;
if (!inspectDragMoved && Math.abs(e.clientX - inspectDragStartX) + Math.abs(e.clientY - inspectDragStartY) > 4) inspectDragMoved = true;
const rawX = inspectPanStartX + (e.clientX - inspectDragStartX);
const rawY = inspectPanStartY + (e.clientY - inspectDragStartY);
const [cx, cy] = clampInspectPan(readerState.inspectScale, rawX, rawY);
readerState.inspectPanX = cx;
readerState.inspectPanY = cy;
}
export function onInspectMouseUp() {
inspectDragging = false;
}
export function handleWheel(e: WheelEvent) {
if (e.ctrlKey) { onWheel(e); return; }
if (style === "longstrip") return;
e.preventDefault();
const delta = e.deltaY < 0 ? INSPECT_ZOOM_STEP : -INSPECT_ZOOM_STEP;
const next = Math.max(1, Math.min(INSPECT_ZOOM_MAX, readerState.inspectScale + delta));
if (next === readerState.inspectScale) return;
if (next === 1) { readerState.inspectScale = 1; readerState.inspectPanX = 0; readerState.inspectPanY = 0; return; }
const img = getInspectImageEl();
const anchor = img ?? containerEl;
const rect = anchor?.getBoundingClientRect();
const cx = rect ? e.clientX - rect.left - rect.width / 2 : 0;
const cy = rect ? e.clientY - rect.top - rect.height / 2 : 0;
const ratio = next / readerState.inspectScale;
const rawPanX = cx + (readerState.inspectPanX - cx) * ratio;
const rawPanY = cy + (readerState.inspectPanY - cy) * ratio;
const [clampedX, clampedY] = clampInspectPan(next, rawPanX, rawPanY);
readerState.inspectScale = next;
readerState.inspectPanX = clampedX;
readerState.inspectPanY = clampedY;
}
function handleTap(e: MouseEvent) {
if (style === "longstrip") return;
if (inspectDragMoved) { inspectDragMoved = false; return; }
onTap(e);
}
function setContainer(el: HTMLDivElement) {
containerEl = el;
bindContainer(el);
}
</script>
<div
use:setContainer
class="viewer"
class:strip={style === "longstrip"}
class:inspect-active={readerState.inspectScale > 1}
style={effectiveWidth != null ? `--effective-width:${effectiveWidth}px` : ""}
role="presentation"
tabindex="-1"
onclick={handleTap}
ondblclick={(e) => { if (tapToToggleBar) { const x = e.clientX / window.innerWidth; if (x >= 0.3 && x <= 0.7) onToggleUi(); } }}
onmousedown={onInspectMouseDown}
onwheel={(e) => { if (e.ctrlKey || style !== "longstrip") e.preventDefault(); }}
onkeydown={(e) => { if (e.key === " " && style === "longstrip") { e.preventDefault(); containerEl?.scrollBy({ top: containerEl.clientHeight * 0.85, behavior: "smooth" }); } }}
>
{#if loading}
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
{/if}
{#if error}
<div class="center-overlay"><p class="error-msg">{error}</p></div>
{/if}
{#if style === "longstrip"}
{#each stripToRender as chunk}
{#each chunk.urls as url, i}
{#await resolveUrl(url, chunk.urls.length - i)}
<img src="" alt="{chunk.chapterName} Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 5 ? "eager" : "lazy"} decoding="async" />
{:then src}
<img {src} alt="{chunk.chapterName} Page {i + 1}" data-local-page={i + 1} data-chapter={chunk.chapterId} data-total={chunk.urls.length} class="{imgCls}{store.settings.pageGap ? ' strip-gap' : ''}" loading={i < 5 ? "eager" : "lazy"} decoding="async" />
{/await}
{/each}
{/each}
<div style="height:1px;flex-shrink:0"></div>
{:else if style === "fade" && pageReady}
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity:0" />
{:then src}
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" style="opacity: {fadingOut ? 0 : 1}; transition: opacity 0.1s ease;" />
{/await}
</div>
{:else if style === "double" && pageReady}
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#if pageGroups.length}
<div class="double-wrap">
{#each currentGroup as pg, i}
{#await resolveUrl(store.pageUrls[pg - 1], 999)}
<img src="" alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
{:then src}
<img {src} alt="Page {pg}" class="{imgCls} page-half {i === 0 ? 'gap-left' : 'gap-right'}" decoding="async" />
{/await}
{/each}
</div>
{:else}
<div class="center-overlay"><CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" /></div>
{/if}
</div>
{:else if pageReady}
<div class="inspect-wrap" style="transform:scale({readerState.inspectScale}) translate({readerState.inspectPanX / readerState.inspectScale}px,{readerState.inspectPanY / readerState.inspectScale}px)">
{#await resolveUrl(store.pageUrls[store.pageNumber - 1], 999)}
<img src="" alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
{:then src}
<img {src} alt="Page {store.pageNumber}" class={imgCls} decoding="async" />
{/await}
</div>
{/if}
</div>
<style>
.viewer { flex: 1; overflow-y: auto; overflow-x: hidden; display: flex; flex-direction: column; align-items: center; justify-content: center; -webkit-overflow-scrolling: touch; position: relative; }
.viewer.strip { justify-content: flex-start; padding: var(--sp-4) 0; }
.viewer:focus { outline: none; }
.viewer.inspect-active { cursor: grab; overflow: hidden; }
.viewer.inspect-active:active { cursor: grabbing; }
.inspect-wrap { display: flex; align-items: center; justify-content: center; transform-origin: center center; will-change: transform; }
.img { display: block; user-select: none; image-rendering: auto; }
.img:global(.optimize-contrast) { image-rendering: -webkit-optimize-contrast; }
:global(.fit-width) { max-width: var(--effective-width, 100%); width: 100%; height: auto; }
:global(.fit-height) { max-height: calc(100vh - 80px); width: auto; max-width: var(--effective-width, 100%); height: auto; }
:global(.fit-screen) { max-width: var(--effective-width, 100%); max-height: calc(100vh - 80px); object-fit: contain; height: auto; }
:global(.fit-original) { max-width: 100%; width: auto; height: auto; }
:global(.strip-gap) { margin-bottom: 8px; }
.double-wrap { display: flex; align-items: flex-start; justify-content: center; max-width: calc(var(--effective-width, 100%) * 2); width: 100%; }
.page-half { flex: 1; min-width: 0; object-fit: contain; }
.gap-left { margin-right: 2px; }
.gap-right { margin-left: 2px; }
.center-overlay { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; }
.error-msg { color: var(--color-error); font-size: var(--text-base); }
</style>
@@ -0,0 +1,513 @@
<script lang="ts">
import { onMount, untrack, tick } from "svelte";
import { getCurrentWindow } from "@tauri-apps/api/window";
import { gql } from "@api/client";
import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
import { store, updateSettings, openReader, closeReader, addHistory,
addBookmark, removeBookmark, addMarker, updateMarker, removeMarker,
setSettingsOpen } from "@store/state.svelte";
import { setReading } from "@store/discord";
import { DEFAULT_KEYBINDS } from "@core/keybinds/defaultBinds";
import { readerState, PAGE_STYLES } from "../store/readerState.svelte";
import { fetchPages, resolveUrl, preloadImage, measureAspect, buildPageGroups } from "../lib/pageLoader";
import { setupScrollTracking, appendNextChapter } from "../lib/scrollHandler";
import { createReaderKeyHandler } from "../lib/readerKeybinds";
import { markChapterRead, getMangaPrefs, toggleBookmark } from "../lib/chapterActions";
import { goForward, goBack, jumpToPage } from "../lib/navigation";
import { clampZoom, captureZoomAnchor, restoreZoomAnchor } from "../lib/zoomHelpers";
import { loadChapter, scheduleResumeDismiss } from "../lib/chapterLoader";
import type { FitMode } from "@store/state.svelte";
import ReaderControls from "./ReaderControls.svelte";
import PageView from "./PageView.svelte";
import ReaderProgressBar from "./ReaderProgressBar.svelte";
import ReaderOverlay from "./ReaderOverlay.svelte";
const win = getCurrentWindow();
const useBlob = $derived((store.settings.serverAuthMode ?? "NONE") === "BASIC_AUTH");
const rtl = $derived(store.settings.readingDirection === "rtl");
const fit = $derived((store.settings.fitMode ?? "width") as FitMode);
const style = $derived((store.settings.pageStyle ?? "single") as typeof PAGE_STYLES[number]);
const zoom = $derived(store.settings.readerZoom ?? 1.0);
const autoNext = $derived(store.settings.autoNextChapter ?? false);
const markOnNext = $derived(store.settings.markReadOnNext ?? true);
const overlayBars = $derived(store.settings.overlayBars ?? false);
const tapToToggleBar = $derived(store.settings.tapToToggleBar ?? false);
const lastPage = $derived(store.pageUrls.length);
const effectiveWidth = $derived(readerState.containerWidth > 0 ? Math.round(readerState.containerWidth * zoom) : undefined);
const zoomPct = $derived(Math.round(zoom * 100));
const displayChapter = $derived(
style === "longstrip" && readerState.visibleChapterId
? (store.activeChapterList.find(c => c.id === readerState.visibleChapterId) ?? store.activeChapter)
: store.activeChapter
);
const currentBookmark = $derived(
store.activeManga ? store.bookmarks.find(b => b.mangaId === store.activeManga!.id) : undefined
);
const isBookmarked = $derived(
!!currentBookmark &&
currentBookmark.chapterId === displayChapter?.id &&
currentBookmark.pageNumber === store.pageNumber
);
const currentPageMarkers = $derived(displayChapter ? store.getMarkersForPage(displayChapter.id, store.pageNumber) : []);
const activeChapterMarkers = $derived(displayChapter ? store.getMarkersForChapter(displayChapter.id) : []);
const hasMarkerOnPage = $derived(currentPageMarkers.length > 0);
const showResumeBanner = $derived(
readerState.resumeVisible && readerState.resumePage > 1 &&
(style === "longstrip" ? readerState.stripResumeReady : store.pageNumber === readerState.resumePage)
);
const adjacent = $derived.by(() => {
const ref = displayChapter ?? store.activeChapter;
if (!ref || !store.activeChapterList.length) return { prev: null, next: null, remaining: [] };
const idx = store.activeChapterList.findIndex(c => c.id === ref.id);
return {
prev: idx > 0 ? store.activeChapterList[idx - 1] : null,
next: idx < store.activeChapterList.length - 1 ? store.activeChapterList[idx + 1] : null,
remaining: store.activeChapterList.slice(idx + 1),
};
});
const visibleChunkLastPage = $derived.by(() => {
if (style !== "longstrip") return lastPage;
const chId = readerState.visibleChapterId ?? store.activeChapter?.id;
const chunk = readerState.stripChapters.find(c => c.chapterId === chId);
return chunk?.urls.length ?? lastPage;
});
const imgCls = $derived([
"img",
fit === "width" && "fit-width",
fit === "height" && "fit-height",
fit === "screen" && "fit-screen",
fit === "original" && "fit-original",
store.settings.optimizeContrast && "optimize-contrast",
].filter(Boolean).join(" "));
const fitLabel = $derived({ width: "Fit W", height: "Fit H", screen: "Fit Screen", original: "1:1" }[fit]);
const stripToRender = $derived(
style === "longstrip"
? (readerState.stripChapters.length > 0
? readerState.stripChapters
: [{ chapterId: store.activeChapter?.id ?? 0, chapterName: store.activeChapter?.name ?? "", urls: store.pageUrls }])
: []
);
const currentGroup = $derived.by(() => {
const group = style === "double" && readerState.pageGroups.length
? (readerState.pageGroups.find(g => g.includes(store.pageNumber)) ?? [store.pageNumber])
: [store.pageNumber];
return rtl ? [...group].reverse() : group;
});
const sliderPage = $derived.by(() => {
if (style === "double" && readerState.pageGroups.length)
return readerState.pageGroups.findIndex(g => g.includes(store.pageNumber)) + 1;
return store.pageNumber;
});
const sliderMax = $derived.by(() => {
if (style === "double" && readerState.pageGroups.length) return readerState.pageGroups.length;
if (style === "longstrip") return visibleChunkLastPage || 1;
return lastPage || 1;
});
const sliderPctRaw = $derived(sliderMax > 1 ? ((sliderPage - 1) / (sliderMax - 1)) * 100 : 0);
const sliderPct = $derived(rtl ? 100 - sliderPctRaw : sliderPctRaw);
let containerEl: HTMLDivElement | null = null;
let pageViewRef: PageView;
let zoomAnchor = { el: null as HTMLElement | null, offset: 0 };
let hideTimer: ReturnType<typeof setTimeout> | null = null;
let markedRead = new Set<number>();
let appending = false;
let abortCtrl = { current: null as AbortController | null };
let hasNavigated = false;
let startAtLastPageRef = { current: false };
let cleanupScroll: () => void = () => {};
let stripChaptersRef = readerState.stripChapters;
$effect(() => { stripChaptersRef = readerState.stripChapters; });
function maybeMarkCurrentRead() {
const ch = displayChapter ?? store.activeChapter;
if (ch && markOnNext) markChapterRead(ch.id, markedRead);
}
function commitMarker() {
const ch = displayChapter;
const manga = store.activeManga;
if (!ch || !manga) return;
if (readerState.markerEditId) {
updateMarker(readerState.markerEditId, { note: readerState.markerNote.trim(), color: readerState.markerColor });
} else {
addMarker({ mangaId: manga.id, mangaTitle: manga.title, thumbnailUrl: manga.thumbnailUrl, chapterId: ch.id, chapterName: ch.name, pageNumber: store.pageNumber, note: readerState.markerNote.trim(), color: readerState.markerColor });
}
readerState.clearMarkerPopover();
}
function deleteCurrentMarker() {
if (readerState.markerEditId) removeMarker(readerState.markerEditId);
readerState.clearMarkerPopover();
}
function showUi() {
readerState.uiVisible = true;
if (hideTimer) clearTimeout(hideTimer);
if (!tapToToggleBar) {
hideTimer = setTimeout(() => {
if (!readerState.markerOpen && !readerState.winOpen) readerState.uiVisible = false;
}, 3000);
}
}
function toggleUiVisibility() {
if (readerState.uiVisible) {
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
readerState.uiVisible = false;
} else {
readerState.uiVisible = true;
}
}
function handleTap(e: MouseEvent) {
const x = e.clientX / window.innerWidth;
if (x > 0.6) goNext(); else if (x < 0.4) goPrev();
}
function handleWheel(e: WheelEvent) {
if (!e.ctrlKey) return;
e.preventDefault();
captureZoomAnchor(containerEl, style, zoomAnchor);
const ZOOM_STEP = 0.05;
updateSettings({ readerZoom: clampZoom(zoom + (e.deltaY < 0 ? ZOOM_STEP : -ZOOM_STEP)) });
restoreZoomAnchor(containerEl, zoomAnchor);
}
const startAtLast = () => { startAtLastPageRef.current = true; };
const goNext = $derived(rtl
? () => goBack(style, adjacent, startAtLast)
: () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast));
const goPrev = $derived(rtl
? () => goForward(style, adjacent, lastPage, maybeMarkCurrentRead, startAtLast)
: () => goBack(style, adjacent, startAtLast));
const onKey = createReaderKeyHandler({
goNext: () => goNext(),
goPrev: () => goPrev(),
closeReader,
goToPage: (p) => jumpToPage(p, style, lastPage, containerEl),
lastPage: () => lastPage,
adjustZoom: (d) => { captureZoomAnchor(containerEl, style, zoomAnchor); updateSettings({ readerZoom: clampZoom(zoom + d) }); restoreZoomAnchor(containerEl, zoomAnchor); },
resetZoom: () => { captureZoomAnchor(containerEl, style, zoomAnchor); updateSettings({ readerZoom: 1.0 }); restoreZoomAnchor(containerEl, zoomAnchor); },
cycleStyle: () => { const idx = PAGE_STYLES.indexOf(style); updateSettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] }); },
toggleDirection: () => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" }),
openSettings: () => setSettingsOpen(true),
toggleBookmark: () => toggleBookmark(displayChapter, store.pageNumber),
toggleMarker: () => {
if (currentPageMarkers.length > 0) {
const first = currentPageMarkers[0];
readerState.openMarker(first.id, first.note, first.color);
} else {
readerState.openMarker("", "", "yellow");
}
},
chapterNext: () => {
const ch = rtl ? adjacent.prev : adjacent.next;
if (ch) { maybeMarkCurrentRead(); openReader(ch, store.activeChapterList); }
},
chapterPrev: () => {
const ch = rtl ? adjacent.next : adjacent.prev;
if (ch) openReader(ch, store.activeChapterList);
},
closePopovers: () => readerState.closeAllPopovers(),
getKeybinds: () => store.settings.keybinds ?? DEFAULT_KEYBINDS,
});
function bindContainer(el: HTMLDivElement) { containerEl = el; }
$effect(() => {
const chapter = displayChapter;
const manga = store.activeManga;
if (store.settings.discordRpc && chapter && manga) setReading(manga, chapter);
});
$effect(() => {
const ch = store.activeChapter;
if (ch) untrack(() => loadChapter(ch.id, useBlob, abortCtrl, startAtLastPageRef, markedRead, adjacent));
});
$effect(() => {
if (style === "longstrip" && store.pageUrls.length && store.activeChapter) {
const ch = store.activeChapter;
const urls = store.pageUrls;
const targetPg = untrack(() => readerState.resumePage);
appending = false;
readerState.stripChapters = [{ chapterId: ch.id, chapterName: ch.name, urls }];
readerState.visibleChapterId = ch.id;
tick().then(() => {
if (!containerEl) return;
if (targetPg > 1) {
const chId = ch.id;
const scrollToResumePage = () => {
const target = containerEl!.querySelector<HTMLImageElement>(`img[data-local-page="${targetPg}"][data-chapter="${chId}"]`);
if (!target) { requestAnimationFrame(scrollToResumePage); return; }
containerEl!.querySelectorAll<HTMLImageElement>(`img[data-chapter="${chId}"]`).forEach((img, i) => { if (i < targetPg) img.loading = "eager"; });
const doScroll = () => { target.scrollIntoView({ block: "start" }); readerState.stripResumeReady = true; };
if (target.complete && target.naturalHeight > 0) doScroll();
else { target.loading = "eager"; target.addEventListener("load", doScroll, { once: true }); }
};
scrollToResumePage();
return;
}
containerEl!.scrollTop = 0;
});
}
});
$effect(() => { if (style !== "longstrip") readerState.resetInspect(); });
$effect(() => {
const chId = readerState.visibleChapterId;
if (!chId || style !== "longstrip") return;
if (chId === store.activeChapter?.id) return;
const wasAppended = untrack(() => readerState.stripChapters.findIndex(c => c.chapterId === chId)) > 0;
if (wasAppended) {
untrack(() => {
readerState.resumePage = 0; readerState.resumeVisible = false;
const prefs = getMangaPrefs();
if (prefs.downloadAhead > 0) {
const list = store.activeChapterList;
const idx = list.findIndex(c => c.id === chId);
if (idx >= 0) {
const toQueue = list.slice(idx + 1, idx + 1 + prefs.downloadAhead).filter(c => !c.isDownloaded && !c.isRead).map(c => c.id);
if (toQueue.length) gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: toQueue }).catch(console.error);
}
}
});
return;
}
const bookmark = store.bookmarks.find(b => b.chapterId === chId);
if (bookmark && bookmark.pageNumber > 1) {
untrack(() => {
readerState.resumePage = bookmark.pageNumber; readerState.resumeDismissed = false;
readerState.resumeVisible = true; readerState.stripResumeReady = true;
scheduleResumeDismiss();
});
} else {
untrack(() => readerState.resetResume());
}
});
$effect(() => {
void style;
if (!containerEl) return;
untrack(() => { cleanupScroll(); cleanupScroll = setupScrollTracking(containerEl!, {
onPageChange: (p) => { store.pageNumber = p; },
onChapterChange: (id) => { readerState.visibleChapterId = id; },
onMarkRead: (id) => markChapterRead(id, markedRead),
onAppend: () => {
if (appending || !readerState.stripChapters.length) return;
appending = true;
appendNextChapter(
stripChaptersRef,
store.activeChapterList,
(id) => fetchPages(id, useBlob),
(url) => preloadImage(url, useBlob),
(next) => { readerState.stripChapters = [...readerState.stripChapters, next]; appending = false; },
() => { appending = false; },
);
},
getStripChapters: () => stripChaptersRef,
getPageUrls: () => store.pageUrls,
shouldAutoMark: () => store.settings.autoMarkRead ?? true,
}); });
});
$effect(() => {
if (store.activeChapter && store.activeChapterList.length) {
const idx = store.activeChapterList.findIndex(c => c.id === store.activeChapter!.id);
if (idx >= 0) {
const next = store.activeChapterList[idx + 1];
const prev = store.activeChapterList[idx - 1];
if (next) fetchPages(next.id, useBlob).then(urls => urls.slice(0, 8).forEach(u => preloadImage(u, useBlob))).catch(() => {});
if (prev) fetchPages(prev.id, useBlob).then(urls => urls.slice(0, 2).forEach(u => preloadImage(u, useBlob))).catch(() => {});
}
}
});
$effect(() => {
if (style === "double" && store.pageUrls.length) {
let cancelled = false;
const snap = store.pageUrls;
Promise.all(snap.map(url => measureAspect(url, useBlob))).then(aspects => {
if (cancelled || snap !== store.pageUrls) return;
readerState.pageGroups = buildPageGroups(snap, aspects, store.settings.offsetDoubleSpreads ?? false);
});
return () => { cancelled = true; };
} else { readerState.pageGroups = []; }
});
$effect(() => {
const ahead = store.settings.preloadPages ?? 3;
const current = store.pageUrls[store.pageNumber - 1];
if (!current) return;
if (useBlob) {
import("@core/cache/imageCache").then(({ getBlobUrl, preloadBlobUrls }) => {
getBlobUrl(current, 999);
const upcoming = Array.from({ length: ahead }, (_, i) => store.pageUrls[store.pageNumber + i]).filter(Boolean) as string[];
const behind = store.pageUrls[store.pageNumber - 2];
preloadBlobUrls(upcoming, ahead);
if (behind) preloadBlobUrls([behind], 0);
});
} else {
for (let i = 1; i <= ahead; i++) {
const url = store.pageUrls[store.pageNumber - 1 + i];
if (url) preloadImage(url, useBlob);
}
const behind = store.pageUrls[store.pageNumber - 2];
if (behind) preloadImage(behind, useBlob);
}
});
$effect(() => {
if (readerState.markerOpen || readerState.winOpen) {
readerState.uiVisible = true;
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
}
});
$effect(() => {
if (tapToToggleBar) {
if (hideTimer) { clearTimeout(hideTimer); hideTimer = null; }
readerState.uiVisible = true;
}
});
$effect(() => {
const ch = displayChapter ?? store.activeChapter;
if (ch && lastPage && store.activeManga) {
const { id: chapterId, name: chapterName } = ch;
const { id: mangaId, title: mangaTitle, thumbnailUrl: thumb } = store.activeManga;
const pageNum = store.pageNumber;
const atLast = pageNum === lastPage;
if (pageNum > 1) hasNavigated = true;
untrack(() => {
if (!hasNavigated) return;
if (style === "longstrip" && readerState.visibleChapterId && chapterId !== readerState.visibleChapterId) return;
addHistory({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, readAt: Date.now() });
if (store.settings.autoBookmark ?? true) {
const existing = store.bookmarks.find(b => b.mangaId === mangaId && b.chapterId !== chapterId);
if (existing) removeBookmark(existing.chapterId);
addBookmark({ mangaId, mangaTitle, thumbnailUrl: thumb, chapterId, chapterName, pageNumber: pageNum });
}
if (style !== "longstrip" && (store.settings.autoMarkRead ?? true) && atLast) markChapterRead(chapterId, markedRead);
});
}
});
onMount(async () => {
showUi();
window.addEventListener("keydown", onKey);
window.addEventListener("mousemove", pageViewRef.onInspectMouseMove);
window.addEventListener("mouseup", pageViewRef.onInspectMouseUp);
readerState.isFullscreen = await win.isFullscreen();
const unlistenFs = await win.onResized(async () => {
readerState.isFullscreen = await win.isFullscreen();
});
let roTimer: ReturnType<typeof setTimeout> | null = null;
const ro = new ResizeObserver(entries => {
const w = entries[0].contentRect.width;
if (roTimer) clearTimeout(roTimer);
roTimer = setTimeout(() => { readerState.containerWidth = w; roTimer = null; }, 50);
});
if (containerEl) ro.observe(containerEl);
return () => {
abortCtrl.current?.abort();
if (hideTimer) clearTimeout(hideTimer);
if (roTimer) clearTimeout(roTimer);
window.removeEventListener("keydown", onKey);
window.removeEventListener("mousemove", pageViewRef.onInspectMouseMove);
window.removeEventListener("mouseup", pageViewRef.onInspectMouseUp);
cleanupScroll();
unlistenFs();
ro.disconnect();
};
});
</script>
<div
class="root"
class:overlay-bars={overlayBars}
role="presentation"
onmousemove={(e) => { if (!tapToToggleBar && (e.clientY < 60 || window.innerHeight - e.clientY < 60)) showUi(); }}
>
<ReaderControls
{displayChapter} {adjacent} {visibleChunkLastPage}
{fit} {fitLabel} {style} {rtl} {zoom} {zoomPct}
isFullscreen={readerState.isFullscreen}
{isBookmarked} {hasMarkerOnPage} {currentPageMarkers}
{autoNext} {markOnNext}
uiVisible={readerState.uiVisible}
{hideTimer}
onCaptureZoomAnchor={() => captureZoomAnchor(containerEl, style, zoomAnchor)}
onRestoreZoomAnchor={() => restoreZoomAnchor(containerEl, zoomAnchor)}
onMaybeMarkRead={maybeMarkCurrentRead}
onToggleBookmark={() => toggleBookmark(displayChapter, store.pageNumber)}
onCommitMarker={commitMarker}
onDeleteMarker={deleteCurrentMarker}
onClampZoom={clampZoom}
onDlOpen={() => readerState.dlOpen = true}
{win}
/>
<ReaderOverlay
{showResumeBanner}
resumePage={readerState.resumePage}
resumeFading={readerState.resumeFading}
{adjacent}
onDismissResume={() => { readerState.resumeVisible = false; readerState.resumeFading = false; }}
/>
<PageView
bind:this={pageViewRef}
{style} {imgCls} {effectiveWidth}
loading={readerState.loading}
error={readerState.error}
pageReady={readerState.pageReady}
pageGroups={readerState.pageGroups}
{currentGroup} {stripToRender}
fadingOut={readerState.fadingOut}
{tapToToggleBar}
resolveUrl={(url, priority) => resolveUrl(url, useBlob, priority)}
onTap={handleTap}
onWheel={handleWheel}
onToggleUi={toggleUiVisibility}
{bindContainer}
/>
<ReaderProgressBar
{style}
loading={readerState.loading}
{rtl} {sliderPage} {sliderMax} {sliderPct} {lastPage}
{displayChapter} {currentBookmark} {activeChapterMarkers} {adjacent}
uiVisible={readerState.uiVisible}
onGoPrev={goPrev}
onGoNext={goNext}
onJumpToPage={(p) => jumpToPage(p, style, lastPage, containerEl)}
/>
</div>
<style>
.root { position: fixed; inset: 0; background: #000; display: flex; flex-direction: column; z-index: var(--z-reader); transform: translateZ(0); will-change: transform; }
.root.overlay-bars :global(.topbar) { position: absolute; top: 0; left: 0; right: 0; z-index: 10; }
.root.overlay-bars :global(.bottombar) { position: absolute; bottom: 0; left: 0; right: 0; z-index: 10; }
.root.overlay-bars :global(.viewer) { height: 100%; }
</style>
@@ -0,0 +1,373 @@
<script lang="ts">
import {
X, CaretLeft, CaretRight,
Square, Rows, BookOpen, MonitorPlay,
ArrowsLeftRight, ArrowsIn, ArrowsOut, ArrowsVertical,
MagnifyingGlassMinus, MagnifyingGlassPlus,
Bookmark, MapPin, Download, Check,
} from "phosphor-svelte";
import { store, updateSettings } from "@store/state.svelte";
import { openReader, closeReader } from "@store/state.svelte";
import { readerState, MARKER_COLORS, MARKER_COLOR_HEX, ZOOM_STEP, ZOOM_MIN, ZOOM_MAX, PAGE_STYLES } from "../store/readerState.svelte";
import type { FitMode } from "@store/state.svelte";
import type { Chapter } from "@types";
interface Props {
displayChapter: Chapter | null;
adjacent: { prev: Chapter | null; next: Chapter | null };
visibleChunkLastPage: number;
fit: FitMode;
fitLabel: string;
style: string;
rtl: boolean;
zoom: number;
zoomPct: number;
isFullscreen: boolean;
isBookmarked: boolean;
hasMarkerOnPage: boolean;
currentPageMarkers: { id: string; color: import("@store/state.svelte").MarkerColor; note: string }[];
autoNext: boolean;
markOnNext: boolean;
uiVisible: boolean;
hideTimer: ReturnType<typeof setTimeout> | null;
onCaptureZoomAnchor: () => void;
onRestoreZoomAnchor: () => void;
onMaybeMarkRead: () => void;
onToggleBookmark: () => void;
onCommitMarker: () => void;
onDeleteMarker: () => void;
onClampZoom: (z: number) => number;
onDlOpen: () => void;
win: import("@tauri-apps/api/window").Window;
}
const {
displayChapter, adjacent, visibleChunkLastPage,
fit, fitLabel, style, rtl, zoom, zoomPct,
isFullscreen, isBookmarked, hasMarkerOnPage, currentPageMarkers,
autoNext, markOnNext, uiVisible, hideTimer,
onCaptureZoomAnchor, onRestoreZoomAnchor,
onMaybeMarkRead, onToggleBookmark, onCommitMarker, onDeleteMarker,
onClampZoom, onDlOpen, win,
}: Props = $props();
function adjustZoom(delta: number) {
onCaptureZoomAnchor();
updateSettings({ readerZoom: onClampZoom(zoom + delta) });
onRestoreZoomAnchor();
}
function resetZoom() {
onCaptureZoomAnchor();
updateSettings({ readerZoom: 1.0 });
onRestoreZoomAnchor();
}
function cycleStyle() {
const idx = PAGE_STYLES.indexOf(style as typeof PAGE_STYLES[number]);
updateSettings({ pageStyle: PAGE_STYLES[(idx + 1) % PAGE_STYLES.length] });
}
function cycleFit() {
const opts: FitMode[] = ["width", "height", "screen", "original"];
updateSettings({ fitMode: opts[(opts.indexOf(fit) + 1) % opts.length] });
}
function keepUiAlive() {
readerState.uiVisible = true;
if (hideTimer) clearTimeout(hideTimer);
}
function openMarkerPopover() {
if (currentPageMarkers.length > 0) {
const first = currentPageMarkers[0];
readerState.openMarker(first.id, first.note, first.color);
} else {
readerState.openMarker("", "", "yellow");
}
}
</script>
<div class="topbar" class:hidden={!uiVisible}>
<div class="topbar-left">
<button class="icon-btn" onclick={closeReader} title="Close reader"><X size={15} weight="light" /></button>
<button class="icon-btn"
onclick={() => { if (adjacent.prev) { onMaybeMarkRead(); openReader(adjacent.prev, store.activeChapterList); } }}
disabled={!adjacent.prev}>
<CaretLeft size={14} weight="light" />
</button>
<span class="ch-label">
<span class="ch-title">{store.activeManga?.title}</span>
<span class="ch-sep">/</span>
<span>{displayChapter?.name}</span>
</span>
<button class="icon-btn"
onclick={() => { if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next, store.activeChapterList); } }}
disabled={!adjacent.next}>
<CaretRight size={14} weight="light" />
</button>
<span class="page-label">{store.pageNumber} / {visibleChunkLastPage || "…"}</span>
</div>
<div class="topbar-right">
<div class="top-sep"></div>
<button class="mode-btn" onclick={cycleFit}>
{#if fit === "width"}<ArrowsLeftRight size={14} weight="light" />
{:else if fit === "height"}<ArrowsVertical size={14} weight="light" />
{:else if fit === "screen"}<ArrowsIn size={14} weight="light" />
{:else}<ArrowsOut size={14} weight="light" />{/if}
<span class="mode-label">{fitLabel}</span>
</button>
<div class="zoom-wrap">
<div class="zoom-inline">
<button class="zoom-step-btn" onclick={() => adjustZoom(-ZOOM_STEP)} title="Zoom out" disabled={zoom <= ZOOM_MIN}>
<MagnifyingGlassMinus size={13} weight="light" />
</button>
<button class="zoom-pct-btn" onclick={() => readerState.zoomOpen = !readerState.zoomOpen} title="Click to adjust zoom">
{zoomPct}%
</button>
<button class="zoom-step-btn" onclick={() => adjustZoom(ZOOM_STEP)} title="Zoom in" disabled={zoom >= ZOOM_MAX}>
<MagnifyingGlassPlus size={13} weight="light" />
</button>
</div>
{#if readerState.zoomOpen}
<div class="zoom-popover">
<div class="zoom-slider-row">
<input type="range" class="zoom-slider" min={10} max={100} step={5} value={zoomPct}
oninput={(e) => { onCaptureZoomAnchor(); updateSettings({ readerZoom: onClampZoom(Number(e.currentTarget.value) / 100) }); onRestoreZoomAnchor(); }} />
</div>
<button class="zoom-reset" onclick={resetZoom} disabled={zoom === 1.0}>Reset</button>
</div>
{/if}
</div>
<button class="mode-btn" class:active={rtl} onclick={() => updateSettings({ readingDirection: rtl ? "ltr" : "rtl" })}>
<ArrowsLeftRight size={14} weight="light" /><span class="mode-label">{rtl ? "RTL" : "LTR"}</span>
</button>
<button class="mode-btn" onclick={cycleStyle} title="Cycle view mode">
{#if style === "single"}<Square size={14} weight="light" />
{:else if style === "fade"}<MonitorPlay size={14} weight="light" />
{:else if style === "double"}<BookOpen size={14} weight="light" />
{:else}<Rows size={14} weight="light" />{/if}
<span class="mode-label">{style}</span>
</button>
<div class="mode-extras">
{#if style === "double"}
<button class="mode-btn" class:active={store.settings.offsetDoubleSpreads}
onclick={() => updateSettings({ offsetDoubleSpreads: !store.settings.offsetDoubleSpreads })}>
<span class="mode-label">Offset</span>
</button>
{/if}
{#if style === "longstrip"}
<button class="mode-btn" class:active={store.settings.pageGap}
onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}>
<span class="mode-label">Gap</span>
</button>
<button class="mode-btn" class:active={autoNext}
onclick={() => updateSettings({ autoNextChapter: !autoNext })}>
<span class="mode-label">Auto</span>
</button>
{/if}
{#if !autoNext}
<button class="mode-btn" class:active={markOnNext}
onclick={() => updateSettings({ markReadOnNext: !markOnNext })}>
<span class="mode-label">Mk.Read</span>
</button>
{/if}
</div>
<button class="mode-btn" onclick={onDlOpen}>
<Download size={14} weight="light" />
</button>
<div class="marker-wrap">
<button
class="icon-btn"
class:active={hasMarkerOnPage}
class:marker-btn-has={hasMarkerOnPage}
onclick={openMarkerPopover}
title={hasMarkerOnPage ? "Edit marker" : "Add marker"}
style={hasMarkerOnPage ? `--marker-color:${MARKER_COLOR_HEX[currentPageMarkers[0].color]}` : ""}
>
<MapPin size={14} weight={hasMarkerOnPage ? "fill" : "regular"} />
</button>
{#if readerState.markerOpen}
<div class="marker-popover" role="presentation"
onclick={(e) => e.stopPropagation()}
onmouseenter={keepUiAlive}
>
<div class="marker-pop-header">
<span class="marker-pop-title">
{readerState.markerEditId ? "Edit marker" : "New marker"} · p.{store.pageNumber}
</span>
{#if readerState.markerEditId}
<button class="marker-delete-btn" onclick={onDeleteMarker} title="Delete marker">
<X size={12} weight="light" />
</button>
{/if}
</div>
<div class="marker-color-row">
{#each MARKER_COLORS as c}
<button
class="marker-swatch"
class:marker-swatch-active={readerState.markerColor === c}
style="--swatch:{MARKER_COLOR_HEX[c]}"
onclick={() => readerState.markerColor = c}
title={c}
>
<span class="swatch-dot"></span>
<span class="swatch-label">{c}</span>
</button>
{/each}
</div>
<textarea
class="marker-textarea"
style="--accent-marker:{MARKER_COLOR_HEX[readerState.markerColor]}"
rows={3}
placeholder="Note (optional)…"
bind:value={readerState.markerNote}
onmouseenter={keepUiAlive}
onkeydown={(e) => {
if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); onCommitMarker(); }
if (e.key === "Escape") readerState.markerOpen = false;
}}
></textarea>
<div class="marker-pop-actions">
<button class="marker-save-btn" style="--accent-marker:{MARKER_COLOR_HEX[readerState.markerColor]}" onclick={onCommitMarker}>
<Check size={12} weight="bold" />
{readerState.markerEditId ? "Update" : "Save"}
</button>
<button class="marker-cancel-btn" onclick={() => readerState.markerOpen = false}>Cancel</button>
</div>
</div>
{/if}
</div>
<button class="icon-btn" class:active={isBookmarked} onclick={onToggleBookmark}
title={isBookmarked ? "Remove bookmark" : "Bookmark this page"}>
<Bookmark size={15} weight={isBookmarked ? "fill" : "regular"} />
</button>
<div class="wc-wrap">
<button
class="icon-btn"
class:active={readerState.winOpen}
onclick={() => { readerState.winOpen = !readerState.winOpen; readerState.markerOpen = false; readerState.zoomOpen = false; readerState.dlOpen = false; }}
title="Window controls"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
<circle cx="6" cy="1.5" r="1.2" fill="currentColor"/>
<circle cx="6" cy="6" r="1.2" fill="currentColor"/>
<circle cx="6" cy="10.5" r="1.2" fill="currentColor"/>
</svg>
</button>
{#if readerState.winOpen}
<div class="wc-dropdown" role="presentation" onclick={(e) => e.stopPropagation()}>
<button class="wc-btn" onclick={() => { readerState.winOpen = false; win.minimize(); }}>
<svg width="10" height="1" viewBox="0 0 10 1"><line x1="0" y1="0.5" x2="10" y2="0.5" stroke="currentColor" stroke-width="1.5"/></svg>
<span>Minimize</span>
</button>
<button class="wc-btn" onclick={() => { readerState.winOpen = false; win.toggleMaximize(); }}>
{#if isFullscreen}
<svg width="10" height="10" viewBox="0 0 10 10">
<polyline points="1,4 1,1 4,1" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="6,1 9,1 9,4" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="9,6 9,9 6,9" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<polyline points="4,9 1,9 1,6" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{:else}
<svg width="9" height="9" viewBox="0 0 9 9"><rect x="0.75" y="0.75" width="7.5" height="7.5" rx="1" fill="none" stroke="currentColor" stroke-width="1.5"/></svg>
{/if}
<span>{isFullscreen ? "Exit Fullscreen" : "Fullscreen"}</span>
</button>
<button class="wc-btn wc-close" onclick={() => { readerState.winOpen = false; win.close(); }}>
<svg width="10" height="10" viewBox="0 0 10 10">
<line x1="1" y1="1" x2="9" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<line x1="9" y1="1" x2="1" y2="9" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
<span>Close</span>
</button>
</div>
{/if}
</div>
</div>
</div>
<style>
.topbar { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-1); padding: 0 var(--sp-3); height: 40px; background: var(--bg-void); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; position: relative; z-index: 2; transition: opacity 0.25s ease; }
.topbar.hidden { opacity: 0; pointer-events: none; }
.topbar-left { display: flex; align-items: center; gap: var(--sp-1); min-width: 0; flex: 1; overflow: hidden; }
.topbar-right { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
.mode-extras { display: flex; align-items: center; gap: var(--sp-1); min-width: 0; }
.icon-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; 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.2; cursor: default; }
.icon-btn.active { color: var(--accent-fg); }
.marker-btn-has { color: var(--marker-color, var(--accent-fg)) !important; }
.ch-label { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
.ch-title { color: var(--text-secondary); font-weight: var(--weight-medium); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ch-sep { color: var(--text-faint); flex-shrink: 0; }
.page-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.top-sep { width: 1px; height: 16px; background: var(--border-dim); flex-shrink: 0; margin: 0 var(--sp-1); }
.mode-btn { display: flex; align-items: center; gap: 4px; padding: 4px var(--sp-2); border-radius: var(--radius-sm); color: var(--text-muted); flex-shrink: 0; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); transition: color var(--t-base), background var(--t-base); }
.mode-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
.mode-btn.active { color: var(--accent-fg); background: var(--accent-muted); }
.mode-label { text-transform: capitalize; }
.zoom-wrap { position: relative; flex-shrink: 0; }
.zoom-inline { display: flex; align-items: center; gap: 1px; background: var(--bg-overlay); border: 1px solid var(--border-base); border-radius: var(--radius-sm); overflow: hidden; }
.zoom-step-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 24px; color: var(--text-muted); transition: color var(--t-base), background var(--t-base); }
.zoom-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.zoom-step-btn:disabled { opacity: 0.25; cursor: default; }
.zoom-pct-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-secondary); padding: 0 var(--sp-2); height: 24px; min-width: 38px; text-align: center; transition: color var(--t-base), background var(--t-base); border-left: 1px solid var(--border-dim); border-right: 1px solid var(--border-dim); }
.zoom-pct-btn:hover { color: var(--text-primary); background: var(--bg-raised); }
.zoom-popover { position: absolute; top: calc(100% + 6px); left: 50%; translate: -50% 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-2); box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 180px; animation: scaleIn 0.1s ease both; transform-origin: top center; }
.zoom-slider-row { display: flex; align-items: center; gap: var(--sp-2); }
.zoom-slider { flex: 1; height: 3px; appearance: none; -webkit-appearance: none; background: var(--border-strong); border-radius: 2px; outline: none; cursor: pointer; }
.zoom-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); cursor: pointer; }
.zoom-reset { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); padding: 3px var(--sp-2); border-radius: var(--radius-sm); border: 1px solid var(--border-dim); transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
.zoom-reset:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-overlay); border-color: var(--border-strong); }
.zoom-reset:disabled { opacity: 0.3; cursor: default; }
.marker-wrap { position: relative; flex-shrink: 0; }
.marker-popover { position: absolute; top: calc(100% + 8px); right: 0; width: 240px; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-3); display: flex; flex-direction: column; gap: var(--sp-3); box-shadow: 0 12px 32px rgba(0,0,0,0.6), 0 2px 8px rgba(0,0,0,0.4); z-index: 100; animation: scaleIn 0.1s ease both; transform-origin: top right; }
.marker-pop-header { display: flex; align-items: center; justify-content: space-between; }
.marker-pop-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.marker-delete-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast); }
.marker-delete-btn:hover { color: var(--color-error); background: var(--color-error-bg); }
.marker-color-row { display: flex; align-items: center; gap: var(--sp-2); }
.marker-swatch { display: flex; flex-direction: column; align-items: center; gap: 4px; padding: 4px; border-radius: var(--radius-sm); background: none; border: none; cursor: pointer; flex: 1; transition: background var(--t-fast); }
.marker-swatch:hover { background: var(--bg-overlay); }
.swatch-dot { width: 14px; height: 14px; border-radius: 50%; background: var(--swatch); box-shadow: 0 0 0 0 var(--swatch); transition: box-shadow var(--t-fast), transform var(--t-fast); flex-shrink: 0; }
.marker-swatch:hover .swatch-dot { transform: scale(1.15); }
.marker-swatch-active .swatch-dot { box-shadow: 0 0 0 3px color-mix(in srgb, var(--swatch) 30%, transparent); transform: scale(1.1); }
.swatch-label { font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); color: var(--text-faint); text-transform: capitalize; line-height: 1; }
.marker-swatch-active .swatch-label { color: var(--text-muted); }
.marker-textarea { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-md); padding: 7px 9px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; resize: none; font-family: inherit; line-height: var(--leading-snug); transition: border-color var(--t-base), box-shadow var(--t-base); }
.marker-textarea:focus { border-color: var(--accent-marker, var(--border-focus)); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-marker, var(--accent)) 18%, transparent); }
.marker-pop-actions { display: flex; align-items: center; gap: var(--sp-2); }
.marker-save-btn { display: flex; align-items: center; gap: 5px; padding: 6px 14px; border-radius: var(--radius-sm); border: 1px solid color-mix(in srgb, var(--accent-marker, var(--accent)) 50%, transparent); background: color-mix(in srgb, var(--accent-marker, var(--accent)) 15%, transparent); color: var(--accent-marker, var(--accent-fg)); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: filter var(--t-fast); }
.marker-save-btn:hover { filter: brightness(1.2); }
.marker-cancel-btn { flex: 1; padding: 6px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); text-align: center; }
.marker-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); }
.wc-wrap { position: relative; flex-shrink: 0; }
.wc-dropdown { position: absolute; top: calc(100% + 6px); right: 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: var(--sp-1); display: flex; flex-direction: column; gap: 2px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 100; min-width: 148px; animation: scaleIn 0.1s ease both; transform-origin: top right; }
.wc-btn { display: flex; align-items: center; gap: var(--sp-2); padding: 6px var(--sp-2); border-radius: var(--radius-sm); background: none; border: none; color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer; width: 100%; transition: color var(--t-base), background var(--t-base); }
.wc-btn svg { flex-shrink: 0; opacity: 0.75; }
.wc-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
.wc-close:hover { color: #fff; background: #c0392b; }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
@@ -0,0 +1,84 @@
<script lang="ts">
import { gql } from "@api/client";
import { store } from "@store/state.svelte";
import { readerState } from "../store/readerState.svelte";
import { ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
import type { Chapter } from "@types";
interface Props {
showResumeBanner: boolean;
resumePage: number;
resumeFading: boolean;
adjacent: { remaining: Chapter[] };
onDismissResume: () => void;
}
const { showResumeBanner, resumePage, resumeFading, adjacent, onDismissResume }: Props = $props();
async function runDl(fn: () => Promise<unknown>) {
readerState.dlBusy = true;
try { await fn(); } catch (e) { console.error(e); }
readerState.dlBusy = false;
readerState.dlOpen = false;
}
</script>
{#if showResumeBanner}
<button class="resume-banner" class:fading={resumeFading} onclick={onDismissResume}>
<span>Bookmark at page {resumePage}</span>
</button>
{/if}
{#if readerState.dlOpen && store.activeChapter}
{@const queueable = adjacent.remaining.filter(c => !c.isDownloaded)}
<div class="dl-backdrop" role="presentation" onclick={() => readerState.dlOpen = false}>
<div class="dl-modal" role="presentation" onclick={(e) => e.stopPropagation()}>
<p class="dl-title">Download</p>
<button class="dl-option" disabled={readerState.dlBusy || !!store.activeChapter.isDownloaded}
onclick={() => runDl(() => gql(ENQUEUE_DOWNLOAD, { chapterId: store.activeChapter!.id }))}>
This chapter
<span class="dl-sub">{store.activeChapter.isDownloaded ? "Already downloaded" : store.activeChapter.name}</span>
</button>
<div class="dl-row">
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
onclick={() => runDl(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: queueable.slice(0, readerState.nextN).map(c => c.id) }))}>
Next chapters
<span class="dl-sub">{Math.min(readerState.nextN, queueable.length)} not yet downloaded</span>
</button>
<div class="dl-stepper" role="presentation" onclick={(e) => e.stopPropagation()}>
<button class="dl-step-btn" onclick={() => readerState.nextN = Math.max(1, readerState.nextN - 1)} disabled={readerState.nextN <= 1}></button>
<span class="dl-step-val">{readerState.nextN}</span>
<button class="dl-step-btn" onclick={() => readerState.nextN = Math.min(queueable.length || 1, readerState.nextN + 1)} disabled={readerState.nextN >= queueable.length}>+</button>
</div>
</div>
<button class="dl-option" disabled={readerState.dlBusy || queueable.length === 0}
onclick={() => runDl(() => gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: queueable.map(c => c.id) }))}>
All remaining
<span class="dl-sub">{queueable.length} not yet downloaded</span>
</button>
</div>
</div>
{/if}
<style>
.resume-banner { position: fixed; top: 48px; left: 50%; display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-lg); padding: 6px var(--sp-3); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); z-index: 20; box-shadow: 0 4px 16px rgba(0,0,0,0.4); animation: bannerIn 0.2s cubic-bezier(0.16,1,0.3,1) both; white-space: nowrap; cursor: pointer; text-align: left; }
.resume-banner.fading { animation: bannerOut 1s ease forwards; }
@keyframes bannerIn { from { opacity: 0; transform: translateX(-50%) translateY(-6px) scale(0.97); } to { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); } }
@keyframes bannerOut { from { opacity: 1; transform: translateX(-50%) translateY(0) scale(1); } to { opacity: 0; transform: translateX(-50%) translateY(-4px) scale(0.97); } }
.dl-backdrop { position: fixed; inset: 0; z-index: calc(var(--z-reader) + 10); display: flex; align-items: flex-start; justify-content: flex-end; padding: 48px var(--sp-4) 0; }
.dl-modal { background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-xl); padding: var(--sp-3); min-width: 210px; display: flex; flex-direction: column; gap: var(--sp-1); box-shadow: 0 8px 32px rgba(0,0,0,0.6); animation: scaleIn 0.12s ease both; transform-origin: top right; }
.dl-title { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; padding: 2px var(--sp-2) var(--sp-2); border-bottom: 1px solid var(--border-dim); margin-bottom: var(--sp-1); }
.dl-option { display: flex; flex-direction: column; align-items: flex-start; gap: 2px; width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-size: var(--text-sm); color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast); }
.dl-option:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
.dl-option:disabled { opacity: 0.3; cursor: default; }
.dl-sub { font-size: var(--text-xs); color: var(--text-faint); }
.dl-row { display: flex; align-items: center; gap: var(--sp-2); }
.dl-stepper { display: flex; align-items: center; gap: 2px; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); overflow: hidden; flex-shrink: 0; }
.dl-step-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 28px; font-size: var(--text-base); color: var(--text-muted); background: none; border: none; cursor: pointer; line-height: 1; transition: color var(--t-fast), background var(--t-fast); }
.dl-step-btn:hover:not(:disabled) { color: var(--text-primary); background: var(--bg-raised); }
.dl-step-btn:disabled { opacity: 0.25; cursor: default; }
.dl-step-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); min-width: 24px; text-align: center; letter-spacing: var(--tracking-wide); }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
@@ -0,0 +1,112 @@
<script lang="ts">
import { ArrowLeft, ArrowRight } from "phosphor-svelte";
import { readerState, MARKER_COLOR_HEX } from "../store/readerState.svelte";
import type { BookmarkEntry, MarkerEntry } from "@store/state.svelte";
import type { Chapter } from "@types";
interface Props {
style: string;
loading: boolean;
rtl: boolean;
sliderPage: number;
sliderMax: number;
sliderPct: number;
lastPage: number;
displayChapter: Chapter | null;
currentBookmark: BookmarkEntry | undefined;
activeChapterMarkers: MarkerEntry[];
adjacent: { prev: Chapter | null; next: Chapter | null };
uiVisible: boolean;
onGoPrev: () => void;
onGoNext: () => void;
onJumpToPage: (page: number) => void;
}
const {
style, loading, rtl, sliderPage, sliderMax, sliderPct, lastPage,
displayChapter, currentBookmark, activeChapterMarkers, adjacent, uiVisible,
onGoPrev, onGoNext, onJumpToPage,
}: Props = $props();
</script>
<div class="bottombar" class:hidden={!uiVisible}>
<button class="nav-btn" onclick={onGoPrev}
disabled={loading || (style === "longstrip" ? !adjacent.prev : (sliderPage === 1 && !adjacent.prev))}>
<ArrowLeft size={13} weight="light" />
</button>
{#if sliderMax > 1}
<div
class="slider-wrap"
class:dragging={readerState.sliderDragging}
role="slider"
aria-valuenow={sliderPage}
aria-valuemin={1}
aria-valuemax={sliderMax}
tabindex="-1"
onmouseenter={() => readerState.sliderHover = true}
onmouseleave={() => { readerState.sliderHover = false; readerState.sliderDragging = false; }}
onmousedown={(e) => {
readerState.sliderDragging = true;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1)));
}}
onmousemove={(e) => {
if (!readerState.sliderDragging) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
onJumpToPage(Math.round(1 + (rtl ? 1 - ratio : ratio) * (sliderMax - 1)));
}}
onmouseup={() => readerState.sliderDragging = false}
>
<div class="slider-track-bg">
<div class="slider-fill" style={rtl ? `width:${100 - sliderPct}%;margin-left:auto` : `width:${sliderPct}%`}></div>
</div>
<div class="slider-thumb" style="left:{sliderPct}%"></div>
{#if currentBookmark && currentBookmark.chapterId === displayChapter?.id}
{@const bOrd = rtl ? lastPage - currentBookmark.pageNumber + 1 : currentBookmark.pageNumber}
{@const bPct = lastPage > 1 ? ((bOrd - 1) / (lastPage - 1)) * 100 : 0}
<div class="slider-checkpoint bookmark-checkpoint" style="left:{bPct}%" title="Bookmark: Page {currentBookmark.pageNumber}"></div>
{/if}
{#each activeChapterMarkers as m (m.id)}
{@const mOrd = rtl ? lastPage - m.pageNumber + 1 : m.pageNumber}
{@const mPct = lastPage > 1 ? ((mOrd - 1) / (lastPage - 1)) * 100 : 0}
<div class="slider-checkpoint marker-checkpoint" style="left:{mPct}%;background:{MARKER_COLOR_HEX[m.color]}" title="{m.note ? m.note : 'Marker'} · Page {m.pageNumber}"></div>
{/each}
{#if readerState.sliderHover || readerState.sliderDragging}
<div class="slider-tooltip" style="left:{sliderPct}%">
{sliderPage} / {sliderMax}
</div>
{/if}
</div>
{/if}
<button class="nav-btn" onclick={onGoNext}
disabled={loading || (style === "longstrip" ? !adjacent.next : (sliderPage === sliderMax && !adjacent.next))}>
<ArrowRight size={13} weight="light" />
</button>
</div>
<style>
.bottombar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-3); border-top: 1px solid var(--border-dim); background: var(--bg-void); flex-shrink: 0; transition: opacity 0.25s ease; }
.bottombar.hidden { opacity: 0; pointer-events: none; }
.nav-btn { display: flex; align-items: center; justify-content: center; width: 34px; height: 34px; flex-shrink: 0; border-radius: var(--radius-md); border: 1px solid var(--border-strong); color: var(--text-muted); transition: background var(--t-base), color var(--t-base); }
.nav-btn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-primary); }
.nav-btn:disabled { opacity: 0.25; cursor: default; }
.slider-wrap { flex: 1; position: relative; display: flex; align-items: center; height: 34px; cursor: pointer; }
.slider-track-bg { position: absolute; left: 0; right: 0; height: 3px; background: var(--border-strong); border-radius: 2px; pointer-events: none; }
.slider-fill { height: 100%; background: var(--accent-fg); border-radius: 2px; transition: width 0.05s linear; position: relative; }
.slider-checkpoint { position: absolute; top: 50%; width: 4px; height: 10px; border-radius: 2px; transform: translate(-50%, -50%); pointer-events: none; z-index: 1; }
.slider-thumb { position: absolute; top: 50%; transform: translate(-50%, -50%); width: 12px; height: 12px; border-radius: 50%; background: var(--accent-fg); pointer-events: none; z-index: 2; box-shadow: 0 0 0 2px rgba(0,0,0,0.5); transition: transform var(--t-fast); }
.slider-wrap:hover .slider-thumb, .slider-wrap.dragging .slider-thumb { transform: translate(-50%, -50%) scale(1.3); }
.bookmark-checkpoint { background: #ffffff; opacity: 0.8; }
.marker-checkpoint { opacity: 0.85; }
.slider-tooltip { position: absolute; bottom: calc(100% + 2px); transform: translateX(-50%); background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); white-space: nowrap; pointer-events: none; z-index: 10; letter-spacing: var(--tracking-wide); }
.slider-wrap:hover .slider-track-bg, .slider-wrap.dragging .slider-track-bg { height: 5px; }
</style>
View File
+76
View File
@@ -0,0 +1,76 @@
import { gql } from "@api/client";
import { store, addHistory, addBookmark, removeBookmark,
checkAndMarkCompleted, DEFAULT_MANGA_PREFS } from "@store/state.svelte";
import { MARK_CHAPTER_READ, DELETE_DOWNLOADED_CHAPTERS } from "@api/mutations/chapters";
import { ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
const AVG_MIN_PER_PAGE = 0.33;
export function getMangaPrefs() {
const mangaId = store.activeManga?.id;
if (!mangaId) return DEFAULT_MANGA_PREFS;
return { ...DEFAULT_MANGA_PREFS, ...(store.settings.mangaPrefs?.[mangaId] ?? {}) };
}
export function markChapterRead(id: number, markedRead: Set<number>) {
if (markedRead.has(id)) return;
markedRead.add(id);
const chapter = store.activeChapterList.find(c => c.id === id) ?? store.activeChapter;
const pages = chapter?.pageCount ?? store.pageUrls.length ?? 15;
const minutes = Math.max(1, Math.round(pages * AVG_MIN_PER_PAGE));
if (store.activeManga && chapter) {
addHistory(
{ mangaId: store.activeManga.id, mangaTitle: store.activeManga.title, thumbnailUrl: store.activeManga.thumbnailUrl, chapterId: id, chapterName: chapter.name, readAt: Date.now() },
true, minutes,
);
}
gql(MARK_CHAPTER_READ, { id, isRead: true })
.then(() => {
const mangaId = store.activeManga?.id;
if (!mangaId) return;
const updated = store.activeChapterList.map(c => c.id === id ? { ...c, isRead: true } : c);
checkAndMarkCompleted(mangaId, updated);
const prefs = getMangaPrefs();
if (prefs.deleteOnRead) {
const ch = store.activeChapterList.find(c => c.id === id);
if (ch?.isDownloaded) {
const delayMs = (prefs.deleteDelayHours ?? 0) * 3_600_000;
const doDelete = () => gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [id] }).catch(console.error);
if (delayMs === 0) doDelete(); else setTimeout(doDelete, delayMs);
}
}
if (prefs.downloadAhead > 0) {
const list = store.activeChapterList;
const idx = list.findIndex(c => c.id === id);
if (idx >= 0) {
const toQueue = list.slice(idx + 1, idx + 1 + prefs.downloadAhead).filter(c => !c.isDownloaded && !c.isRead).map(c => c.id);
if (toQueue.length) gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: toQueue }).catch(console.error);
}
}
if (prefs.maxKeepChapters > 0) {
const downloaded = store.activeChapterList.filter(c => c.isDownloaded).sort((a, b) => a.sourceOrder - b.sourceOrder);
const excess = downloaded.slice(0, Math.max(0, downloaded.length - prefs.maxKeepChapters));
if (excess.length) gql(DELETE_DOWNLOADED_CHAPTERS, { ids: excess.map(c => c.id) }).catch(console.error);
}
})
.catch(e => { markedRead.delete(id); console.error(e); });
}
export function toggleBookmark(
displayChapter: import("@types").Chapter | null | undefined,
pageNumber: number,
) {
const ch = displayChapter;
const manga = store.activeManga;
if (!ch || !manga) return;
const isBookmarked = !!store.bookmarks.find(
b => b.mangaId === manga.id && b.chapterId === ch.id && b.pageNumber === pageNumber,
);
if (isBookmarked) {
removeBookmark(ch.id);
} else {
const existing = store.bookmarks.find(b => b.mangaId === manga.id && b.chapterId !== ch.id);
if (existing) removeBookmark(existing.chapterId);
addBookmark({ mangaId: manga.id, mangaTitle: manga.title, thumbnailUrl: manga.thumbnailUrl, chapterId: ch.id, chapterName: ch.name, pageNumber });
}
}
+48
View File
@@ -0,0 +1,48 @@
import { store, openReader } from "@store/state.svelte";
import { readerState } from "../store/readerState.svelte";
import { fetchPages } from "./pageLoader";
export function scheduleResumeDismiss() {
setTimeout(() => { readerState.resumeFading = true; }, 1500);
setTimeout(() => { readerState.resumeVisible = false; readerState.resumeFading = false; }, 2500);
}
export async function loadChapter(
id: number,
useBlob: boolean,
abortCtrl: { current: AbortController | null },
startAtLastPage: { current: boolean },
markedRead: Set<number>,
adjacent: { next: { id: number } | null },
) {
abortCtrl.current?.abort();
const ctrl = new AbortController();
abortCtrl.current = ctrl;
startAtLastPage.current = false;
markedRead.clear();
readerState.resetForChapter();
store.pageUrls = [];
const bookmark = store.bookmarks.find(b => b.chapterId === id);
const resumeTo = bookmark ? bookmark.pageNumber : 0;
readerState.resumePage = resumeTo > 1 ? resumeTo : 0;
readerState.resumeDismissed = false;
readerState.resumeVisible = resumeTo > 1;
if (resumeTo > 1) scheduleResumeDismiss();
store.pageNumber = 1;
try {
const urls = await fetchPages(id, useBlob, ctrl.signal, resumeTo > 1 ? resumeTo - 1 : 0);
if (ctrl.signal.aborted) return;
store.pageUrls = urls;
if (startAtLastPage.current) store.pageNumber = urls.length;
else if (resumeTo > 1) store.pageNumber = Math.min(resumeTo, urls.length || resumeTo);
readerState.pageReady = true;
readerState.loading = false;
if (adjacent.next) fetchPages(adjacent.next.id, useBlob).catch(() => {});
} catch (e: any) {
if (ctrl.signal.aborted) return;
readerState.error = e instanceof Error ? e.message : String(e);
readerState.loading = false;
}
}
+14
View File
@@ -0,0 +1,14 @@
export { readerState } from "./store/readerState.svelte";
export type { PageStyle } from "./store/readerState.svelte";
export { PAGE_STYLES, MARKER_COLORS, MARKER_COLOR_HEX, ZOOM_STEP, ZOOM_MIN, ZOOM_MAX } from "./store/readerState.svelte";
export { fetchPages, resolveUrl, preloadImage, measureAspect, buildPageGroups, clearPageCache } from "./lib/pageLoader";
export { setupScrollTracking, appendNextChapter } from "./lib/scrollHandler";
export type { StripChapter, ScrollHandlerCallbacks } from "./lib/scrollHandler";
export { createReaderKeyHandler } from "./lib/readerKeybinds";
export type { ReaderKeyActions } from "./lib/readerKeybinds";
export { markChapterRead, getMangaPrefs, toggleBookmark } from "./lib/chapterActions";
export { goForward, goBack, jumpToPage, animateFade } from "./lib/navigation";
export { clampZoom, captureZoomAnchor, restoreZoomAnchor } from "./lib/zoomHelpers";
export { loadChapter, scheduleResumeDismiss } from "./lib/chapterLoader";
+84
View File
@@ -0,0 +1,84 @@
import { store, openReader, closeReader } from "@store/state.svelte";
import { readerState } from "../store/readerState.svelte";
import type { Chapter } from "@types";
interface Adjacent {
prev: Chapter | null;
next: Chapter | null;
}
export function advanceGroup(forward: boolean, adjacent: Adjacent, startAtLastPage: () => void) {
if (!readerState.pageGroups.length) return;
const gi = readerState.pageGroups.findIndex(g => g.includes(store.pageNumber));
if (forward) {
if (gi < readerState.pageGroups.length - 1) store.pageNumber = readerState.pageGroups[gi + 1][0];
else if (adjacent.next) { store.pageNumber = 1; openReader(adjacent.next, store.activeChapterList); }
else closeReader();
} else {
if (gi > 0) store.pageNumber = readerState.pageGroups[gi - 1][0];
else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, store.activeChapterList); }
}
}
export async function animateFade(fn: () => void) {
readerState.fadingOut = true;
await new Promise(r => setTimeout(r, 100));
fn();
readerState.fadingOut = false;
}
export function goForward(
style: string,
adjacent: Adjacent,
lastPage: number,
onMaybeMarkRead: () => void,
startAtLastPage: () => void,
) {
if (readerState.loading) return;
if (style === "longstrip") {
if (adjacent.next) { onMaybeMarkRead(); openReader(adjacent.next, store.activeChapterList); }
return;
}
if (style === "double" && readerState.pageGroups.length) { advanceGroup(true, adjacent, startAtLastPage); return; }
if (!store.pageUrls.length) return;
if (store.pageNumber < lastPage) {
if (style === "fade") animateFade(() => { store.pageNumber++; });
else store.pageNumber++;
} else if (adjacent.next) {
onMaybeMarkRead();
store.pageNumber = 1;
openReader(adjacent.next, store.activeChapterList);
} else closeReader();
}
export function goBack(
style: string,
adjacent: Adjacent,
startAtLastPage: () => void,
) {
if (readerState.loading) return;
if (style === "longstrip") {
if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, store.activeChapterList); }
return;
}
if (style === "double" && readerState.pageGroups.length) { advanceGroup(false, adjacent, startAtLastPage); return; }
if (!store.pageUrls.length) return;
if (store.pageNumber > 1) {
if (style === "fade") animateFade(() => { store.pageNumber--; });
else store.pageNumber--;
} else if (adjacent.prev) { startAtLastPage(); openReader(adjacent.prev, store.activeChapterList); }
}
export function jumpToPage(page: number, style: string, lastPage: number, containerEl: HTMLElement | null) {
if (style === "longstrip") {
const chId = readerState.visibleChapterId ?? store.activeChapter?.id;
containerEl?.querySelector<HTMLImageElement>(`img[data-local-page="${page}"][data-chapter="${chId}"]`)?.scrollIntoView({ block: "start" });
return;
}
if (style === "double" && readerState.pageGroups.length) {
const group = readerState.pageGroups[page - 1];
if (group) store.pageNumber = group[0];
} else {
store.pageNumber = Math.max(1, Math.min(lastPage, page));
}
}
+103
View File
@@ -0,0 +1,103 @@
import { gql, plainThumbUrl } from "@api/client";
import { getBlobUrl, preloadBlobUrls } from "@core/cache/imageCache";
import { dedupeRequest } from "@core/async/batchRequests";
import { FETCH_CHAPTER_PAGES } from "@api/mutations/chapters";
export interface PageLoaderOptions {
useBlob: () => boolean;
}
const pageCache = new Map<number, string[]>();
const inflight = new Map<number, Promise<string[]>>();
const resolvedUrlCache = new Map<string, Promise<string>>();
const preloadedUrls = new Set<string>();
const aspectCache = new Map<string, number>();
export function resolveUrl(url: string, useBlob: boolean, priority = 0): Promise<string> {
if (!useBlob) return Promise.resolve(url);
if (!resolvedUrlCache.has(url)) resolvedUrlCache.set(url, getBlobUrl(url, priority));
return resolvedUrlCache.get(url)!;
}
export function fetchPages(
chapterId: number,
useBlob: boolean,
signal?: AbortSignal,
priorityPage = 0,
): Promise<string[]> {
const cached = pageCache.get(chapterId);
if (cached) return Promise.resolve(cached);
if (signal?.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"));
if (!inflight.has(chapterId)) {
const p = dedupeRequest(`chapter-pages:${chapterId}`, () =>
gql<{ fetchChapterPages: { pages: string[] } }>(FETCH_CHAPTER_PAGES, { chapterId })
.then(d => {
const urls = d.fetchChapterPages.pages.map(p => plainThumbUrl(p));
if (useBlob) {
if (urls[priorityPage]) getBlobUrl(urls[priorityPage], urls.length + 999);
preloadBlobUrls(urls.filter((_, i) => i !== priorityPage), urls.length);
}
pageCache.set(chapterId, urls);
return urls;
})
).finally(() => inflight.delete(chapterId));
inflight.set(chapterId, p);
}
const base = inflight.get(chapterId)!;
if (!signal) return base;
return new Promise((resolve, reject) => {
signal.addEventListener("abort", () => reject(new DOMException("Aborted", "AbortError")), { once: true });
base.then(resolve, reject);
});
}
export function measureAspect(url: string, useBlob: boolean): Promise<number> {
if (aspectCache.has(url)) return Promise.resolve(aspectCache.get(url)!);
return resolveUrl(url, useBlob).then(src => new Promise(res => {
const img = new Image();
img.onload = () => {
const r = img.naturalHeight > 0 ? img.naturalWidth / img.naturalHeight : 0.67;
aspectCache.set(url, r);
res(r);
};
img.onerror = () => res(0.67);
img.src = src;
}));
}
export function preloadImage(url: string, useBlob: boolean): void {
if (preloadedUrls.has(url)) return;
preloadedUrls.add(url);
resolveUrl(url, useBlob).then(src => { new Image().src = src; }).catch(() => {});
}
export function buildPageGroups(
urls: string[],
aspects: number[],
offsetSpreads: boolean,
): number[][] {
const groups: number[][] = [[1]];
if (offsetSpreads) groups.push([2]);
let i = offsetSpreads ? 3 : 2;
while (i <= urls.length) {
const a = aspects[i - 1];
if (a > 1.2 || i === urls.length) { groups.push([i++]); }
else { groups.push([i, i + 1]); i += 2; }
}
return groups;
}
export function clearPageCache(chapterId?: number): void {
if (chapterId !== undefined) {
pageCache.delete(chapterId);
inflight.delete(chapterId);
} else {
pageCache.clear();
inflight.clear();
resolvedUrlCache.clear();
preloadedUrls.clear();
aspectCache.clear();
}
}
+59
View File
@@ -0,0 +1,59 @@
import { matchesKeybind, toggleFullscreen, DEFAULT_KEYBINDS } from "@core/keybinds";
import type { Keybinds } from "@core/keybinds";
export interface ReaderKeyActions {
goNext: () => void;
goPrev: () => void;
closeReader: () => void;
goToPage: (page: number) => void;
lastPage: () => number;
adjustZoom: (delta: number) => void;
resetZoom: () => void;
cycleStyle: () => void;
toggleDirection: () => void;
openSettings: () => void;
toggleBookmark: () => void;
toggleMarker: () => void;
chapterNext: () => void;
chapterPrev: () => void;
closePopovers: () => boolean;
getKeybinds: () => Keybinds;
}
const ZOOM_STEP = 0.10;
export function createReaderKeyHandler(actions: ReaderKeyActions): (e: KeyboardEvent) => void {
return function onKey(e: KeyboardEvent) {
const target = e.target as HTMLElement;
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return;
if (e.key === "Escape") {
e.preventDefault();
if (actions.closePopovers()) return;
actions.closeReader();
return;
}
if (e.ctrlKey) {
if (e.key === "=" || e.key === "+") { e.preventDefault(); actions.adjustZoom(ZOOM_STEP); return; }
if (e.key === "-") { e.preventDefault(); actions.adjustZoom(-ZOOM_STEP); return; }
if (e.key === "0") { e.preventDefault(); actions.resetZoom(); return; }
}
const kb = actions.getKeybinds();
if (matchesKeybind(e, kb.exitReader)) { e.preventDefault(); actions.closeReader(); }
else if (matchesKeybind(e, kb.turnPageRight)) { e.preventDefault(); actions.goNext(); }
else if (matchesKeybind(e, kb.turnPageLeft)) { e.preventDefault(); actions.goPrev(); }
else if (matchesKeybind(e, kb.firstPage)) { e.preventDefault(); actions.goToPage(1); }
else if (matchesKeybind(e, kb.lastPage)) { e.preventDefault(); actions.goToPage(actions.lastPage()); }
else if (matchesKeybind(e, kb.turnChapterRight)) { e.preventDefault(); actions.chapterNext(); }
else if (matchesKeybind(e, kb.turnChapterLeft)) { e.preventDefault(); actions.chapterPrev(); }
else if (matchesKeybind(e, kb.togglePageStyle)) { e.preventDefault(); actions.cycleStyle(); }
else if (matchesKeybind(e, kb.toggleReadingDirection)) { e.preventDefault(); actions.toggleDirection(); }
else if (matchesKeybind(e, kb.toggleFullscreen)) { e.preventDefault(); toggleFullscreen().catch(console.error); }
else if (matchesKeybind(e, kb.openSettings)) { e.preventDefault(); actions.openSettings(); }
else if (matchesKeybind(e, kb.toggleBookmark)) { e.preventDefault(); actions.toggleBookmark(); }
else if (matchesKeybind(e, kb.toggleMarker)) { e.preventDefault(); actions.toggleMarker(); }
};
}
+110
View File
@@ -0,0 +1,110 @@
export const READ_LINE_PCT = 0.50;
export interface StripChapter {
chapterId: number;
chapterName: string;
urls: string[];
}
export interface ScrollHandlerCallbacks {
onPageChange: (page: number) => void;
onChapterChange: (chapterId: number) => void;
onMarkRead: (chapterId: number) => void;
onAppend: () => void;
getStripChapters: () => StripChapter[];
getPageUrls: () => string[];
shouldAutoMark: () => boolean;
}
export function setupScrollTracking(
containerEl: HTMLElement,
callbacks: ScrollHandlerCallbacks,
): () => void {
const {
onPageChange, onChapterChange, onMarkRead,
onAppend, getStripChapters, getPageUrls, shouldAutoMark,
} = callbacks;
function onScroll() {
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
if (!imgs.length) return;
const containerTop = containerEl.getBoundingClientRect().top;
const readLineY = containerTop + containerEl.clientHeight * READ_LINE_PCT;
let activePage: number | null = null;
let activeChId: number | null = null;
for (const img of imgs) {
if (img.getBoundingClientRect().top <= readLineY) {
activePage = Number(img.dataset.localPage);
activeChId = Number(img.dataset.chapter);
} else break;
}
if (activePage === null) {
activePage = Number(imgs[0].dataset.localPage);
activeChId = Number(imgs[0].dataset.chapter);
}
if (activePage !== null) onPageChange(activePage);
if (activeChId) onChapterChange(activeChId);
if (shouldAutoMark() && activePage !== null && activeChId) {
const chunks = getStripChapters();
const chunk = chunks.find(c => c.chapterId === activeChId);
const total = chunk ? chunk.urls.length : getPageUrls().length;
if (total > 0 && activePage >= total) onMarkRead(activeChId);
}
const atBottom = containerEl.scrollTop + containerEl.clientHeight >= containerEl.scrollHeight - 40;
if (atBottom && shouldAutoMark()) {
const chunks = getStripChapters();
const last = chunks[chunks.length - 1];
if (last) onMarkRead(last.chapterId);
}
}
function onScrollAppend() {
const pct = (containerEl.scrollTop + containerEl.clientHeight) / containerEl.scrollHeight;
if (pct >= 0.80) onAppend();
}
containerEl.addEventListener("scroll", onScroll, { passive: true });
containerEl.addEventListener("scroll", onScrollAppend, { passive: true });
return () => {
containerEl.removeEventListener("scroll", onScroll);
containerEl.removeEventListener("scroll", onScrollAppend);
};
}
export function appendNextChapter(
stripChapters: StripChapter[],
chapterList: { id: number; name: string }[],
fetchPages: (chapterId: number) => Promise<string[]>,
preloadImage: (url: string) => void,
onAppended: (next: StripChapter) => void,
onDone: () => void,
): void {
if (!stripChapters.length) return;
const lastChunk = stripChapters[stripChapters.length - 1];
const lastIdx = chapterList.findIndex(c => c.id === lastChunk.chapterId);
if (lastIdx < 0 || lastIdx >= chapterList.length - 1) return;
const next = chapterList[lastIdx + 1];
if (!next || stripChapters.some(c => c.chapterId === next.id)) return;
fetchPages(next.id)
.then(urls => {
urls.slice(0, 6).forEach(preloadImage);
return urls;
})
.then(urls => {
if (stripChapters.some(c => c.chapterId === next.id)) { onDone(); return; }
onAppended({ chapterId: next.id, chapterName: next.name, urls });
onDone();
})
.catch(() => onDone());
}
+38
View File
@@ -0,0 +1,38 @@
import { readerState } from "../store/readerState.svelte";
export function clampZoom(z: number): number {
const { ZOOM_MIN, ZOOM_MAX } = readerState;
return Math.round(Math.min(ZOOM_MAX, Math.max(ZOOM_MIN, z)) * 1000) / 1000;
}
export function captureZoomAnchor(
containerEl: HTMLElement | null,
style: string,
out: { el: HTMLElement | null; offset: number },
) {
if (!containerEl || style !== "longstrip") return;
const imgs = containerEl.querySelectorAll<HTMLElement>("img[data-local-page]");
const containerTop = containerEl.getBoundingClientRect().top;
for (const img of imgs) {
const rect = img.getBoundingClientRect();
if (rect.bottom > containerTop) {
out.el = img;
out.offset = rect.top - containerTop;
return;
}
}
}
export function restoreZoomAnchor(
containerEl: HTMLElement | null,
out: { el: HTMLElement | null; offset: number },
) {
if (!out.el || !containerEl) return;
const el = out.el;
out.el = null;
requestAnimationFrame(() => {
const containerTop = containerEl!.getBoundingClientRect().top;
const newRect = el.getBoundingClientRect();
containerEl!.scrollTop += (newRect.top - containerTop) - out.offset;
});
}
@@ -0,0 +1,107 @@
import type { MarkerColor } from "@store/state.svelte";
import type { StripChapter } from "../lib/scrollHandler";
export const PAGE_STYLES = ["single", "fade", "double", "longstrip"] as const;
export type PageStyle = typeof PAGE_STYLES[number];
export const MARKER_COLORS: MarkerColor[] = ["yellow", "red", "blue", "green", "purple"];
export const MARKER_COLOR_HEX: Record<MarkerColor, string> = {
yellow: "#c4a94a",
red: "#c47a7a",
blue: "#7a9ec4",
green: "#7aab7a",
purple: "#a07ac4",
};
export const ZOOM_STEP = 0.05;
export const ZOOM_MIN = 0.1;
export const ZOOM_MAX = 1.0;
class ReaderState {
loading = $state(true);
error = $state<string | null>(null);
pageReady = $state(false);
pageGroups = $state<number[][]>([]);
stripChapters = $state<StripChapter[]>([]);
visibleChapterId = $state<number | null>(null);
uiVisible = $state(true);
isFullscreen = $state(false);
dlOpen = $state(false);
zoomOpen = $state(false);
winOpen = $state(false);
nextN = $state(5);
dlBusy = $state(false);
fadingOut = $state(false);
sliderDragging = $state(false);
sliderHover = $state(false);
resumePage = $state(0);
resumeDismissed = $state(false);
resumeFading = $state(false);
resumeVisible = $state(false);
stripResumeReady = $state(false);
markerOpen = $state(false);
markerNote = $state("");
markerColor = $state<MarkerColor>("yellow");
markerEditId = $state("");
inspectScale = $state(1);
inspectPanX = $state(0);
inspectPanY = $state(0);
containerWidth = $state(0);
resetForChapter() {
this.loading = true;
this.error = null;
this.pageReady = false;
this.pageGroups = [];
this.stripChapters = [];
this.visibleChapterId = null;
this.fadingOut = false;
this.markerOpen = false;
}
resetResume() {
this.resumePage = 0;
this.resumeDismissed = false;
this.resumeVisible = false;
this.stripResumeReady = false;
}
resetInspect() {
this.inspectScale = 1;
this.inspectPanX = 0;
this.inspectPanY = 0;
}
closeAllPopovers(): boolean {
if (this.markerOpen) { this.markerOpen = false; return true; }
if (this.zoomOpen) { this.zoomOpen = false; return true; }
if (this.dlOpen) { this.dlOpen = false; return true; }
if (this.winOpen) { this.winOpen = false; return true; }
return false;
}
openMarker(editId: string, note: string, color: MarkerColor) {
this.markerEditId = editId;
this.markerNote = note;
this.markerColor = color;
this.markerOpen = true;
this.zoomOpen = false;
this.dlOpen = false;
this.winOpen = false;
}
clearMarkerPopover() {
this.markerOpen = false;
this.markerNote = "";
this.markerEditId = "";
}
}
export const readerState = new ReaderState();
@@ -0,0 +1,173 @@
<script lang="ts">
import { Download, CheckCircle, Circle, CircleNotch, Trash } from "phosphor-svelte";
import ContextMenu from "@shared/ui/ContextMenu.svelte";
import type { MenuEntry } from "@shared/ui/ContextMenu.svelte";
import type { Chapter } from "@types";
interface Props {
pageChapters: Chapter[];
sortedChapters: Chapter[];
viewMode: "list" | "grid";
loadingChapters: boolean;
selectedIds: Set<number>;
enqueueing: Set<number>;
chapterPage: number;
totalPages: number;
onOpen: (ch: Chapter, inProgress: boolean) => void;
onToggleSelect: (id: number, e: MouseEvent | KeyboardEvent) => void;
onEnqueue: (ch: Chapter, e: MouseEvent) => void;
onDeleteDownload:(id: number) => void;
onPageChange: (page: number) => void;
buildCtxItems: (ch: Chapter, idx: number) => MenuEntry[];
}
let {
pageChapters, sortedChapters, viewMode, loadingChapters,
selectedIds, enqueueing, chapterPage, totalPages,
onOpen, onToggleSelect, onEnqueue, onDeleteDownload,
onPageChange, buildCtxItems,
}: Props = $props();
let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null);
const hasSelection = $derived(selectedIds.size > 0);
function formatDate(ts: string | null | undefined): string {
if (!ts) return "";
const n = Number(ts);
const d = new Date(n > 1e10 ? n : n * 1000);
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}
</script>
<div class={viewMode === "grid" ? "ch-grid" : "ch-list"}>
{#if loadingChapters && sortedChapters.length === 0}
{#if viewMode === "grid"}
{#each Array(24) as _}<div class="grid-cell-skeleton skeleton"></div>{/each}
{:else}
{#each Array(8) as _}
<div class="row-skeleton">
<div class="skeleton sk-line" style="width:55%;height:12px"></div>
<div class="skeleton sk-line" style="width:25%;height:11px"></div>
</div>
{/each}
{/if}
{:else if viewMode === "grid"}
{#each sortedChapters as ch, i}
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
{@const isGridSelected = selectedIds.has(ch.id)}
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:grid-selected={isGridSelected}
onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, inProgress)}
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
title={ch.name}>
<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 enqueueing.has(ch.id)}<span class="grid-cell-spinner"><CircleNotch size={10} weight="light" class="anim-spin" /></span>{/if}
</button>
{/each}
{:else}
{#each pageChapters as ch}
{@const idxInSorted = sortedChapters.indexOf(ch)}
{@const isSelected = selectedIds.has(ch.id)}
{@const chInProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} class:ch-selected={isSelected}
onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, chInProgress)}
onkeydown={(e) => e.key === "Enter" && (hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, chInProgress))}
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}>
<button class="ch-check" class:ch-check-visible={hasSelection} onclick={(e) => onToggleSelect(ch.id, e)} title="Select">
{#if isSelected}<CheckCircle size={15} weight="fill" />{:else}<Circle size={15} weight="light" />{/if}
</button>
<div class="ch-left">
<span class="ch-name">{ch.name}</span>
<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}
</div>
</div>
<div class="ch-right">
{#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
{#if ch.isDownloaded}
<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"><Trash size={13} weight="light" /></button>
</div>
{:else if enqueueing.has(ch.id)}
<CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" />
{:else}
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); onEnqueue(ch, e); }} title="Download"><Download size={13} weight="light" /></button>
{/if}
</div>
</div>
{/each}
{/if}
</div>
{#if totalPages > 1}
<div class="pagination-bottom">
<button class="page-btn" onclick={() => onPageChange(Math.max(1, chapterPage - 1))} disabled={chapterPage === 1}>← Prev</button>
<span class="page-num">{chapterPage} / {totalPages}</span>
<button class="page-btn" onclick={() => onPageChange(Math.min(totalPages, chapterPage + 1))} disabled={chapterPage === totalPages}>Next →</button>
</div>
{/if}
{#if ctx}
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.chapter, ctx.idx)} onClose={() => ctx = null} />
{/if}
<style>
.ch-list { flex: 1; overflow-y: auto; }
.ch-grid { flex: 1; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); gap: 4px; padding: var(--sp-3); align-content: start; }
.ch-row { display: flex; align-items: center; padding: 10px var(--sp-4); border-bottom: 1px solid var(--border-dim); cursor: pointer; transition: background var(--t-fast); gap: var(--sp-3); }
.ch-row:hover { background: var(--bg-raised); }
.ch-row.read { opacity: 0.45; }
.ch-left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.ch-name { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ch-meta { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.ch-meta-item { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.ch-right { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
:global(.read-icon) { color: var(--text-faint); }
:global(.enqueue-icon) { color: var(--text-faint); }
.dl-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); opacity: 0; }
.ch-row:hover .dl-btn { opacity: 1; }
.dl-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.dl-btn-delete { color: var(--color-error) !important; opacity: 0; }
.ch-row:hover .dl-btn-delete { opacity: 1; }
.dl-btn-delete:hover { background: var(--color-error-bg) !important; }
.ch-dl-wrap { position: relative; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; }
:global(.ch-dl-icon) { color: var(--text-faint); transition: opacity var(--t-fast); }
.ch-row:hover .ch-dl-wrap :global(.ch-dl-icon) { opacity: 0; }
.ch-dl-wrap .dl-btn-delete { position: absolute; inset: 0; opacity: 0; }
.ch-row:hover .ch-dl-wrap .dl-btn-delete { opacity: 1; }
.ch-check { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; flex-shrink: 0; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; opacity: 0; transition: opacity var(--t-fast), color var(--t-fast); padding: 0; }
.ch-row:hover .ch-check { opacity: 1; }
.ch-check-visible { opacity: 1 !important; }
.ch-selected { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
.ch-selected .ch-check { color: var(--accent-fg); opacity: 1; }
.row-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); padding: 12px var(--sp-4); border-bottom: 1px solid var(--border-dim); }
.grid-cell { display: flex; align-items: center; justify-content: center; aspect-ratio: 1; border-radius: var(--radius-sm); background: var(--bg-raised); border: 1px solid var(--border-dim); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); cursor: pointer; position: relative; transition: background var(--t-fast), border-color var(--t-fast); }
.grid-cell:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
.grid-cell.read { background: var(--color-read); color: var(--text-faint); border-color: transparent; }
.grid-cell.in-progress { border-color: var(--accent-dim); color: var(--accent-fg); }
.grid-cell-num { font-size: 10px; }
.grid-cell-dot { position: absolute; bottom: 3px; right: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--text-faint); }
.grid-cell-dl { position: absolute; top: 3px; left: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent-fg); }
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
.grid-selected { background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; }
.pagination-bottom { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.page-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
.page-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
.page-btn:disabled { opacity: 0.3; cursor: default; }
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
</style>
@@ -0,0 +1,642 @@
<script lang="ts">
import {
Download, CheckCircle, Circle, SortAscending, SortDescending,
CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus,
Trash, DownloadSimple, X, MagnifyingGlass, Funnel, Check,
} from "phosphor-svelte";
import type { Chapter, Category } from "@types";
import type { ChapterSortMode, ChapterSortDir } from "../lib/chapterList";
import { updateSettings } from "@store/state.svelte";
interface ContinueChapter {
chapter: Chapter;
type: "start" | "continue" | "reread";
resumePage: number | null;
}
interface Props {
chapters: Chapter[];
sortedChapters: Chapter[];
sortMode: ChapterSortMode;
sortDir: ChapterSortDir;
viewMode: "list" | "grid";
chapterPage: number;
totalPages: number;
downloadedCount: number;
totalCount: number;
deletingAll: boolean;
hasSelection: boolean;
selectedCount: number;
continueChapter: ContinueChapter | null;
availableScanlators: string[];
scanlatorFilter: string[];
scanlatorBlacklist: string[];
scanlatorForce: boolean;
allCategories: Category[];
mangaCategories: Category[];
catsLoading: boolean;
onViewModeToggle: () => void;
onPageChange: (page: number) => void;
onDownloadSelected: () => void;
onDeleteSelected: () => void;
onMarkSelectedRead: (isRead: boolean) => void;
onClearSelection: () => void;
onEnqueueNext: (n: number) => void;
onEnqueueMultiple: (ids: number[]) => void;
onDeleteAll: () => void;
onRefresh: () => void;
onToggleCategory: (cat: Category) => void;
onCreateCategory: (name: string) => void;
onSetScanlatorFilter: (v: string[]) => void;
onSetScanlatorBlacklist: (v: string[]) => void;
onSetScanlatorForce: (v: boolean) => void;
refreshing: boolean;
}
let {
chapters, sortedChapters, sortMode, sortDir, viewMode,
chapterPage, totalPages, downloadedCount, totalCount, deletingAll,
hasSelection, selectedCount, continueChapter,
availableScanlators, scanlatorFilter, scanlatorBlacklist, scanlatorForce,
allCategories, mangaCategories, catsLoading,
onViewModeToggle, onPageChange, onDownloadSelected, onDeleteSelected,
onMarkSelectedRead, onClearSelection, onEnqueueNext, onEnqueueMultiple,
onDeleteAll, onRefresh, onToggleCategory, onCreateCategory,
onSetScanlatorFilter, onSetScanlatorBlacklist, onSetScanlatorForce,
refreshing,
}: Props = $props();
let sortMenuOpen: boolean = $state(false);
let jumpOpen: boolean = $state(false);
let jumpInput: string = $state("");
let scanFilterOpen: boolean = $state(false);
let scanTab: "prefer" | "block" = $state("prefer");
let dlOpen: boolean = $state(false);
let showRange: boolean = $state(false);
let rangeFrom: string = $state("");
let rangeTo: string = $state("");
let folderPickerOpen: boolean = $state(false);
let folderCreating: boolean = $state(false);
let folderNewName: string = $state("");
let dlDropRef: HTMLDivElement | undefined = $state();
let folderPickerRef: HTMLDivElement | undefined = $state();
const hasFolders = $derived(mangaCategories.filter(c => c.id !== 0).length > 0);
const jumpChapter = $derived.by(() => {
const q = jumpInput.trim().toLowerCase();
if (!q) return null;
const num = parseFloat(q);
if (!isNaN(num)) return sortedChapters.find(c => c.chapterNumber === num) ?? null;
return sortedChapters.find(c => c.name.toLowerCase().includes(q)) ?? null;
});
function focusOnMount(node: HTMLElement) { node.focus(); }
function doJump() {
if (!jumpChapter) return;
const pageIdx = sortedChapters.indexOf(jumpChapter);
if (pageIdx >= 0) onPageChange(Math.floor(pageIdx / 25) + 1);
jumpOpen = false; jumpInput = "";
}
function enqueueRange() {
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));
}
function submitNewFolder() {
const name = folderNewName.trim();
if (!name) return;
onCreateCategory(name);
folderNewName = ""; folderCreating = false;
}
$effect(() => {
if (dlOpen) { setTimeout(() => document.addEventListener("mousedown", handleDlOutside, true), 0); }
else document.removeEventListener("mousedown", handleDlOutside, true);
});
$effect(() => {
if (folderPickerOpen) { setTimeout(() => document.addEventListener("mousedown", handleFolderOutside, true), 0); }
else document.removeEventListener("mousedown", handleFolderOutside, true);
});
$effect(() => {
if (!scanFilterOpen) return;
function onOutside(e: MouseEvent) {
if (!(e.target as HTMLElement).closest(".scan-filter-wrap")) scanFilterOpen = false;
}
setTimeout(() => document.addEventListener("mousedown", onOutside, true), 0);
return () => document.removeEventListener("mousedown", onOutside, true);
});
function handleDlOutside(e: MouseEvent) { if (dlDropRef && !dlDropRef.contains(e.target as Node)) dlOpen = false; }
function handleFolderOutside(e: MouseEvent) { if (folderPickerRef && !folderPickerRef.contains(e.target as Node)) { folderPickerOpen = false; folderCreating = false; folderNewName = ""; } }
</script>
<div class="list-header">
<div class="list-header-left">
{#if hasSelection}
<span class="sel-count">{selectedCount} selected</span>
<button class="sel-action-btn" onclick={onDownloadSelected} title="Download selected"><Download size={13} weight="light" /></button>
<button class="sel-action-btn sel-action-danger" onclick={onDeleteSelected} title="Delete selected downloads"><Trash size={13} weight="light" /></button>
<button class="sel-action-btn" onclick={() => onMarkSelectedRead(true)} title="Mark selected as read"><CheckCircle size={13} weight="light" /></button>
<button class="sel-action-btn" onclick={() => onMarkSelectedRead(false)} title="Mark selected as unread"><Circle size={13} weight="light" /></button>
<button class="sel-action-btn" onclick={onClearSelection} title="Clear selection"><X size={13} weight="light" /></button>
{:else}
<div class="sort-wrap">
<button class="sort-btn" onclick={() => sortMenuOpen = !sortMenuOpen}>
{#if sortDir === "desc"}<SortDescending size={14} weight="light" />{:else}<SortAscending size={14} weight="light" />{/if}
{{ source: "Source order", chapterNumber: "Ch. number", uploadDate: "Upload date" }[sortMode]}
<CaretDown size={10} weight="light" />
</button>
{#if sortMenuOpen}
<div class="sort-menu" role="presentation" onmouseleave={() => sortMenuOpen = false}>
{#each [["source","Source order"],["chapterNumber","Chapter number"],["uploadDate","Upload date"]] as [val, label]}
<button class="sort-option" class:active={sortMode === val}
onclick={() => { updateSettings({ chapterSortMode: val as any }); onPageChange(1); sortMenuOpen = false; }}>
{label}
</button>
{/each}
<div class="sort-divider"></div>
<button class="sort-option" onclick={() => { updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" }); onPageChange(1); sortMenuOpen = false; }}>
{sortDir === "desc" ? "↑ Ascending" : "↓ Descending"}
</button>
</div>
{/if}
</div>
<button class="icon-btn" class:active={viewMode === "grid"} onclick={onViewModeToggle} title={viewMode === "list" ? "Grid view" : "List view"}>
{#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
</button>
{/if}
</div>
<div class="list-header-right">
<!-- Jump to chapter -->
<div class="jump-wrap">
<button class="icon-btn" class:active={jumpOpen} onclick={() => { jumpOpen = !jumpOpen; jumpInput = ""; }} title="Jump to chapter">
<MagnifyingGlass size={14} weight="light" />
</button>
{#if jumpOpen}
<div class="jump-popover">
<input class="jump-input" placeholder="Chapter # or name…" bind:value={jumpInput} use:focusOnMount
onkeydown={(e) => { if (e.key === "Enter") doJump(); if (e.key === "Escape") { jumpOpen = false; jumpInput = ""; } }} />
{#if jumpChapter}
<button class="jump-go" onclick={doJump}>Go · {jumpChapter.name}</button>
{:else if jumpInput.trim()}
<p class="jump-none">No match</p>
{/if}
</div>
{/if}
</div>
<!-- Scanlator filter -->
{#if availableScanlators.length > 1}
<div class="scan-filter-wrap">
<button class="icon-btn" class:active={scanlatorFilter.length > 0 || scanlatorBlacklist.length > 0} onclick={() => scanFilterOpen = !scanFilterOpen} title="Filter by scanlator">
<Funnel size={14} weight={scanlatorFilter.length > 0 || scanlatorBlacklist.length > 0 ? "fill" : "light"} />
</button>
{#if scanFilterOpen}
<div class="scan-filter-panel" role="menu">
<div class="scan-filter-header">
<div class="scan-filter-tabs">
<button class="scan-filter-tab" class:scan-filter-tab-active={scanTab === "prefer"} onclick={() => scanTab = "prefer"}>Prefer</button>
<button class="scan-filter-tab" class:scan-filter-tab-active={scanTab === "block"} onclick={() => scanTab = "block"}>Block</button>
</div>
{#if scanTab === "prefer" && scanlatorFilter.length > 0}
<button class="scan-filter-clear" onclick={() => { onSetScanlatorFilter([]); onSetScanlatorForce(false); onPageChange(1); }}>Clear</button>
{:else if scanTab === "block" && scanlatorBlacklist.length > 0}
<button class="scan-filter-clear" onclick={() => { onSetScanlatorBlacklist([]); onPageChange(1); }}>Clear</button>
{/if}
</div>
<div class="scan-filter-divider"></div>
{#if scanTab === "prefer"}
<div class="scan-filter-force-row">
<span class="scan-filter-force-label" title="Hide chapters with no preferred group match, rather than falling back to any available group.">Enforce</span>
<button class="scan-force-toggle" class:scan-force-on={scanlatorForce}
onclick={() => { onSetScanlatorForce(!scanlatorForce); onPageChange(1); }}>
{scanlatorForce ? "On" : "Off"}
</button>
</div>
<div class="scan-filter-divider"></div>
{#each availableScanlators as s}
<button class="scan-filter-item" class:scan-filter-item-active={scanlatorFilter.includes(s)} role="menuitem"
onclick={() => { onSetScanlatorFilter(scanlatorFilter.includes(s) ? scanlatorFilter.filter(x => x !== s) : [...scanlatorFilter, s]); onPageChange(1); }}>
<span class="scan-filter-check" class:scan-filter-check-on={scanlatorFilter.includes(s)}>
{#if scanlatorFilter.includes(s)}<Check size={9} weight="bold" />{/if}
</span>
{s}
</button>
{/each}
{:else}
{#each availableScanlators as s}
<button class="scan-filter-item" class:scan-filter-item-active={scanlatorBlacklist.includes(s)} class:scan-filter-item-block={scanlatorBlacklist.includes(s)} role="menuitem"
onclick={() => { onSetScanlatorBlacklist(scanlatorBlacklist.includes(s) ? scanlatorBlacklist.filter(x => x !== s) : [...scanlatorBlacklist, s]); onPageChange(1); }}>
<span class="scan-filter-check" class:scan-filter-check-block={scanlatorBlacklist.includes(s)}>
{#if scanlatorBlacklist.includes(s)}<X size={9} weight="bold" />{/if}
</span>
{s}
</button>
{/each}
{/if}
</div>
{/if}
</div>
{/if}
<!-- Refresh -->
<button class="icon-btn" onclick={onRefresh} disabled={refreshing}>
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
</button>
<!-- Folder picker -->
<div class="fp-wrap" bind:this={folderPickerRef}>
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
</button>
{#if folderPickerOpen}
<div class="fp-menu">
{#if catsLoading}
<p class="fp-empty">Loading…</p>
{:else if allCategories.length === 0 && !folderCreating}
<p class="fp-empty">No folders yet</p>
{/if}
{#each allCategories as cat}
{@const isIn = mangaCategories.some(c => c.id === cat.id)}
<button class="fp-item" class:fp-item-active={isIn} onclick={() => onToggleCategory(cat)}>
<span class="fp-check">{isIn ? "✓" : ""}</span>{cat.name}
</button>
{/each}
<div class="fp-div"></div>
{#if folderCreating}
<div class="fp-create">
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName} use:focusOnMount
onkeydown={(e) => { if (e.key === "Enter") submitNewFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} />
<button class="fp-confirm" onclick={submitNewFolder} disabled={!folderNewName.trim()}>Add</button>
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}><X size={12} weight="light" /></button>
</div>
{:else}
<button class="fp-new" onclick={() => folderCreating = true}>+ New folder</button>
{/if}
</div>
{/if}
</div>
<!-- Download dropdown -->
{#if chapters.length > 0}
<div class="dl-wrap" bind:this={dlDropRef}>
<button class="icon-btn dl-unified-btn" class:active={dlOpen} class:dl-has-count={downloadedCount > 0} onclick={() => dlOpen = !dlOpen} title="Download options">
<Download size={13} weight={downloadedCount > 0 ? "fill" : "light"} />
{#if downloadedCount > 0}<span class="dl-unified-count">{downloadedCount}</span>{/if}
</button>
{#if dlOpen}
<div class="dl-dropdown">
{#if downloadedCount > 0}
<p class="dl-section-label">{downloadedCount} / {totalCount} downloaded</p>
<div class="dl-divider"></div>
{/if}
{#if continueChapter}
{@const contIdx = sortedChapters.indexOf(continueChapter.chapter)}
{#if contIdx >= 0}
<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}
<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>
{/each}
</div>
<div class="dl-divider"></div>
{/if}
{/if}
{#if !showRange}
<button class="dl-item" onclick={() => showRange = true}>
<span>Custom range…</span><span class="dl-item-sub">Enter chapter numbers</span>
</button>
{:else}
<div class="dl-range-row">
<button class="dl-range-back" onclick={() => showRange = false}></button>
<input class="dl-range-input" placeholder="From" bind:value={rangeFrom} onkeydown={(e) => e.key === "Enter" && enqueueRange()} use:focusOnMount />
<span class="dl-range-sep"></span>
<input class="dl-range-input" placeholder="To" bind:value={rangeTo} onkeydown={(e) => e.key === "Enter" && enqueueRange()} />
<button class="dl-range-go" disabled={!rangeFrom.trim() || !rangeTo.trim()} onclick={enqueueRange}>Go</button>
</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>
<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>
{#if downloadedCount > 0}
<div class="dl-divider"></div>
<button class="dl-item dl-item-danger" onclick={() => { onDeleteAll(); dlOpen = false; }} disabled={deletingAll}>
<span>{deletingAll ? "Deleting…" : "Delete all downloads"}</span>
<span class="dl-item-sub">{downloadedCount} downloaded</span>
</button>
{/if}
</div>
{/if}
</div>
{/if}
<!-- Top pagination -->
{#if totalPages > 1}
<div class="pagination">
<button class="page-btn" onclick={() => onPageChange(Math.max(1, chapterPage - 1))} disabled={chapterPage === 1}></button>
<span class="page-num">{chapterPage} / {totalPages}</span>
<button class="page-btn" onclick={() => onPageChange(Math.min(totalPages, chapterPage + 1))} disabled={chapterPage === totalPages}></button>
</div>
{/if}
</div>
</div>
<style>
/* ─── Header bar ──────────────────────────────────────────── */
.list-header {
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); flex-wrap: wrap;
}
.list-header-left,
.list-header-right { display: flex; align-items: center; gap: var(--sp-1); }
/* ─── Sort ────────────────────────────────────────────────── */
.sort-btn {
display: flex; align-items: center; gap: 5px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
color: var(--text-muted); transition: color var(--t-base), background var(--t-base);
}
.sort-btn:hover { color: var(--text-secondary); background: var(--bg-raised); }
.sort-wrap { position: relative; }
.sort-menu {
position: absolute; top: calc(100% + 4px); left: 0; min-width: 160px;
background: var(--bg-raised); border: 1px solid var(--border-base);
border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
animation: scaleIn 0.1s ease both; transform-origin: top left;
}
.sort-option {
display: block; width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm);
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary);
background: none; border: none; cursor: pointer; text-align: left;
transition: background var(--t-fast), color var(--t-fast);
}
.sort-option:hover { background: var(--bg-overlay); color: var(--text-primary); }
.sort-option.active { color: var(--accent-fg); }
.sort-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
/* ─── Icon buttons ────────────────────────────────────────── */
.icon-btn {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); color: var(--text-muted);
background: none; cursor: pointer;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.icon-btn:disabled { opacity: 0.3; cursor: default; }
/* ─── Jump ────────────────────────────────────────────────── */
.jump-wrap { position: relative; }
.jump-popover {
position: absolute; top: calc(100% + 4px); right: 0; width: 220px;
background: var(--bg-raised); border: 1px solid var(--border-base);
border-radius: var(--radius-md); padding: var(--sp-2); z-index: 200;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
animation: scaleIn 0.1s ease both; transform-origin: top right;
display: flex; flex-direction: column; gap: var(--sp-1);
}
.jump-input {
width: 100%; background: var(--bg-overlay); border: 1px solid var(--border-strong);
border-radius: var(--radius-sm); padding: 5px 9px;
font-size: var(--text-xs); color: var(--text-secondary); outline: none;
transition: border-color var(--t-base);
}
.jump-input:focus { border-color: var(--border-focus); }
.jump-go {
width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-sm);
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
cursor: pointer; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
transition: background var(--t-fast), border-color var(--t-fast);
}
.jump-go:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast, #fff); }
.jump-none { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: 4px var(--sp-1); letter-spacing: var(--tracking-wide); }
/* ─── Folder picker ───────────────────────────────────────── */
.fp-wrap { position: relative; }
.fp-menu {
position: absolute; top: calc(100% + 4px); right: 0; min-width: 180px;
background: var(--bg-raised); border: 1px solid var(--border-base);
border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
animation: scaleIn 0.1s ease both; transform-origin: top right;
}
.fp-empty { padding: var(--sp-2) var(--sp-3); font-size: var(--text-xs); color: var(--text-faint); }
.fp-item {
display: flex; align-items: center; gap: var(--sp-2); width: 100%;
padding: 6px var(--sp-3); border-radius: var(--radius-sm); font-size: var(--text-xs);
color: var(--text-secondary); background: none; border: none; cursor: pointer; text-align: left;
transition: background var(--t-fast), color var(--t-fast);
}
.fp-item:hover { background: var(--bg-overlay); }
.fp-item.fp-item-active { color: var(--accent-fg); }
.fp-check { width: 12px; font-size: var(--text-xs); color: var(--accent-fg); flex-shrink: 0; }
.fp-div { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
.fp-create { display: flex; align-items: center; gap: var(--sp-1); padding: 4px var(--sp-2); }
.fp-input {
flex: 1; background: var(--bg-overlay); border: 1px solid var(--border-strong);
border-radius: var(--radius-sm); padding: 4px 8px;
font-size: var(--text-xs); color: var(--text-secondary); outline: none; min-width: 0;
}
.fp-input:focus { border-color: var(--border-focus); }
.fp-confirm {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 4px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer;
}
.fp-confirm:disabled { opacity: 0.4; cursor: default; }
.fp-cancel {
display: flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: var(--radius-sm);
border: 1px solid transparent; background: none; color: var(--text-faint); cursor: pointer;
transition: color var(--t-base), border-color var(--t-base);
}
.fp-cancel:hover { color: var(--text-muted); border-color: var(--border-dim); }
.fp-new {
width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm);
font-size: var(--text-xs); color: var(--text-faint); background: none; border: none;
cursor: pointer; text-align: left; transition: color var(--t-fast), background var(--t-fast);
}
.fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); }
/* ─── Download dropdown ───────────────────────────────────── */
.dl-wrap { position: relative; }
.dl-dropdown {
position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px;
background: var(--bg-raised); border: 1px solid var(--border-base);
border-radius: var(--radius-lg); padding: var(--sp-1); z-index: 200;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
animation: scaleIn 0.1s ease both; transform-origin: top right;
}
.dl-section-label {
padding: 6px var(--sp-3) 4px; font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.dl-next-row { display: flex; gap: 4px; padding: 2px var(--sp-2) var(--sp-2); }
.dl-next-btn {
flex: 1; display: flex; align-items: center; justify-content: center; gap: 5px;
padding: 5px 6px; border-radius: var(--radius-md); border: 1px solid var(--border-dim);
background: var(--bg-overlay); color: var(--text-secondary);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer;
transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast);
}
.dl-next-btn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.dl-next-btn:disabled { opacity: 0.3; cursor: default; }
.dl-next-sub { font-size: var(--text-2xs); color: var(--text-faint); }
.dl-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
.dl-item {
display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md);
font-size: var(--text-sm); color: var(--text-secondary); background: none; border: none;
cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast);
}
.dl-item:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
.dl-item:disabled { opacity: 0.3; cursor: default; }
.dl-item.dl-item-danger { color: var(--color-error); }
.dl-item.dl-item-danger:hover:not(:disabled) { background: var(--color-error-bg); }
.dl-item-sub { font-size: var(--text-xs); color: var(--text-faint); }
.dl-range-row { display: flex; align-items: center; gap: 4px; padding: 7px var(--sp-2); }
.dl-range-back {
display: flex; align-items: center; justify-content: center;
width: 20px; height: 20px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 14px; cursor: pointer;
}
.dl-range-back:hover { color: var(--text-muted); background: var(--bg-overlay); }
.dl-range-input {
flex: 1; min-width: 0; padding: 4px 8px; background: var(--bg-overlay);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs);
outline: none; text-align: center;
}
.dl-range-input:focus { border-color: var(--border-focus); }
.dl-range-sep { color: var(--text-faint); font-size: var(--text-xs); }
.dl-range-go {
padding: 4px 10px; border-radius: var(--radius-sm);
border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg);
font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer;
}
.dl-range-go:disabled { opacity: 0.3; cursor: default; }
.dl-unified-btn { gap: 5px; padding: 0 8px; width: auto; min-width: 28px; }
.dl-unified-count {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
color: var(--text-faint); transition: color var(--t-base);
}
.dl-unified-btn:hover .dl-unified-count,
.dl-unified-btn.active .dl-unified-count { color: var(--text-secondary); }
.dl-unified-btn.dl-has-count { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.dl-unified-btn.dl-has-count .dl-unified-count { color: var(--accent-fg); opacity: 0.8; }
.dl-unified-btn.dl-has-count:hover { background: var(--accent-muted); border-color: var(--accent); opacity: 0.9; }
.dl-unified-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
/* ─── Pagination (top) ────────────────────────────────────── */
.pagination { display: flex; align-items: center; gap: var(--sp-2); }
.page-btn {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
color: var(--text-faint); background: none; cursor: pointer;
transition: color var(--t-base), border-color var(--t-base);
}
.page-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
.page-btn:disabled { opacity: 0.3; cursor: default; }
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
/* ─── Selection toolbar ───────────────────────────────────── */
.sel-count {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted);
letter-spacing: var(--tracking-wide); padding: 0 var(--sp-1);
}
.sel-action-btn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer;
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
}
.sel-action-btn:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
.sel-action-danger { color: var(--color-error) !important; }
.sel-action-danger:hover { background: var(--color-error-bg) !important; border-color: var(--color-error) !important; }
/* ─── Scanlator filter ────────────────────────────────────── */
.scan-filter-wrap { position: relative; }
.scan-filter-panel {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 200; 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: scaleIn 0.1s ease both; transform-origin: top right;
}
.scan-filter-header { display: flex; align-items: center; justify-content: space-between; padding: 4px 8px 6px; }
.scan-filter-clear {
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);
}
.scan-filter-clear:hover { color: var(--color-error); }
.scan-filter-divider { height: 1px; background: var(--border-dim); margin: 0 2px 4px; }
.scan-filter-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);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.scan-filter-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.scan-filter-item-active { color: var(--accent-fg); background: var(--accent-muted); }
.scan-filter-item-active:hover { background: var(--accent-dim); }
.scan-filter-tabs {
display: flex; gap: 2px; background: var(--bg-overlay);
border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px;
}
.scan-filter-tab {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 2px 8px; border-radius: 2px; border: none; background: none;
color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast);
}
.scan-filter-tab:hover { color: var(--text-muted); }
.scan-filter-tab.scan-filter-tab-active { background: var(--bg-surface); color: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
.scan-filter-force-row { display: flex; align-items: center; justify-content: space-between; padding: 5px 10px; }
.scan-filter-force-label {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted);
letter-spacing: var(--tracking-wide); cursor: default;
text-decoration: underline; text-decoration-style: dotted;
text-decoration-color: var(--border-strong); text-underline-offset: 3px;
}
.scan-force-toggle {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
background: none; color: var(--text-faint); cursor: pointer;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.scan-force-toggle:hover { color: var(--text-muted); border-color: var(--border-strong); }
.scan-force-toggle.scan-force-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.scan-filter-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);
}
.scan-filter-check-on { background: var(--accent); border-color: var(--accent); }
.scan-filter-check-block { background: var(--color-error); border-color: var(--color-error); }
.scan-filter-item-block { color: var(--color-error) !important; background: color-mix(in srgb, var(--color-error) 8%, transparent) !important; }
.scan-filter-item-block:hover { background: color-mix(in srgb, var(--color-error) 14%, transparent) !important; }
/* ─── Shared animation (used by dropdowns/popovers) ───────── */
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
@@ -0,0 +1,778 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { gql } from "@api/client";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { X } from "phosphor-svelte";
import { GET_MANGA, GET_ALL_MANGA, GET_CATEGORIES } from "@api/queries/manga";
import { GET_CHAPTERS } from "@api/queries/chapters";
import { UPDATE_MANGA, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "@api/mutations/manga";
import { FETCH_CHAPTERS, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS } from "@api/mutations/chapters";
import { ENQUEUE_DOWNLOAD, ENQUEUE_CHAPTERS_DOWNLOAD } from "@api/mutations/downloads";
import { cache, CACHE_KEYS, recordSourceAccess } from "@core/cache";
import {
store, addToast, openReader, setActiveManga, linkManga, unlinkManga,
addBookmark, acknowledgeUpdate,
checkAndMarkCompleted as storeCheckAndMarkCompleted,
clearMarkersForManga,
} from "@store/state.svelte";
import type { MangaPrefs } from "@store/state.svelte";
import { DEFAULT_MANGA_PREFS } from "@store/state.svelte";
import type { Manga, Chapter, Category } from "@types";
import ContextMenu from "@shared/ui/ContextMenu.svelte";
import type { MenuEntry } from "@shared/ui/ContextMenu.svelte";
import MigrateModal from "../panels/MigrateModal.svelte";
import TrackingPanel from "../panels/TrackingPanel.svelte";
import AutomationPanel from "../panels/AutomationPanel.svelte";
import MarkersPanel from "../panels/MarkersPanel.svelte";
import SeriesHeader from "./SeriesHeader.svelte";
import SeriesActions from "./SeriesActions.svelte";
import ChapterList from "./ChapterList.svelte";
import { buildChapterList, chaptersAscending } from "../lib/chapterList";
import { getPref, setPref } from "../lib/mangaPrefs";
const CHAPTERS_PER_PAGE = 25;
const MANGA_TTL_MS = 5 * 60 * 1000;
const CHAPTER_TTL_MS = 2 * 60 * 1000;
const mangaStore: Map<number, { data: Manga; fetchedAt: number }> = new Map();
const chapterStore: Map<number, { data: Chapter[]; fetchedAt: number }> = new Map();
let manga: Manga | null = $state(null);
let chapters: Chapter[] = $state([]);
let loadingManga: boolean = $state(false);
let loadingChapters: boolean = $state(true);
let enqueueing: Set<number> = $state(new Set());
let togglingLibrary: boolean = $state(false);
let chapterPage: number = $state(1);
let viewMode: "list" | "grid" = $state("list");
let deletingAll: boolean = $state(false);
let refreshing: boolean = $state(false);
let selectedIds: Set<number> = $state(new Set());
let migrateOpen: boolean = $state(false);
let autoOpen: boolean = $state(false);
let trackingOpen: boolean = $state(false);
let markersOpen: boolean = $state(false);
let linkPickerOpen: boolean = $state(false);
let linkSearch: string = $state("");
let allMangaForLink: Manga[] = $state([]);
let loadingLinkList: boolean = $state(false);
let mangaCategories: Category[] = $state([]);
let allCategories: Category[] = $state([]);
let catsLoading: boolean = $state(false);
let mangaAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null;
let loadingFor: number | null = null;
let _prevChapterIds: Set<number> = new Set();
const get = <K extends keyof MangaPrefs>(key: K) =>
store.activeManga ? getPref(store.activeManga.id, key) : DEFAULT_MANGA_PREFS[key];
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => {
if (store.activeManga) setPref(store.activeManga.id, key, value);
};
const hasSelection = $derived(selectedIds.size > 0);
const sortDir = $derived(store.settings.chapterSortDir);
const sortMode = $derived(store.settings.chapterSortMode ?? "source");
const scanlatorFilter = $derived((get("scanlatorFilter") ?? []) as string[]);
const scanlatorBlacklist = $derived((get("scanlatorBlacklist") ?? []) as string[]);
const scanlatorForce = $derived((get("scanlatorForce") ?? false) as boolean);
const availableScanlators = $derived(
[...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))]
.sort((a, b) => a.localeCompare(b))
);
const sortedChapters = $derived(buildChapterList(chapters, {
sortMode, sortDir,
preferredScanlator: get("preferredScanlator") as string,
scanlatorFilter: scanlatorFilter as string[],
scanlatorBlacklist: scanlatorBlacklist as string[],
scanlatorForce: scanlatorForce as boolean,
}));
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
const readCount = $derived(chapters.filter(c => c.isRead).length);
const totalCount = $derived(chapters.length);
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0);
const downloadedCount = $derived(chapters.filter(c => c.isDownloaded).length);
const continueChapter = $derived((() => {
if (!sortedChapters.length) return null;
const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
const anyRead = asc.some(c => c.isRead);
const bookmark = store.activeManga
? store.bookmarks.find(b => b.mangaId === store.activeManga!.id)
: null;
if (bookmark) {
const ch = asc.find(c => c.id === bookmark.chapterId);
if (ch) {
const isLastChapter = asc[asc.length - 1]?.id === ch.id;
const allRead = asc.every(c => c.isRead);
if (!(isLastChapter && allRead))
return { chapter: ch, type: "continue" as const, resumePage: bookmark.pageNumber };
}
}
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0);
if (inProgress) return { chapter: inProgress, type: "continue" as const, resumePage: inProgress.lastPageRead! };
const firstUnread = asc.find(c => !c.isRead);
if (firstUnread) return { chapter: firstUnread, type: (anyRead ? "continue" : "start") as const, resumePage: null };
return { chapter: asc[0], type: "reread" as const, resumePage: null };
})());
const hasAnyAutomation = $derived(
get("autoDownload") ||
(get("downloadAhead") as number) > 0 ||
(get("maxKeepChapters") as number) > 0 ||
get("deleteOnRead") ||
get("pauseUpdates") ||
get("refreshInterval") !== "global" ||
!!(get("preferredScanlator") as string)
);
const linkedIds = $derived(
store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : []
);
const linkPickerResults = $derived.by(() => {
const id = store.activeManga?.id;
const others = allMangaForLink.filter(m => m.id !== id);
const q = linkSearch.trim().toLowerCase();
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
const linked = filtered.filter(m => linkedIds.includes(m.id));
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
return [...linked, ...rest];
});
function focusOnMount(node: HTMLElement) { node.focus(); }
function clearSelection() { selectedIds = new Set(); }
function toggleSelect(id: number, e: MouseEvent | KeyboardEvent) {
e.stopPropagation();
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id); else next.add(id);
selectedIds = next;
}
function applyChapters(nodes: Chapter[]) {
if (get("autoDownload") && _prevChapterIds.size > 0) {
const newChapters = nodes.filter(c => !_prevChapterIds.has(c.id) && !c.isDownloaded);
if (newChapters.length) enqueueMultiple(newChapters.map(c => c.id));
}
_prevChapterIds = new Set(nodes.map(c => c.id));
chapters = nodes;
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
}
function loadCategories(mangaId: number) {
catsLoading = true;
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
.then(d => {
allCategories = d.categories.nodes.filter(c => c.id !== 0);
mangaCategories = allCategories.filter(c => c.mangas?.nodes.some(m => m.id === mangaId));
})
.catch(console.error)
.finally(() => { catsLoading = false; });
}
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
const mangaStatus = manga?.status;
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA, mangaStatus);
if (chaps.length && mangaStatus !== "ONGOING") {
const allRead = chaps.every(c => c.isRead);
const completed = allCategories.find(c => c.name === "Completed");
if (completed) {
const inCompleted = mangaCategories.some(c => c.id === completed.id);
if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed];
else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter(c => c.id !== completed.id);
}
}
}
function loadManga(id: number) {
mangaAbort?.abort();
const ctrl = new AbortController();
mangaAbort = ctrl; loadingFor = id;
const cached = mangaStore.get(id);
if (cached) {
manga = cached.data; loadingManga = false;
if (Date.now() - cached.fetchedAt < MANGA_TTL_MS) return;
gql<{ manga: Manga }>(GET_MANGA, { id }, ctrl.signal).then(d => {
if (ctrl.signal.aborted || loadingFor !== id) return;
mangaStore.set(id, { data: d.manga, fetchedAt: Date.now() });
manga = d.manga;
if (d.manga.source?.id) recordSourceAccess(d.manga.source.id);
}).catch(() => {});
return;
}
loadingManga = true;
gql<{ manga: Manga }>(GET_MANGA, { id }, ctrl.signal).then(d => {
if (ctrl.signal.aborted || loadingFor !== id) return;
mangaStore.set(id, { data: d.manga, fetchedAt: Date.now() });
manga = d.manga;
if (d.manga.source?.id) recordSourceAccess(d.manga.source.id);
}).catch(() => {}).finally(() => { if (!ctrl.signal.aborted && loadingFor === id) loadingManga = false; });
}
function loadChapters(id: number) {
chapterAbort?.abort();
const ctrl = new AbortController();
chapterAbort = ctrl;
const cached = chapterStore.get(id);
if (cached) {
applyChapters(cached.data); loadingChapters = false;
if (Date.now() - cached.fetchedAt < CHAPTER_TTL_MS) return;
gql(FETCH_CHAPTERS, { mangaId: id }, ctrl.signal)
.then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, ctrl.signal))
.then(d => {
if (ctrl.signal.aborted || loadingFor !== id) return;
chapterStore.set(id, { data: d.chapters.nodes, fetchedAt: Date.now() });
applyChapters(d.chapters.nodes);
}).catch(() => {});
return;
}
chapters = []; loadingChapters = true;
gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, ctrl.signal).then(d => {
if (ctrl.signal.aborted || loadingFor !== id) return;
applyChapters(d.chapters.nodes); loadingChapters = false;
return gql(FETCH_CHAPTERS, { mangaId: id }, ctrl.signal)
.then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, ctrl.signal))
.then(fresh => {
if (ctrl.signal.aborted || loadingFor !== id) return;
chapterStore.set(id, { data: fresh.chapters.nodes, fetchedAt: Date.now() });
applyChapters(fresh.chapters.nodes);
});
}).catch(() => { if (!ctrl.signal.aborted) loadingChapters = false; });
}
$effect(() => {
const m = store.activeManga;
if (m) untrack(() => { acknowledgeUpdate(m.id); loadManga(m.id); loadChapters(m.id); loadCategories(m.id); });
});
let prevChapterId: number | null = null;
$effect(() => {
const wasOpen = prevChapterId !== null;
prevChapterId = store.activeChapter?.id ?? null;
if (wasOpen && !store.activeChapter && store.activeManga) {
const id = store.activeManga.id;
untrack(() => { loadChapters(id); cache.clear(CACHE_KEYS.LIBRARY); });
}
});
async function toggleLibrary() {
if (!manga) return;
togglingLibrary = true;
const next = !manga.inLibrary;
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
manga = { ...manga, inLibrary: next };
if (mangaStore.has(manga.id)) { const e = mangaStore.get(manga.id)!; mangaStore.set(manga.id, { ...e, data: manga }); }
cache.clear(CACHE_KEYS.LIBRARY);
togglingLibrary = false;
}
async function reloadChapters(id: number) {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id });
chapterStore.set(id, { data: d.chapters.nodes, fetchedAt: Date.now() });
applyChapters(d.chapters.nodes);
}
async function enqueue(ch: Chapter, e: MouseEvent) {
e.stopPropagation();
enqueueing = new Set(enqueueing).add(ch.id);
await gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error);
addToast({ kind: "download", title: "Download queued", body: ch.name });
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing);
if (store.activeManga) reloadChapters(store.activeManga.id);
}
async function enqueueMultiple(chapterIds: number[]) {
if (!chapterIds.length) return;
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
addToast({ kind: "download", title: "Download queued", body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added` });
if (store.activeManga) reloadChapters(store.activeManga.id);
}
async function markRead(chapterId: number, isRead: boolean) {
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
if (isRead) {
if (get("deleteOnRead")) {
const ch = chapters.find(c => c.id === chapterId);
if (ch?.isDownloaded) {
const delayMs = (get("deleteDelayHours") as number) * 60 * 60 * 1000;
if (delayMs === 0) deleteDownloaded(chapterId);
else setTimeout(() => deleteDownloaded(chapterId), delayMs);
}
}
const ahead = get("downloadAhead") as number;
if (ahead > 0) {
const idx = sortedChapters.findIndex(c => c.id === chapterId);
if (idx >= 0) {
const toQueue = sortedChapters.slice(idx + 1, idx + 1 + ahead).filter(c => !c.isDownloaded).map(c => c.id);
if (toQueue.length) enqueueMultiple(toQueue);
}
}
}
}
async function markBulk(ids: number[], isRead: boolean) {
if (!ids.length) return;
await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error);
const idSet = new Set(ids);
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
if (isRead && get("deleteOnRead")) {
const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.isDownloaded);
if (toDelete.length) {
const delayMs = (get("deleteDelayHours") as number) * 60 * 60 * 1000;
const doDelete = async () => {
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: toDelete }).catch(console.error);
chapters = chapters.map(c => toDelete.includes(c.id) ? { ...c, isDownloaded: false } : c);
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
};
if (delayMs === 0) doDelete();
else setTimeout(doDelete, delayMs);
}
}
}
async function deleteSelected() {
const ids = [...selectedIds].filter(id => chapters.find(c => c.id === id)?.isDownloaded);
if (ids.length) {
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
chapters = chapters.map(c => ids.includes(c.id) ? { ...c, isDownloaded: false } : c);
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
}
clearSelection();
}
async function downloadSelected() { await enqueueMultiple([...selectedIds].filter(id => !chapters.find(c => c.id === id)?.isDownloaded)); clearSelection(); }
async function markSelectedRead(isRead: boolean) { await markBulk([...selectedIds], isRead); clearSelection(); }
const markAboveRead = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => !c.isRead).map(c => c.id), true);
const markBelowRead = (i: number) => markBulk(sortedChapters.slice(i).filter(c => !c.isRead).map(c => c.id), true);
const markAboveUnread = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => c.isRead).map(c => c.id), false);
const markBelowUnread = (i: number) => markBulk(sortedChapters.slice(i).filter(c => c.isRead).map(c => c.id), false);
async function deleteDownloaded(chapterId: number) {
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error);
chapters = chapters.map(c => c.id === chapterId ? { ...c, isDownloaded: false } : c);
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
}
async function deleteAllDownloads() {
const ids = chapters.filter(c => c.isDownloaded).map(c => c.id);
if (!ids.length) return;
deletingAll = true;
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
chapters = chapters.map(c => ({ ...c, isDownloaded: false }));
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
deletingAll = false;
}
async function refreshChapters() {
if (!store.activeManga || refreshing) return;
refreshing = true;
chapterStore.delete(store.activeManga.id);
gql(FETCH_CHAPTERS, { mangaId: store.activeManga.id })
.then(() => reloadChapters(store.activeManga!.id))
.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);
}
function buildCtxItems(ch: Chapter, idx: number): MenuEntry[] {
const { CheckCircle, Circle } = { CheckCircle: null as any, Circle: null as any };
const above = sortedChapters.slice(0, idx + 1), below = sortedChapters.slice(idx), last = sortedChapters.length - 1;
return [
{ label: ch.isRead ? "Mark as unread" : "Mark as read", onClick: () => markRead(ch.id, !ch.isRead) },
{ separator: true },
{ label: "Mark above as read", onClick: () => markAboveRead(idx), disabled: above.filter(c => !c.isRead).length === 0 },
{ label: "Mark above as unread", onClick: () => markAboveUnread(idx), disabled: above.filter(c => c.isRead).length === 0 },
{ separator: true },
{ label: "Mark below as read", onClick: () => markBelowRead(idx), disabled: idx === last || below.filter(c => !c.isRead).length === 0 },
{ label: "Mark below as unread", onClick: () => markBelowUnread(idx), disabled: idx === last || below.filter(c => c.isRead).length === 0 },
{ separator: true },
{ label: ch.isDownloaded ? "Delete download" : "Download", danger: ch.isDownloaded, onClick: () => ch.isDownloaded ? deleteDownloaded(ch.id) : gql(ENQUEUE_DOWNLOAD, { chapterId: ch.id }).catch(console.error) },
{ separator: true },
{ label: "Download next 5 from here", onClick: () => enqueueMultiple(sortedChapters.slice(idx, idx + 5).filter(c => !c.isDownloaded).map(c => c.id)) },
{ label: "Download all from here", onClick: () => enqueueMultiple(sortedChapters.slice(idx).filter(c => !c.isDownloaded).map(c => c.id)) },
];
}
function enqueueNext(n: number) {
if (!continueChapter) return;
const idx = sortedChapters.indexOf(continueChapter.chapter);
if (idx < 0) return;
enqueueMultiple(sortedChapters.slice(idx, idx + n).filter(c => !c.isDownloaded).map(c => c.id));
}
function openReaderWithAhead(ch: Chapter, inProgress: boolean) {
const ascList = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
const type = inProgress ? "continue" : undefined;
const resumePage = inProgress ? ch.lastPageRead ?? null : null;
const ahead = get("downloadAhead") as number;
if (ahead > 0) {
const idx = ascList.indexOf(ch);
if (idx >= 0) {
const toQueue = ascList.slice(idx + 1, idx + 1 + ahead).filter(c => !c.isDownloaded).map(c => c.id);
if (toQueue.length) enqueueMultiple(toQueue);
}
}
if (type === "continue" && resumePage && resumePage > 1) {
const existing = store.bookmarks.find(b => b.chapterId === ch.id);
if (!existing || existing.pageNumber < resumePage) {
addBookmark({
mangaId: store.activeManga!.id,
mangaTitle: store.activeManga!.title,
thumbnailUrl: store.activeManga!.thumbnailUrl,
chapterId: ch.id,
chapterName: ch.name,
pageNumber: resumePage,
});
}
}
openReader(ch, ascList);
}
function handleContinue(cc: typeof continueChapter) {
if (!cc) return;
const ascList = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
const ahead = get("downloadAhead") as number;
if (ahead > 0) {
const idx = ascList.indexOf(cc.chapter);
if (idx >= 0) {
const toQueue = ascList.slice(idx + 1, idx + 1 + ahead).filter(c => !c.isDownloaded).map(c => c.id);
if (toQueue.length) enqueueMultiple(toQueue);
}
}
if (cc.type === "continue" && cc.resumePage && cc.resumePage > 1) {
const existing = store.bookmarks.find(b => b.chapterId === cc.chapter.id);
if (!existing || existing.pageNumber < cc.resumePage) {
addBookmark({
mangaId: store.activeManga!.id,
mangaTitle: store.activeManga!.title,
thumbnailUrl: store.activeManga!.thumbnailUrl,
chapterId: cc.chapter.id,
chapterName: cc.chapter.name,
pageNumber: cc.resumePage,
});
}
}
openReader(cc.chapter, ascList);
}
async function openLinkPicker() {
linkPickerOpen = true; linkSearch = "";
if (allMangaForLink.length) return;
loadingLinkList = true;
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
.then(d => { allMangaForLink = d.mangas.nodes; })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
}
function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; }
function handleLink(other: Manga) {
if (!store.activeManga) return;
if (linkedIds.includes(other.id)) unlinkManga(store.activeManga.id, other.id);
else linkManga(store.activeManga.id, other.id);
}
async function toggleCategory(cat: Category) {
if (!store.activeManga) return;
const inCat = mangaCategories.some(c => c.id === cat.id);
try {
await gql(UPDATE_MANGA_CATEGORIES, {
mangaId: store.activeManga.id,
addTo: inCat ? [] : [cat.id],
removeFrom: inCat ? [cat.id] : [],
});
if (!inCat && !manga?.inLibrary) {
await gql(UPDATE_MANGA, { id: store.activeManga.id, inLibrary: true }).catch(console.error);
if (manga) manga = { ...manga, inLibrary: true };
}
mangaCategories = inCat ? mangaCategories.filter(c => c.id !== cat.id) : [...mangaCategories, cat];
} catch (e) { console.error(e); }
}
async function createCategory(name: string) {
if (!name || !store.activeManga) return;
try {
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name });
const cat = res.createCategory.category;
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: store.activeManga.id, addTo: [cat.id], removeFrom: [] });
if (!manga?.inLibrary) {
await gql(UPDATE_MANGA, { id: store.activeManga.id, inLibrary: true }).catch(console.error);
if (manga) manga = { ...manga, inLibrary: true };
}
allCategories = [...allCategories, cat];
mangaCategories = [...mangaCategories, cat];
} catch (e) { console.error(e); }
}
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
</script>
{#if store.activeManga}
<div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}>
<SeriesHeader
{manga}
{loadingManga}
{totalCount}
{readCount}
{progressPct}
{downloadedCount}
{deletingAll}
{continueChapter}
{hasAnyAutomation}
{markersOpen}
{linkedIds}
{allMangaForLink}
{loadingLinkList}
{mangaCategories}
{togglingLibrary}
onRead={handleContinue}
onToggleLibrary={toggleLibrary}
onDeleteAll={deleteAllDownloads}
onMigrateOpen={() => migrateOpen = true}
onTrackingOpen={() => trackingOpen = true}
onAutoOpen={() => autoOpen = true}
onMarkersToggle={() => markersOpen = !markersOpen}
onLinkPickerOpen={openLinkPicker}
/>
<div class="list-wrap">
<SeriesActions
{chapters}
{sortedChapters}
{sortMode}
{sortDir}
{viewMode}
{chapterPage}
{totalPages}
{downloadedCount}
{totalCount}
{deletingAll}
{hasSelection}
selectedCount={selectedIds.size}
{continueChapter}
{availableScanlators}
{scanlatorFilter}
{scanlatorBlacklist}
{scanlatorForce}
{allCategories}
{mangaCategories}
{catsLoading}
{refreshing}
onViewModeToggle={() => viewMode = viewMode === "list" ? "grid" : "list"}
onPageChange={(p) => chapterPage = p}
onDownloadSelected={downloadSelected}
onDeleteSelected={deleteSelected}
onMarkSelectedRead={markSelectedRead}
onClearSelection={clearSelection}
onEnqueueNext={enqueueNext}
onEnqueueMultiple={enqueueMultiple}
onDeleteAll={deleteAllDownloads}
onRefresh={refreshChapters}
onToggleCategory={toggleCategory}
onCreateCategory={createCategory}
onSetScanlatorFilter={(v) => set("scanlatorFilter", v)}
onSetScanlatorBlacklist={(v) => set("scanlatorBlacklist", v)}
onSetScanlatorForce={(v) => set("scanlatorForce", v)}
/>
<ChapterList
{pageChapters}
{sortedChapters}
{viewMode}
{loadingChapters}
{selectedIds}
{enqueueing}
{chapterPage}
{totalPages}
onOpen={openReaderWithAhead}
onToggleSelect={toggleSelect}
onEnqueue={enqueue}
onDeleteDownload={deleteDownloaded}
onPageChange={(p) => chapterPage = p}
{buildCtxItems}
/>
</div>
</div>
{#if migrateOpen && manga}
<MigrateModal
{manga}
currentChapters={chapters}
onClose={() => migrateOpen = false}
onMigrated={(newManga) => { setActiveManga(newManga); migrateOpen = false; cache.clear(CACHE_KEYS.LIBRARY); }}
/>
{/if}
{#if trackingOpen && store.activeManga}
<TrackingPanel mangaId={store.activeManga.id} mangaTitle={store.activeManga.title} onClose={() => trackingOpen = false} />
{/if}
{#if autoOpen && store.activeManga}
<AutomationPanel mangaId={store.activeManga.id} onClose={() => autoOpen = false} />
{/if}
{#if markersOpen && store.activeManga}
<div class="markers-panel-overlay" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) markersOpen = false; }}>
<div class="markers-panel-drawer">
<MarkersPanel mangaId={store.activeManga.id} {chapters} onClose={() => markersOpen = false} />
</div>
</div>
{/if}
{#if linkPickerOpen}
<div class="link-backdrop" role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}>
<div class="link-modal">
<div class="link-header">
<span class="link-title">Link as same series</span>
<button class="link-close" onclick={closeLinkPicker}><X size={14} weight="light" /></button>
</div>
<p class="link-hint">Mark two manga as the same series so duplicates are merged in search. Click a linked entry again to unlink.</p>
<div class="link-search-wrap">
<input class="link-search" placeholder="Search your library…" bind:value={linkSearch} use:focusOnMount />
</div>
<div class="link-list">
{#if loadingLinkList}
<p class="link-empty">Loading…</p>
{:else if linkPickerResults.length === 0}
<p class="link-empty">No results</p>
{:else}
{#each linkPickerResults as m (m.id)}
{@const isLinked = linkedIds.includes(m.id)}
<button class="link-row" class:link-row-linked={isLinked} onclick={() => handleLink(m)}>
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="link-thumb" />
<div class="link-info">
<span class="link-manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
</div>
<span class="link-status">{isLinked ? "✓ Linked" : "Link"}</span>
</button>
{/each}
{/if}
</div>
</div>
</div>
{/if}
{/if}
<style>
/* ─── Root layout ─────────────────────────────────────────── */
.root {
display: flex;
height: 100%;
overflow: hidden;
animation: fadeIn 0.14s ease both;
}
/* ─── List area wrapper ───────────────────────────────────── */
.list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
/* ─── Link picker modal ───────────────────────────────────── */
.link-backdrop {
position: fixed;
inset: 0;
background: rgba(0,0,0,0.72);
z-index: var(--z-settings);
display: flex;
align-items: center;
justify-content: center;
backdrop-filter: blur(4px);
-webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.12s ease both;
}
.link-modal {
width: min(460px, calc(100vw - 48px));
max-height: 70vh;
display: flex;
flex-direction: column;
background: var(--bg-surface);
border: 1px solid var(--border-base);
border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.14s ease both;
}
.link-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.link-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
.link-close {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: var(--radius-sm);
color: var(--text-faint); background: none; border: none; cursor: pointer;
transition: color var(--t-base), background var(--t-base);
}
.link-close:hover { color: var(--text-muted); background: var(--bg-raised); }
.link-hint {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
letter-spacing: var(--tracking-wide); line-height: var(--leading-snug);
padding: var(--sp-3) var(--sp-5) 0; flex-shrink: 0;
}
.link-search-wrap { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.link-search {
width: 100%; background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: 6px 10px; color: var(--text-primary);
font-size: var(--text-sm); outline: none; transition: border-color var(--t-base);
}
.link-search:focus { border-color: var(--border-strong); }
.link-list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
.link-list::-webkit-scrollbar { display: none; }
.link-empty {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
padding: var(--sp-4) var(--sp-3); text-align: center; letter-spacing: var(--tracking-wide);
}
.link-row {
display: flex; align-items: center; gap: var(--sp-3); width: 100%;
padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none;
background: none; text-align: left; cursor: pointer; transition: background var(--t-fast);
}
.link-row:hover { background: var(--bg-raised); }
.link-row-linked { background: var(--accent-muted) !important; }
:global(.link-thumb) { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.link-status {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
color: var(--text-faint); flex-shrink: 0; padding: 2px 8px;
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
}
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
/* ─── Markers panel overlay ───────────────────────────────── */
.markers-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 {
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;
}
/* ─── Animations ──────────────────────────────────────────── */
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
@keyframes drawerIn { from { opacity: 0; transform: translateX(-12px) } to { opacity: 1; transform: translateX(0) } }
</style>
@@ -0,0 +1,313 @@
<script lang="ts">
import {
ArrowLeft, BookmarkSimple, ArrowSquareOut, Play, CaretDown,
Eye, ArrowsClockwise, LinkSimpleHorizontalBreak, ChartLineUp,
MapPin, Gear, Trash, X,
} from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Manga, Chapter, Category } from "@types";
import type { MangaPrefs } from "@store/state.svelte";
import { store, setActiveManga, setGenreFilter, setNavPage, setPreviewManga, linkManga, unlinkManga } from "@store/state.svelte";
interface ContinueChapter {
chapter: Chapter;
type: "start" | "continue" | "reread";
resumePage: number | null;
}
interface Props {
manga: Manga | null;
loadingManga: boolean;
totalCount: number;
readCount: number;
progressPct: number;
downloadedCount: number;
deletingAll: boolean;
continueChapter: ContinueChapter | null;
hasAnyAutomation: boolean;
markersOpen: boolean;
linkedIds: number[];
allMangaForLink: Manga[];
loadingLinkList: boolean;
mangaCategories: Category[];
onRead: (ch: ContinueChapter) => void;
onToggleLibrary: () => void;
onDeleteAll: () => void;
onMigrateOpen: () => void;
onTrackingOpen: () => void;
onAutoOpen: () => void;
onMarkersToggle: () => void;
onLinkPickerOpen: () => void;
togglingLibrary: boolean;
}
let {
manga, loadingManga, totalCount, readCount, progressPct,
downloadedCount, deletingAll, continueChapter, hasAnyAutomation,
markersOpen, linkedIds, allMangaForLink, loadingLinkList,
mangaCategories,
onRead, onToggleLibrary, onDeleteAll, onMigrateOpen,
onTrackingOpen, onAutoOpen, onMarkersToggle, onLinkPickerOpen,
togglingLibrary,
}: Props = $props();
let manageOpen: boolean = $state(false);
let genresExpanded: boolean = $state(false);
const statusLabel = $derived(
manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null
);
const markerCount = $derived(
store.activeManga ? store.getMarkersForManga(store.activeManga.id).length : 0
);
function focusOnMount(node: HTMLElement) { node.focus(); }
</script>
<div class="sidebar">
<button class="back" onclick={() => setActiveManga(null)}>
<ArrowLeft size={13} weight="light" /> Back
</button>
<div class="cover-wrap">
<Thumbnail src={store.activeManga!.thumbnailUrl} alt={store.activeManga!.title} class="cover" />
</div>
{#if loadingManga}
<div class="meta-skeleton">
<div class="skeleton sk-line" style="width:90%;height:14px"></div>
<div class="skeleton sk-line" style="width:60%;height:11px"></div>
</div>
{:else}
<div class="meta">
<p class="title">{manga?.title}</p>
{#if manga?.author || manga?.artist}
<p class="byline">{[manga?.author, manga?.artist].filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}</p>
{/if}
{#if statusLabel}
<span class="status-badge" class:ongoing={manga?.status === "ONGOING"} class:ended={manga?.status !== "ONGOING"}>{statusLabel}</span>
{/if}
{#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>
{/each}
{#if manga.genre.length > 3}
<button class="genre-toggle" onclick={() => genresExpanded = !genresExpanded}>
{genresExpanded ? "less" : `+${manga.genre.length - 3}`}
</button>
{/if}
</div>
{/if}
{#if manga?.description}
<p class="desc">{manga.description}</p>
{/if}
</div>
{/if}
<div class="cta-section">
{#if continueChapter}
<button class="read-btn" onclick={() => onRead(continueChapter!)}>
<Play size={12} weight="fill" />
{continueChapter.type === "reread" ? "Read again"
: continueChapter.type === "start" ? "Start reading"
: `Continue · Ch.${continueChapter.chapter.chapterNumber}${continueChapter.resumePage ? ` p.${continueChapter.resumePage}` : ""}`}
</button>
{/if}
<div class="actions">
<button class="library-btn" class:active={manga?.inLibrary} onclick={onToggleLibrary} disabled={togglingLibrary || loadingManga}>
<BookmarkSimple size={13} weight={manga?.inLibrary ? "fill" : "light"} />
{manga?.inLibrary ? "In Library" : "Add to Library"}
</button>
{#if manga?.realUrl}
<a href={manga.realUrl} target="_blank" rel="noreferrer" class="external-link">
<ArrowSquareOut size={13} weight="light" />
</a>
{/if}
</div>
</div>
{#if totalCount > 0}
<div class="progress-section">
<div class="progress-header">
<span class="progress-label">{readCount} / {totalCount} read</span>
<span class="progress-pct">{Math.round(progressPct)}%</span>
</div>
<div class="progress-track"><div class="progress-fill" style="width:{progressPct}%"></div></div>
</div>
{/if}
{#if !loadingManga && manga}
<div class="details-section">
<button class="details-toggle" onclick={() => manageOpen = !manageOpen}>
<span>Manage</span>
<CaretDown size={11} weight="light" style="transform:{manageOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease" />
</button>
{#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>
<button class="detail-action-btn" class:detail-action-active={linkedIds.length > 0} onclick={onLinkPickerOpen}>
<LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} />
Series Link{linkedIds.length > 0 ? ` (${linkedIds.length})` : ""}
</button>
<button class="detail-action-btn" onclick={onTrackingOpen}>
<ChartLineUp size={12} weight="light" /> Tracking
</button>
<button class="detail-action-btn" class:detail-action-active={markersOpen} onclick={onMarkersToggle}>
<MapPin size={12} weight={markersOpen ? "fill" : "light"} />
Markers{markerCount > 0 ? ` (${markerCount})` : ""}
</button>
{#if manga?.inLibrary}
<button class="detail-action-btn" class:detail-action-active={hasAnyAutomation} onclick={onAutoOpen}>
<Gear size={12} weight={hasAnyAutomation ? "fill" : "light"} /> Automation
</button>
{/if}
{#if downloadedCount > 0}
<button class="detail-action-btn detail-action-danger" onclick={onDeleteAll} disabled={deletingAll}>
<Trash size={12} weight="light" /> {deletingAll ? "Deleting…" : `Delete Downloads (${downloadedCount})`}
</button>
{/if}
</div>
</div>
{/if}
</div>
{/if}
</div>
<style>
.sidebar {
width: 240px;
flex-shrink: 0;
padding: var(--sp-5);
border-right: 1px solid var(--border-dim);
overflow-y: auto;
display: flex;
flex-direction: column;
gap: var(--sp-4);
background: var(--bg-base);
}
.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;
transition: color var(--t-base);
}
.back:hover { color: var(--text-secondary); }
.cover-wrap {
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;
}
:global(.cover) { width: 100%; height: 100%; object-fit: cover; }
.meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
.sk-line { border-radius: var(--radius-sm); }
.meta { display: flex; flex-direction: column; gap: var(--sp-3); }
.title {
font-size: var(--text-base); font-weight: var(--weight-medium);
color: var(--text-primary); line-height: var(--leading-snug);
letter-spacing: var(--tracking-tight);
}
.byline { font-size: var(--text-xs); color: var(--text-muted); font-family: var(--font-ui); }
.status-badge {
display: inline-block; font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
padding: 2px 7px; border-radius: var(--radius-sm); width: fit-content;
}
.status-badge.ongoing { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
.status-badge.ended { background: var(--bg-raised); color: var(--text-faint); border: 1px solid var(--border-dim); }
.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.genre {
font-size: var(--text-2xs); font-family: var(--font-ui); color: var(--text-faint);
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-sm); padding: 1px 6px; letter-spacing: var(--tracking-wide);
cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.genre:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.genre-toggle {
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-sm); padding: 1px 6px; letter-spacing: var(--tracking-wide);
cursor: pointer; transition: color var(--t-base), border-color var(--t-base);
}
.genre-toggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
.desc {
font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base);
display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
}
.cta-section { display: flex; flex-direction: column; gap: var(--sp-2); }
.read-btn {
display: flex; align-items: center; justify-content: center; gap: var(--sp-2);
width: 100%; padding: 9px var(--sp-3); border-radius: var(--radius-md);
background: var(--accent); border: 1px solid var(--accent);
color: var(--accent-contrast, #fff); font-size: var(--text-xs); font-family: var(--font-ui);
letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.read-btn:hover { opacity: 0.88; }
.actions { display: flex; align-items: center; gap: var(--sp-2); }
.library-btn {
display: flex; align-items: center; gap: var(--sp-2);
font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide);
padding: 5px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-strong);
color: var(--text-muted); background: var(--bg-raised);
transition: border-color var(--t-base), color var(--t-base), background var(--t-base); flex: 1;
}
.library-btn:hover { border-color: var(--accent); color: var(--accent-fg); }
.library-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.library-btn:disabled { opacity: 0.4; cursor: default; }
.external-link {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); color: var(--text-faint); flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.external-link:hover { color: var(--text-muted); border-color: var(--border-strong); }
.progress-section { display: flex; flex-direction: column; gap: var(--sp-1); }
.progress-header { display: flex; justify-content: space-between; align-items: center; }
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.progress-pct { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
.progress-track { height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
.details-section { display: flex; flex-direction: column; gap: 2px; }
.details-toggle {
display: flex; align-items: center; justify-content: space-between;
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
letter-spacing: var(--tracking-wide); padding: 4px 0; transition: color var(--t-base);
}
.details-toggle:hover { color: var(--text-muted); }
.details-body { display: flex; flex-direction: column; gap: var(--sp-2); padding-top: var(--sp-2); }
.detail-actions { display: flex; flex-direction: column; gap: var(--sp-1); padding-top: var(--sp-1); }
.detail-action-btn {
display: flex; align-items: center; gap: var(--sp-2); width: 100%;
padding: 6px var(--sp-2); border-radius: var(--radius-md);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
color: var(--text-faint); background: none; border: 1px solid var(--border-dim); cursor: pointer;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.detail-action-btn:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.detail-action-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.detail-action-active:hover { color: var(--accent-fg); border-color: var(--accent); }
.detail-action-danger { color: var(--color-error); }
.detail-action-danger:hover:not(:disabled) { background: var(--color-error-bg); border-color: var(--color-error); color: var(--color-error); }
.detail-action-danger:disabled { opacity: 0.4; cursor: default; }
</style>
+10
View File
@@ -0,0 +1,10 @@
export { default as SeriesDetail } from "./components/SeriesDetail.svelte";
export { default as SeriesHeader } from "./components/SeriesHeader.svelte";
export { default as SeriesActions } from "./components/SeriesActions.svelte";
export { default as ChapterList } from "./components/ChapterList.svelte";
export { default as AutomationPanel } from "./panels/AutomationPanel.svelte";
export { default as MarkersPanel } from "./panels/MarkersPanel.svelte";
export { default as MigrateModal } from "./panels/MigrateModal.svelte";
export { default as TrackingPanel } from "./panels/TrackingPanel.svelte";
export { buildChapterList, chaptersAscending } from "./lib/chapterList";
export type { ChapterDisplayPrefs, ChapterSortMode, ChapterSortDir } from "./lib/chapterList";
+79
View File
@@ -0,0 +1,79 @@
import type { Chapter } from "@types";
export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate";
export type ChapterSortDir = "asc" | "desc";
export interface ChapterDisplayPrefs {
sortMode?: ChapterSortMode;
sortDir?: ChapterSortDir;
preferredScanlator?: string;
scanlatorFilter?: string[];
scanlatorBlacklist?: string[];
scanlatorForce?: boolean;
}
function sortByMode(a: Chapter, b: Chapter, mode: ChapterSortMode): number {
if (mode === "chapterNumber") return a.chapterNumber - b.chapterNumber;
if (mode === "uploadDate") return Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0);
return a.sourceOrder - b.sourceOrder;
}
export function buildChapterList(chapters: Chapter[], prefs: ChapterDisplayPrefs = {}): Chapter[] {
const {
sortMode = "source",
sortDir = "asc",
preferredScanlator = "",
scanlatorFilter = [],
scanlatorBlacklist = [],
scanlatorForce = false,
} = prefs;
let base = [...chapters];
if (scanlatorBlacklist.length > 0) {
base = base.filter(c => !scanlatorBlacklist.includes(c.scanlator ?? ""));
}
base.sort((a, b) => sortByMode(a, b, sortMode));
if (preferredScanlator) {
const pref: Chapter[] = [], rest: Chapter[] = [];
for (const c of base) (c.scanlator === preferredScanlator ? pref : rest).push(c);
base = [...pref, ...rest];
}
if (scanlatorFilter.length > 0) {
const seen = new Map<number, Chapter>();
for (const ch of base) {
const existing = seen.get(ch.chapterNumber);
if (!existing) {
if (!scanlatorForce || scanlatorFilter.includes(ch.scanlator ?? "")) {
seen.set(ch.chapterNumber, ch);
}
} else {
const np = scanlatorFilter.indexOf(ch.scanlator ?? "");
const op = scanlatorFilter.indexOf(existing.scanlator ?? "");
if (np !== -1 && (op === -1 || np < op)) seen.set(ch.chapterNumber, ch);
}
}
base = [...seen.values()].sort((a, b) => sortByMode(a, b, sortMode));
}
return sortDir === "desc" ? base.reverse() : base;
}
export function chaptersAscending(chapters: Chapter[]): Chapter[] {
return [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
}
export function buildReaderChapterList(
chapters: Chapter[],
prefs: Pick<ChapterDisplayPrefs, "preferredScanlator" | "scanlatorFilter"> | undefined,
): Chapter[] {
return buildChapterList(chapters, {
sortMode: "source",
sortDir: "asc",
preferredScanlator: prefs?.preferredScanlator,
scanlatorFilter: prefs?.scanlatorFilter,
});
}
+17
View File
@@ -0,0 +1,17 @@
import { store, updateSettings } from "@store/state.svelte";
import { DEFAULT_MANGA_PREFS } from "@store/state.svelte";
import type { MangaPrefs } from "@store/state.svelte";
export function getPref<K extends keyof MangaPrefs>(mangaId: number, key: K): MangaPrefs[K] {
const prefs = store.settings.mangaPrefs?.[mangaId] ?? {};
return (prefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
}
export function setPref<K extends keyof MangaPrefs>(mangaId: number, key: K, value: MangaPrefs[K]) {
updateSettings({
mangaPrefs: {
...store.settings.mangaPrefs,
[mangaId]: { ...(store.settings.mangaPrefs?.[mangaId] ?? {}), [key]: value },
},
});
}
@@ -0,0 +1,242 @@
<script lang="ts">
import { X } from "phosphor-svelte";
import { getPref, setPref } from "../lib/mangaPrefs";
import type { MangaPrefs } from "@store/state.svelte";
let { mangaId, onClose }: {
mangaId: number;
onClose: () => void;
} = $props();
const DOWNLOAD_AHEAD_OPTIONS = [
{ value: 0, label: "Off" },
{ value: 2, label: "2" },
{ value: 5, label: "5" },
{ value: 10, label: "10" },
];
const MAX_KEEP_OPTIONS = [
{ value: 0, label: "Off" },
{ value: 5, label: "5" },
{ value: 10, label: "10" },
{ value: 25, label: "25" },
];
const DELETE_DELAY_OPTIONS = [
{ value: 0, label: "Now" },
{ value: 24, label: "1 day" },
{ value: 168, label: "1 week" },
];
const REFRESH_INTERVAL_OPTIONS = [
{ value: "global", label: "Default" },
{ value: "daily", label: "Daily" },
{ value: "weekly", label: "Weekly" },
{ value: "manual", label: "Manual" },
];
const get = <K extends keyof MangaPrefs>(key: K) => getPref(mangaId, key);
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => setPref(mangaId, key, value);
function onBackdrop(e: MouseEvent) {
if (e.target === e.currentTarget) onClose();
}
</script>
<div class="backdrop" role="presentation" tabindex="-1" onmousedown={onBackdrop}>
<div class="modal" role="dialog" aria-modal="true" aria-label="Automation">
<div class="modal-header">
<div class="header-left">
<span class="modal-title">Automation</span>
<span class="modal-subtitle">Per-series rules</span>
</div>
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button>
</div>
<div class="modal-body">
<p class="section-label">Downloads</p>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Auto-download new chapters</span>
<span class="auto-desc">Queue new chapters when this series refreshes</span>
</div>
<button
role="switch"
aria-checked={get("autoDownload")}
aria-label="Auto-download new chapters"
class="auto-toggle"
class:auto-toggle-on={get("autoDownload")}
onclick={() => set("autoDownload", !get("autoDownload"))}
><span class="auto-toggle-thumb"></span></button>
</div>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Download ahead</span>
<span class="auto-desc">Pre-fetch chapters while reading</span>
</div>
<div class="auto-chip-group">
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={get("downloadAhead") === opt.value}
onclick={() => set("downloadAhead", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Max chapters to keep</span>
<span class="auto-desc">Delete oldest downloads when limit is exceeded</span>
</div>
<div class="auto-chip-group">
{#each MAX_KEEP_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={get("maxKeepChapters") === opt.value}
onclick={() => set("maxKeepChapters", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
<div class="divider"></div>
<p class="section-label">On Read</p>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Delete after reading</span>
<span class="auto-desc">Remove download when chapter is marked read</span>
</div>
<button
role="switch"
aria-checked={get("deleteOnRead")}
aria-label="Delete after reading"
class="auto-toggle"
class:auto-toggle-on={get("deleteOnRead")}
onclick={() => set("deleteOnRead", !get("deleteOnRead"))}
><span class="auto-toggle-thumb"></span></button>
</div>
{#if get("deleteOnRead")}
<div class="auto-row auto-row-sub">
<span class="auto-label">Delete delay</span>
<div class="auto-chip-group">
{#each DELETE_DELAY_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={get("deleteDelayHours") === opt.value}
onclick={() => set("deleteDelayHours", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
{/if}
<div class="divider"></div>
<p class="section-label">Updates</p>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Pause updates</span>
<span class="auto-desc">Skip this series during global refresh</span>
</div>
<button
role="switch"
aria-checked={get("pauseUpdates")}
aria-label="Pause updates"
class="auto-toggle"
class:auto-toggle-on={get("pauseUpdates")}
onclick={() => set("pauseUpdates", !get("pauseUpdates"))}
><span class="auto-toggle-thumb"></span></button>
</div>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Refresh interval</span>
<span class="auto-desc">How often to check for new chapters</span>
</div>
<div class="auto-chip-group">
{#each REFRESH_INTERVAL_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={get("refreshInterval") === opt.value}
onclick={() => set("refreshInterval", opt.value as MangaPrefs["refreshInterval"])}
>{opt.label}</button>
{/each}
</div>
</div>
</div>
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0; z-index: 300;
background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.1s ease both;
}
.modal {
width: 420px; max-width: calc(100vw - var(--sp-6));
max-height: 80vh;
display: flex; flex-direction: column;
background: var(--bg-surface); border: 1px solid var(--border-base);
border-radius: var(--radius-xl); overflow: hidden;
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.15s ease both;
}
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.header-left { display: flex; flex-direction: column; gap: 2px; }
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; 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; }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.modal-body {
flex: 1; overflow-y: auto; scrollbar-width: none;
display: flex; flex-direction: column; gap: var(--sp-3);
padding: var(--sp-4) var(--sp-5);
}
.modal-body::-webkit-scrollbar { display: none; }
.section-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-widest); color: var(--text-faint);
text-transform: uppercase; margin: 0;
}
.divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
.auto-row { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.auto-row-sub { padding-left: var(--sp-3); border-left: 2px solid var(--border-dim); }
.auto-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.auto-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.auto-desc { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
.auto-toggle { width: 28px; height: 16px; border-radius: var(--radius-full); border: 1px solid var(--border-strong); background: var(--bg-overlay); cursor: pointer; padding: 0; flex-shrink: 0; position: relative; transition: background var(--t-base), border-color var(--t-base); }
.auto-toggle-on { background: var(--accent); border-color: var(--accent); }
.auto-toggle-thumb { position: absolute; top: 1px; left: 1px; width: 12px; height: 12px; border-radius: 50%; background: var(--text-faint); transition: transform var(--t-base), background var(--t-base); }
.auto-toggle-on .auto-toggle-thumb { transform: translateX(12px); background: var(--bg-base); }
.auto-chip-group { display: flex; flex-direction: row; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.auto-chip { 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(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.auto-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.auto-chip-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
@@ -0,0 +1,198 @@
<script lang="ts">
import { X, MapPin, Trash, PencilSimple, Check } from "phosphor-svelte";
import { store, removeMarker, updateMarker, openReader } from "@store/state.svelte";
import type { MarkerEntry, MarkerColor } from "@store/state.svelte";
import type { Chapter } from "@types";
interface Props {
mangaId: number;
chapters: Chapter[];
onClose: () => void;
}
let { mangaId, chapters, onClose }: Props = $props();
const COLOR_HEX: Record<MarkerColor, string> = {
yellow: "#c4a94a",
red: "#c47a7a",
blue: "#7a9ec4",
green: "#7aab7a",
purple: "#a07ac4",
};
const markers = $derived(store.getMarkersForManga(mangaId));
const grouped = $derived.by(() => {
const map = new Map<number, MarkerEntry[]>();
for (const m of markers) {
if (!map.has(m.chapterId)) map.set(m.chapterId, []);
map.get(m.chapterId)!.push(m);
}
const entries = [...map.entries()].map(([chapterId, items]) => ({
chapterId,
chapterName: items[0].chapterName,
items: [...items].sort((a, b) => a.pageNumber - b.pageNumber),
}));
const chapterOrder = new Map(chapters.map((c, i) => [c.id, i]));
entries.sort((a, b) => (chapterOrder.get(a.chapterId) ?? 9999) - (chapterOrder.get(b.chapterId) ?? 9999));
return entries;
});
let editingId: string = $state("");
let editNote: string = $state("");
let editColor: MarkerColor = $state("yellow");
function startEdit(m: MarkerEntry) {
editingId = m.id;
editNote = m.note;
editColor = m.color;
}
function commitEdit() {
if (!editingId) return;
updateMarker(editingId, { note: editNote.trim(), color: editColor });
editingId = "";
}
function jumpToMarker(m: MarkerEntry) {
const chapter = chapters.find(c => c.id === m.chapterId);
if (!chapter) return;
const chaptersAsc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
openReader(chapter, chaptersAsc);
}
function formatDate(ts: number): string {
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
</script>
<div class="panel">
<div class="panel-header">
<div class="panel-title">
<MapPin size={13} weight="fill" />
<span>Markers</span>
{#if markers.length > 0}
<span class="count">{markers.length}</span>
{/if}
</div>
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
</div>
<div class="panel-body">
{#if grouped.length === 0}
<div class="empty">
<MapPin size={22} weight="light" style="color:var(--text-faint);opacity:0.4" />
<p>No markers yet</p>
<p class="empty-sub">Mark pages while reading with the marker button or keybind</p>
</div>
{:else}
{#each grouped as group}
<div class="group">
<div class="group-header">
<span class="group-name">{group.chapterName}</span>
<span class="group-count">{group.items.length}</span>
</div>
{#each group.items as m (m.id)}
<div class="marker-row" class:editing={editingId === m.id}>
<div class="marker-dot" style="background:{COLOR_HEX[m.color]}"></div>
<div class="marker-body">
{#if editingId === m.id}
<div class="edit-wrap">
<div class="color-row">
{#each Object.entries(COLOR_HEX) as [c, hex]}
<button
class="color-swatch"
class:color-active={editColor === c}
style="background:{hex}"
onclick={() => editColor = c as MarkerColor}
title={c}
></button>
{/each}
</div>
<textarea
class="edit-input"
rows={3}
bind:value={editNote}
placeholder="Add a note…"
onkeydown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); commitEdit(); } if (e.key === "Escape") editingId = ""; }}
></textarea>
<div class="edit-actions">
<button class="edit-save" onclick={commitEdit}><Check size={12} weight="bold" /> Save</button>
<button class="edit-cancel" onclick={() => editingId = ""}>Cancel</button>
</div>
</div>
{:else}
<button class="marker-jump" onclick={() => jumpToMarker(m)}>
<span class="page-label">p.{m.pageNumber}</span>
{#if m.note}
<span class="marker-note">{m.note}</span>
{:else}
<span class="marker-note marker-note-empty">No note</span>
{/if}
<span class="marker-date">{formatDate(m.updatedAt ?? m.createdAt)}</span>
</button>
<div class="marker-actions">
<button class="marker-action-btn" onclick={() => startEdit(m)} title="Edit"><PencilSimple size={11} weight="light" /></button>
<button class="marker-action-btn danger" onclick={() => removeMarker(m.id)} title="Delete"><Trash size={11} weight="light" /></button>
</div>
{/if}
</div>
</div>
{/each}
</div>
{/each}
{/if}
</div>
</div>
<style>
.panel { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.panel-header { 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; }
.panel-title { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); }
.count { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-full); font-size: var(--text-2xs); padding: 0 5px; color: var(--text-faint); }
.close-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.panel-body { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: var(--sp-1); }
.empty { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); padding: var(--sp-8) var(--sp-4); text-align: center; }
.empty p { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.empty-sub { font-size: var(--text-2xs) !important; opacity: 0.7; max-width: 180px; line-height: var(--leading-snug); }
.group { display: flex; flex-direction: column; gap: 2px; }
.group-header { display: flex; align-items: center; justify-content: space-between; padding: 6px var(--sp-2) 4px; }
.group-name { 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; }
.group-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.6; flex-shrink: 0; }
.marker-row { display: flex; align-items: flex-start; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); transition: background var(--t-fast); }
.marker-row:hover { background: var(--bg-raised); }
.marker-row.editing { background: var(--bg-raised); }
.marker-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; }
.marker-body { flex: 1; min-width: 0; display: flex; align-items: flex-start; gap: var(--sp-1); }
.marker-jump { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; text-align: left; background: none; border: none; padding: 0; cursor: pointer; }
.page-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
.marker-note { font-size: var(--text-xs); color: var(--text-secondary); line-height: var(--leading-snug); white-space: pre-wrap; word-break: break-word; }
.marker-note-empty { color: var(--text-faint); font-style: italic; }
.marker-date { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.marker-actions { display: flex; flex-direction: column; gap: 2px; flex-shrink: 0; opacity: 0; transition: opacity var(--t-fast); }
.marker-row:hover .marker-actions { opacity: 1; }
.marker-action-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
.marker-action-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.marker-action-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
.edit-wrap { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); }
.color-row { display: flex; gap: 5px; }
.color-swatch { width: 14px; height: 14px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; transition: border-color var(--t-fast), transform var(--t-fast); flex-shrink: 0; }
.color-swatch:hover { transform: scale(1.15); }
.color-active { border-color: var(--text-primary) !important; }
.edit-input { width: 100%; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 6px 8px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; resize: none; font-family: inherit; line-height: var(--leading-snug); transition: border-color var(--t-base); }
.edit-input:focus { border-color: var(--border-focus); }
.edit-actions { display: flex; align-items: center; gap: var(--sp-2); }
.edit-save { display: flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: filter var(--t-fast); }
.edit-save:hover { filter: brightness(1.15); }
.edit-cancel { padding: 4px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
.edit-cancel:hover { color: var(--text-muted); border-color: var(--border-strong); }
</style>
@@ -0,0 +1,524 @@
<script lang="ts">
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte";
import { untrack } from "svelte";
import { gql } from "@api/client";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { GET_SOURCES } from "@api/queries/extensions";
import { UPDATE_MANGA } from "@api/mutations/manga";
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
import { FETCH_CHAPTERS, UPDATE_CHAPTERS_PROGRESS } from "@api/mutations/chapters";
import { store } from "@store/state.svelte";
import type { Manga, Chapter } from "@types";
import type { Source } from "@types";
interface Props {
manga: Manga;
currentChapters: Chapter[];
onClose: () => void;
onMigrated: (newManga: Manga) => void;
}
let { manga, currentChapters, onClose, onMigrated }: Props = $props();
type Step = "source" | "search" | "confirm";
interface Match {
manga: Manga;
chapters: Chapter[];
readCount: number;
similarity: number;
}
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") onClose(); }
function focusOnMount(node: HTMLElement) { node.focus(); }
let step: Step = $state("source");
let sources: Source[] = $state([]);
let loadingSources = $state(true);
let selectedSource: Source | null = $state(null);
let selectedLang: string = $state("all");
let langStripEl: HTMLDivElement | undefined = $state();
const availableLangs = $derived.by(() => {
const langs = Array.from(new Set<string>(sources.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);
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" });
}
}
const visibleSources = $derived.by(() => {
if (selectedLang !== "all") return sources.filter(s => s.lang === selectedLang);
const map = new Map<string, Source>();
for (const s of sources) {
const existing = map.get(s.name);
if (!existing || s.lang < existing.lang) map.set(s.name, s);
}
return Array.from(map.values());
});
let query = $state(untrack(() => manga.title));
let results: { manga: Manga; similarity: number }[] = $state([]);
let searching = $state(false);
let selectedMatch: Match | null = $state(null);
let loadingMatchId: number | null = $state(null);
let migrating = $state(false);
let error: string | null = $state(null);
const readCount = $derived(currentChapters.filter(c => c.isRead).length);
const totalCount = $derived(currentChapters.length);
const chapterDiff = $derived(selectedMatch ? selectedMatch.chapters.length - totalCount : 0);
const STEPS = ["source", "search", "confirm"] as const satisfies Step[];
const stepIdx = $derived(STEPS.indexOf(step));
$effect(() => {
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then(d => {
const filtered = d.sources.nodes.filter(s => s.id !== "0" && s.id !== manga.source?.id);
sources = filtered;
const prefLang = store?.settings?.preferredExtensionLang ?? "";
const langs = new Set(filtered.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);
});
async function searchSource(src: Source, q: string) {
if (!src || !q.trim()) return;
searching = true; results = []; error = null;
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
source: src.id, type: "SEARCH", page: 1, query: q.trim(),
});
results = d.fetchSourceManga.mangas
.map(m => ({ manga: m, similarity: titleSimilarity(manga.title, m.title) }))
.sort((a, b) => b.similarity - a.similarity);
} catch (e: any) {
error = e.message;
} finally {
searching = false;
}
}
function pickSource(src: Source) {
selectedSource = src;
step = "search";
searchSource(src, query);
}
async function selectMatch(m: Manga, similarity: number) {
loadingMatchId = m.id; error = null;
try {
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: m.id });
const chapters = d.fetchChapters.chapters;
const matchReadCount = chapters.filter(c => {
const old = currentChapters.find(o => Math.abs(o.chapterNumber - c.chapterNumber) < 0.01);
return old?.isRead;
}).length;
selectedMatch = { manga: m, chapters, readCount: matchReadCount, similarity };
step = "confirm";
} catch (e: any) {
error = e.message;
} finally {
loadingMatchId = null;
}
}
async function migrate() {
if (!selectedMatch) return;
migrating = true; error = null;
try {
const { manga: newManga, chapters: newChapters } = selectedMatch;
const oldByNum = new Map(currentChapters.map(c => [Math.round(c.chapterNumber * 100), c]));
const toMarkRead: number[] = [];
const toMarkBookmarked: number[] = [];
const progressUpdates: { id: number; lastPageRead: number }[] = [];
for (const nc of newChapters) {
const old = oldByNum.get(Math.round(nc.chapterNumber * 100));
if (!old) continue;
if (old.isRead) toMarkRead.push(nc.id);
if (old.isBookmarked) toMarkBookmarked.push(nc.id);
if ((old.lastPageRead ?? 0) > 0 && !old.isRead)
progressUpdates.push({ id: nc.id, lastPageRead: old.lastPageRead! });
}
if (toMarkRead.length)
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
if (toMarkBookmarked.length)
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true });
for (const { id, lastPageRead } of progressUpdates)
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead });
await gql(UPDATE_MANGA, { id: newManga.id, inLibrary: true });
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false });
onMigrated({ ...newManga, inLibrary: true });
} catch (e: any) {
error = e.message;
migrating = false;
}
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="modal">
<div class="modal-header">
<div class="modal-title">
<span class="modal-title-label">Migrate source</span>
<span class="modal-title-manga">{manga.title}</span>
</div>
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
</div>
<div class="steps">
{#each STEPS as st, i}
<div class="step" class:step-active={step === st} class:step-done={i < stepIdx}>
<span class="step-dot">
{#if i < stepIdx}<Check size={9} weight="bold" />{:else}{i + 1}{/if}
</span>
<span class="step-label">
{st === "source" ? "Pick source" : st === "search" ? (selectedSource ? selectedSource.displayName : "Search") : "Confirm"}
</span>
</div>
{/each}
</div>
<div class="body">
{#if step === "source"}
{#if loadingSources}
<div class="centered">
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
</div>
{:else if sources.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"
class:source-row-active={selectedSource?.id === src.id}
onclick={() => pickSource(src)}>
<Thumbnail src={src.iconUrl} alt={src.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
<div class="source-info">
<span class="source-name">{src.displayName}</span>
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
</div>
<ArrowRight size={13} weight="light" class="source-arrow" />
</button>
{/each}
</div>
{/if}
{:else if step === "search"}
<div class="search-step">
{#if selectedSource}
<div class="search-context">
<Thumbnail src={selectedSource.iconUrl} alt="" class="search-context-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
<span class="search-context-name">{selectedSource.displayName}</span>
<button class="search-context-change" onclick={() => { step = "source"; results = []; }}>Change</button>
</div>
{/if}
<div class="search-row">
<div class="search-bar">
<MagnifyingGlass size={13} weight="light" class="search-icon" />
<input
class="search-input"
bind:value={query}
onkeydown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
placeholder="Search title…"
use:focusOnMount />
</div>
<button class="search-btn"
onclick={() => selectedSource && searchSource(selectedSource, query)}
disabled={searching || !selectedSource}>
{#if searching}
<CircleNotch size={13} weight="light" class="anim-spin" />
{:else}
<MagnifyingGlass size={12} weight="bold" /> Search
{/if}
</button>
</div>
{#if error}<p class="error"><Warning size={13} weight="light" /> {error}</p>{/if}
<div class="results">
{#if searching}
{#each Array(6) as _}
<div class="sk-result">
<div class="skeleton sk-cover"></div>
<div class="sk-meta">
<div class="skeleton sk-title"></div>
<div class="skeleton sk-title" style="width:40%"></div>
</div>
</div>
{/each}
{:else}
{#each results as { manga: m, similarity }, idx}
<button class="result-row"
onclick={() => selectMatch(m, similarity)}
disabled={loadingMatchId !== null}>
<div class="result-cover-wrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="result-cover" />
</div>
<div class="result-info">
<span class="result-title">{m.title}</span>
<div class="result-meta">
{#if idx === 0 && similarity > 0.5}
<span class="best-match-badge"><Sparkle size={9} weight="fill" /> Best match</span>
{/if}
<span class="sim-bar"><span class="sim-fill" style="width:{Math.round(similarity * 100)}%"></span></span>
<span class="sim-label">{Math.round(similarity * 100)}% match</span>
</div>
</div>
{#if loadingMatchId === m.id}
<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint);flex-shrink:0" />
{:else}
<ArrowRight size={13} weight="light" style="color:var(--text-faint);flex-shrink:0;opacity:0.5" />
{/if}
</button>
{/each}
{#if results.length === 0 && !error}
<div class="centered">
<span class="hint">{query ? "No results — try a different title." : "Enter a title to search."}</span>
</div>
{/if}
{/if}
</div>
</div>
{:else if step === "confirm" && selectedMatch}
<div class="confirm-step">
<div class="confirm-row">
<div class="confirm-manga">
<div class="confirm-cover-wrap">
<Thumbnail src={manga.thumbnailUrl} alt={manga.title} class="confirm-cover" />
</div>
<p class="confirm-title">{manga.title}</p>
<p class="confirm-source">{manga.source?.displayName ?? "Unknown"}</p>
<span class="confirm-tag">Current</span>
</div>
<div class="confirm-divider">
<ArrowRight size={16} weight="light" class="confirm-arrow" />
</div>
<div class="confirm-manga">
<div class="confirm-cover-wrap">
<Thumbnail src={selectedMatch.manga.thumbnailUrl} alt={selectedMatch.manga.title} class="confirm-cover" />
</div>
<p class="confirm-title">{selectedMatch.manga.title}</p>
<p class="confirm-source">{selectedSource?.displayName ?? "Unknown"}</p>
<span class="confirm-tag confirm-tag-new">New</span>
</div>
</div>
<div class="confirm-stats">
<div class="stat-row">
<span class="stat-label">Title match</span>
<span class="stat-val"
class:stat-good={selectedMatch.similarity > 0.7}
class:stat-warn={selectedMatch.similarity > 0.4 && selectedMatch.similarity <= 0.7}
class:stat-bad={selectedMatch.similarity <= 0.4}>
{Math.round(selectedMatch.similarity * 100)}%
</span>
</div>
<div class="stat-row">
<span class="stat-label">Chapters on new source</span>
<span class="stat-val" class:stat-warn={chapterDiff < -5}>
{selectedMatch.chapters.length}
{#if chapterDiff !== 0}
<span class="chapter-diff">{chapterDiff > 0 ? `+${chapterDiff}` : chapterDiff} vs current</span>
{/if}
</span>
</div>
<div class="stat-row">
<span class="stat-label">Read progress to carry over</span>
<span class="stat-val">{selectedMatch.readCount} / {readCount} chapters</span>
</div>
</div>
{#if chapterDiff < -5}
<div class="warn-box">
<Warning size={13} weight="light" />
New source has {Math.abs(chapterDiff)} fewer chapters — some content may be missing.
</div>
{/if}
<p class="confirm-note">The current entry will be removed from your library. Downloads are not transferred.</p>
{#if error}<p class="error"><Warning size={13} weight="light" /> {error}</p>{/if}
<div class="confirm-actions">
<button class="back-btn" onclick={() => step = "search"} disabled={migrating}>Back</button>
<button class="migrate-btn" onclick={migrate} disabled={migrating}>
{#if migrating}
<CircleNotch size={13} weight="light" class="anim-spin" /> Migrating…
{:else}
<Check size={13} weight="bold" /> Migrate
{/if}
</button>
</div>
</div>
{/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: 520px; max-height: 80vh; 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; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.modal-title { display: flex; flex-direction: column; gap: 2px; }
.modal-title-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.modal-title-manga { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
.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; }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.steps { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-3) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.step { display: flex; align-items: center; gap: var(--sp-2); opacity: 0.4; transition: opacity var(--t-base); }
.step + .step::before { content: ""; color: var(--text-faint); margin-right: var(--sp-1); font-size: var(--text-sm); }
.step-active { opacity: 1; }
.step-done { opacity: 0.6; }
.step-dot { width: 18px; height: 18px; border-radius: 50%; background: var(--bg-raised); border: 1px solid var(--border-base); display: flex; align-items: center; justify-content: center; font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); flex-shrink: 0; }
.step-active .step-dot { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.step-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); }
.step-active .step-label { color: var(--text-secondary); }
.body { flex: 1; overflow: hidden; display: flex; flex-direction: column; }
.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); }
.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: 9px 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-row-active { background: var(--accent-muted); border-color: var(--accent-dim); }
:global(.source-icon) { width: 28px; height: 28px; border-radius: var(--radius-md); object-fit: cover; flex-shrink: 0; background: var(--bg-raised); }
.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); }
.source-row:hover :global(.source-arrow) { opacity: 1; }
.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), border-color var(--t-base), background var(--t-base); }
.src-lang-nav:hover { color: var(--text-muted); border-color: var(--border-strong); 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; scroll-behavior: smooth; }
.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); border-color: var(--border-strong); background: var(--bg-raised); }
.src-lang-chip-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.search-step { flex: 1; overflow: hidden; display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); }
.search-context { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); flex-shrink: 0; }
:global(.search-context-icon) { width: 18px; height: 18px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; }
.search-context-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.search-context-change { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; transition: opacity var(--t-base); }
.search-context-change:hover { opacity: 0.75; }
.search-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.search-bar { flex: 1; display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 0 var(--sp-3) 0 var(--sp-2); transition: border-color var(--t-base); }
.search-bar:focus-within { border-color: var(--border-strong); }
:global(.search-icon) { color: var(--text-faint); flex-shrink: 0; }
.search-input { flex: 1; background: none; border: none; outline: none; color: var(--text-primary); font-size: var(--text-sm); padding: 7px 0; }
.search-input::placeholder { color: var(--text-faint); }
.search-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 12px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; display: flex; align-items: center; gap: var(--sp-1); transition: filter var(--t-base); }
.search-btn:hover:not(:disabled) { filter: brightness(1.1); }
.search-btn:disabled { opacity: 0.4; cursor: default; }
.results { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 1px; }
.result-row { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); border-radius: var(--radius-md); border: none; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast); }
.result-row:hover:not(:disabled) { background: var(--bg-raised); }
.result-row:disabled { opacity: 0.5; cursor: default; }
.result-cover-wrap { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
:global(.result-cover) { width: 100%; height: 100%; object-fit: cover; }
.result-info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; }
.result-title { font-size: var(--text-sm); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.result-meta { display: flex; align-items: center; gap: var(--sp-2); }
.best-match-badge { display: inline-flex; align-items: center; gap: 3px; font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm); flex-shrink: 0; }
.sim-bar { width: 48px; height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; flex-shrink: 0; display: inline-block; }
.sim-fill { display: block; height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.2s ease; }
.sim-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
.sk-result { display: flex; align-items: center; gap: var(--sp-3); padding: 7px var(--sp-2); }
.sk-cover { width: 36px; height: 54px; border-radius: var(--radius-sm); flex-shrink: 0; }
.sk-meta { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); }
.sk-title { height: 13px; width: 65%; border-radius: var(--radius-sm); }
.confirm-step { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); padding: var(--sp-4) var(--sp-5); }
.confirm-row { display: flex; align-items: center; justify-content: center; gap: var(--sp-4); }
.confirm-manga { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); flex: 1; max-width: 160px; }
.confirm-cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); }
:global(.confirm-cover) { width: 100%; height: 100%; object-fit: cover; }
.confirm-title { font-size: var(--text-xs); color: var(--text-secondary); text-align: center; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: var(--leading-snug); }
.confirm-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
.confirm-divider { display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
:global(.confirm-arrow) { color: var(--text-faint); }
.confirm-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-full); background: var(--bg-raised); border: 1px solid var(--border-dim); color: var(--text-faint); }
.confirm-tag-new { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.confirm-stats { display: flex; flex-direction: column; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: var(--sp-3) var(--sp-4); }
.stat-row { display: flex; justify-content: space-between; align-items: center; }
.stat-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.stat-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); }
.stat-good { color: var(--color-success) !important; }
.stat-warn { color: #d97706 !important; }
.stat-bad { color: var(--color-error) !important; }
.chapter-diff { font-family: var(--font-ui); font-size: var(--text-2xs); color: #d97706; letter-spacing: var(--tracking-wide); margin-left: var(--sp-2); }
.warn-box { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: rgba(217,119,6,0.08); border: 1px solid rgba(217,119,6,0.25); border-radius: var(--radius-md); font-size: var(--text-xs); color: #d97706; line-height: var(--leading-snug); }
.confirm-note { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-base); }
.confirm-actions { display: flex; justify-content: flex-end; gap: var(--sp-2); 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; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.back-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
.back-btn:disabled { opacity: 0.4; cursor: default; }
.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.5; cursor: default; }
.error { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-xs); color: var(--color-error); padding: var(--sp-2) var(--sp-3); background: rgba(180,60,60,0.08); border-radius: var(--radius-md); border: 1px solid rgba(180,60,60,0.2); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
@@ -0,0 +1,539 @@
<script lang="ts">
import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise } from "phosphor-svelte";
import { gql } from "@api/client";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { GET_TRACKERS, GET_MANGA_TRACK_RECORDS, SEARCH_TRACKER } from "@api/queries/tracking";
import { BIND_TRACK, UPDATE_TRACK, UNBIND_TRACK, FETCH_TRACK } from "@api/mutations/tracking";
import { addToast } from "@store/state.svelte";
import type { Tracker, TrackRecord, TrackSearch } from "@types";
let { mangaId, mangaTitle, onClose }: {
mangaId: number;
mangaTitle: string;
onClose: () => void;
} = $props();
type TabId = "records" | number;
let trackers: Tracker[] = $state([]);
let records: TrackRecord[] = $state([]);
let loading: boolean = $state(true);
let activeTab: TabId = $state("records");
let searchQuery: string = $state("");
let searchResults: TrackSearch[] = $state([]);
let searching: boolean = $state(false);
let searchInited: Set<number> = $state(new Set());
let binding: boolean = $state(false);
let updatingRecord: number | null = $state(null);
let syncing: number | null = $state(null);
let editingChapter: number | null = $state(null);
let chapterDraft: number = $state(0);
function autoFocus(node: HTMLElement) { setTimeout(() => node.focus(), 50); }
async function load() {
loading = true;
try {
const [tRes, rRes] = await Promise.all([
gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS),
gql<{ manga: { trackRecords: { nodes: TrackRecord[] } } }>(GET_MANGA_TRACK_RECORDS, { mangaId }),
]);
trackers = tRes.trackers.nodes;
records = rRes.manga.trackRecords.nodes;
} catch (e: any) {
addToast({ kind: "error", title: "Failed to load tracking", body: e?.message });
} finally {
loading = false;
}
}
$effect(() => { load(); });
$effect(() => {
const tab = activeTab;
if (typeof tab !== "number") return;
if (searchInited.has(tab)) return;
searchQuery = mangaTitle;
searchInited = new Set([...searchInited, tab]);
doSearch(tab, mangaTitle);
});
function trackerFor(id: number) { return trackers.find(t => t.id === id); }
function recordFor(trackerId: number) { return records.find(r => r.trackerId === trackerId); }
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
let searchTimer: ReturnType<typeof setTimeout>;
function onSearchInput() {
clearTimeout(searchTimer);
if (typeof activeTab !== "number") return;
const tid = activeTab;
if (!searchQuery.trim()) { searchResults = []; return; }
searchTimer = setTimeout(() => doSearch(tid, searchQuery), 400);
}
async function doSearch(trackerId: number, query: string) {
if (!query.trim()) return;
searching = true; searchResults = [];
try {
const res = await gql<{ searchTracker: { trackSearches: TrackSearch[] } }>(
SEARCH_TRACKER, { trackerId, query: query.trim() }
);
searchResults = res.searchTracker.trackSearches;
} catch (e: any) {
addToast({ kind: "error", title: "Search failed", body: e?.message });
} finally {
searching = false;
}
}
async function bind(result: TrackSearch) {
if (typeof activeTab !== "number") return;
binding = true;
try {
const res = await gql<{ bindTrack: { trackRecord: TrackRecord } }>(
BIND_TRACK, { mangaId, trackerId: activeTab, remoteId: result.remoteId }
);
records = [...records.filter(r => r.trackerId !== activeTab), res.bindTrack.trackRecord];
activeTab = "records";
addToast({ kind: "success", title: "Now tracking", body: result.title });
} catch (e: any) {
addToast({ kind: "error", title: "Failed to bind", body: e?.message });
} finally {
binding = false;
}
}
async function unbind(record: TrackRecord) {
updatingRecord = record.id;
try {
await gql(UNBIND_TRACK, { recordId: record.id });
records = records.filter(r => r.id !== record.id);
addToast({ kind: "info", title: "Unlinked from " + trackerFor(record.trackerId)?.name });
} catch (e: any) {
addToast({ kind: "error", title: "Failed to unlink", body: e?.message });
} finally {
updatingRecord = null;
}
}
function patchRecord(updated: Partial<TrackRecord> & { id: number }) {
records = records.map(r => r.id === updated.id ? { ...r, ...updated } : r);
}
async function updateStatus(record: TrackRecord, status: number) {
updatingRecord = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, status });
patchRecord(res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally {
updatingRecord = null;
}
}
async function updateScore(record: TrackRecord, scoreString: string) {
updatingRecord = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, scoreString });
patchRecord(res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally {
updatingRecord = null;
}
}
async function togglePrivate(record: TrackRecord) {
updatingRecord = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, private: !record.private });
patchRecord(res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally {
updatingRecord = null;
}
}
async function syncRecord(record: TrackRecord) {
syncing = record.id;
try {
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(FETCH_TRACK, { recordId: record.id });
patchRecord(res.fetchTrack.trackRecord);
addToast({ kind: "success", title: "Synced from tracker" });
} catch (e: any) {
addToast({ kind: "error", title: "Sync failed", body: e?.message });
} finally {
syncing = null;
}
}
function openChapterEditor(record: TrackRecord) {
editingChapter = record.id;
chapterDraft = record.lastChapterRead;
}
function cancelChapterEditor() { editingChapter = null; }
async function submitChapter(record: TrackRecord) {
const val = Math.max(0, chapterDraft);
editingChapter = null;
if (val === record.lastChapterRead) return;
updatingRecord = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, lastChapterRead: val });
patchRecord(res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally {
updatingRecord = null;
}
}
</script>
<svelte:window onkeydown={(e) => e.key === "Escape" && onClose()} />
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="modal" role="dialog" aria-label="Tracking">
<div class="modal-header">
<div class="header-left">
<span class="modal-title">Tracking</span>
<span class="modal-subtitle">{mangaTitle}</span>
</div>
<button class="close-btn" onclick={onClose}><X size={15} weight="light" /></button>
</div>
{#if loading}
<div class="state-body">
<CircleNotch size={20} weight="light" class="anim-spin" style="color:var(--text-faint)" />
<span class="state-label">Loading…</span>
</div>
{:else if loggedInTrackers.length === 0}
<div class="state-body">
<p class="state-text">No trackers connected.</p>
<p class="state-hint">Go to Settings → Tracking to log in.</p>
</div>
{:else}
<div class="tabs">
<button class="tab" class:tab-active={activeTab === "records"} onclick={() => activeTab = "records"}>
My List
{#if records.length > 0}<span class="tab-badge">{records.length}</span>{/if}
</button>
{#each loggedInTrackers as t}
{@const rec = recordFor(t.id)}
<button class="tab" class:tab-active={activeTab === t.id} onclick={() => { activeTab = t.id; searchResults = []; }}>
<Thumbnail src={t.icon} alt={t.name} class="tab-icon" />
{t.name}
{#if rec}<span class="tab-dot"></span>{/if}
</button>
{/each}
</div>
{#if activeTab === "records"}
<div class="tab-body">
{#if records.length === 0}
<div class="state-body">
<p class="state-text">Not tracking this manga yet.</p>
<p class="state-hint">Click a tracker tab above to search and add it.</p>
</div>
{:else}
{#each records as record (record.id)}
{@const tracker = trackerFor(record.trackerId)}
{@const isBusy = updatingRecord === record.id}
<div class="record-card" class:record-busy={isBusy}>
<div class="record-head">
<div class="record-source">
{#if tracker}<Thumbnail src={tracker.icon} alt={tracker.name} class="record-tracker-icon" />{/if}
<span class="record-source-name">{tracker?.name ?? "Tracker"}</span>
</div>
<div class="record-head-actions">
{#if tracker?.supportsPrivateTracking}
<button
class="record-icon-btn"
class:icon-active={record.private}
title={record.private ? "Private — click to make public" : "Public"}
disabled={isBusy}
onclick={() => togglePrivate(record)}
>
{#if record.private}<Lock size={11} weight="fill" />{:else}<LockOpen size={11} weight="light" />{/if}
</button>
{/if}
<button class="record-icon-btn" title="Sync from tracker" disabled={syncing === record.id} onclick={() => syncRecord(record)}>
<ArrowsClockwise size={11} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
</button>
<button class="record-icon-btn icon-danger" title="Unlink" disabled={isBusy} onclick={() => unbind(record)}>
<X size={11} weight="bold" />
</button>
</div>
</div>
{#if record.remoteUrl}
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="record-title">
{record.title} <ArrowSquareOut size={10} weight="light" />
</a>
{:else}
<span class="record-title-plain">{record.title}</span>
{/if}
<div class="record-selects">
<select class="record-select record-select-status" value={record.status} disabled={isBusy}
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}>
{#each (tracker?.statuses ?? []) as s}
<option value={s.value}>{s.name}</option>
{/each}
</select>
<select class="record-select record-select-score" value={record.displayScore} disabled={isBusy}
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}>
{#each (tracker?.scores ?? []) as s}
<option value={s}> {s}</option>
{/each}
</select>
</div>
{#if editingChapter === record.id}
<div class="chapter-editor">
<div class="chapter-editor-top">
<span class="chapter-editor-label">Chapter read</span>
<div class="chapter-input-wrap">
<input
type="number" class="chapter-input"
min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
step="0.5" bind:value={chapterDraft}
onkeydown={(e) => { if (e.key === "Enter") submitChapter(record); if (e.key === "Escape") cancelChapterEditor(); }}
use:autoFocus
/>
{#if record.totalChapters > 0}
<span class="chapter-total">/ {record.totalChapters}</span>
{/if}
</div>
</div>
{#if record.totalChapters > 0}
<input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
{/if}
<div class="chapter-editor-actions">
<button class="chapter-cancel-btn" onclick={cancelChapterEditor}>Cancel</button>
<button class="chapter-save-btn" onclick={() => submitChapter(record)}>Save</button>
</div>
</div>
{:else}
<div class="record-progress clickable" role="button" tabindex="0"
onclick={() => openChapterEditor(record)}
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
title="Click to edit"
>
<div class="record-progress-header">
<span class="record-progress-label">
{#if record.totalChapters > 0}
Ch. {record.lastChapterRead} / {record.totalChapters}
{:else if record.lastChapterRead > 0}
Ch. {record.lastChapterRead} read
{:else}
Set chapter…
{/if}
</span>
<span class="edit-hint">Edit</span>
</div>
{#if record.totalChapters > 0}
<div class="record-progress-track">
<div class="record-progress-fill" style="width:{Math.min(100,(record.lastChapterRead/record.totalChapters)*100)}%"></div>
</div>
{/if}
</div>
{/if}
</div>
{/each}
{/if}
</div>
{:else}
{@const tracker = trackerFor(activeTab as number)}
{@const boundRecord = recordFor(activeTab as number)}
<div class="search-bar">
<MagnifyingGlass size={13} weight="light" class="search-icon" />
<input
class="search-input"
placeholder="Search {tracker?.name}…"
bind:value={searchQuery}
oninput={onSearchInput}
onkeydown={(e) => e.key === "Enter" && doSearch(activeTab as number, searchQuery)}
use:autoFocus
/>
{#if searching}<CircleNotch size={13} weight="light" class="anim-spin search-icon" />{/if}
</div>
<div class="search-results">
{#if searching && searchResults.length === 0}
<div class="state-body"><p class="state-hint">Searching…</p></div>
{:else if !searching && searchQuery.trim() && searchResults.length === 0}
<div class="state-body"><p class="state-text">No results for "{searchQuery}"</p></div>
{:else if !searchQuery.trim()}
<div class="state-body"><p class="state-hint">Type a title to search</p></div>
{:else}
{#each searchResults as result (result.trackerId + ":" + result.remoteId)}
{@const isBound = boundRecord?.remoteId === result.remoteId}
<button
class="result-row"
class:result-bound={isBound}
onclick={() => isBound ? unbind(boundRecord!) : bind(result)}
disabled={binding}
>
{#if result.coverUrl}
<img src={result.coverUrl} alt={result.title} class="result-cover" loading="lazy"
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
{:else}
<div class="result-cover result-cover-empty"></div>
{/if}
<div class="result-info">
<span class="result-title">{result.title}</span>
<div class="result-meta">
{#if result.publishingType}<span class="result-tag">{result.publishingType}</span>{/if}
{#if result.publishingStatus}<span class="result-tag">{result.publishingStatus}</span>{/if}
{#if result.totalChapters > 0}<span class="result-tag">{result.totalChapters} ch</span>{/if}
</div>
{#if result.summary}
<p class="result-summary">{result.summary.slice(0,140)}{result.summary.length > 140 ? "…" : ""}</p>
{/if}
</div>
<span class="result-action" class:result-action-on={isBound}>
{isBound ? "✓ Tracking" : "Track"}
</span>
</button>
{/each}
{/if}
</div>
{/if}
{/if}
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,0.72);
z-index: var(--z-settings);
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.12s ease both;
}
.modal {
width: min(560px, calc(100vw - 48px));
max-height: min(660px, calc(100vh - 80px));
display: flex; flex-direction: column;
background: var(--bg-surface); border: 1px solid var(--border-base);
border-radius: var(--radius-xl); overflow: hidden;
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.15s ease both;
}
.modal-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.header-left { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.modal-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.close-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; 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; }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.state-body { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-10) var(--sp-5); flex: 1; }
.state-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.state-text { font-size: var(--text-sm); color: var(--text-muted); }
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
.tabs { display: flex; align-items: center; gap: 1px; padding: 0 var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; overflow-x: auto; scrollbar-width: none; }
.tabs::-webkit-scrollbar { display: none; }
.tab { display: flex; align-items: center; gap: var(--sp-2); position: relative; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 10px 10px 9px; color: var(--text-faint); background: none; border: none; border-bottom: 2px solid transparent; cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base); margin-bottom: -1px; }
.tab:hover { color: var(--text-muted); }
.tab-active { color: var(--text-secondary); border-bottom-color: var(--accent); }
:global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; }
.tab-badge { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; }
.tab-active .tab-badge { background: var(--accent-muted); color: var(--accent-fg); }
.tab-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
.tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3); scrollbar-width: none; display: flex; flex-direction: column; gap: var(--sp-2); }
.tab-body::-webkit-scrollbar { display: none; }
.record-card { display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-4); border-radius: var(--radius-lg); border: 1px solid var(--border-dim); background: var(--bg-raised); transition: opacity var(--t-base), border-color var(--t-base); }
.record-card:hover { border-color: var(--border-strong); }
.record-busy { opacity: 0.4; pointer-events: none; }
.record-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
.record-source { display: flex; align-items: center; gap: var(--sp-2); }
:global(.record-tracker-icon) { width: 14px; height: 14px; border-radius: 2px; flex-shrink: 0; object-fit: contain; opacity: 0.75; }
.record-source-name { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.record-head-actions { display: flex; align-items: center; gap: 2px; }
.record-title { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); text-decoration: none; line-height: var(--leading-snug); transition: color var(--t-base); }
.record-title:hover { color: var(--accent-fg); }
.record-title-plain { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); line-height: var(--leading-snug); }
.record-selects { display: flex; gap: var(--sp-2); flex-wrap: wrap; }
.record-select { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 5px 24px 5px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-surface); color: var(--text-muted); outline: none; cursor: pointer; flex: 1; min-width: 0; appearance: none; -webkit-appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 8px center; transition: border-color var(--t-base), color var(--t-base); }
.record-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
.record-select:focus { border-color: var(--accent-dim); color: var(--text-secondary); }
.record-select:disabled { opacity: 0.35; cursor: default; }
.record-select option { background: var(--bg-surface); color: var(--text-secondary); }
.record-select-score { flex: 0 0 auto; min-width: 80px; }
.record-select-status { flex: 1; }
.record-icon-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.record-icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-overlay); }
.record-icon-btn.icon-active { color: var(--accent-fg); }
.record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
.record-icon-btn:disabled { opacity: 0.3; cursor: default; }
.record-progress { display: flex; flex-direction: column; gap: 6px; }
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 4px 6px; margin: -4px -6px; transition: background var(--t-fast); }
.record-progress.clickable:hover { background: var(--bg-overlay); }
.record-progress-header { display: flex; align-items: center; justify-content: space-between; }
.record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.edit-hint { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); opacity: 0; transition: opacity var(--t-fast); }
.record-progress.clickable:hover .edit-hint { opacity: 0.6; }
.record-progress-track { height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; }
.record-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-surface); }
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); }
.chapter-input { width: 64px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; }
.chapter-input:focus { border-color: var(--accent); }
.chapter-total { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
.chapter-editor-actions { display: flex; gap: var(--sp-2); justify-content: flex-end; }
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
.chapter-save-btn:hover { filter: brightness(1.15); }
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); }
.chapter-cancel-btn:hover { color: var(--text-muted); }
.search-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; background: var(--bg-surface); }
:global(.search-icon) { color: var(--text-faint); flex-shrink: 0; }
.search-input { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); }
.search-input::placeholder { color: var(--text-faint); }
.search-results { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
.search-results::-webkit-scrollbar { display: none; }
.result-row { display: flex; align-items: flex-start; gap: var(--sp-3); width: 100%; padding: var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
.result-row:hover:not(:disabled) { background: var(--bg-raised); }
.result-row:disabled { opacity: 0.4; cursor: default; }
.result-bound { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
.result-cover { width: 44px; height: 62px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); flex-shrink: 0; }
.result-cover-empty { background: var(--bg-raised); }
.result-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); padding-top: 2px; }
.result-title { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); text-align: left; }
.result-meta { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.result-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 1px 5px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); }
.result-summary { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-align: left; }
.result-action { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); flex-shrink: 0; align-self: center; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.result-row:hover:not(:disabled) .result-action { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,173 @@
<script lang="ts">
import { tick } from "svelte";
import { X, Book, Image, Sliders, Info, Keyboard, Gear, HardDrives, FolderSimple, Wrench, PaintBrush, ListChecks, Lock, ShieldCheck } from "phosphor-svelte";
import { store, setSettingsOpen, updateSettings } from "@store/state.svelte";
import { eventToKeybind } from "@core/keybinds/keybindEngine";
import type { Keybinds } from "@types/settings";
import "./Settings.css";
import GeneralSettings from "../sections/GeneralSettings.svelte";
import AppearanceSettings from "../sections/AppearanceSettings.svelte";
import ReaderSettings from "../sections/ReaderSettings.svelte";
import LibrarySettings from "../sections/LibrarySettings.svelte";
import PerformanceSettings from "../sections/PerformanceSettings.svelte";
import KeybindsSettings from "../sections/KeybindsSettings.svelte";
import StorageSettings from "../sections/StorageSettings.svelte";
import FoldersSettings from "../sections/FoldersSettings.svelte";
import TrackingSettings from "../sections/TrackingSettings.svelte";
import SecuritySettings from "../sections/SecuritySettings.svelte";
import ContentSettings from "../sections/ContentSettings.svelte";
import AboutSettings from "../sections/AboutSettings.svelte";
import DevtoolsSettings from "../sections/DevtoolsSettings.svelte";
interface Props { onOpenThemeEditor?: (id?: string | null) => void; }
let { onOpenThemeEditor }: Props = $props();
type Tab = "general"|"appearance"|"reader"|"library"|"performance"|"keybinds"|"storage"|"folders"|"tracking"|"security"|"content"|"about"|"devtools";
const TABS: { id: Tab; label: string; icon: any }[] = [
{ id: "general", label: "General", icon: Gear },
{ id: "appearance", label: "Appearance", icon: PaintBrush },
{ id: "reader", label: "Reader", icon: Book },
{ id: "library", label: "Library", icon: Image },
{ id: "performance", label: "Performance", icon: Sliders },
{ id: "keybinds", label: "Keybinds", icon: Keyboard },
{ id: "storage", label: "Storage", icon: HardDrives },
{ id: "folders", label: "Folders", icon: FolderSimple },
{ id: "tracking", label: "Tracking", icon: ListChecks },
{ id: "security", label: "Security", icon: Lock },
{ id: "content", label: "Content", icon: ShieldCheck },
{ id: "about", label: "About", icon: Info },
{ id: "devtools", label: "Dev Tools", icon: Wrench },
];
const anims = $derived(store.settings.qolAnimations ?? true);
let tab: Tab = $state("general");
let prevTabIndex = $state(0);
let tabSlideDir = $state<"up"|"down">("down");
let tabIconKey = $state(0);
let contentBodyEl: HTMLDivElement;
$effect(() => { tab; tick().then(() => contentBodyEl?.scrollTo({ top: 0 })); });
function setTab(id: Tab) {
if (anims) {
const next = TABS.findIndex(t => t.id === id);
tabSlideDir = next > prevTabIndex ? "down" : "up";
prevTabIndex = next;
tabIconKey++;
}
tab = id;
}
function close() { setSettingsOpen(false); }
// Keybind capture
let listeningKey: keyof Keybinds | null = $state(null);
$effect(() => {
const onKey = (e: KeyboardEvent) => { if (e.key === "Escape" && !listeningKey) close(); };
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
$effect(() => {
if (!listeningKey) return;
const capture = (e: KeyboardEvent) => {
e.preventDefault(); e.stopPropagation();
const bind = eventToKeybind(e);
if (!bind) return;
updateSettings({ keybinds: { ...store.settings.keybinds, [listeningKey!]: bind } });
listeningKey = null;
};
window.addEventListener("keydown", capture, true);
return () => window.removeEventListener("keydown", capture, true);
});
// Shared select dropdown state (passed to sections that need it)
let selectOpen: string | null = $state(null);
function toggleSelect(id: string) { selectOpen = selectOpen === id ? null : id; }
$effect(() => {
const handler = (e: MouseEvent) => {
if (selectOpen && !(e.target as HTMLElement).closest(".s-select")) selectOpen = null;
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
});
</script>
<div class="s-backdrop" role="presentation" tabindex="-1"
onclick={(e) => { if (e.target === e.currentTarget) close(); }}
onkeydown={(e) => { if (e.key === "Escape") close(); }}>
<div class="s-modal" role="dialog" aria-label="Settings">
<div class="s-sidebar">
<p class="s-sidebar-title">Settings</p>
<nav>
{#each TABS as t}
<button class="s-nav-item" class:active={tab === t.id} class:anims onclick={() => setTab(t.id)}>
<span class="s-nav-icon"
class:slide-down={anims && tab === t.id && tabSlideDir === "down"}
class:slide-up={anims && tab === t.id && tabSlideDir === "up"}>
{#key anims && tab === t.id ? tabIconKey : 0}
<t.icon size={14} weight={tab === t.id ? "regular" : "light"} />
{/key}
</span>
<span>{t.label}</span>
</button>
{/each}
</nav>
</div>
<div class="s-content">
<div class="s-content-header">
<div class="s-content-header-left">
<span class="s-header-icon"
class:slide-down={anims && tabSlideDir === "down"}
class:slide-up={anims && tabSlideDir === "up"}>
{#key tabIconKey}
{#each TABS as t}
{#if t.id === tab}
<t.icon size={13} weight="light" />
{/if}
{/each}
{/key}
</span>
<p class="s-content-title">{TABS.find(t => t.id === tab)?.label}</p>
</div>
<button class="s-close-btn" aria-label="Close settings" onclick={close}><X size={15} weight="light" /></button>
</div>
<div class="s-content-body" bind:this={contentBodyEl}>
{#if tab === "general"}
<GeneralSettings {selectOpen} {toggleSelect} />
{:else if tab === "appearance"}
<AppearanceSettings {onOpenThemeEditor} />
{:else if tab === "reader"}
<ReaderSettings {selectOpen} {toggleSelect} />
{:else if tab === "library"}
<LibrarySettings {selectOpen} {toggleSelect} />
{:else if tab === "performance"}
<PerformanceSettings />
{:else if tab === "keybinds"}
<KeybindsSettings bind:listeningKey />
{:else if tab === "storage"}
<StorageSettings {selectOpen} {toggleSelect} />
{:else if tab === "folders"}
<FoldersSettings />
{:else if tab === "tracking"}
<TrackingSettings />
{:else if tab === "security"}
<SecuritySettings {selectOpen} {toggleSelect} />
{:else if tab === "content"}
<ContentSettings />
{:else if tab === "about"}
<AboutSettings />
{:else if tab === "devtools"}
<DevtoolsSettings />
{/if}
</div>
</div>
</div>
</div>
@@ -0,0 +1,501 @@
<script lang="ts">
import { X, FloppyDisk, UploadSimple, DownloadSimple, ArrowLeft, Trash } from "phosphor-svelte";
import {
store, updateSettings, saveCustomTheme, deleteCustomTheme,
type CustomTheme, type ThemeTokens, DEFAULT_THEME_TOKENS,
} from "@store/state.svelte";
interface Props {
editingId?: string | null;
onClose: () => void;
}
let { editingId = $bindable(null), onClose }: Props = $props();
const TOKEN_GROUPS: { label: string; tokens: (keyof ThemeTokens)[] }[] = [
{ label: "Backgrounds", tokens: ["bg-void", "bg-base", "bg-surface", "bg-raised", "bg-overlay", "bg-subtle"] },
{ label: "Borders", tokens: ["border-dim", "border-base", "border-strong", "border-focus"] },
{ label: "Text", tokens: ["text-primary", "text-secondary", "text-muted", "text-faint", "text-disabled"] },
{ label: "Accent", tokens: ["accent", "accent-dim", "accent-muted", "accent-fg", "accent-bright"] },
{ label: "Semantic", tokens: ["color-error", "color-error-bg", "color-success", "color-info", "color-info-bg"] },
];
const TOKEN_LABELS: Record<keyof ThemeTokens, string> = {
"bg-void": "Void (deepest bg)",
"bg-base": "Base",
"bg-surface": "Surface",
"bg-raised": "Raised",
"bg-overlay": "Overlay",
"bg-subtle": "Subtle",
"border-dim": "Dim border",
"border-base": "Base border",
"border-strong": "Strong border",
"border-focus": "Focus ring",
"text-primary": "Primary text",
"text-secondary": "Secondary text",
"text-muted": "Muted text",
"text-faint": "Faint text",
"text-disabled": "Disabled text",
"accent": "Accent",
"accent-dim": "Accent dim",
"accent-muted": "Accent muted",
"accent-fg": "Accent foreground",
"accent-bright": "Accent bright",
"color-error": "Error",
"color-error-bg": "Error background",
"color-success": "Success",
"color-info": "Info",
"color-info-bg": "Info background",
};
function loadInitial(): { name: string; tokens: ThemeTokens } {
if (editingId) {
const existing = store.settings.customThemes.find(t => t.id === editingId);
if (existing) return { name: existing.name, tokens: { ...existing.tokens } };
}
return { name: "My Theme", tokens: { ...DEFAULT_THEME_TOKENS } };
}
const initial = loadInitial();
let themeName: string = $state(initial.name);
let tokens: ThemeTokens = $state(initial.tokens);
let saveStatus: "idle" | "saved" = $state("idle");
let importError: string | null = $state(null);
function toCssVars(t: ThemeTokens): string {
return Object.entries(t).map(([k, v]) => `--${k}: ${v};`).join(" ");
}
function handleSave() {
const name = themeName.trim() || "Untitled Theme";
const id = editingId ?? `custom:${Math.random().toString(36).slice(2, 10)}`;
const theme: CustomTheme = { id, name, tokens: { ...tokens } };
saveCustomTheme(theme);
updateSettings({ theme: id });
editingId = id;
saveStatus = "saved";
setTimeout(() => (saveStatus = "idle"), 1800);
}
function handleDelete() {
if (!editingId) { onClose(); return; }
if (!confirm(`Delete theme "${themeName}"? This cannot be undone.`)) return;
deleteCustomTheme(editingId);
onClose();
}
async function handleExport() {
const data: CustomTheme = {
id: editingId ?? "custom:export",
name: themeName.trim() || "Untitled Theme",
tokens: { ...tokens },
};
const filename = `${data.name.replace(/[^a-z0-9]/gi, "-").toLowerCase()}-theme.json`;
const json = JSON.stringify(data, null, 2);
try {
const handle = await (window as any).showSaveFilePicker({
suggestedName: filename,
types: [{ description: "Theme JSON", accept: { "application/json": [".json"] } }],
});
const writable = await handle.createWritable();
await writable.write(json);
await writable.close();
} catch (e: any) {
if (e?.name === "AbortError") return;
const blob = new Blob([json], { type: "application/json" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = filename; a.click();
URL.revokeObjectURL(url);
}
}
function handleImport() {
const inp = document.createElement("input");
inp.type = "file";
inp.accept = ".json";
inp.onchange = async () => {
const file = inp.files?.[0];
if (!file) return;
try {
const text = await file.text();
const data = JSON.parse(text);
if (!data.tokens || typeof data.tokens !== "object") throw new Error("Invalid theme file — missing tokens");
if (typeof data.name === "string") themeName = data.name;
tokens = { ...DEFAULT_THEME_TOKENS, ...data.tokens };
importError = null;
} catch (e: any) {
importError = e.message ?? "Could not parse theme file";
setTimeout(() => (importError = null), 3000);
}
};
inp.click();
}
function resetToDefaults() { tokens = { ...DEFAULT_THEME_TOKENS }; }
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
</script>
<svelte:window onkeydown={onKey} />
<div class="backdrop" role="presentation" onclick={onClose} onkeydown={(e) => e.key === "Escape" && onClose()}>
<div class="shell" role="dialog" aria-label="Theme editor" tabindex="0" style={toCssVars(tokens)} onclick={(e) => e.stopPropagation()}>
<header class="header">
<div class="header-left">
<button class="icon-btn" onclick={onClose} title="Close editor">
<ArrowLeft size={14} weight="bold" />
</button>
<input bind:value={themeName} class="name-input" placeholder="Theme name" maxlength={40} spellcheck={false} />
</div>
<div class="header-actions">
{#if importError}
<span class="import-err">{importError}</span>
{/if}
<button class="action-btn" onclick={handleImport} title="Import from JSON">
<UploadSimple size={13} /><span>Import</span>
</button>
<button class="action-btn" onclick={handleExport} title="Export as JSON">
<DownloadSimple size={13} /><span>Export</span>
</button>
<button class="action-btn ghost" onclick={resetToDefaults} title="Reset all to dark defaults">Reset</button>
{#if editingId}
<button class="action-btn danger" onclick={handleDelete} title="Delete theme">
<Trash size={13} />
</button>
{/if}
<button class="save-btn" class:saved={saveStatus === "saved"} onclick={handleSave}>
<FloppyDisk size={13} /><span>{saveStatus === "saved" ? "Saved!" : "Save Theme"}</span>
</button>
<button class="icon-btn" onclick={onClose} title="Close">
<X size={14} weight="bold" />
</button>
</div>
</header>
<div class="body">
<aside class="preview-pane">
<div class="pane-label">Live Preview</div>
<div class="preview-ui" style={toCssVars(tokens)}>
<div class="prv-sidebar">
{#each [true, false, false, false] as active}
<div class="prv-sb-dot" class:active></div>
{/each}
</div>
<div class="prv-main">
<div class="prv-titlebar">
<div class="prv-win-dots"><span></span><span></span><span></span></div>
<div class="prv-win-title">Moku</div>
</div>
<div class="prv-content">
<div class="prv-row">
<div class="prv-bar" style="width:52px;background:var(--text-secondary);opacity:0.45"></div>
<div class="prv-bar" style="width:18px;background:var(--accent)"></div>
</div>
<div class="prv-grid">
{#each Array(6) as _, i}
<div class="prv-card" class:active-card={i === 0}>
<div class="prv-cover"></div>
<div class="prv-card-line"></div>
</div>
{/each}
</div>
<div class="prv-reader"><div class="prv-page"></div></div>
<div class="prv-toast">
<div class="prv-toast-dot"></div>
<div class="prv-toast-lines">
<div class="prv-bar" style="width:80%;background:var(--text-secondary)"></div>
<div class="prv-bar" style="width:55%;background:var(--text-faint);margin-top:3px"></div>
</div>
</div>
</div>
</div>
</div>
<div class="swatches" style={toCssVars(tokens)}>
{#each ["bg-base","bg-surface","accent","accent-fg","text-primary","text-muted","color-error"] as v}
<div class="swatch" style="background:var(--{v})" title={v}></div>
{/each}
</div>
</aside>
<div class="editor-pane">
{#each TOKEN_GROUPS as group}
<div class="group">
<div class="group-label">{group.label}</div>
<div class="token-list">
{#each group.tokens as token}
<div class="token-row">
<label class="color-swatch" style="background:{tokens[token]}" title="Pick colour">
<input
type="color"
class="color-picker"
value={tokens[token].length === 7 ? tokens[token] : tokens[token].slice(0, 7)}
oninput={(e) => { tokens = { ...tokens, [token]: (e.target as HTMLInputElement).value }; }}
/>
</label>
<span class="token-name">{TOKEN_LABELS[token]}</span>
<span class="token-key">{token}</span>
<input
type="text"
class="hex-input"
value={tokens[token]}
spellcheck={false}
oninput={(e) => {
const v = (e.target as HTMLInputElement).value.trim();
if (/^#[0-9a-fA-F]{3,8}$/.test(v)) tokens = { ...tokens, [token]: v };
}}
onblur={(e) => {
const v = (e.target as HTMLInputElement).value.trim();
if (!/^#[0-9a-fA-F]{3,8}$/.test(v)) (e.target as HTMLInputElement).value = tokens[token];
}}
/>
</div>
{/each}
</div>
</div>
{/each}
</div>
</div>
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.72);
z-index: 200;
display: flex; align-items: center; justify-content: center;
animation: backdropIn 0.14s ease both;
}
@keyframes backdropIn { from { opacity: 0 } to { opacity: 1 } }
.shell {
width: calc(100% - var(--sp-12)); max-width: 1100px;
height: calc(100% - var(--sp-12)); max-height: 760px;
display: flex; flex-direction: column;
background: var(--bg-base);
border: 1px solid var(--border-base);
border-radius: var(--radius-xl);
overflow: hidden;
animation: shellIn 0.2s cubic-bezier(0.22,1,0.36,1) both;
}
@keyframes shellIn {
from { transform: translateY(10px) scale(0.99); opacity: 0 }
to { transform: translateY(0) scale(1); opacity: 1 }
}
.header {
display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-3); padding: 0 var(--sp-4); height: 46px;
border-bottom: 1px solid var(--border-dim);
background: var(--bg-surface);
flex-shrink: 0;
}
.header-left { display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; }
.header-actions { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.icon-btn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px;
border-radius: var(--radius-md);
color: var(--text-muted);
transition: color var(--t-base), background var(--t-base);
flex-shrink: 0;
}
.icon-btn:hover { color: var(--text-primary); background: var(--bg-overlay); }
.name-input {
flex: 1; min-width: 0;
background: none; border: none; outline: none;
font-family: var(--font-sans); font-size: var(--text-sm); font-weight: var(--weight-medium);
color: var(--text-primary);
border-bottom: 1px solid transparent;
padding: 3px 0;
transition: border-color var(--t-base);
}
.name-input:focus { border-color: var(--border-focus); }
.name-input::placeholder { color: var(--text-faint); }
.import-err {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
color: var(--color-error); flex-shrink: 0;
}
.action-btn {
display: flex; align-items: center; gap: var(--sp-1);
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 4px var(--sp-2); border-radius: var(--radius-sm);
border: 1px solid var(--border-dim);
background: none; color: var(--text-muted);
cursor: pointer; flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.action-btn:hover { color: var(--text-secondary); border-color: var(--border-strong); }
.action-btn.ghost { border-color: transparent; }
.action-btn.ghost:hover { border-color: var(--border-dim); }
.action-btn.danger { color: var(--color-error); border-color: transparent; }
.action-btn.danger:hover { background: var(--color-error-bg); border-color: var(--color-error); }
.save-btn {
display: flex; align-items: center; gap: var(--sp-1);
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 5px var(--sp-3); border-radius: var(--radius-sm);
border: 1px solid var(--accent-dim);
background: var(--accent-muted); color: var(--accent-fg);
cursor: pointer; flex-shrink: 0;
transition: filter var(--t-base), background var(--t-base);
}
.save-btn:hover { filter: brightness(1.12); }
.save-btn.saved { background: var(--accent-dim); border-color: var(--accent); }
.body { flex: 1; overflow: hidden; display: flex; min-height: 0; }
.preview-pane {
width: 260px; flex-shrink: 0;
border-right: 1px solid var(--border-dim);
background: var(--bg-void);
display: flex; flex-direction: column;
padding: var(--sp-4); gap: var(--sp-3);
}
.pane-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
color: var(--text-faint); flex-shrink: 0;
}
.preview-ui {
flex: 1; min-height: 0;
border-radius: var(--radius-lg); overflow: hidden;
border: 1px solid var(--border-base);
display: flex; background: var(--bg-void);
}
.prv-sidebar {
width: 34px; flex-shrink: 0;
background: var(--bg-surface);
border-right: 1px solid var(--border-dim);
display: flex; flex-direction: column;
align-items: center; padding: var(--sp-3) 0; gap: var(--sp-2);
}
.prv-sb-dot {
width: 10px; height: 10px; border-radius: 50%;
background: var(--text-faint); opacity: 0.4;
transition: background var(--t-base), opacity var(--t-base);
}
.prv-sb-dot.active { background: var(--accent); opacity: 1; }
.prv-main { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.prv-titlebar {
height: 26px; flex-shrink: 0;
background: var(--bg-raised);
border-bottom: 1px solid var(--border-dim);
display: flex; align-items: center; padding: 0 var(--sp-2); gap: var(--sp-2);
}
.prv-win-dots { display: flex; gap: var(--sp-1); }
.prv-win-dots span { width: 6px; height: 6px; border-radius: 50%; background: var(--border-strong); }
.prv-win-title { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wider); color: var(--text-faint); }
.prv-content {
flex: 1; overflow: hidden;
padding: var(--sp-2); display: flex; flex-direction: column; gap: var(--sp-2);
background: var(--bg-base);
}
.prv-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.prv-bar { height: 3px; border-radius: 2px; }
.prv-grid { display: grid; grid-template-columns: repeat(3,1fr); gap: var(--sp-1); flex-shrink: 0; }
.prv-card {
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
background: var(--bg-raised); overflow: hidden;
transition: border-color var(--t-base);
}
.prv-card.active-card { border-color: var(--accent); }
.prv-cover { height: 34px; background: var(--bg-overlay); }
.prv-card-line { height: 3px; margin: 4px; border-radius: 2px; background: var(--text-faint); opacity: 0.5; }
.prv-reader {
flex: 1; min-height: 0;
border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
background: var(--bg-overlay);
display: flex; align-items: center; justify-content: center;
}
.prv-page { width: 68%; height: 86%; background: var(--bg-subtle); border-radius: 2px; }
.prv-toast {
flex-shrink: 0;
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-2);
border-radius: var(--radius-md);
background: var(--bg-overlay); border: 1px solid var(--accent-dim);
}
.prv-toast-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
.prv-toast-lines { flex: 1; }
.swatches { display: flex; gap: var(--sp-1); flex-wrap: wrap; flex-shrink: 0; }
.swatch { width: 22px; height: 22px; border-radius: var(--radius-sm); border: 1px solid rgba(255,255,255,0.07); flex-shrink: 0; }
.editor-pane {
flex: 1; overflow-y: auto;
padding: var(--sp-4) var(--sp-5);
display: flex; flex-direction: column; gap: var(--sp-6);
}
.editor-pane::-webkit-scrollbar { width: 4px; }
.editor-pane::-webkit-scrollbar-track { background: transparent; }
.editor-pane::-webkit-scrollbar-thumb { background: var(--border-strong); border-radius: 9999px; }
.group { display: flex; flex-direction: column; gap: var(--sp-1); }
.group-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
color: var(--text-faint);
padding-bottom: var(--sp-2); margin-bottom: var(--sp-1);
border-bottom: 1px solid var(--border-dim);
}
.token-list { display: flex; flex-direction: column; gap: 1px; }
.token-row {
display: flex; align-items: center; gap: var(--sp-3);
padding: 5px var(--sp-2); border-radius: var(--radius-md);
transition: background var(--t-base);
}
.token-row:hover { background: var(--bg-raised); }
.color-swatch {
width: 36px; height: 18px; border-radius: var(--radius-md);
flex-shrink: 0;
border: 1px solid rgba(255,255,255,0.12);
box-shadow: 0 0 0 1px rgba(0,0,0,0.2);
cursor: pointer; position: relative; overflow: hidden; display: block;
}
.color-swatch:hover { box-shadow: 0 0 0 2px var(--border-focus); }
.color-picker {
position: absolute; inset: 0;
width: 100%; height: 100%;
opacity: 0; cursor: pointer; padding: 0; border: none;
}
.token-name { flex: 1; font-size: var(--text-xs); color: var(--text-secondary); }
.token-key {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
color: var(--text-faint); flex-shrink: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 160px;
}
.hex-input {
width: 82px; flex-shrink: 0;
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
color: var(--text-muted);
background: var(--bg-overlay);
border: 1px solid var(--border-dim);
border-radius: var(--radius-sm); padding: 3px var(--sp-2);
outline: none;
transition: border-color var(--t-base), color var(--t-base);
}
.hex-input:focus { border-color: var(--border-focus); color: var(--text-primary); }
</style>
View File
@@ -0,0 +1,216 @@
<script lang="ts">
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { getVersion } from "@tauri-apps/api/app";
import { open as openUrl } from "@tauri-apps/plugin-shell";
interface ReleaseInfo { tag_name: string; name: string; body: string; published_at: string; html_url: string; }
type UpdatePhase = "idle" | "downloading" | "ready" | "error";
const IS_WINDOWS = navigator.userAgent.includes("Windows");
let appVersion = $state("…");
let releases = $state<ReleaseInfo[]>([]);
let releasesLoading = $state(false);
let releasesError = $state<string | null>(null);
let expandedTag = $state<string | null>(null);
let updatePhase = $state<UpdatePhase>("idle");
let updateError = $state<string | null>(null);
let dlBytes = $state(0);
let dlTotal = $state<number | null>(null);
let targetTag = $state<string | null>(null);
let releasesLoaded = false;
$effect(() => {
getVersion().then(v => appVersion = v).catch(() => appVersion = "unknown");
if (!releasesLoaded) { releasesLoaded = true; loadReleases(); }
});
$effect(() => {
let unlisten: (() => void) | undefined;
listen<{ downloaded: number; total: number | null }>("update-progress", (e) => {
dlBytes = e.payload.downloaded; dlTotal = e.payload.total ?? null;
}).then(fn => { unlisten = fn; });
return () => unlisten?.();
});
async function loadReleases() {
releasesLoading = true; releasesError = null;
try {
const timeout = new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Request timed out after 10s")), 10_000));
const all = await Promise.race([invoke<ReleaseInfo[]>("list_releases"), timeout]);
releases = all.filter(r => typeof r.tag_name === "string" && r.tag_name.trim());
} catch (e: any) {
releasesError = e instanceof Error ? e.message : String(e);
} finally { releasesLoading = false; }
}
function stripV(v: string) { return v.replace(/^v/, ""); }
function isCurrentVersion(tag: string) { return stripV(tag) === appVersion; }
function parseSemver(v: string) { return stripV(v).split(".").map(Number); }
function compareSemver(a: string, b: string) {
const pa = parseSemver(a), pb = parseSemver(b);
for (let i = 0; i < 3; i++) if ((pa[i] ?? 0) !== (pb[i] ?? 0)) return (pb[i] ?? 0) - (pa[i] ?? 0);
return 0;
}
const onLatestVersion = $derived((() => {
if (releasesLoading || releases.length === 0 || !appVersion || appVersion === "…") return false;
const sorted = releases.slice().sort((a, b) => compareSemver(a.tag_name, b.tag_name));
return compareSemver(appVersion, sorted[0].tag_name) >= 0;
})());
function fmtDate(iso: string) {
if (!iso) return "";
return new Date(iso).toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" });
}
function fmtBytes(bytes: number) {
if (bytes === 0) return "0 B";
const units = ["B","KB","MB","GB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`;
}
function fmtProgress() {
return dlTotal ? `${fmtBytes(dlBytes)} / ${fmtBytes(dlTotal)} (${Math.round((dlBytes / dlTotal) * 100)}%)` : fmtBytes(dlBytes);
}
async function installUpdate(release: ReleaseInfo) {
if (updatePhase === "downloading") return;
targetTag = release.tag_name; updatePhase = "downloading"; updateError = null; dlBytes = 0; dlTotal = null;
try {
if (IS_WINDOWS) {
try { await invoke("kill_server"); } catch {}
await invoke("download_and_install_update");
updatePhase = "ready";
} else {
await openUrl(release.html_url);
updatePhase = "idle"; targetTag = null;
}
} catch (e: any) {
updateError = e instanceof Error ? e.message : String(e);
updatePhase = "error";
}
}
async function restartNow() { await invoke("restart_app"); }
function cancelUpdate() { updatePhase = "idle"; updateError = null; targetTag = null; dlBytes = 0; dlTotal = null; }
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Moku</p>
<div class="s-section-body">
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-1)">
<span class="s-label">A manga reader frontend for Suwayomi / Tachidesk.</span>
<span class="s-desc">Built with Tauri + Svelte.</span>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Version</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Installed</span><span class="s-desc">v{appVersion}</span></div>
<button class="s-btn" onclick={() => { releasesError = null; loadReleases(); }} disabled={releasesLoading}>
{releasesLoading ? "Loading…" : "Refresh"}
</button>
</div>
{#if onLatestVersion}
<div class="s-row">
<span class="s-desc" style="color:var(--accent-fg)">✓ You're on the latest version.</span>
</div>
{/if}
{#if updatePhase === "downloading" && IS_WINDOWS}
<div class="s-update-progress">
<div class="s-update-bar">
<div class="s-update-fill" style="width:{dlTotal ? Math.round((dlBytes / dlTotal) * 100) : 0}%"></div>
</div>
<div class="s-update-labels">
<span>Downloading {targetTag ?? "update"}</span>
<span>{fmtProgress()}</span>
</div>
</div>
{/if}
{#if updatePhase === "ready"}
<div class="s-update-ready">
<span class="s-update-ready-label">{targetTag} downloaded — restart to finish installing.</span>
<button class="s-btn s-btn-accent" onclick={restartNow}>Restart now</button>
<button class="s-btn-icon" onclick={cancelUpdate} title="Dismiss"></button>
</div>
{/if}
{#if updatePhase === "error"}
<div class="s-row">
<span class="s-desc" style="color:var(--color-error)">{updateError}</span>
<button class="s-btn" onclick={cancelUpdate}>Dismiss</button>
</div>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">Releases</p>
<div class="s-section-body">
{#if releasesError}
<p class="s-empty" style="color:var(--color-error)">{releasesError}</p>
{:else if releasesLoading}
<p class="s-empty">Fetching releases…</p>
{:else if releases.length === 0}
<p class="s-empty">No releases found.</p>
{:else}
<div class="s-release-scroll">
{#each releases as release}
{@const isCurrent = isCurrentVersion(release.tag_name)}
{@const isExpanded = expandedTag === release.tag_name}
{@const isTarget = targetTag === release.tag_name}
{@const isInstalling = isTarget && updatePhase === "downloading"}
<div class="s-release-row" class:current={isCurrent}>
<div class="s-release-header">
<div class="s-release-meta">
<span class="s-release-tag">{release.tag_name}</span>
{#if isCurrent}<span class="s-release-badge">installed</span>{/if}
{#if release.published_at}<span class="s-release-date">{fmtDate(release.published_at)}</span>{/if}
</div>
<div class="s-btn-row">
{#if release.body.trim()}
<button class="s-btn" onclick={() => expandedTag = isExpanded ? null : release.tag_name}>
{isExpanded ? "Hide" : "Changelog"}
</button>
{/if}
{#if !isCurrent}
{#if IS_WINDOWS}
<button class="s-btn" class:s-btn-accent={!isInstalling}
disabled={updatePhase === "downloading"} onclick={() => installUpdate(release)}>
{isInstalling ? "Downloading…" : "Install"}
</button>
{:else}
<button class="s-btn" onclick={() => installUpdate(release)}>Open on GitHub</button>
{/if}
{/if}
</div>
</div>
{#if isExpanded && release.body.trim()}
<div class="s-release-body">
<pre class="s-release-body pre">{release.body.trim()}</pre>
</div>
{/if}
</div>
{/each}
</div>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">Links</p>
<div class="s-section-body">
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-2)">
<a href="https://github.com/Youwes09/Moku" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">GitHub →</a>
<a href="https://discord.gg/Jq3pwuNqPp" target="_blank" class="s-label" style="color:var(--accent-fg);text-decoration:none">Discord →</a>
</div>
</div>
</div>
</div>
@@ -0,0 +1,93 @@
<script lang="ts">
import { Pencil, Trash, Plus } from "phosphor-svelte";
import { store, updateSettings, deleteCustomTheme } from "@store/state.svelte";
interface Props {
onOpenThemeEditor?: (id?: string | null) => void;
}
let { onOpenThemeEditor }: Props = $props();
const THEMES: { id: string; label: string; description: string; swatches: string[] }[] = [
{ id: "dark", label: "Dark", description: "Default near-black", swatches: ["#101010","#151515","#a8c4a8","#f0efec"] },
{ id: "high-contrast", label: "High Contrast", description: "Darker base, sharper text", swatches: ["#080808","#111111","#bcd8bc","#ffffff"] },
{ id: "light", label: "Light", description: "Warm off-white", swatches: ["#f4f2ee","#faf8f4","#2a5a2a","#1a1916"] },
{ id: "light-contrast", label: "Light Contrast", description: "Light with maximum contrast", swatches: ["#ece8e2","#f5f2ec","#183818","#080806"] },
{ id: "midnight", label: "Midnight", description: "Deep blue-black tint", swatches: ["#0c1020","#101428","#a8b4e8","#eeeef8"] },
{ id: "warm", label: "Warm", description: "Amber and sepia tones", swatches: ["#16130c","#1c1810","#e0b860","#f5f0e0"] },
];
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Theme</p>
<div class="s-theme-grid">
{#each THEMES as theme}
{@const active = (store.settings.theme ?? "dark") === theme.id}
<div class="s-theme-card" class:active>
<button class="s-theme-card" style="border:none;padding:0;width:100%;display:block" onclick={() => updateSettings({ theme: theme.id })} title={theme.description}>
<div class="s-theme-preview">
<div class="s-theme-preview-bg" style="background:{theme.swatches[0]}">
<div class="s-theme-preview-sidebar" style="background:{theme.swatches[1]}"></div>
<div class="s-theme-preview-content">
<div class="s-theme-preview-accent" style="background:{theme.swatches[2]}"></div>
<div class="s-theme-preview-text" style="background:{theme.swatches[3]}55"></div>
<div class="s-theme-preview-text" style="background:{theme.swatches[3]}33;width:60%"></div>
</div>
</div>
</div>
<div class="s-theme-info">
<span class="s-theme-name">{theme.label}</span>
<span class="s-theme-desc">{theme.description}</span>
</div>
{#if active}<span class="s-theme-check"></span>{/if}
</button>
</div>
{/each}
{#each store.settings.customThemes ?? [] as custom}
{@const active = store.settings.theme === custom.id}
<div class="s-theme-card" class:active>
<div class="s-theme-actions">
<button class="s-theme-action-btn edit" onclick={() => onOpenThemeEditor?.(custom.id)} title="Edit theme">
<Pencil size={10} />
</button>
<button class="s-theme-action-btn delete"
onclick={() => { if (confirm(`Delete theme "${custom.name}"?`)) deleteCustomTheme(custom.id); }}
title="Delete theme">
<Trash size={10} />
</button>
</div>
<button style="border:none;padding:0;width:100%;display:block;background:none;cursor:pointer"
onclick={() => updateSettings({ theme: custom.id })} title="Apply {custom.name}">
<div class="s-theme-preview">
<div class="s-theme-preview-bg" style="background:{custom.tokens['bg-base']}">
<div class="s-theme-preview-sidebar" style="background:{custom.tokens['bg-surface']}"></div>
<div class="s-theme-preview-content">
<div class="s-theme-preview-accent" style="background:{custom.tokens['accent']}"></div>
<div class="s-theme-preview-text" style="background:{custom.tokens['text-primary']}55"></div>
<div class="s-theme-preview-text" style="background:{custom.tokens['text-primary']}33;width:60%"></div>
</div>
</div>
</div>
<div class="s-theme-info">
<span class="s-theme-name">{custom.name}</span>
<span class="s-theme-desc" style="color:var(--accent-fg)">Custom</span>
</div>
</button>
{#if active}<span class="s-theme-check"></span>{/if}
</div>
{/each}
<button class="s-theme-card s-theme-new" onclick={() => onOpenThemeEditor?.(null)} title="Create a custom theme">
<Plus size={18} weight="light" />
<div class="s-theme-info">
<span class="s-theme-name">New Theme</span>
<span class="s-theme-desc">Create custom</span>
</div>
</button>
</div>
</div>
</div>
@@ -0,0 +1,170 @@
<script lang="ts">
import { Plus, Tag } from "phosphor-svelte";
import { store, updateSettings } from "@store/state.svelte";
import { gql, thumbUrl } from "@api/client";
import { GET_SOURCES } from "@api/queries/index";
import type { Source } from "../../lib/types";
let contentSources: Source[] = $state([]);
let contentSourcesLoading: boolean = $state(false);
let newTagInput = $state("");
let tagsRevealed = $state(false);
let sourceSearch = $state("");
$effect(() => {
if (contentSources.length === 0 && !contentSourcesLoading) loadContentSources();
});
async function loadContentSources() {
contentSourcesLoading = true;
try {
const d = await gql<{ sources: { nodes: Source[] } }>(GET_SOURCES);
contentSources = d.sources.nodes.filter(s => s.id !== "0");
} catch (e) { console.error(e); }
finally { contentSourcesLoading = false; }
}
function addTag() {
const t = newTagInput.trim().toLowerCase();
if (!t) return;
const tags = store.settings.nsfwFilteredTags ?? [];
if (!tags.includes(t)) updateSettings({ nsfwFilteredTags: [...tags, t] });
newTagInput = "";
}
function removeTag(tag: string) {
updateSettings({ nsfwFilteredTags: (store.settings.nsfwFilteredTags ?? []).filter(t => t !== tag) });
}
function resetTags() {
updateSettings({ nsfwFilteredTags: ["adult","mature","hentai","ecchi","erotic","pornograph","18+","smut","lemon","explicit","sexual violence"] });
}
function toggleSourceAllowed(ids: string[]) {
const allowed = store.settings.nsfwAllowedSourceIds ?? [];
const blocked = store.settings.nsfwBlockedSourceIds ?? [];
const allAllowed = ids.every(id => allowed.includes(id));
if (allAllowed) {
updateSettings({ nsfwAllowedSourceIds: allowed.filter(x => !ids.includes(x)) });
} else {
updateSettings({
nsfwAllowedSourceIds: [...allowed.filter(x => !ids.includes(x)), ...ids],
nsfwBlockedSourceIds: blocked.filter(x => !ids.includes(x)),
});
}
}
function toggleSourceBlocked(ids: string[]) {
const allowed = store.settings.nsfwAllowedSourceIds ?? [];
const blocked = store.settings.nsfwBlockedSourceIds ?? [];
const allBlocked = ids.every(id => blocked.includes(id));
if (allBlocked) {
updateSettings({ nsfwBlockedSourceIds: blocked.filter(x => !ids.includes(x)) });
} else {
updateSettings({
nsfwBlockedSourceIds: [...blocked.filter(x => !ids.includes(x)), ...ids],
nsfwAllowedSourceIds: allowed.filter(x => !ids.includes(x)),
});
}
}
interface ContentSourceGroup { name: string; iconUrl: string; isNsfw: boolean; sources: Source[]; }
const contentSourcesFiltered = $derived.by(() => {
const q = sourceSearch.trim().toLowerCase();
const filtered = q ? contentSources.filter(s => s.displayName.toLowerCase().includes(q) || s.lang.toLowerCase().includes(q)) : contentSources;
const map = new Map<string, ContentSourceGroup>();
for (const s of filtered) {
const key = s.name;
if (!map.has(key)) map.set(key, { name: s.name, iconUrl: s.iconUrl, isNsfw: s.isNsfw, sources: [] });
map.get(key)!.sources.push(s);
}
return Array.from(map.values());
});
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Content Filter</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Show adult content</span><span class="s-desc">Sources and manga matching blocked tags are hidden when off</span></div>
<button role="switch" aria-checked={store.settings.showNsfw} aria-label="Show adult content" class="s-toggle" class:on={store.settings.showNsfw}
onclick={() => updateSettings({ showNsfw: !store.settings.showNsfw })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">
Blocked Genre Tags
<button class="s-btn" onclick={() => tagsRevealed = !tagsRevealed}>
{tagsRevealed ? "Hide" : `Show (${(store.settings.nsfwFilteredTags ?? []).length})`}
</button>
</p>
<div class="s-section-body">
<div class="s-row" style="padding-bottom:var(--sp-2)">
<span class="s-desc">Manga matching any of these substrings are filtered. Case-insensitive, partial match.</span>
</div>
{#if tagsRevealed}
<div class="s-tag-grid">
{#each (store.settings.nsfwFilteredTags ?? []) as tag}
<span class="s-tag">
<Tag size={10} weight="light" />
{tag}
<button class="s-tag-remove" onclick={() => removeTag(tag)} title="Remove tag">×</button>
</span>
{/each}
</div>
{/if}
<div class="s-tag-add">
<input class="s-input full" placeholder="Add tag substring…" bind:value={newTagInput}
onkeydown={(e) => { if (e.key === "Enter") addTag(); }} />
<button class="s-btn s-btn-accent" onclick={addTag} disabled={!newTagInput.trim()}>
<Plus size={13} weight="bold" /> Add
</button>
<button class="s-btn" onclick={resetTags}>Reset</button>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Source Overrides</p>
<div class="s-section-body">
<div class="s-row">
<span class="s-desc">Allow lets a source through even if flagged NSFW. Block always hides it.</span>
</div>
<div class="s-search-wrap">
<input class="s-input full" placeholder="Filter sources…" bind:value={sourceSearch} />
</div>
{#if contentSourcesLoading}
<p class="s-empty">Loading sources…</p>
{:else if contentSources.length === 0}
<p class="s-empty">No sources found — check your server connection.</p>
{:else}
<div class="s-source-list">
{#each contentSourcesFiltered as group (group.name)}
{@const ids = group.sources.map(s => s.id)}
{@const allowed = store.settings.nsfwAllowedSourceIds ?? []}
{@const blocked = store.settings.nsfwBlockedSourceIds ?? []}
{@const isAllowed = ids.every(id => allowed.includes(id))}
{@const isBlocked = ids.every(id => blocked.includes(id))}
<div class="s-source-row" class:allowed={isAllowed} class:blocked={isBlocked}>
<img src={thumbUrl(group.iconUrl)} alt="" class="s-source-icon" loading="lazy" decoding="async" />
<div class="s-source-info">
<span class="s-source-name">{group.name}</span>
<span class="s-source-meta">{group.sources[0].isNsfw ? "NSFW · " : ""}{group.sources.length > 1 ? `${group.sources.length} languages` : group.sources[0].lang.toUpperCase()}</span>
</div>
<div class="s-source-actions">
<button class="s-source-action-btn" class:allow={isAllowed} onclick={() => toggleSourceAllowed(ids)}>Allow</button>
<button class="s-source-action-btn" class:block={isBlocked} onclick={() => toggleSourceBlocked(ids)}>Block</button>
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,131 @@
<script lang="ts">
import ThreeDCard from "@shared/manga/ThreeDCard.svelte";
import { store, addToast } from "@store/state.svelte";
import { cache } from "@core/cache/index";
interface PerfSnapshot { cacheEntries: number; cacheKeys: string[]; oldestEntryMs: number | null; newestEntryMs: number | null; }
let perfSnapshot = $state<PerfSnapshot | null>(null);
let splashTriggered = $state(false);
let expOpen = $state(false);
let appVersion = $state("…");
$effect(() => {
import("@tauri-apps/api/app").then(m => m.getVersion()).then(v => appVersion = v).catch(() => {});
refreshPerfMetrics();
});
function refreshPerfMetrics() {
let entries = 0, oldest: number | null = null, newest: number | null = null;
const foundKeys: string[] = [];
const checkKey = (k: string) => {
const age = cache.ageOf(k);
if (age !== undefined) {
entries++;
foundKeys.push(k);
const ts = Date.now() - age;
if (oldest === null || ts < oldest) oldest = ts;
if (newest === null || ts > newest) newest = ts;
}
};
["library","sources","popular"].forEach(checkKey);
["Action","Romance","Fantasy","Comedy","Drama","Horror","Sci-Fi","Adventure","Thriller",
"Isekai","Supernatural","Historical","Psychological","Sports","Mystery","Mecha",
"Slice of Life","School Life","Martial Arts","Magic","Military"].forEach(g => checkKey(`genre:${g}`));
perfSnapshot = { cacheEntries: entries, cacheKeys: foundKeys, oldestEntryMs: oldest, newestEntryMs: newest };
}
function fmtAge(ts: number | null) {
if (ts === null) return "—";
const secs = Math.floor((Date.now() - ts) / 1000);
if (secs < 60) return `${secs}s ago`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
return `${Math.floor(mins / 60)}h ago`;
}
function triggerSplash() {
splashTriggered = true;
setTimeout(() => splashTriggered = false, 200);
(window as any).__mokuShowSplash?.();
}
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Toasts</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Fire test toast</span><span class="s-desc">Triggers each kind with realistic content</span></div>
<div class="s-dev-pill-group">
{#each ([["success","S"],["error","E"],["info","I"],["download","D"]] as const) as [kind, label]}
<button class="s-dev-pill {kind}" onclick={() => addToast({
kind,
title: kind === "success" ? "Library updated" : kind === "error" ? "Could not reach server" : kind === "info" ? "Already up to date" : "Download complete",
body: kind === "success" ? "3 new chapters across 2 series" : kind === "error" ? "Connection refused on port 4567" : kind === "info" ? "No new chapters found" : "Berserk · Ch. 372 ready to read",
})}>{label}</button>
{/each}
</div>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Previews</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Idle splash</span><span class="s-desc">Dismiss with any click or key</span></div>
<button class="s-btn" class:s-btn-accent={splashTriggered} onclick={triggerSplash}>Show</button>
</div>
</div>
</div>
<div class="s-section">
<button class="s-collapsible-trigger" onclick={() => expOpen = !expOpen} aria-expanded={expOpen}>
<span class="s-label">Experimental</span>
<svg class="s-collapsible-caret" class:open={expOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if expOpen}
<div class="s-collapsible-body">
<div class="s-row" style="flex-direction:column;align-items:flex-start;gap:var(--sp-3)">
<span class="s-desc">3D tilt cards — hover to preview</span>
<div style="display:flex;gap:var(--sp-3)">
{#each [{ title: "Berserk", sub: "Ch. 372", hue: "265" },{ title: "Vinland Saga", sub: "Ch. 208", hue: "200" },{ title: "Dungeon Meshi", sub: "Ch. 97", hue: "140" }] as card}
<ThreeDCard>
<div style="width:72px;height:100px;border-radius:var(--radius-md);background:hsl({card.hue},40%,18%);display:flex;flex-direction:column;align-items:center;justify-content:flex-end;padding:var(--sp-2)">
<span style="font-size:var(--text-2xs);color:var(--text-secondary);text-align:center;line-height:1.2">{card.title}</span>
<span style="font-size:10px;color:var(--text-faint)">{card.sub}</span>
</div>
</ThreeDCard>
{/each}
</div>
</div>
</div>
{/if}
</div>
<div class="s-section">
<p class="s-section-title">Runtime</p>
<div class="s-section-body">
<div class="s-dev-grid">
<span class="s-dev-key">Filter</span> <span class="s-dev-val">{store.libraryFilter}</span>
<span class="s-dev-key">Folders</span> <span class="s-dev-val">{store.categories.filter(c => c.id !== 0).map(c => c.name).join(", ") || "none"}</span>
<span class="s-dev-key">History</span> <span class="s-dev-val">{store.history.length} entries</span>
<span class="s-dev-key">Cache</span> <span class="s-dev-val">{perfSnapshot?.cacheEntries ?? "—"} entries</span>
<span class="s-dev-key">Toasts</span> <span class="s-dev-val">{store.toasts.length} queued</span>
<span class="s-dev-key">Version</span> <span class="s-dev-val">{appVersion} · {import.meta.env.MODE}</span>
</div>
<div class="s-row">
<div class="s-row-info">
{#if perfSnapshot && perfSnapshot.cacheEntries > 0}
<span class="s-desc">{perfSnapshot.cacheKeys.join(", ")}</span>
<span class="s-desc">Oldest: {fmtAge(perfSnapshot.oldestEntryMs)} · Newest: {fmtAge(perfSnapshot.newestEntryMs)}</span>
{/if}
</div>
<button class="s-btn-icon" onclick={refreshPerfMetrics} title="Refresh cache stats"></button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,158 @@
<script lang="ts">
import { FolderSimple, Plus, Pencil, Trash, Star, Eye, EyeSlash } from "phosphor-svelte";
import { gql } from "@api/client";
import { GET_CATEGORIES } from "@api/queries/manga";
import { CREATE_CATEGORY, UPDATE_CATEGORY, DELETE_CATEGORY, UPDATE_CATEGORY_ORDER } from "@api/mutations/manga";
import type { Category } from "@types";
import { store, updateSettings, toggleHiddenCategory, setCategories } from "@store/state.svelte";
let catsLoading = $state(false);
let catsError = $state<string | null>(null);
let newFolderName = $state("");
let editingId = $state<number | null>(null);
let editingName = $state("");
async function loadCategories() {
catsLoading = true; catsError = null;
try {
const res = await gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES);
const zeroCat = store.categories.filter(c => c.id === 0);
const fresh = res.categories.nodes.filter(c => c.id !== 0);
const merged = fresh.map(f => {
const existing = store.categories.find(c => c.id === f.id);
return existing ? { ...existing, ...f } : f;
});
setCategories([...zeroCat, ...merged]);
} catch (e: any) {
catsError = e?.message ?? "Failed to load folders";
} finally { catsLoading = false; }
}
async function createFolder() {
const name = newFolderName.trim();
if (!name) return;
try {
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name });
setCategories([...store.categories, res.createCategory.category]);
newFolderName = "";
} catch (e: any) { catsError = e?.message ?? "Failed to create folder"; }
}
function startEdit(id: number, name: string) { editingId = id; editingName = name; }
async function commitEdit() {
if (editingId !== null && editingName.trim()) {
try {
await gql(UPDATE_CATEGORY, { id: editingId, name: editingName.trim() });
setCategories(store.categories.map(c => c.id === editingId ? { ...c, name: editingName.trim() } : c));
} catch (e: any) { catsError = e?.message ?? "Failed to rename"; }
}
editingId = null; editingName = "";
}
async function deleteFolder(id: number) {
try {
await gql(DELETE_CATEGORY, { id });
setCategories(store.categories.filter(c => c.id !== id));
} catch (e: any) { catsError = e?.message ?? "Failed to delete folder"; }
}
async function moveCategory(id: number, direction: -1 | 1) {
const zeroCat = store.categories.filter(c => c.id === 0);
const sortable = store.categories.filter(c => c.id !== 0).sort((a, b) => a.order - b.order);
const idx = sortable.findIndex(c => c.id === id);
if (idx < 0) return;
const newPos = idx + 1 + direction;
if (newPos < 1 || newPos > sortable.length) return;
const reordered = [...sortable];
const [moved] = reordered.splice(idx, 1);
reordered.splice(idx + direction, 0, moved);
setCategories([...zeroCat, ...reordered.map((c, i) => ({ ...c, order: i + 1 }))]);
try {
const res = await gql<{ updateCategoryOrder: { categories: Category[] } }>(UPDATE_CATEGORY_ORDER, { id, position: newPos });
const updated = res.updateCategoryOrder.categories.filter(c => c.id !== 0);
setCategories([
...zeroCat,
...updated.sort((a, b) => a.order - b.order).map(fresh => {
const existing = store.categories.find(c => c.id === fresh.id);
return existing ? { ...existing, ...fresh } : fresh;
}),
]);
} catch (e: any) {
catsError = e?.message ?? "Failed to reorder";
await loadCategories();
}
}
function focusInput(node: HTMLElement) { node.focus(); }
$effect(() => {
if (!store.categories.length && !catsLoading) loadCategories();
});
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Manage Folders</p>
<div class="s-section-body">
<div class="s-row">
<span class="s-desc">Folders are stored as Suwayomi categories. Changes sync across all clients.</span>
</div>
{#if catsError}
<div class="s-banner s-banner-error">{catsError}</div>
{/if}
<div class="s-folder-create">
<input class="s-input full" placeholder="New folder name…" bind:value={newFolderName}
onkeydown={(e) => e.key === "Enter" && createFolder()} />
<button class="s-btn s-btn-accent" onclick={createFolder} disabled={!newFolderName.trim()}>
<Plus size={13} weight="bold" /> Create
</button>
</div>
{#if catsLoading}
<p class="s-empty">Loading folders…</p>
{:else if store.categories.filter(c => c.id !== 0).length === 0}
<p class="s-empty">No folders yet. Create one above.</p>
{:else}
{@const displayCats = store.categories
.filter(c => c.id !== 0)
.sort((a, b) => {
const defaultId = store.settings.defaultLibraryCategoryId ?? null;
if (a.id === defaultId) return -1;
if (b.id === defaultId) return 1;
return a.order - b.order;
})}
{#each displayCats as cat, i}
<div class="s-folder-row">
{#if editingId === cat.id}
<input class="s-input full" bind:value={editingName}
onkeydown={(e) => { if (e.key === "Enter") commitEdit(); if (e.key === "Escape") editingId = null; }}
onblur={commitEdit} use:focusInput />
<button class="s-btn-icon" onclick={commitEdit} title="Save"></button>
{:else}
<FolderSimple size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<span class="s-folder-name">{cat.name}</span>
<span class="s-folder-count">{cat.mangas?.nodes.length ?? 0} manga</span>
<button class="s-btn-icon"
class:accent={(store.settings.defaultLibraryCategoryId ?? null) === cat.id}
onclick={() => updateSettings({ defaultLibraryCategoryId: (store.settings.defaultLibraryCategoryId ?? null) === cat.id ? null : cat.id })}
title={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "Remove as default folder" : "Set as default folder"}>
<Star size={13} weight={(store.settings.defaultLibraryCategoryId ?? null) === cat.id ? "fill" : "light"} />
</button>
<button class="s-btn-icon"
onclick={() => toggleHiddenCategory(cat.id)}
title={(store.settings.hiddenCategoryIds ?? []).includes(cat.id) ? "Show in Saved tab" : "Hide from Saved tab"}>
{#if (store.settings.hiddenCategoryIds ?? []).includes(cat.id)}<EyeSlash size={13} weight="light" />{:else}<Eye size={13} weight="light" />{/if}
</button>
<button class="s-btn-icon" onclick={() => moveCategory(cat.id, -1)} disabled={i === 0} title="Move up"></button>
<button class="s-btn-icon" onclick={() => moveCategory(cat.id, 1)} disabled={i === displayCats.length - 1} title="Move down"></button>
<button class="s-btn-icon" onclick={() => startEdit(cat.id, cat.name)} title="Rename"><Pencil size={12} weight="light" /></button>
<button class="s-btn-icon danger" onclick={() => deleteFolder(cat.id)} title="Delete"><Trash size={12} weight="light" /></button>
{/if}
</div>
{/each}
{/if}
</div>
</div>
</div>
@@ -0,0 +1,110 @@
<script lang="ts">
import { store, updateSettings } from "@store/state.svelte";
interface Props {
selectOpen: string | null;
onToggleSelect: (id: string) => void;
}
let { selectOpen, onToggleSelect }: Props = $props();
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Interface Scale</p>
<div class="s-section-body">
<div class="s-slider-row">
<input type="range" min={50} max={200} step={5}
value={Math.round((store.settings.uiZoom ?? 1.0) * 100)}
oninput={(e) => updateSettings({ uiZoom: Number(e.currentTarget.value) / 100 })}
class="s-slider" />
<input type="number" min={50} max={200} step={1} class="s-slider-val"
value={Math.round((store.settings.uiZoom ?? 1.0) * 100)}
oninput={(e) => { const n = parseInt(e.currentTarget.value, 10); if (!isNaN(n) && n >= 50 && n <= 200) updateSettings({ uiZoom: n / 100 }); }}
onblur={(e) => { const n = parseInt(e.currentTarget.value, 10); if (isNaN(n) || n < 50) { updateSettings({ uiZoom: 0.5 }); e.currentTarget.value = "50"; } else if (n > 200) { updateSettings({ uiZoom: 2.0 }); e.currentTarget.value = "200"; } }}
/>
<span class="s-slider-unit">%</span>
<button class="s-btn-icon" onclick={() => updateSettings({ uiZoom: 1.0 })} disabled={(store.settings.uiZoom ?? 1.0) === 1.0} title="Reset to 100%"></button>
</div>
<div class="s-presets">
{#each [50,60,70,80,90,100,110,125,150,175,200] as v}
<button class="s-preset" class:active={Math.round((store.settings.uiZoom ?? 1.0) * 100) === v} onclick={() => updateSettings({ uiZoom: v / 100 })}>{v}%</button>
{/each}
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Server</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Server URL</span><span class="s-desc">Base URL of your Suwayomi instance</span></div>
<input class="s-input" value={store.settings.serverUrl ?? "http://localhost:4567"} oninput={(e) => updateSettings({ serverUrl: e.currentTarget.value })} placeholder="http://localhost:4567" spellcheck="false" />
</div>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Auto-start server</span><span class="s-desc">Launch tachidesk-server when Moku opens</span></div>
<button role="switch" aria-checked={store.settings.autoStartServer} aria-label="Auto-start server" class="s-toggle" class:on={store.settings.autoStartServer} onclick={() => updateSettings({ autoStartServer: !store.settings.autoStartServer })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Inactivity</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Idle screen timeout</span><span class="s-desc">Show the Moku idle splash after this much inactivity</span></div>
<div class="s-select" id="idle-timeout">
<button class="s-select-btn" onclick={() => onToggleSelect("idle-timeout")}>
<span>{{ "0":"Never","1":"1 minute","2":"2 minutes","5":"5 minutes","10":"10 minutes","15":"15 minutes","30":"30 minutes" }[String(store.settings.idleTimeoutMin ?? 5)] ?? `${store.settings.idleTimeoutMin} min`}</span>
<svg class="s-select-caret" class:open={selectOpen === "idle-timeout"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "idle-timeout"}
<div class="s-select-menu">
{#each [["0","Never"],["1","1 minute"],["2","2 minutes"],["5","5 minutes"],["10","10 minutes"],["15","15 minutes"],["30","30 minutes"]] as [v, l]}
<button class="s-select-option" class:active={String(store.settings.idleTimeoutMin ?? 5) === v} onclick={() => { updateSettings({ idleTimeoutMin: Number(v) }); onToggleSelect("idle-timeout"); }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Integrations</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Discord Rich Presence</span><span class="s-desc">Show what you're reading in your Discord status</span></div>
<button role="switch" aria-checked={store.settings.discordRpc} aria-label="Discord Rich Presence" class="s-toggle" class:on={store.settings.discordRpc} onclick={() => updateSettings({ discordRpc: !store.settings.discordRpc })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Animations</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">QOL Animations</span><span class="s-desc">Hover lifts, active-tab transitions, and icon micro-animations</span></div>
<button role="switch" aria-checked={store.settings.qolAnimations ?? true} aria-label="QOL Animations" class="s-toggle" class:on={store.settings.qolAnimations ?? true} onclick={() => updateSettings({ qolAnimations: !(store.settings.qolAnimations ?? true) })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Language</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Preferred source language</span>
<span class="s-desc">Used to pre-select languages in Search and deduplicate sources</span>
</div>
<input class="s-input" style="width:72px;text-align:center;text-transform:uppercase"
value={store.settings.preferredExtensionLang ?? ""}
oninput={(e) => updateSettings({ preferredExtensionLang: e.currentTarget.value.trim().toLowerCase() })}
placeholder="en" spellcheck="false" />
</div>
</div>
</div>
</div>
@@ -0,0 +1,53 @@
<script lang="ts">
import { store, updateSettings, resetKeybinds } from "@store/state.svelte";
import { KEYBIND_LABELS, DEFAULT_KEYBINDS, eventToKeybind } from "@core/keybinds";
import type { Keybinds } from "@core/keybinds";
let listeningKey: keyof Keybinds | null = $state(null);
function startListen(key: keyof Keybinds) {
listeningKey = listeningKey === key ? null : key;
}
function onKeyCapture(e: KeyboardEvent) {
if (!listeningKey) return;
e.preventDefault(); e.stopPropagation();
const bind = eventToKeybind(e);
if (!bind) return;
updateSettings({ keybinds: { ...store.settings.keybinds, [listeningKey]: bind } });
listeningKey = null;
}
$effect(() => {
if (listeningKey) {
window.addEventListener("keydown", onKeyCapture, true);
return () => window.removeEventListener("keydown", onKeyCapture, true);
}
});
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">
Keyboard Shortcuts
<button class="s-btn" onclick={resetKeybinds}>Reset all</button>
</p>
<p class="s-kb-hint">Click a binding to rebind, then press the new key combination.</p>
<div class="s-section-body">
{#each Object.keys(KEYBIND_LABELS) as key}
{@const k = key as keyof Keybinds}
{@const isListening = listeningKey === k}
{@const isDefault = store.settings.keybinds[k] === DEFAULT_KEYBINDS[k]}
<div class="s-kb-row">
<span class="s-kb-label">{KEYBIND_LABELS[k]}</span>
<div class="s-kb-right">
<button class="s-kb-bind" class:listening={isListening} onclick={() => startListen(k)}>
{isListening ? "Press key…" : store.settings.keybinds[k]}
</button>
<button class="s-btn-icon" onclick={() => updateSettings({ keybinds: { ...store.settings.keybinds, [k]: DEFAULT_KEYBINDS[k] } })} disabled={isDefault} title="Reset"></button>
</div>
</div>
{/each}
</div>
</div>
</div>
@@ -0,0 +1,65 @@
<script lang="ts">
import { store, updateSettings, clearHistory, wipeAllData } from "@store/state.svelte";
import type { Settings } from "@types/settings";
interface Props {
selectOpen: string | null;
onToggleSelect: (id: string) => void;
}
let { selectOpen, onToggleSelect }: Props = $props();
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Display</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Crop cover images</span><span class="s-desc">Fills the card with the cover art instead of letterboxing</span></div>
<button role="switch" aria-checked={store.settings.libraryCropCovers} aria-label="Crop cover images" class="s-toggle" class:on={store.settings.libraryCropCovers} onclick={() => updateSettings({ libraryCropCovers: !store.settings.libraryCropCovers })}><span class="s-toggle-thumb"></span></button>
</label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Show all in Saved tab</span><span class="s-desc">Include manga that are in folders — lets you see your whole library in one place</span></div>
<button role="switch" aria-checked={store.settings.libraryShowAllInSaved ?? true} aria-label="Show all manga in Saved tab" class="s-toggle" class:on={store.settings.libraryShowAllInSaved ?? true} onclick={() => updateSettings({ libraryShowAllInSaved: !(store.settings.libraryShowAllInSaved ?? true) })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Chapters</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Default sort direction</span><span class="s-desc">Initial chapter list order when opening a manga</span></div>
<div class="s-select" id="sort-dir">
<button class="s-select-btn" onclick={() => onToggleSelect("sort-dir")}>
<span>{{ "desc":"Newest first","asc":"Oldest first" }[store.settings.chapterSortDir]}</span>
<svg class="s-select-caret" class:open={selectOpen === "sort-dir"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "sort-dir"}
<div class="s-select-menu">
{#each [["desc","Newest first"],["asc","Oldest first"]] as [v, l]}
<button class="s-select-option" class:active={store.settings.chapterSortDir === v} onclick={() => { updateSettings({ chapterSortDir: v as Settings["chapterSortDir"] }); onToggleSelect("sort-dir"); }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">History</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Reading history</span><span class="s-desc">{store.history.length} entries</span></div>
<button class="s-btn s-btn-danger" onclick={clearHistory} disabled={store.history.length === 0}>Clear</button>
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Wipe all data</span><span class="s-desc">History, stats, pins, and manga links</span></div>
<button class="s-btn s-btn-danger" onclick={wipeAllData}>Wipe</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,152 @@
<script lang="ts">
import { store, updateSettings } from "@store/state.svelte";
import { cache } from "@core/cache";
interface PerfSnapshot {
cacheEntries: number;
cacheKeys: string[];
oldestEntryMs: number | null;
newestEntryMs: number | null;
}
let perfSnapshot = $state<PerfSnapshot | null>(null);
let clearing = $state(false);
let cleared = $state(false);
function refreshPerfMetrics() {
let entries = 0, oldest: number | null = null, newest: number | null = null;
const foundKeys: string[] = [];
const checkKey = (k: string) => {
const age = cache.ageOf(k);
if (age !== undefined) {
entries++;
foundKeys.push(k);
const ts = Date.now() - age;
if (oldest === null || ts < oldest) oldest = ts;
if (newest === null || ts > newest) newest = ts;
}
};
["library","sources","popular"].forEach(checkKey);
["Action","Romance","Fantasy","Comedy","Drama","Horror","Sci-Fi","Adventure","Thriller",
"Isekai","Supernatural","Historical","Psychological","Sports","Mystery","Mecha",
"Slice of Life","School Life","Martial Arts","Magic","Military"].forEach(g => checkKey(`genre:${g}`));
perfSnapshot = { cacheEntries: entries, cacheKeys: foundKeys, oldestEntryMs: oldest, newestEntryMs: newest };
}
function fmtAge(ts: number | null): string {
if (ts === null) return "—";
const secs = Math.floor((Date.now() - ts) / 1000);
if (secs < 60) return `${secs}s ago`;
const mins = Math.floor(secs / 60);
if (mins < 60) return `${mins}m ago`;
return `${Math.floor(mins / 60)}h ago`;
}
function handleClearCache() {
clearing = true;
caches.keys().then((names) => Promise.all(names.map((n) => caches.delete(n)))).catch(() => {})
.finally(() => { clearing = false; cleared = true; setTimeout(() => cleared = false, 2500); refreshPerfMetrics(); });
}
$effect(() => { refreshPerfMetrics(); });
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Render Limit</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Items per page</span>
<span class="s-desc">Lower = faster on large libraries</span>
</div>
<div class="s-stepper">
<button class="s-step-btn" onclick={() => updateSettings({ renderLimit: Math.max(12, (store.settings.renderLimit ?? 48) - 12) })} disabled={(store.settings.renderLimit ?? 48) <= 12}></button>
<span class="s-step-val">{store.settings.renderLimit ?? 48}</span>
<button class="s-step-btn" onclick={() => updateSettings({ renderLimit: Math.min(200, (store.settings.renderLimit ?? 48) + 12) })} disabled={(store.settings.renderLimit ?? 48) >= 200}>+</button>
</div>
</div>
<div class="s-presets">
{#each [12, 24, 48, 96, 200] as v}
<button class="s-preset" class:active={(store.settings.renderLimit ?? 48) === v} onclick={() => updateSettings({ renderLimit: v })}>{v}</button>
{/each}
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Rendering</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">GPU acceleration</span><span class="s-desc">Uses the GPU for rendering; disable if you see visual glitches</span></div>
<button role="switch" aria-checked={store.settings.gpuAcceleration} aria-label="GPU acceleration" class="s-toggle" class:on={store.settings.gpuAcceleration} onclick={() => updateSettings({ gpuAcceleration: !store.settings.gpuAcceleration })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Idle / Splash Screen</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Animated card background</span><span class="s-desc">Shows cover art cards floating in the background on the idle screen</span></div>
<button role="switch" aria-checked={store.settings.splashCards ?? true} aria-label="Animated card background" class="s-toggle" class:on={store.settings.splashCards ?? true} onclick={() => updateSettings({ splashCards: !(store.settings.splashCards ?? true) })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Interface</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Compact sidebar</span><span class="s-desc">Collapses the sidebar to icons only</span></div>
<button role="switch" aria-checked={store.settings.compactSidebar} aria-label="Compact sidebar" class="s-toggle" class:on={store.settings.compactSidebar} onclick={() => updateSettings({ compactSidebar: !store.settings.compactSidebar })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Session Cache</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Cache entries</span>
<span class="s-desc">In-memory, cleared on restart</span>
</div>
<div class="s-btn-row">
<span class="s-step-val">{perfSnapshot?.cacheEntries ?? 0} entries</span>
<button class="s-btn-icon" onclick={refreshPerfMetrics} title="Refresh"></button>
</div>
</div>
{#if perfSnapshot && perfSnapshot.cacheEntries > 0}
<div class="s-row">
<div class="s-row-info"><span class="s-label">Oldest entry</span></div>
<span class="s-step-val">{fmtAge(perfSnapshot.oldestEntryMs)}</span>
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Newest entry</span></div>
<span class="s-step-val">{fmtAge(perfSnapshot.newestEntryMs)}</span>
</div>
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Cached keys</span>
<span class="s-desc">{perfSnapshot.cacheKeys.join(", ")}</span>
</div>
</div>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">Cache</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Image cache</span><span class="s-desc">Webview page image cache</span></div>
<button class="s-btn s-btn-danger" onclick={handleClearCache} disabled={clearing}>
{cleared ? "Cleared" : clearing ? "Clearing…" : "Clear"}
</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,141 @@
<script lang="ts">
import { store, updateSettings } from "@store/state.svelte";
import type { Settings, FitMode } from "@types/settings";
interface Props {
selectOpen: string | null;
onToggleSelect: (id: string) => void;
}
let { selectOpen, onToggleSelect }: Props = $props();
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Page Layout</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Default layout</span><span class="s-desc">How chapters open by default</span></div>
<div class="s-select" id="page-style">
<button class="s-select-btn" onclick={() => onToggleSelect("page-style")}>
<span>{{ "single":"Single page","longstrip":"Long strip" }[store.settings.pageStyle === "double" ? "single" : store.settings.pageStyle]}</span>
<svg class="s-select-caret" class:open={selectOpen === "page-style"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "page-style"}
<div class="s-select-menu">
{#each [["single","Single page"],["longstrip","Long strip"]] as [v, l]}
<button class="s-select-option" class:active={(store.settings.pageStyle === "double" ? "single" : store.settings.pageStyle) === v} onclick={() => { updateSettings({ pageStyle: v as Settings["pageStyle"] }); onToggleSelect("page-style"); }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Reading direction</span><span class="s-desc">Left-to-right for most manga, right-to-left for Japanese</span></div>
<div class="s-select" id="reading-dir">
<button class="s-select-btn" onclick={() => onToggleSelect("reading-dir")}>
<span>{{ "ltr":"Left to right","rtl":"Right to left" }[store.settings.readingDirection]}</span>
<svg class="s-select-caret" class:open={selectOpen === "reading-dir"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "reading-dir"}
<div class="s-select-menu">
{#each [["ltr","Left to right"],["rtl","Right to left"]] as [v, l]}
<button class="s-select-option" class:active={store.settings.readingDirection === v} onclick={() => { updateSettings({ readingDirection: v as Settings["readingDirection"] }); onToggleSelect("reading-dir"); }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Page gap</span><span class="s-desc">Adds spacing between pages in single-page mode</span></div>
<button role="switch" aria-checked={store.settings.pageGap} aria-label="Page gap" class="s-toggle" class:on={store.settings.pageGap} onclick={() => updateSettings({ pageGap: !store.settings.pageGap })}><span class="s-toggle-thumb"></span></button>
</label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Overlay bars</span><span class="s-desc">Floats the nav and chapter bars over the page instead of pushing content</span></div>
<button role="switch" aria-checked={store.settings.overlayBars ?? false} aria-label="Overlay bars" class="s-toggle" class:on={store.settings.overlayBars ?? false} onclick={() => updateSettings({ overlayBars: !(store.settings.overlayBars ?? false) })}><span class="s-toggle-thumb"></span></button>
</label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Tap to toggle bar</span><span class="s-desc">Double-tap the center of the reader to show or hide the bars — ideal for touchscreens</span></div>
<button role="switch" aria-checked={store.settings.tapToToggleBar ?? false} aria-label="Tap to toggle bar" class="s-toggle" class:on={store.settings.tapToToggleBar ?? false} onclick={() => updateSettings({ tapToToggleBar: !(store.settings.tapToToggleBar ?? false) })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Fit &amp; Zoom</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info"><span class="s-label">Default fit mode</span><span class="s-desc">How pages are scaled to fill the reader on open</span></div>
<div class="s-select" id="fit-mode">
<button class="s-select-btn" onclick={() => onToggleSelect("fit-mode")}>
<span>{{ "width":"Fit width","height":"Fit height","screen":"Fit screen","original":"Original (1:1)" }[store.settings.fitMode ?? "width"]}</span>
<svg class="s-select-caret" class:open={selectOpen === "fit-mode"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "fit-mode"}
<div class="s-select-menu">
{#each [["width","Fit width"],["height","Fit height"],["screen","Fit screen"],["original","Original (1:1)"]] as [v, l]}
<button class="s-select-option" class:active={(store.settings.fitMode ?? "width") === v} onclick={() => { updateSettings({ fitMode: v as FitMode }); onToggleSelect("fit-mode"); }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="s-slider-row">
<input type="range" min={10} max={400} step={5}
value={Math.round((store.settings.readerZoom ?? 0.5) * 100)}
oninput={(e) => updateSettings({ readerZoom: Number(e.currentTarget.value) / 100 })}
class="s-slider" />
<input type="number" min={10} max={400} step={5} class="s-slider-val"
value={Math.round((store.settings.readerZoom ?? 0.5) * 100)}
oninput={(e) => { const n = parseInt(e.currentTarget.value, 10); if (!isNaN(n) && n >= 10 && n <= 400) updateSettings({ readerZoom: n / 100 }); }}
onblur={(e) => { const n = parseInt(e.currentTarget.value, 10); if (isNaN(n) || n < 10) { updateSettings({ readerZoom: 0.1 }); e.currentTarget.value = "10"; } else if (n > 400) { updateSettings({ readerZoom: 4.0 }); e.currentTarget.value = "400"; } }}
/>
<span class="s-slider-unit">%</span>
<button class="s-btn-icon" onclick={() => updateSettings({ readerZoom: 0.5 })} disabled={(store.settings.readerZoom ?? 0.5) === 0.5} title="Reset to 100%"></button>
</div>
<div class="s-presets">
{#each [50, 75, 100, 125, 150, 200] as v}
<button class="s-preset" class:active={Math.round((store.settings.readerZoom ?? 0.5) * 100) === v} onclick={() => updateSettings({ readerZoom: v / 100 })}>{v}%</button>
{/each}
</div>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Optimize contrast</span><span class="s-desc">Sharpens dark lines on light pages; best for black-and-white manga</span></div>
<button role="switch" aria-checked={store.settings.optimizeContrast} aria-label="Optimize contrast" class="s-toggle" class:on={store.settings.optimizeContrast} onclick={() => updateSettings({ optimizeContrast: !store.settings.optimizeContrast })}><span class="s-toggle-thumb"></span></button>
</label>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Behaviour</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Auto-mark read</span><span class="s-desc">Marks a chapter as read when you reach the last page</span></div>
<button role="switch" aria-checked={store.settings.autoMarkRead} aria-label="Auto-mark chapters read" class="s-toggle" class:on={store.settings.autoMarkRead} onclick={() => updateSettings({ autoMarkRead: !store.settings.autoMarkRead })}><span class="s-toggle-thumb"></span></button>
</label>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Auto-advance chapters</span><span class="s-desc">Automatically loads the next chapter when you pass the last page</span></div>
<button role="switch" aria-checked={store.settings.autoNextChapter ?? false} aria-label="Auto-advance chapters" class="s-toggle" class:on={store.settings.autoNextChapter} onclick={() => updateSettings({ autoNextChapter: !(store.settings.autoNextChapter ?? false) })}><span class="s-toggle-thumb"></span></button>
</label>
{#if !(store.settings.autoNextChapter ?? false)}
<label class="s-row">
<div class="s-row-info"><span class="s-label">Mark read when skipping</span><span class="s-desc">Marks the current chapter read when you manually jump to the next</span></div>
<button role="switch" aria-checked={store.settings.markReadOnNext ?? true} aria-label="Mark read when skipping" class="s-toggle" class:on={store.settings.markReadOnNext ?? true} onclick={() => updateSettings({ markReadOnNext: !(store.settings.markReadOnNext ?? true) })}><span class="s-toggle-thumb"></span></button>
</label>
{/if}
<label class="s-row">
<div class="s-row-info"><span class="s-label">Auto-bookmark</span><span class="s-desc">Automatically saves your page position as you read</span></div>
<button role="switch" aria-checked={store.settings.autoBookmark ?? true} aria-label="Enable auto-bookmark" class="s-toggle" class:on={store.settings.autoBookmark ?? true} onclick={() => updateSettings({ autoBookmark: !(store.settings.autoBookmark ?? true) })}><span class="s-toggle-thumb"></span></button>
</label>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Pages to preload</span><span class="s-desc">How many pages ahead to fetch in the background while reading</span></div>
<div class="s-stepper">
<button class="s-step-btn" onclick={() => updateSettings({ preloadPages: Math.max(0, store.settings.preloadPages - 1) })} disabled={store.settings.preloadPages <= 0}></button>
<span class="s-step-val">{store.settings.preloadPages}</span>
<button class="s-step-btn" onclick={() => updateSettings({ preloadPages: Math.min(10, store.settings.preloadPages + 1) })} disabled={store.settings.preloadPages >= 10}>+</button>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,361 @@
<script lang="ts">
import { store, updateSettings, addToast } from "@store/state.svelte";
import { gql } from "@api/client";
import { authSession } from "@core/auth";
import { GET_SERVER_SECURITY } from "@api/queries/extensions";
import { SET_SERVER_AUTH, SET_SOCKS_PROXY, SET_FLARESOLVERR } from "@api/mutations/extensions";
interface Props { selectOpen: string | null; toggleSelect: (id: string) => void; }
let { selectOpen, toggleSelect }: Props = $props();
let showAuthPass = $state(false);
let showSocksPass = $state(false);
let pinInput = $state(store.settings.appLockPin ?? "");
let pinError = $state("");
let secLoading = $state(false);
let secError = $state<string | null>(null);
let secSaved = $state<string | null>(null);
let secLoaded = $state(false);
let authMode = $state(store.settings.serverAuthMode ?? "NONE");
let authUsername = $state(store.settings.serverAuthUser ?? "");
let authPassword = $state("");
const authModeUnsupported = $derived(
store.settings.serverAuthMode === "SIMPLE_LOGIN" ||
store.settings.serverAuthMode === "UI_LOGIN"
);
let socksEnabled = $state(store.settings.socksProxyEnabled ?? false);
let socksHost = $state(store.settings.socksProxyHost ?? "");
let socksPort = $state(store.settings.socksProxyPort ?? "1080");
let socksVersion = $state(store.settings.socksProxyVersion ?? 5);
let socksUsername = $state(store.settings.socksProxyUsername ?? "");
let socksPassword = $state(store.settings.socksProxyPassword ?? "");
let flareEnabled = $state(store.settings.flareSolverrEnabled ?? false);
let flareUrl = $state(store.settings.flareSolverrUrl ?? "http://localhost:8191");
let flareTimeout = $state(store.settings.flareSolverrTimeout ?? 60);
let flareSession = $state(store.settings.flareSolverrSessionName ?? "moku");
let flareTtl = $state(store.settings.flareSolverrSessionTtl ?? 15);
let flareFallback = $state(store.settings.flareSolverrFallback ?? false);
function showSaved(key: string) {
secSaved = key; secError = null;
setTimeout(() => { if (secSaved === key) secSaved = null; }, 2000);
}
$effect(() => {
if (!secLoaded) { secLoaded = true; loadServerSecurity(); }
});
async function loadServerSecurity() {
try {
const res = await gql<{ settings: {
authMode: string; authUsername: string;
socksProxyEnabled: boolean; socksProxyHost: string; socksProxyPort: string;
socksProxyVersion: number; socksProxyUsername: string;
flareSolverrEnabled: boolean; flareSolverrUrl: string; flareSolverrTimeout: number;
flareSolverrSessionName: string; flareSolverrSessionTtl: number;
flareSolverrAsResponseFallback: boolean;
}}>(GET_SERVER_SECURITY);
const s = res.settings;
const mode = (s.authMode ?? "NONE") as "NONE" | "BASIC_AUTH" | "SIMPLE_LOGIN" | "UI_LOGIN";
authMode = mode; authUsername = s.authUsername;
updateSettings({ serverAuthMode: mode, serverAuthUser: s.authUsername });
socksEnabled = s.socksProxyEnabled; socksHost = s.socksProxyHost;
socksPort = s.socksProxyPort; socksVersion = s.socksProxyVersion;
socksUsername = s.socksProxyUsername;
flareEnabled = s.flareSolverrEnabled; flareUrl = s.flareSolverrUrl;
flareTimeout = s.flareSolverrTimeout; flareSession = s.flareSolverrSessionName;
flareTtl = s.flareSolverrSessionTtl; flareFallback = s.flareSolverrAsResponseFallback;
updateSettings({
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost, socksProxyPort: socksPort,
socksProxyVersion: socksVersion, socksProxyUsername: socksUsername,
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
flareSolverrSessionTtl: flareTtl, flareSolverrFallback: flareFallback,
});
} catch {}
}
async function saveAuth() {
if (authMode === "BASIC_AUTH" && (!authUsername.trim() || !authPassword.trim())) {
secError = "Username and password are required for Basic Auth"; return;
}
secLoading = true; secError = null;
const prev = { mode: store.settings.serverAuthMode, user: store.settings.serverAuthUser, pass: store.settings.serverAuthPass };
const newUser = authMode === "BASIC_AUTH" ? authUsername.trim() : "";
const newPass = authMode === "BASIC_AUTH" ? authPassword.trim() : "";
if (authMode === "BASIC_AUTH" && !prev.pass.trim())
updateSettings({ serverAuthMode: authMode as any, serverAuthUser: newUser, serverAuthPass: newPass });
try {
await gql(SET_SERVER_AUTH, { authMode, authUsername: newUser, authPassword: newPass });
updateSettings({ serverAuthMode: authMode as any, serverAuthUser: newUser, serverAuthPass: newPass });
if (authMode === "NONE") { authSession.clearTokens(); authPassword = ""; }
showSaved("auth");
} catch (e: any) {
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass });
secError = e?.message ?? "Failed to save authentication settings";
} finally { secLoading = false; }
}
async function clearAuth() {
secLoading = true; secError = null;
const prev = { mode: store.settings.serverAuthMode, user: store.settings.serverAuthUser, pass: store.settings.serverAuthPass };
try {
await gql(SET_SERVER_AUTH, { authMode: "NONE", authUsername: "", authPassword: "" });
updateSettings({ serverAuthMode: "NONE", serverAuthUser: "", serverAuthPass: "" });
authMode = "NONE"; authUsername = ""; authPassword = "";
authSession.clearTokens(); showSaved("auth");
} catch (e: any) {
updateSettings({ serverAuthMode: prev.mode, serverAuthUser: prev.user, serverAuthPass: prev.pass });
secError = e?.message ?? "Failed to disable authentication";
} finally { secLoading = false; }
}
async function saveSocksProxy() {
secLoading = true; secError = null;
try {
await gql(SET_SOCKS_PROXY, {
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost.trim(),
socksProxyPort: socksPort.trim(), socksProxyVersion: socksVersion,
socksProxyUsername: socksUsername.trim(), socksProxyPassword: socksPassword.trim(),
});
updateSettings({
socksProxyEnabled: socksEnabled, socksProxyHost: socksHost,
socksProxyPort: socksPort, socksProxyVersion: socksVersion,
socksProxyUsername: socksUsername, socksProxyPassword: socksPassword,
});
showSaved("socks");
} catch (e: any) {
secError = e?.message ?? "Failed to save SOCKS proxy";
} finally { secLoading = false; }
}
async function saveFlareSolverr() {
secLoading = true; secError = null;
try {
await gql(SET_FLARESOLVERR, {
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl.trim(),
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession.trim(),
flareSolverrSessionTtl: flareTtl, flareSolverrAsResponseFallback: flareFallback,
});
updateSettings({
flareSolverrEnabled: flareEnabled, flareSolverrUrl: flareUrl,
flareSolverrTimeout: flareTimeout, flareSolverrSessionName: flareSession,
flareSolverrSessionTtl: flareTtl, flareSolverrFallback: flareFallback,
});
showSaved("flare");
} catch (e: any) {
secError = e?.message ?? "Failed to save FlareSolverr";
} finally { secLoading = false; }
}
function commitPin() {
const cleaned = pinInput.replace(/\D/g, "").slice(0, 8);
pinInput = cleaned;
if (cleaned.length >= 4) { updateSettings({ appLockPin: cleaned }); pinError = ""; }
else if (cleaned.length > 0) { pinError = "PIN must be at least 4 digits"; }
else { updateSettings({ appLockPin: "" }); pinError = ""; }
}
const EyeOpen = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/><circle cx="12" cy="12" r="3"/></svg>`;
const EyeClose = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"/><path d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"/><line x1="1" y1="1" x2="23" y2="23"/></svg>`;
</script>
<div class="s-panel">
{#if secError}
<div class="s-banner s-banner-error">{secError}</div>
{/if}
<div class="s-section">
<p class="s-section-title">
Server Authentication
<span class="s-pill" class:on={store.settings.serverAuthMode === "BASIC_AUTH"} class:warn={authModeUnsupported}>
{store.settings.serverAuthMode === "BASIC_AUTH" ? "Basic Auth" :
store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login — unsupported" :
store.settings.serverAuthMode === "UI_LOGIN" ? "UI Login — unsupported" : "Disabled"}
</span>
</p>
<div class="s-section-body">
{#if authModeUnsupported}
<div class="s-banner s-banner-warn">
<strong>{store.settings.serverAuthMode === "SIMPLE_LOGIN" ? "Simple Login" : "UI Login"}</strong> is not supported — only <strong>Basic Auth</strong> works here. Switch your server to <code>basic_auth</code> and set the mode to <strong>Basic</strong>.
</div>
{/if}
<div class="s-row">
<div class="s-row-info"><span class="s-label">Mode</span><span class="s-desc">How Suwayomi verifies requests</span></div>
<div class="s-segment">
{#each [{ value: "NONE", label: "None" }, { value: "BASIC_AUTH", label: "Basic" }] as opt}
<button class="s-segment-btn" class:active={authMode === opt.value}
onclick={() => authMode = opt.value as any} disabled={secLoading}>{opt.label}</button>
{/each}
</div>
</div>
{#if authMode !== "NONE"}
<div class="s-row">
<div class="s-row-info"><span class="s-label">Username</span></div>
<input class="s-input" bind:value={authUsername} placeholder="Username" autocomplete="off" spellcheck="false" disabled={secLoading} />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Password</span></div>
<div class="s-field-wrap">
<input class="s-input" type={showAuthPass ? "text" : "password"} bind:value={authPassword} placeholder="Password" autocomplete="off" spellcheck="false" disabled={secLoading} />
<button class="s-eye-btn" onclick={() => showAuthPass = !showAuthPass} tabindex="-1">{@html showAuthPass ? EyeClose : EyeOpen}</button>
</div>
</div>
{/if}
{#if store.settings.serverAuthMode === "BASIC_AUTH"}
<div class="s-row">
<span class="s-desc">Images are proxied through Tauri when Basic Auth is active, which reduces loading speed.</span>
</div>
{/if}
<div class="s-row">
<div class="s-row-info"></div>
<div class="s-btn-row">
{#if store.settings.serverAuthMode !== "NONE"}
<button class="s-btn s-btn-danger" onclick={clearAuth} disabled={secLoading}>
{secLoading ? "Saving…" : "Disable"}
</button>
{/if}
<button class="s-btn s-btn-accent" onclick={saveAuth}
disabled={secLoading || (authMode === "BASIC_AUTH" && (!authUsername.trim() || !authPassword.trim()))}>
{secLoading ? "Saving…" : secSaved === "auth" ? "Saved ✓" : store.settings.serverAuthMode === "BASIC_AUTH" ? "Update" : authMode === "NONE" ? "Save" : "Enable"}
</button>
</div>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">App Lock</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">PIN lock</span><span class="s-desc">Require a PIN on launch and after idle timeout</span></div>
<button role="switch" aria-checked={store.settings.appLockEnabled ?? false} class="s-toggle" class:on={store.settings.appLockEnabled}
onclick={() => updateSettings({ appLockEnabled: !store.settings.appLockEnabled })}><span class="s-toggle-thumb"></span></button>
</label>
{#if store.settings.appLockEnabled}
<div class="s-row">
<div class="s-row-info"><span class="s-label">PIN</span><span class="s-desc">48 digits</span></div>
<div class="s-btn-row">
<input class="s-input" type="password" inputmode="numeric" maxlength={8} placeholder="48 digits"
value={pinInput}
oninput={(e) => { pinInput = e.currentTarget.value.replace(/\D/g, "").slice(0, 8); pinError = ""; }}
onkeydown={(e) => e.key === "Enter" && commitPin()}
autocomplete="off" style="width:120px;letter-spacing:0.2em" />
<button class="s-btn s-btn-accent" onclick={commitPin} disabled={pinInput.length > 0 && pinInput.length < 4}>
{store.settings.appLockPin && pinInput === store.settings.appLockPin ? "Saved ✓" : "Save"}
</button>
</div>
</div>
{#if pinError}<div class="s-row"><span class="s-pin-error">{pinError}</span></div>{/if}
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">SOCKS Proxy</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Enable SOCKS proxy</span><span class="s-desc">Route Suwayomi traffic through a SOCKS4/5 proxy</span></div>
<button role="switch" aria-checked={socksEnabled} class="s-toggle" class:on={socksEnabled}
onclick={() => { socksEnabled = !socksEnabled; saveSocksProxy(); }}><span class="s-toggle-thumb"></span></button>
</label>
{#if socksEnabled}
<div class="s-row">
<div class="s-row-info"><span class="s-label">Version</span></div>
<div class="s-select" id="socks-ver">
<button class="s-select-btn" onclick={() => toggleSelect("socks-ver")}>
<span>SOCKS{socksVersion}</span>
<svg class="s-select-caret" class:open={selectOpen === "socks-ver"} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if selectOpen === "socks-ver"}
<div class="s-select-menu">
{#each [[4,"SOCKS4"],[5,"SOCKS5"]] as [v, l]}
<button class="s-select-option" class:active={socksVersion === v} onclick={() => { socksVersion = v as number; toggleSelect("socks-ver"); }}>{l}</button>
{/each}
</div>
{/if}
</div>
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Host</span></div>
<input class="s-input" bind:value={socksHost} placeholder="127.0.0.1" autocomplete="off" spellcheck="false" />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Port</span></div>
<input class="s-input" style="width:80px" bind:value={socksPort} placeholder="1080" autocomplete="off" spellcheck="false" />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Username</span><span class="s-desc">Optional</span></div>
<input class="s-input" bind:value={socksUsername} placeholder="Username" autocomplete="off" spellcheck="false" />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Password</span><span class="s-desc">Optional</span></div>
<div class="s-field-wrap">
<input class="s-input" type={showSocksPass ? "text" : "password"} bind:value={socksPassword} placeholder="Password" autocomplete="off" spellcheck="false" />
<button class="s-eye-btn" onclick={() => showSocksPass = !showSocksPass} tabindex="-1">{@html showSocksPass ? EyeClose : EyeOpen}</button>
</div>
</div>
<div class="s-row">
<div class="s-row-info"></div>
<button class="s-btn s-btn-accent" onclick={saveSocksProxy} disabled={secLoading}>
{secLoading ? "Saving…" : secSaved === "socks" ? "Saved ✓" : "Save"}
</button>
</div>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">FlareSolverr</p>
<div class="s-section-body">
<label class="s-row">
<div class="s-row-info"><span class="s-label">Enable FlareSolverr</span><span class="s-desc">Bypass Cloudflare challenges for sources that require it</span></div>
<button role="switch" aria-checked={flareEnabled} class="s-toggle" class:on={flareEnabled}
onclick={() => { flareEnabled = !flareEnabled; saveFlareSolverr(); }}><span class="s-toggle-thumb"></span></button>
</label>
{#if flareEnabled}
<div class="s-row">
<div class="s-row-info"><span class="s-label">URL</span><span class="s-desc">FlareSolverr instance address</span></div>
<input class="s-input" bind:value={flareUrl} placeholder="http://localhost:8191" autocomplete="off" spellcheck="false" />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Timeout</span><span class="s-desc">Max wait per request, in seconds</span></div>
<div class="s-stepper">
<button class="s-step-btn" onclick={() => flareTimeout = Math.max(10, flareTimeout - 10)}></button>
<span class="s-step-val">{flareTimeout}s</span>
<button class="s-step-btn" onclick={() => flareTimeout = Math.min(300, flareTimeout + 10)}>+</button>
</div>
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Session name</span><span class="s-desc">Reuse browser session across requests</span></div>
<input class="s-input" bind:value={flareSession} placeholder="moku" autocomplete="off" spellcheck="false" />
</div>
<div class="s-row">
<div class="s-row-info"><span class="s-label">Session TTL</span><span class="s-desc">Minutes before session is refreshed</span></div>
<div class="s-stepper">
<button class="s-step-btn" onclick={() => flareTtl = Math.max(1, flareTtl - 1)}></button>
<span class="s-step-val">{flareTtl}m</span>
<button class="s-step-btn" onclick={() => flareTtl = Math.min(60, flareTtl + 1)}>+</button>
</div>
</div>
<label class="s-row">
<div class="s-row-info"><span class="s-label">Response fallback</span><span class="s-desc">Use FlareSolverr's response when the direct request fails</span></div>
<button role="switch" aria-checked={flareFallback} class="s-toggle" class:on={flareFallback}
onclick={() => flareFallback = !flareFallback}><span class="s-toggle-thumb"></span></button>
</label>
<div class="s-row">
<div class="s-row-info"></div>
<button class="s-btn s-btn-accent" onclick={saveFlareSolverr} disabled={secLoading}>
{secLoading ? "Saving…" : secSaved === "flare" ? "Saved ✓" : "Save"}
</button>
</div>
{/if}
</div>
</div>
</div>
@@ -0,0 +1,629 @@
<script lang="ts">
import { Trash, ClockCounterClockwise } from "phosphor-svelte";
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { gql } from "@api/client";
import { GET_DOWNLOADS_PATH, GET_RESTORE_STATUS, VALIDATE_BACKUP } from "@api/queries/manga";
import { CREATE_BACKUP, RESTORE_BACKUP } from "@api/mutations/manga";
import { SET_DOWNLOADS_PATH, SET_LOCAL_SOURCE_PATH } from "@api/mutations/downloads";
import { untrack } from "svelte";
import { store, updateSettings, addToast } from "@store/state.svelte";
interface StorageInfo { manga_bytes: number; total_bytes: number; free_bytes: number; path: string; }
const isExternalServer = $derived.by(() => {
const url = (store.settings.serverUrl ?? "http://localhost:4567").toLowerCase().trim();
try {
const host = new URL(url).hostname;
return host !== "localhost" && host !== "127.0.0.1" && host !== "::1";
} catch { return false; }
});
let storageInfo = $state<StorageInfo | null>(null);
let storageLoading = $state(false);
let storageError = $state<string | null>(null);
let downloadsPathInput = $state(store.settings.serverDownloadsPath ?? "");
let localSourcePathInput = $state(store.settings.serverLocalSourcePath ?? "");
let pathsSaving = $state(false);
let pathsError = $state<string | null>(null);
let pathsFieldError = $state<{ dl?: string; loc?: string }>({});
let pathsSaved = $state(false);
let defaultDownloadsPath = $state("");
$effect(() => {
if (!isExternalServer) {
invoke<string>("get_default_downloads_path").then(p => { defaultDownloadsPath = p; });
} else {
defaultDownloadsPath = "";
}
});
let confirmedDownloadsPath = $state(store.settings.serverDownloadsPath ?? "");
let confirmedLocalSourcePath = $state(store.settings.serverLocalSourcePath ?? "");
let migrateFrom = $state<string | null>(null);
let migrateTo = $state<string | null>(null);
let migrating = $state(false);
let migrateProgress = $state<{ done: number; total: number; current: string } | null>(null);
let migrateError = $state<string | null>(null);
let migrateUnlisten: (() => void) | null = null;
let extraScanDirs = $state<string[]>([...(store.settings.extraScanDirs ?? [])]);
let newScanDir = $state("");
let multiStorageInfos = $state<(StorageInfo & { label: string })[]>([]);
let advStorageOpen = $state(false);
let backupSectionOpen = $state(false);
async function fetchStorage() {
storageLoading = true; storageError = null;
try {
const pathData = await gql<{ settings: { downloadsPath: string; localSourcePath: string } }>(GET_DOWNLOADS_PATH);
const dl = pathData.settings.downloadsPath ?? "";
const loc = pathData.settings.localSourcePath ?? "";
downloadsPathInput = dl; localSourcePathInput = loc;
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc;
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc });
if (isExternalServer) { multiStorageInfos = []; storageInfo = null; return; }
const effectiveDl = dl || defaultDownloadsPath;
const dirsToScan: { path: string; label: string }[] = [];
if (effectiveDl) dirsToScan.push({ path: effectiveDl, label: dl ? "Downloads" : "Downloads (default)" });
if (loc && loc !== effectiveDl) dirsToScan.push({ path: loc, label: "Local source" });
for (const p of extraScanDirs) {
if (p && !dirsToScan.find(d => d.path === p)) dirsToScan.push({ path: p, label: p });
}
if (dirsToScan.length === 0) { multiStorageInfos = []; storageInfo = null; return; }
const results = await Promise.allSettled(
dirsToScan.map(d => invoke<StorageInfo>("get_storage_info", { downloadsPath: d.path }).then(info => ({ ...info, label: d.label })))
);
multiStorageInfos = results
.filter((r): r is PromiseFulfilledResult<StorageInfo & { label: string }> => r.status === "fulfilled")
.map(r => r.value);
storageInfo = multiStorageInfos[0] ?? null;
} catch (e: any) {
storageError = e instanceof Error ? e.message : String(e);
} finally { storageLoading = false; }
}
async function validatePath(path: string): Promise<string | null> {
if (!path.trim()) return null;
if (isExternalServer) return null;
try {
const exists = await invoke<boolean>("check_path_exists", { path: path.trim() });
return exists ? null : "Directory does not exist";
} catch { return "Could not check path"; }
}
async function createDirectory(path: string): Promise<void> {
if (isExternalServer) throw new Error("Cannot create directories on an external server");
await invoke("create_directory", { path });
}
async function savePaths() {
const dl = downloadsPathInput.trim();
const loc = localSourcePathInput.trim();
pathsError = null; pathsFieldError = {};
const [dlErr, locErr] = await Promise.all([validatePath(dl), validatePath(loc)]);
if (dlErr || locErr) { pathsFieldError = { ...(dlErr ? { dl: dlErr } : {}), ...(locErr ? { loc: locErr } : {}) }; return; }
pathsSaving = true;
try {
await gql(SET_DOWNLOADS_PATH, { path: dl });
if (loc) await gql(SET_LOCAL_SOURCE_PATH, { path: loc });
updateSettings({ serverDownloadsPath: dl, serverLocalSourcePath: loc });
if (!isExternalServer) {
const oldDl = confirmedDownloadsPath || defaultDownloadsPath;
const newDl = dl || defaultDownloadsPath;
if (newDl && oldDl && newDl !== oldDl) {
const hadContent = await invoke<boolean>("check_path_exists", { path: oldDl });
if (hadContent) { migrateFrom = oldDl; migrateTo = newDl; }
}
}
confirmedDownloadsPath = dl; confirmedLocalSourcePath = loc;
pathsSaved = true; setTimeout(() => pathsSaved = false, 2000);
await fetchStorage();
} catch (e: any) {
pathsError = e?.message ?? "Failed to save paths";
} finally { pathsSaving = false; }
}
async function startMigration() {
if (!migrateFrom || !migrateTo) return;
migrating = true; migrateError = null; migrateProgress = { done: 0, total: 0, current: "" };
const { listen: tauriListen } = await import("@tauri-apps/api/event");
migrateUnlisten = await tauriListen<{ done: number; total: number; current: string }>(
"migrate_progress", (e) => { migrateProgress = e.payload; }
);
try {
await invoke("migrate_downloads", { src: migrateFrom, dst: migrateTo });
migrateFrom = null; migrateTo = null; migrateProgress = null;
await fetchStorage();
} catch (e: any) {
migrateError = e?.message ?? "Migration failed";
} finally { migrating = false; migrateUnlisten?.(); migrateUnlisten = null; }
}
function dismissMigration() { migrateFrom = null; migrateTo = null; migrateError = null; migrateProgress = null; }
async function browseDownloadsFolder() {
const picked = await invoke<string | null>("pick_downloads_folder");
if (picked) { downloadsPathInput = picked; pathsFieldError = { ...pathsFieldError, dl: undefined }; }
}
function addExtraScanDir() {
const dir = newScanDir.trim();
if (!dir || extraScanDirs.includes(dir)) return;
extraScanDirs = [...extraScanDirs, dir];
updateSettings({ extraScanDirs }); newScanDir = ""; fetchStorage();
}
function removeExtraScanDir(path: string) {
extraScanDirs = extraScanDirs.filter(d => d !== path);
updateSettings({ extraScanDirs }); fetchStorage();
}
function fmtBytes(bytes: number): string {
if (bytes === 0) return "0 B";
const units = ["B","KB","MB","GB","TB"];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return `${(bytes / Math.pow(1024, i)).toFixed(i >= 2 ? 1 : 0)} ${units[i]}`;
}
let backupLoading = $state(false);
let backupError = $state<string | null>(null);
let backupList = $state<{ url: string; name: string; deleting?: boolean }[]>([]);
function loadBackupList() {
try { backupList = JSON.parse(localStorage.getItem("moku_backups") ?? "[]"); } catch { backupList = []; }
}
function saveBackupList() {
try { localStorage.setItem("moku_backups", JSON.stringify(backupList)); } catch {}
}
async function createBackup() {
backupLoading = true; backupError = null;
try {
const res = await gql<{ createBackup: { url: string } }>(CREATE_BACKUP);
const url = res.createBackup.url;
const name = url.split("/").pop() ?? url;
backupList = [{ url, name }, ...backupList]; saveBackupList();
} catch (e: any) { backupError = e?.message ?? "Failed to create backup"; }
finally { backupLoading = false; }
}
async function deleteBackup(url: string) {
backupList = backupList.map(b => b.url === url ? { ...b, deleting: true } : b);
try {
const serverUrl = (store.settings.serverUrl ?? "http://localhost:4567").replace(/\/$/, "");
const headers: Record<string, string> = {};
const pass = store.settings.serverAuthPass ?? "", user = store.settings.serverAuthUser ?? "";
if (store.settings.serverAuthMode === "BASIC_AUTH" && user && pass)
headers["Authorization"] = "Basic " + btoa(`${user}:${pass}`);
await fetch(`${serverUrl}${url}`, { method: "DELETE", headers });
backupList = backupList.filter(b => b.url !== url); saveBackupList();
} catch (e: any) {
backupList = backupList.map(b => b.url === url ? { ...b, deleting: false } : b);
backupError = (e as any)?.message ?? "Failed to delete backup";
}
}
async function downloadBackup(backup: { url: string; name: string }) {
try {
const serverUrl = (store.settings.serverUrl ?? "http://localhost:4567").replace(/\/$/, "");
const headers: Record<string, string> = {};
const pass = store.settings.serverAuthPass ?? "", user = store.settings.serverAuthUser ?? "";
if (store.settings.serverAuthMode === "BASIC_AUTH" && user && pass)
headers["Authorization"] = "Basic " + btoa(`${user}:${pass}`);
const resp = await fetch(`${serverUrl}${backup.url}`, { headers });
if (!resp.ok) throw new Error(`Server returned ${resp.status}`);
const blob = await resp.blob();
if ("showSaveFilePicker" in window) {
try {
const handle = await (window as any).showSaveFilePicker({
suggestedName: backup.name,
types: [{ description: "Backup file", accept: { "application/octet-stream": [".tachibk", ".proto.gz"] } }],
});
const writable = await handle.createWritable();
await writable.write(blob); await writable.close();
addToast({ kind: "success", title: "Backup saved", body: backup.name }); return;
} catch (pickerErr: any) { if (pickerErr?.name === "AbortError") return; }
}
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objectUrl; a.download = backup.name;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(objectUrl), 5000);
addToast({ kind: "download", title: "Backup downloaded", body: backup.name });
} catch (e: any) { backupError = e?.message ?? "Failed to download backup"; }
}
let restoreLoading = $state(false);
let restoreError = $state<string | null>(null);
let restoreJobId = $state<string | null>(null);
let restoreStatus = $state<{ mangaProgress: number; state: string; totalManga: number } | null>(null);
let restorePollInterval = $state<ReturnType<typeof setInterval> | null>(null);
let validateLoading = $state(false);
let validateError = $state<string | null>(null);
let validateResult = $state<{ missingSources: { id: string; name: string }[]; missingTrackers: { name: string }[] } | null>(null);
let restoreFile = $state<File | null>(null);
function stopRestorePoll() {
if (restorePollInterval) { clearInterval(restorePollInterval); restorePollInterval = null; }
}
async function pollRestoreStatus(id: string) {
try {
const res = await gql<{ restoreStatus: typeof restoreStatus }>(GET_RESTORE_STATUS, { id });
restoreStatus = res.restoreStatus;
if (res.restoreStatus?.state === "SUCCESS" || res.restoreStatus?.state === "FAILURE") stopRestorePoll();
} catch {}
}
function buildBackupFormData(file: File, query: string, variables: Record<string, unknown>) {
const form = new FormData();
form.append("operations", JSON.stringify({ query, variables }));
form.append("map", JSON.stringify({ "0": ["variables.backup"] }));
form.append("0", file, file.name);
return form;
}
function buildAuthHeaders(): Record<string, string> {
const headers: Record<string, string> = { "Accept": "application/json" };
const pass = store.settings.serverAuthPass ?? "", user = store.settings.serverAuthUser ?? "";
if (store.settings.serverAuthMode === "BASIC_AUTH" && user && pass)
headers["Authorization"] = "Basic " + btoa(`${user}:${pass}`);
return headers;
}
async function submitRestore() {
if (!restoreFile) return;
restoreLoading = true; restoreError = null; restoreStatus = null; restoreJobId = null;
stopRestorePoll();
try {
const serverUrl = (store.settings.serverUrl ?? "http://localhost:4567").replace(/\/$/, "");
const form = buildBackupFormData(
restoreFile,
`mutation RestoreBackup($backup: Upload!) { restoreBackup(input: { backup: $backup }) { id status { mangaProgress state totalManga } } }`,
{ backup: null }
);
const resp = await fetch(`${serverUrl}/api/graphql`, { method: "POST", headers: buildAuthHeaders(), body: form });
const json = await resp.json();
if (json.errors?.length) throw new Error(json.errors[0].message);
const result = json.data.restoreBackup;
restoreJobId = result.id; restoreStatus = result.status;
if (result.status?.state !== "SUCCESS" && result.status?.state !== "FAILURE")
restorePollInterval = setInterval(() => pollRestoreStatus(result.id), 1500);
} catch (e: any) { restoreError = e?.message ?? "Failed to start restore"; }
finally { restoreLoading = false; }
}
async function submitValidate() {
if (!restoreFile) return;
validateLoading = true; validateError = null; validateResult = null;
try {
const serverUrl = (store.settings.serverUrl ?? "http://localhost:4567").replace(/\/$/, "");
const form = buildBackupFormData(
restoreFile,
`query ValidateBackup($backup: Upload!) { validateBackup(input: { backup: $backup }) { missingSources { id name } missingTrackers { name } } }`,
{ backup: null }
);
const resp = await fetch(`${serverUrl}/api/graphql`, { method: "POST", headers: buildAuthHeaders(), body: form });
const json = await resp.json();
if (json.errors?.length) throw new Error(json.errors[0].message);
validateResult = json.data.validateBackup;
} catch (e: any) { validateError = e?.message ?? "Failed to validate backup"; }
finally { validateLoading = false; }
}
$effect(() => { untrack(() => { loadBackupList(); fetchStorage(); }); });
$effect(() => { return () => stopRestorePoll(); });
</script>
<div class="s-panel">
{#if migrateFrom && !isExternalServer}
<div class="s-migrate-banner">
<div class="s-migrate-body">
<span class="s-migrate-title">Manga found at previous path — move to new location?</span>
<span class="s-migrate-paths">{migrateFrom}{migrateTo}</span>
{#if migrateProgress && migrateProgress.total > 0}
<div class="s-migrate-bar"><div class="s-migrate-fill" style="width:{Math.round((migrateProgress.done/migrateProgress.total)*100)}%"></div></div>
<span class="s-migrate-paths">{migrateProgress.current} · {migrateProgress.done} / {migrateProgress.total}</span>
{/if}
{#if migrateError}<span class="s-desc" style="color:var(--color-error)">{migrateError}</span>{/if}
</div>
<div class="s-migrate-actions">
<button class="s-btn s-btn-accent" onclick={startMigration} disabled={migrating}>
{migrating ? (migrateProgress ? `Moving… ${migrateProgress.done}/${migrateProgress.total}` : "Starting…") : "Move files"}
</button>
<button class="s-btn" onclick={dismissMigration} disabled={migrating}>Skip</button>
</div>
</div>
{/if}
<div class="s-section">
<p class="s-section-title">
Disk Usage
<button class="s-btn" onclick={fetchStorage} disabled={storageLoading}>{storageLoading ? "…" : "↻"}</button>
</p>
<div class="s-section-body">
{#if storageLoading}
<p class="s-empty">Reading filesystem…</p>
{:else if storageError}
<p class="s-empty" style="color:var(--color-error)">{storageError}</p>
{:else if isExternalServer}
<p class="s-empty">Disk usage is unavailable for external servers — filesystem access requires a local connection.</p>
{:else if multiStorageInfos.length > 0}
{#each multiStorageInfos as info}
{@const limitGb = store.settings.storageLimitGb ?? null}
{@const limitBytes = limitGb !== null ? limitGb * 1024 ** 3 : null}
{@const available = info.manga_bytes + info.free_bytes}
{@const cap = limitBytes !== null ? Math.min(limitBytes, available) : available}
{@const pct = cap > 0 ? Math.min(100, (info.manga_bytes / cap) * 100) : 0}
<div class="s-storage-wrap">
<div class="s-storage-header">
<span class="s-storage-label">{info.label}</span>
<span class="s-storage-used">{fmtBytes(info.manga_bytes)} of {fmtBytes(cap)}</span>
</div>
<div class="s-storage-bar">
<div class="s-storage-fill" class:critical={pct > 90} class:warn={pct > 75 && pct <= 90} style="width:{pct}%"></div>
</div>
<div class="s-storage-footer">
<span>{info.path}</span>
<span>{fmtBytes(info.free_bytes)} free</span>
</div>
</div>
{/each}
{:else}
<p class="s-empty">No download path configured.</p>
{/if}
</div>
</div>
<div class="s-section">
<p class="s-section-title">Downloads Path</p>
<div class="s-section-body">
{#if isExternalServer}
<div class="s-row">
<span class="s-desc">Connected to an external server. The path below is read from the server — changes here will update the server's config directly.</span>
</div>
{/if}
<div class="s-row" style="gap:var(--sp-2)">
<input class="s-input full" class:error={!!pathsFieldError.dl}
bind:value={downloadsPathInput}
placeholder={isExternalServer ? "Server default" : (defaultDownloadsPath || "Default location")}
spellcheck="false"
onkeydown={(e) => e.key === "Enter" && savePaths()}
oninput={() => { pathsFieldError = { ...pathsFieldError, dl: undefined }; }} />
{#if !isExternalServer}
<button class="s-btn" onclick={browseDownloadsFolder}>Browse</button>
{/if}
</div>
<div class="s-row">
<div class="s-row-info">
{#if pathsFieldError.dl}
<span class="s-desc" style="color:var(--color-error)">{pathsFieldError.dl}</span>
{/if}
{#if pathsError}
<span class="s-desc" style="color:var(--color-error)">{pathsError}</span>
{/if}
</div>
<div class="s-btn-row">
{#if pathsFieldError.dl && !isExternalServer}
<button class="s-btn" onclick={async () => {
try { await createDirectory(downloadsPathInput.trim()); pathsFieldError = { ...pathsFieldError, dl: undefined }; }
catch (e: any) { pathsFieldError = { ...pathsFieldError, dl: e?.message ?? "Failed" }; }
}}>Create</button>
{/if}
<button class="s-btn s-btn-accent" onclick={savePaths} disabled={pathsSaving}>
{pathsSaved ? "Saved ✓" : pathsSaving ? "Saving…" : "Save"}
</button>
</div>
</div>
</div>
</div>
<div class="s-section">
<p class="s-section-title">Storage Limit</p>
<div class="s-section-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Warn when limit is reached</span>
<span class="s-desc">{store.settings.storageLimitGb === null ? "No limit set" : `Warn above ${store.settings.storageLimitGb} GB`}</span>
</div>
{#if store.settings.storageLimitGb === null}
<button class="s-btn" onclick={() => updateSettings({ storageLimitGb: 10 })}>Set limit</button>
{:else}
<div class="s-stepper">
<button class="s-step-btn" onclick={() => updateSettings({ storageLimitGb: Math.max(1, (store.settings.storageLimitGb ?? 10) - 1) })} disabled={(store.settings.storageLimitGb ?? 10) <= 1}></button>
<input type="number" min="1" step="1" class="s-slider-val" style="width:52px"
value={store.settings.storageLimitGb}
oninput={(e) => { const n = parseFloat(e.currentTarget.value); if (!isNaN(n) && n > 0) updateSettings({ storageLimitGb: n }); }} />
<span class="s-slider-unit">GB</span>
<button class="s-step-btn" onclick={() => updateSettings({ storageLimitGb: (store.settings.storageLimitGb ?? 10) + 1 })}>+</button>
<button class="s-btn-icon" title="Remove limit" onclick={() => updateSettings({ storageLimitGb: null })}>↺</button>
</div>
{/if}
</div>
</div>
</div>
<div class="s-section">
<button class="s-collapsible-trigger" onclick={() => advStorageOpen = !advStorageOpen}>
<span class="s-label">Advanced</span>
<svg class="s-collapsible-caret" class:open={advStorageOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if advStorageOpen}
<div class="s-collapsible-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Local source path</span>
<span class="s-desc">Read manga already on disk without an extension. Leave blank if unused.</span>
</div>
<div style="display:flex;flex-direction:column;align-items:flex-end;gap:4px">
<div class="s-btn-row">
<input class="s-input mono" class:error={!!pathsFieldError.loc}
bind:value={localSourcePathInput} placeholder="Optional" spellcheck="false"
onkeydown={(e) => e.key === "Enter" && savePaths()}
oninput={() => { pathsFieldError = { ...pathsFieldError, loc: undefined }; }} />
{#if pathsFieldError.loc && !isExternalServer}
<button class="s-btn" onclick={async () => {
try { await createDirectory(localSourcePathInput.trim()); pathsFieldError = { ...pathsFieldError, loc: undefined }; }
catch (e: any) { pathsFieldError = { ...pathsFieldError, loc: e?.message ?? "Failed" }; }
}}>Create</button>
{/if}
</div>
{#if pathsFieldError.loc}<span class="s-desc" style="color:var(--color-error)">{pathsFieldError.loc}</span>{/if}
</div>
</div>
{#each extraScanDirs as dir}
<div class="s-row">
<div class="s-row-info">
<span class="s-label mono" style="font-family:monospace;font-size:var(--text-xs)">{dir}</span>
<span class="s-desc">Extra scan directory</span>
</div>
<button class="s-btn s-btn-danger" onclick={() => removeExtraScanDir(dir)}>Remove</button>
</div>
{/each}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Additional scan path</span>
<span class="s-desc">Include an extra directory in disk usage readings</span>
</div>
<div class="s-btn-row">
<input class="s-input mono" bind:value={newScanDir} placeholder="/path/to/dir" spellcheck="false"
onkeydown={(e) => e.key === "Enter" && addExtraScanDir()} />
<button class="s-btn" onclick={addExtraScanDir} disabled={!newScanDir.trim() || extraScanDirs.includes(newScanDir.trim())}>Add</button>
</div>
</div>
<div class="s-row">
<div class="s-row-info"></div>
<button class="s-btn s-btn-accent" onclick={savePaths} disabled={pathsSaving}>
{pathsSaved ? "Saved ✓" : pathsSaving ? "Saving…" : "Save"}
</button>
</div>
</div>
{/if}
</div>
<div class="s-section">
<button class="s-collapsible-trigger" onclick={() => backupSectionOpen = !backupSectionOpen}>
<span class="s-label">Backup</span>
<svg class="s-collapsible-caret" class:open={backupSectionOpen} width="10" height="6" viewBox="0 0 10 6"><path d="M0 0l5 6 5-6" fill="currentColor"/></svg>
</button>
{#if backupSectionOpen}
<div class="s-collapsible-body">
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Create backup</span>
<span class="s-desc">Snapshot your library, categories, and tracker links</span>
</div>
<button class="s-btn s-btn-accent" onclick={createBackup} disabled={backupLoading}>
{backupLoading ? "Creating…" : "Create backup"}
</button>
</div>
{#if backupError}
<div class="s-banner s-banner-error">{backupError}</div>
{/if}
{#if backupList.length === 0}
<p class="s-empty">No backups yet — create one above.</p>
{:else}
{#each backupList as backup}
<div class="s-folder-row">
<ClockCounterClockwise size={14} weight="light" style="color:var(--text-faint);flex-shrink:0" />
<span class="s-folder-name" style="font-family:monospace;font-size:var(--text-xs)">{backup.name}</span>
<button class="s-btn-icon" onclick={() => downloadBackup(backup)} title="Download"></button>
<button class="s-btn-icon danger" onclick={() => deleteBackup(backup.url)} disabled={backup.deleting} title="Delete">
<Trash size={12} weight="light" />
</button>
</div>
{/each}
{/if}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">Restore from file</span>
<span class="s-desc">{restoreFile ? restoreFile.name : "Select a .tachibk file"}</span>
</div>
<label class="s-btn" style="cursor:pointer">
Browse
<input type="file" accept=".tachibk,.proto.gz" style="display:none"
onchange={(e) => {
const f = (e.currentTarget as HTMLInputElement).files?.[0] ?? null;
restoreFile = f; restoreStatus = null; restoreError = null; validateResult = null; validateError = null;
}} />
</label>
</div>
{#if restoreFile}
<div class="s-row">
<div class="s-row-info"></div>
<div class="s-btn-row">
<button class="s-btn" onclick={submitValidate} disabled={validateLoading || restoreLoading}>
{validateLoading ? "Checking…" : "Validate"}
</button>
<button class="s-btn s-btn-accent" onclick={submitRestore} disabled={restoreLoading || validateLoading}>
{restoreLoading ? "Restoring…" : "Restore"}
</button>
</div>
</div>
{/if}
{#if validateError}
<div class="s-banner s-banner-error">{validateError}</div>
{/if}
{#if validateResult}
{#if validateResult.missingSources.length === 0 && validateResult.missingTrackers.length === 0}
<div class="s-row"><span class="s-desc" style="color:var(--color-success,#4caf50)">✓ All sources and trackers present</span></div>
{:else}
{#if validateResult.missingSources.length > 0}
<div class="s-row">
<div class="s-row-info">
<span class="s-label" style="color:var(--color-error)">Missing sources</span>
<span class="s-desc">{validateResult.missingSources.map(s => s.name).join(", ")}</span>
</div>
</div>
{/if}
{#if validateResult.missingTrackers.length > 0}
<div class="s-row">
<div class="s-row-info">
<span class="s-label" style="color:var(--color-error)">Missing trackers</span>
<span class="s-desc">{validateResult.missingTrackers.map(t => t.name).join(", ")}</span>
</div>
</div>
{/if}
{/if}
{/if}
{#if restoreError}
<div class="s-banner s-banner-error">{restoreError}</div>
{/if}
{#if restoreStatus}
<div class="s-row">
<div class="s-row-info">
<span class="s-label">
{restoreStatus.state === "SUCCESS" ? "✓ Restore complete" :
restoreStatus.state === "FAILURE" ? "✗ Restore failed" : "Restoring…"}
</span>
{#if restoreStatus.totalManga > 0}
<span class="s-desc">{restoreStatus.mangaProgress} / {restoreStatus.totalManga} manga</span>
{/if}
</div>
{#if restoreStatus.state !== "SUCCESS" && restoreStatus.state !== "FAILURE" && restoreStatus.totalManga > 0}
<div class="s-storage-bar" style="width:160px;flex-shrink:0">
<div class="s-storage-fill" style="width:{Math.round((restoreStatus.mangaProgress / restoreStatus.totalManga) * 100)}%"></div>
</div>
{/if}
</div>
{/if}
</div>
{/if}
</div>
</div>
@@ -0,0 +1,151 @@
<script lang="ts">
import { gql, thumbUrl } from "@api/client";
import { GET_TRACKERS } from "@api/queries/tracking";
import { LOGIN_TRACKER_OAUTH, LOGIN_TRACKER_CREDENTIALS, LOGOUT_TRACKER } from "@api/mutations/tracking";
import { open as openUrl } from "@tauri-apps/plugin-shell";
import type { Tracker } from "../../lib/types";
let trackers = $state<Tracker[]>([]);
let trackersLoading = $state(false);
let trackersError = $state<string | null>(null);
let oauthTrackerId = $state<number | null>(null);
let oauthCallbackInput = $state("");
let oauthSubmitting = $state(false);
let credsTrackerId = $state<number | null>(null);
let credsUsername = $state("");
let credsPassword = $state("");
let credsSubmitting = $state(false);
let loggingOut = $state<number | null>(null);
$effect(() => {
if (trackers.length === 0 && !trackersLoading) loadTrackers();
});
async function loadTrackers() {
trackersLoading = true; trackersError = null;
try {
const res = await gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS);
trackers = res.trackers.nodes;
} catch (e: any) {
trackersError = e?.message ?? "Failed to load trackers";
} finally { trackersLoading = false; }
}
async function startOAuth(tracker: Tracker) {
if (!tracker.authUrl) return;
oauthTrackerId = tracker.id; oauthCallbackInput = "";
await openUrl(tracker.authUrl);
}
async function submitOAuth() {
if (!oauthTrackerId || !oauthCallbackInput.trim()) return;
oauthSubmitting = true;
try {
await gql(LOGIN_TRACKER_OAUTH, { trackerId: oauthTrackerId, callbackUrl: oauthCallbackInput.trim() });
await loadTrackers();
oauthTrackerId = null; oauthCallbackInput = "";
} catch (e: any) {
trackersError = e?.message ?? "Login failed";
} finally { oauthSubmitting = false; }
}
function cancelOAuth() { oauthTrackerId = null; oauthCallbackInput = ""; }
function startCredentials(tracker: Tracker) { credsTrackerId = tracker.id; credsUsername = ""; credsPassword = ""; }
async function submitCredentials() {
if (!credsTrackerId || !credsUsername.trim() || !credsPassword.trim()) return;
credsSubmitting = true;
try {
await gql(LOGIN_TRACKER_CREDENTIALS, { trackerId: credsTrackerId, username: credsUsername.trim(), password: credsPassword.trim() });
await loadTrackers();
credsTrackerId = null; credsUsername = ""; credsPassword = "";
} catch (e: any) {
trackersError = e?.message ?? "Login failed";
} finally { credsSubmitting = false; }
}
function cancelCredentials() { credsTrackerId = null; credsUsername = ""; credsPassword = ""; }
async function logoutTracker(trackerId: number) {
loggingOut = trackerId;
try {
await gql(LOGOUT_TRACKER, { trackerId });
await loadTrackers();
} catch (e: any) {
trackersError = e?.message ?? "Logout failed";
} finally { loggingOut = null; }
}
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
</script>
<div class="s-panel">
<div class="s-section">
<p class="s-section-title">Connected Trackers</p>
<div class="s-section-body">
{#if trackersError}
<div class="s-banner s-banner-error">{trackersError}</div>
{/if}
{#if trackersLoading}
<p class="s-empty">Loading trackers…</p>
{:else}
{#each trackers as tracker}
<div class="s-tracker-row" class:expanded={oauthTrackerId === tracker.id || credsTrackerId === tracker.id}>
<div class="s-tracker-identity">
<img src={thumbUrl(tracker.icon)} alt={tracker.name} class="s-tracker-logo" />
<div class="s-row-info">
<span class="s-label">{tracker.name}</span>
<span class="s-pill" class:on={tracker.isLoggedIn}>
{tracker.isLoggedIn ? "Connected" : "Not connected"}
</span>
</div>
</div>
<div class="s-tracker-action">
{#if tracker.isLoggedIn}
<button class="s-btn s-btn-danger" onclick={() => logoutTracker(tracker.id)} disabled={loggingOut === tracker.id}>
{loggingOut === tracker.id ? "Disconnecting…" : "Disconnect"}
</button>
{:else if oauthTrackerId !== tracker.id && credsTrackerId !== tracker.id}
<button class="s-btn" onclick={() => tracker.authUrl ? startOAuth(tracker) : startCredentials(tracker)}>
{tracker.authUrl ? "Connect via browser →" : "Connect"}
</button>
{/if}
</div>
{#if oauthTrackerId === tracker.id}
<div class="s-tracker-expand">
<p class="s-oauth-hint">Browser opened {tracker.name} login — authorise then paste the callback URL below.</p>
<input class="s-input full" placeholder="https://suwayomi.org/tracker-oauth#access_token=…"
bind:value={oauthCallbackInput}
onkeydown={(e) => { if (e.key === "Enter") submitOAuth(); if (e.key === "Escape") cancelOAuth(); }}
use:focusEl />
<div class="s-oauth-btns">
<button class="s-btn s-btn-accent" onclick={submitOAuth} disabled={oauthSubmitting || !oauthCallbackInput.trim()}>
{oauthSubmitting ? "Connecting…" : "Connect"}
</button>
<button class="s-btn" onclick={cancelOAuth}>Cancel</button>
</div>
</div>
{/if}
{#if credsTrackerId === tracker.id}
<div class="s-tracker-expand">
<input class="s-input full" placeholder="Username / Email" bind:value={credsUsername}
onkeydown={(e) => e.key === "Escape" && cancelCredentials()} use:focusEl />
<input class="s-input full" type="password" placeholder="Password" bind:value={credsPassword}
onkeydown={(e) => { if (e.key === "Enter") submitCredentials(); if (e.key === "Escape") cancelCredentials(); }} />
<div class="s-oauth-btns">
<button class="s-btn s-btn-accent" onclick={submitCredentials} disabled={credsSubmitting || !credsUsername.trim() || !credsPassword.trim()}>
{credsSubmitting ? "Connecting…" : "Connect"}
</button>
<button class="s-btn" onclick={cancelCredentials}>Cancel</button>
</div>
</div>
{/if}
</div>
{/each}
{/if}
</div>
</div>
</div>
@@ -0,0 +1,667 @@
<script lang="ts">
import { CircleNotch, ArrowSquareOut, ArrowsClockwise, X, Lock, MagnifyingGlass } from "phosphor-svelte";
import { gql } from "@api/client";
import { addToast, setActiveManga, setNavPage } from "@store/state.svelte";
import { GET_ALL_TRACKER_RECORDS } from "@api/queries";
import { UPDATE_TRACK, UNBIND_TRACK, FETCH_TRACK } from "@api/mutations";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { TrackRecord } from "@types/index";
import {
flattenRecords, filterRecords, sortRecords, dedupeStatuses,
scoreToStars, calcProgress, patchTracker, removeRecord,
type TrackerWithRecords, type FlatRecord, type SortKey,
} from "../lib/trackingSync";
let trackers = $state<TrackerWithRecords[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
let activeTrackerId = $state<number | "all">("all");
let statusFilter = $state<number | "all">("all");
let searchQuery = $state("");
let sortBy = $state<SortKey>("title");
let updatingId = $state<number | null>(null);
let syncingId = $state<number | null>(null);
let editingChapter = $state<number | null>(null);
let chapterDraft = $state(0);
let confirmUnbind = $state<FlatRecord | null>(null);
async function load() {
loading = true; error = null;
try {
const res = await gql<{ trackers: { nodes: TrackerWithRecords[] } }>(GET_ALL_TRACKER_RECORDS);
trackers = res.trackers.nodes;
} catch (e: any) {
error = e?.message ?? "Failed to load tracking data";
} finally { loading = false; }
}
$effect(() => { load(); });
const loggedIn = $derived(trackers.filter((t) => t.isLoggedIn));
const allRecords = $derived(flattenRecords(trackers));
const totalCount = $derived(allRecords.length);
const statusOptions = $derived(
activeTrackerId === "all"
? dedupeStatuses(trackers)
: loggedIn.find((t) => t.id === activeTrackerId)?.statuses ?? []
);
const filtered = $derived(
sortRecords(filterRecords(allRecords, activeTrackerId, statusFilter, searchQuery), sortBy)
);
async function updateStatus(record: FlatRecord, status: number) {
updatingId = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, status });
trackers = patchTracker(trackers, record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { updatingId = null; }
}
async function updateScore(record: FlatRecord, scoreString: string) {
updatingId = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, scoreString });
trackers = patchTracker(trackers, record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { updatingId = null; }
}
async function syncRecord(record: FlatRecord) {
syncingId = record.id;
try {
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(FETCH_TRACK, { recordId: record.id });
trackers = patchTracker(trackers, record.trackerId, res.fetchTrack.trackRecord);
addToast({ kind: "success", title: "Synced from tracker" });
} catch (e: any) {
addToast({ kind: "error", title: "Sync failed", body: e?.message });
} finally { syncingId = null; }
}
async function unbind(record: FlatRecord) {
updatingId = record.id;
try {
await gql(UNBIND_TRACK, { recordId: record.id });
trackers = removeRecord(trackers, record.trackerId, record.id);
addToast({ kind: "info", title: `Unlinked from ${record.tracker.name}` });
} catch (e: any) {
addToast({ kind: "error", title: "Unbind failed", body: e?.message });
} finally { updatingId = null; }
}
async function submitChapter(record: FlatRecord) {
const val = Math.max(0, chapterDraft);
editingChapter = null;
if (val === record.lastChapterRead) return;
updatingId = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, lastChapterRead: val });
trackers = patchTracker(trackers, record.trackerId, res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { updatingId = null; }
}
function openManga(record: FlatRecord) {
if (!record.manga) return;
setActiveManga(record.manga as any);
setNavPage("library");
}
function openChapterEditor(record: FlatRecord) {
editingChapter = record.id;
chapterDraft = record.lastChapterRead;
}
function focusEl(node: HTMLElement) { setTimeout(() => node.focus(), 0); }
</script>
<div class="page">
<div class="header">
<div class="header-top">
<h1 class="heading">Tracking</h1>
<button class="icon-btn" onclick={load} disabled={loading} title="Refresh">
<ArrowsClockwise size={14} weight="light" class={loading ? "anim-spin" : ""} />
</button>
</div>
{#if !loading && loggedIn.length > 0}
<div class="tracker-tabs">
<button
class="tracker-tab" class:active={activeTrackerId === "all"}
onclick={() => { activeTrackerId = "all"; statusFilter = "all"; }}
>
All
<span class="tab-pill">{totalCount}</span>
</button>
{#each loggedIn as t}
<button
class="tracker-tab" class:active={activeTrackerId === t.id}
onclick={() => { activeTrackerId = Number(t.id); statusFilter = "all"; }}
>
<Thumbnail src={t.icon} alt={t.name} class="tab-icon" />
{t.name}
<span class="tab-pill">{t.trackRecords.nodes.length}</span>
</button>
{/each}
</div>
<div class="filter-bar">
<div class="search-wrap">
<MagnifyingGlass size={12} weight="light" class="search-ico" />
<input class="filter-input" placeholder="Search…" bind:value={searchQuery} />
</div>
<select class="filter-select" bind:value={statusFilter}
onchange={(e) => {
const v = (e.target as HTMLSelectElement).value;
statusFilter = v === "all" ? "all" : parseInt(v);
}}>
<option value="all">All statuses</option>
{#each statusOptions as s}
<option value={s.value}>{s.name}</option>
{/each}
</select>
<select class="filter-select" bind:value={sortBy}>
<option value="title">Title</option>
<option value="status">Status</option>
<option value="score">Score</option>
<option value="progress">Progress</option>
</select>
</div>
{/if}
</div>
<div class="body">
{#if loading}
<div class="state">
<CircleNotch size={18} weight="light" class="anim-spin" style="color:var(--text-faint)" />
</div>
{:else if error}
<div class="state">
<span class="state-error">{error}</span>
<button class="ghost-btn" onclick={load}>Retry</button>
</div>
{:else if loggedIn.length === 0}
<div class="state">
<span class="state-text">No trackers connected.</span>
<span class="state-hint">Settings → Tracking to connect AniList, MAL, or others.</span>
</div>
{:else if filtered.length === 0}
<div class="state">
<span class="state-text">{searchQuery || statusFilter !== "all" ? "No results." : "Nothing tracked yet."}</span>
{#if searchQuery || statusFilter !== "all"}
<button class="ghost-btn" onclick={() => { searchQuery = ""; statusFilter = "all"; }}>Clear filters</button>
{/if}
</div>
{:else}
<div class="grid">
{#each filtered as record (record.tracker.id + ":" + record.id)}
{@const isBusy = updatingId === record.id}
{@const isSyncing = syncingId === record.id}
{@const progress = calcProgress(record.lastChapterRead, record.totalChapters)}
{@const stars = scoreToStars(record.displayScore, record.tracker.scores)}
<div class="card" class:busy={isBusy}>
<div class="cover-wrap">
<div class="cover-click"
role="button" tabindex="0"
onclick={() => openManga(record)}
onkeydown={(e) => e.key === "Enter" && openManga(record)}
>
{#if record.manga?.thumbnailUrl}
<Thumbnail src={record.manga.thumbnailUrl} alt={record.title} class="cover-img" />
{:else}
<div class="cover-empty"></div>
{/if}
</div>
<div class="cover-actions">
{#if record.private}
<span class="cover-btn" title="Private"><Lock size={10} weight="fill" /></span>
{/if}
{#if isSyncing}
<span class="cover-btn"><CircleNotch size={10} weight="light" class="anim-spin" /></span>
{:else}
<button class="cover-btn" title="Sync" onclick={() => syncRecord(record)}>
<ArrowsClockwise size={10} weight="light" />
</button>
{/if}
{#if record.remoteUrl}
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="cover-btn" title="Open on {record.tracker.name}">
<ArrowSquareOut size={10} weight="light" />
</a>
{/if}
<button class="cover-btn destroy" title="Unlink" onclick={() => confirmUnbind = record} disabled={isBusy}>
<X size={10} weight="bold" />
</button>
</div>
<div class="tracker-badge">
<Thumbnail src={record.tracker.icon} alt={record.tracker.name} class="badge-img" />
</div>
</div>
<div class="card-body">
<div class="stars">
{#each Array(5) as _, i}
<span class="star" class:lit={i < stars}>★</span>
{/each}
</div>
<div class="title-block"
role="button" tabindex="0"
onclick={() => openManga(record)}
onkeydown={(e) => e.key === "Enter" && openManga(record)}
>
<span class="title">{record.title}</span>
{#if record.manga?.title && record.manga.title !== record.title}
<span class="local-title">{record.manga.title}</span>
{/if}
</div>
<div class="controls-row">
<select class="status-select"
value={record.status} disabled={isBusy}
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}>
{#each (record.tracker.statuses ?? []) as s}
<option value={s.value}>{s.name}</option>
{/each}
</select>
<select class="score-select"
value={record.displayScore} disabled={isBusy}
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}>
{#each (record.tracker.scores ?? []) as s}
<option value={s}> {s}</option>
{/each}
</select>
</div>
{#if editingChapter === record.id}
<div class="chapter-editor" role="presentation" onclick={(e) => e.stopPropagation()}>
<div class="chapter-editor-top">
<span class="chapter-label">Chapter</span>
<div class="chapter-input-row">
<input
type="number" class="chapter-input"
min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
step="0.5" bind:value={chapterDraft}
onkeydown={(e) => {
if (e.key === "Enter") submitChapter(record);
if (e.key === "Escape") editingChapter = null;
}}
use:focusEl
/>
{#if record.totalChapters > 0}
<span class="chapter-total">/ {record.totalChapters}</span>
{/if}
</div>
</div>
{#if record.totalChapters > 0}
<input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
{/if}
<div class="chapter-actions">
<button class="chapter-cancel" onclick={() => editingChapter = null}>Cancel</button>
<button class="chapter-save" onclick={() => submitChapter(record)}>Save</button>
</div>
</div>
{:else}
<div class="progress-block"
role="button" tabindex="0"
onclick={() => openChapterEditor(record)}
onkeydown={(e) => e.key === "Enter" && openChapterEditor(record)}
>
<div class="progress-labels">
<span class="progress-text">
{#if progress !== null}
Ch.&nbsp;{record.lastChapterRead}&thinsp;/&thinsp;{record.totalChapters}
{:else if record.lastChapterRead > 0}
Ch.&nbsp;{record.lastChapterRead}&nbsp;read
{:else}
Set chapter…
{/if}
</span>
{#if progress !== null}
<span class="progress-pct">{Math.round(progress)}%</span>
{/if}
</div>
<div class="progress-track">
<div class="progress-fill" style="width:{progress ?? 0}%"></div>
</div>
</div>
{/if}
</div>
</div>
{/each}
</div>
{/if}
</div>
</div>
{#if confirmUnbind}
{@const r = confirmUnbind}
<div class="modal-backdrop" role="presentation" onclick={() => confirmUnbind = null}>
<div class="modal" role="dialog" aria-modal="true" onclick={(e) => e.stopPropagation()}>
<div class="modal-icon"><X size={16} weight="bold" /></div>
<p class="modal-title">Unlink from {r.tracker.name}?</p>
<p class="modal-body">
<strong>{r.title}</strong> will be removed from your list. Your progress on {r.tracker.name} is unaffected.
</p>
<div class="modal-actions">
<button class="modal-cancel" onclick={() => confirmUnbind = null}>Cancel</button>
<button class="modal-confirm" onclick={async () => { const rec = r; confirmUnbind = null; await unbind(rec); }}>Unlink</button>
</div>
</div>
</div>
{/if}
<style>
.page { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.16s ease both; }
.header { flex-shrink: 0; border-bottom: 1px solid var(--border-dim); }
.header-top {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-6) var(--sp-3);
}
.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;
}
.icon-btn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: var(--radius-sm);
color: var(--text-faint); background: none;
transition: color var(--t-base), background var(--t-base);
}
.icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.3; cursor: default; }
.tracker-tabs {
display: flex; align-items: center; gap: 1px;
padding: 0 var(--sp-5); overflow-x: auto; scrollbar-width: none;
}
.tracker-tabs::-webkit-scrollbar { display: none; }
.tracker-tab {
display: flex; align-items: center; gap: var(--sp-2);
padding: 9px 10px 8px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
color: var(--text-faint); background: none; border: none;
border-bottom: 2px solid transparent;
cursor: pointer; white-space: nowrap; margin-bottom: -1px;
transition: color var(--t-base), border-color var(--t-base);
}
.tracker-tab:hover { color: var(--text-muted); }
.tracker-tab.active { color: var(--text-secondary); border-bottom-color: var(--accent); }
:global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.85; }
.tab-pill {
font-size: 10px; padding: 0 5px; border-radius: var(--radius-full);
background: var(--bg-overlay); color: var(--text-faint);
min-width: 18px; text-align: center; line-height: 17px;
}
.tracker-tab.active .tab-pill { background: var(--accent-muted); color: var(--accent-fg); }
.filter-bar {
display: flex; align-items: center; gap: var(--sp-2);
padding: var(--sp-2) var(--sp-5);
border-top: 1px solid var(--border-dim);
}
.search-wrap {
display: flex; align-items: center; gap: var(--sp-2); flex: 1;
background: var(--bg-raised); border: 1px solid var(--border-dim);
border-radius: var(--radius-md); padding: 4px 10px;
}
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
.filter-input {
flex: 1; background: none; border: none; outline: none;
font-size: var(--text-sm); color: var(--text-primary); min-width: 0;
}
.filter-input::placeholder { color: var(--text-faint); }
.filter-select {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 4px 22px 4px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: var(--bg-raised);
color: var(--text-faint); outline: none; cursor: pointer; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 6px center;
transition: border-color var(--t-base), color var(--t-base);
flex-shrink: 0;
}
.filter-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
.filter-select option { background: var(--bg-surface); color: var(--text-secondary); }
.body { flex: 1; overflow-y: auto; padding: var(--sp-5); scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
.state {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: var(--sp-3); height: 100%; text-align: center;
}
.state-text { font-size: var(--text-sm); color: var(--text-muted); }
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); max-width: 260px; line-height: 1.5; }
.state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
.ghost-btn {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 5px 14px; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer;
transition: color var(--t-base), border-color var(--t-base);
}
.ghost-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(178px, 1fr));
gap: var(--sp-4); align-content: start;
}
.card {
display: flex; flex-direction: column;
border-radius: var(--radius-lg);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
overflow: hidden;
transition: border-color var(--t-base), transform var(--t-base), opacity var(--t-base);
}
.card:hover { border-color: var(--border-strong); transform: translateY(-1px); }
.card.busy { opacity: 0.35; pointer-events: none; }
.cover-wrap { position: relative; aspect-ratio: 2/3; flex-shrink: 0; overflow: hidden; background: var(--bg-overlay); }
.cover-click { position: absolute; inset: 0; cursor: pointer; }
:global(.cover-img) { width: 100%; height: 100%; object-fit: cover; display: block; transition: transform 0.35s ease, opacity 0.2s ease; }
.cover-wrap:hover :global(.cover-img) { transform: scale(1.04); opacity: 0.85; }
.cover-empty { width: 100%; height: 100%; background: var(--bg-overlay); }
.cover-actions {
position: absolute; top: 6px; right: 6px; z-index: 2;
display: flex; gap: 2px; opacity: 0;
transition: opacity var(--t-base);
}
.cover-wrap:hover .cover-actions { opacity: 1; }
.cover-btn {
display: flex; align-items: center; justify-content: center;
width: 22px; height: 22px; border-radius: var(--radius-sm);
background: rgba(0,0,0,0.55); backdrop-filter: blur(8px);
border: 1px solid rgba(255,255,255,0.08);
color: rgba(255,255,255,0.7); cursor: pointer; text-decoration: none;
transition: background var(--t-base), color var(--t-base);
}
.cover-btn:hover { background: rgba(0,0,0,0.75); color: #fff; }
.cover-btn.destroy:hover { background: rgba(180,40,40,0.65); }
.cover-btn:disabled { opacity: 0.3; cursor: default; }
.tracker-badge {
position: absolute; bottom: 8px; right: 8px; z-index: 2;
width: 20px; height: 20px; border-radius: 5px;
border: 1px solid rgba(0,0,0,0.3); background: var(--bg-raised);
box-shadow: 0 2px 6px rgba(0,0,0,0.5); overflow: hidden;
display: flex; align-items: center; justify-content: center;
}
:global(.badge-img) { width: 100%; height: 100%; object-fit: contain; display: block; }
.card-body { display: flex; flex-direction: column; gap: 9px; padding: 11px 12px 12px; }
.stars { display: flex; gap: 2px; align-items: center; }
.star { font-size: 13px; line-height: 1; color: var(--border-strong); transition: color var(--t-base); }
.star.lit { color: #f5c518; }
.title-block {
display: flex; flex-direction: column; gap: 2px;
cursor: pointer; min-width: 0;
}
.title {
font-size: var(--text-sm); font-weight: var(--weight-medium);
color: var(--text-secondary); line-height: 1.38;
display: -webkit-box; -webkit-line-clamp: 2;
-webkit-box-orient: vertical; overflow: hidden;
transition: color var(--t-base);
}
.title-block:hover .title { color: var(--accent-fg); }
.local-title {
font-family: var(--font-ui); font-size: 10px; color: var(--text-faint);
letter-spacing: var(--tracking-wide);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.controls-row { display: flex; align-items: center; gap: var(--sp-1); }
.status-select {
flex: 1; min-width: 0;
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 4px 18px 4px 8px; border-radius: var(--radius-full);
border: 1px solid var(--border-dim); background: var(--bg-overlay);
color: var(--text-muted); outline: none; cursor: pointer; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 6px center;
transition: border-color var(--t-base), color var(--t-base);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.status-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
.status-select:disabled { opacity: 0.35; cursor: default; }
.status-select option { background: var(--bg-surface); color: var(--text-secondary); }
.score-select {
flex-shrink: 0; width: 54px;
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 4px 14px 4px 5px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: var(--bg-overlay);
color: var(--text-faint); outline: none; cursor: pointer; appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 4px center;
transition: border-color var(--t-base), color var(--t-base);
}
.score-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
.score-select:disabled { opacity: 0.35; cursor: default; }
.score-select option { background: var(--bg-surface); color: var(--text-secondary); }
.progress-block {
display: flex; flex-direction: column; gap: 6px;
padding: 4px 5px; margin: 0 -5px;
cursor: pointer; border-radius: var(--radius-sm);
transition: background var(--t-fast);
}
.progress-block:hover { background: var(--bg-overlay); }
.progress-labels { display: flex; align-items: center; justify-content: space-between; }
.progress-text { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.progress-pct { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.progress-track { height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
.chapter-editor {
display: flex; flex-direction: column; gap: var(--sp-2);
padding: var(--sp-2); border-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: var(--bg-surface);
}
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); }
.chapter-label { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-input-row { display: flex; align-items: center; gap: var(--sp-1); }
.chapter-input {
width: 52px; background: var(--bg-raised);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-primary); outline: none; text-align: center;
appearance: none; -moz-appearance: textfield;
}
.chapter-input:focus { border-color: var(--accent); }
.chapter-input::-webkit-outer-spin-button,
.chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.chapter-total { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
.chapter-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; }
.chapter-save {
font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
padding: 3px 10px; border-radius: var(--radius-sm);
border: 1px solid var(--accent-dim); background: var(--accent-muted);
color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base);
}
.chapter-save:hover { filter: brightness(1.15); }
.chapter-cancel {
font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide);
padding: 3px 6px; border-radius: var(--radius-sm);
border: none; background: none; color: var(--text-faint);
cursor: pointer; transition: color var(--t-base);
}
.chapter-cancel:hover { color: var(--text-muted); }
.modal-backdrop {
position: fixed; inset: 0; z-index: 200;
background: rgba(0,0,0,0.5); backdrop-filter: blur(4px);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.12s ease both;
}
.modal {
background: var(--bg-surface); border: 1px solid var(--border-dim);
border-radius: var(--radius-xl); padding: var(--sp-6);
width: 300px; max-width: calc(100vw - 32px);
display: flex; flex-direction: column; align-items: center; gap: var(--sp-3);
box-shadow: 0 24px 60px rgba(0,0,0,0.5);
animation: modalIn 0.18s cubic-bezier(0.34,1.56,0.64,1) both;
}
.modal-icon {
width: 36px; height: 36px; border-radius: 50%;
background: rgba(200,50,50,0.1); border: 1px solid rgba(200,50,50,0.2);
color: var(--color-error); display: flex; align-items: center; justify-content: center;
}
.modal-title {
font-size: var(--text-sm); font-weight: var(--weight-medium);
color: var(--text-primary); text-align: center; margin: 0;
}
.modal-body {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-muted); text-align: center; line-height: 1.5; margin: 0;
}
.modal-body strong { color: var(--text-secondary); font-weight: var(--weight-medium); }
.modal-actions { display: flex; gap: var(--sp-2); width: 100%; margin-top: var(--sp-1); }
.modal-cancel {
flex: 1; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 8px 0; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: none;
color: var(--text-muted); cursor: pointer;
transition: border-color var(--t-base), color var(--t-base);
}
.modal-cancel:hover { border-color: var(--border-strong); color: var(--text-primary); }
.modal-confirm {
flex: 1; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 8px 0; border-radius: var(--radius-md);
border: 1px solid rgba(200,50,50,0.25); background: rgba(200,50,50,0.08);
color: var(--color-error); cursor: pointer;
transition: filter var(--t-base), background var(--t-base);
}
.modal-confirm:hover { filter: brightness(1.2); background: rgba(200,50,50,0.16); }
@keyframes modalIn {
from { opacity: 0; transform: scale(0.94) translateY(6px); }
to { opacity: 1; transform: none; }
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(4px); }
to { opacity: 1; transform: none; }
}
</style>
+2
View File
@@ -0,0 +1,2 @@
export { default as Tracking } from "./components/Tracking.svelte";
export * from "./lib/trackingSync";
+111
View File
@@ -0,0 +1,111 @@
import type { Tracker, TrackRecord } from "@types/index";
export interface TrackerWithRecords extends Tracker {
trackRecords: { nodes: TrackRecord[] };
}
export interface FlatRecord extends TrackRecord {
tracker: Tracker;
}
export type SortKey = "title" | "status" | "score" | "progress";
export function flattenRecords(trackers: TrackerWithRecords[]): FlatRecord[] {
return trackers
.filter((t) => t.isLoggedIn)
.flatMap((t) =>
t.trackRecords.nodes.map((r) => ({
...r,
trackerId: r.trackerId ?? t.id,
tracker: t as Tracker,
}))
);
}
export function dedupeStatuses(trackers: TrackerWithRecords[]): { value: number; name: string }[] {
const seen = new Map<string, { value: number; name: string }>();
for (const t of trackers.filter((t) => t.isLoggedIn))
for (const s of t.statuses ?? [])
seen.set(`${s.value}:${s.name}`, s);
return [...seen.values()];
}
export function filterRecords(
records: FlatRecord[],
trackerId: number | "all",
statusFilter: number | "all",
query: string,
): FlatRecord[] {
let list = trackerId === "all"
? records
: records.filter((r) => Number(r.trackerId) === Number(trackerId));
if (statusFilter !== "all")
list = list.filter((r) => Number(r.status) === Number(statusFilter));
if (query.trim()) {
const q = query.toLowerCase();
list = list.filter((r) =>
r.title.toLowerCase().includes(q) ||
r.manga?.title?.toLowerCase().includes(q)
);
}
return list;
}
export function sortRecords(records: FlatRecord[], sortBy: SortKey): FlatRecord[] {
return [...records].sort((a, b) => {
if (sortBy === "title") return a.title.localeCompare(b.title);
if (sortBy === "status") return a.status - b.status;
if (sortBy === "score") return parseFloat(b.displayScore ?? "0") - parseFloat(a.displayScore ?? "0");
if (sortBy === "progress") {
const ap = a.totalChapters > 0 ? a.lastChapterRead / a.totalChapters : 0;
const bp = b.totalChapters > 0 ? b.lastChapterRead / b.totalChapters : 0;
return bp - ap;
}
return 0;
});
}
export function scoreToStars(score: string | undefined, scores: string[] | undefined): number {
if (!score || !scores || scores.length === 0) return 0;
const idx = scores.indexOf(score);
if (idx < 0) return 0;
return Math.round((idx / (scores.length - 1)) * 5);
}
export function calcProgress(lastChapterRead: number, totalChapters: number): number | null {
if (totalChapters <= 0) return null;
return Math.min(100, (lastChapterRead / totalChapters) * 100);
}
export function patchTracker(
trackers: TrackerWithRecords[],
trackerId: number,
updated: Partial<TrackRecord> & { id: number },
): TrackerWithRecords[] {
return trackers.map((t) =>
t.id !== trackerId ? t : {
...t,
trackRecords: {
nodes: t.trackRecords.nodes.map((r) =>
r.id === updated.id ? { ...r, ...updated } : r
),
},
}
);
}
export function removeRecord(
trackers: TrackerWithRecords[],
trackerId: number,
recordId: number,
): TrackerWithRecords[] {
return trackers.map((t) =>
t.id !== trackerId ? t : {
...t,
trackRecords: { nodes: t.trackRecords.nodes.filter((r) => r.id !== recordId) },
}
);
}