mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Feat: Reworked ENTIRE Project for Readability
This commit is contained in:
@@ -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>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as Search } from "./components/Search.svelte";
|
||||
export * from "./lib/searchFilter";
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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: "A–Z",
|
||||
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>
|
||||
@@ -0,0 +1,3 @@
|
||||
export { default as Library } from "./components/Library.svelte";
|
||||
export { sortLibrary, librarySorter } from "./lib/librarySort";
|
||||
export * from "./store/libraryState.svelte";
|
||||
@@ -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>
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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(); }
|
||||
};
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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";
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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 & 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">4–8 digits</span></div>
|
||||
<div class="s-btn-row">
|
||||
<input class="s-input" type="password" inputmode="numeric" maxlength={8} placeholder="4–8 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. {record.lastChapterRead} / {record.totalChapters}
|
||||
{:else if record.lastChapterRead > 0}
|
||||
Ch. {record.lastChapterRead} 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>
|
||||
@@ -0,0 +1,2 @@
|
||||
export { default as Tracking } from "./components/Tracking.svelte";
|
||||
export * from "./lib/trackingSync";
|
||||
@@ -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) },
|
||||
}
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user