mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
482 lines
18 KiB
Svelte
482 lines
18 KiB
Svelte
<script lang="ts">
|
|
import { onMount, onDestroy } from "svelte";
|
|
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash } from "phosphor-svelte";
|
|
import { gql, thumbUrl } from "../../lib/client";
|
|
import { GET_LIBRARY, GET_ALL_MANGA, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
|
|
import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
|
|
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
|
|
import { settings, activeManga, libraryFilter, genreFilter, activeChapter } from "../../store";
|
|
import { addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders } from "../../store";
|
|
import type { Manga, Chapter } from "../../lib/types";
|
|
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
|
|
|
|
const CARD_MIN_W = 130;
|
|
const CARD_GAP = 16;
|
|
|
|
let allManga: Manga[] = []; // inLibrary only — used for Saved tab, tags, counts
|
|
let allMangaUnfiltered: Manga[] = []; // every manga Suwayomi knows — used for folder tabs
|
|
let loading = true;
|
|
let error: string | null = null;
|
|
let retryCount = 0;
|
|
let search = "";
|
|
let renderVisible = 0;
|
|
let scrollEl: HTMLDivElement;
|
|
let containerWidth = 800;
|
|
let ctx: { x: number; y: number; manga: Manga } | null = null;
|
|
let emptyCtx: { x: number; y: number } | null = null;
|
|
|
|
let prevChapterId: number | null = null;
|
|
$: {
|
|
const wasOpen = prevChapterId !== null;
|
|
prevChapterId = $activeChapter?.id ?? null;
|
|
if (wasOpen && !$activeChapter) cache.clear(CACHE_KEYS.LIBRARY);
|
|
}
|
|
|
|
function fetchLibrary() {
|
|
return cache.get(CACHE_KEYS.LIBRARY, () =>
|
|
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then((d) => d.mangas.nodes)
|
|
);
|
|
}
|
|
|
|
function loadData() {
|
|
fetchLibrary()
|
|
.then((nodes) => {
|
|
allManga = dedupeMangaByTitle(dedupeMangaById(nodes));
|
|
error = null;
|
|
})
|
|
.catch((e) => error = e.message)
|
|
.finally(() => loading = false);
|
|
|
|
cache.get(CACHE_KEYS.ALL_MANGA, () =>
|
|
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA).then((d) => d.mangas.nodes),
|
|
DEFAULT_TTL_MS,
|
|
CACHE_GROUPS.LIBRARY,
|
|
).then((nodes) => {
|
|
allMangaUnfiltered = dedupeMangaByTitle(dedupeMangaById(nodes));
|
|
}).catch(console.error);
|
|
}
|
|
|
|
$: {
|
|
retryCount;
|
|
loading = true; error = null;
|
|
if (retryCount > 0) cache.clear(CACHE_KEYS.LIBRARY);
|
|
loadData();
|
|
}
|
|
|
|
$: if (scrollEl) scrollEl.scrollTo({ top: 0 });
|
|
|
|
$: {
|
|
const f = $settings.folders.find((f) => f.id === $libraryFilter);
|
|
if (f && !f.showTab) libraryFilter.set("library");
|
|
}
|
|
|
|
const isBuiltin = (f: string) => f === "library" || f === "downloaded";
|
|
|
|
$: filtered = (() => {
|
|
if ($libraryFilter === "library") {
|
|
let items = allManga;
|
|
if (search.trim()) {
|
|
const q = search.toLowerCase();
|
|
items = items.filter((m) => m.title.toLowerCase().includes(q));
|
|
}
|
|
return items;
|
|
}
|
|
if ($libraryFilter === "downloaded") {
|
|
let items = allManga.filter((m) => (m.downloadCount ?? 0) > 0);
|
|
if (search.trim()) {
|
|
const q = search.toLowerCase();
|
|
items = items.filter((m) => m.title.toLowerCase().includes(q));
|
|
}
|
|
return items;
|
|
}
|
|
// Folder tab — use folderPool (library manga + non-library manga merged)
|
|
const folder = $settings.folders.find((f) => f.id === $libraryFilter);
|
|
if (folder) {
|
|
let items = folderPool.filter((m) => folder.mangaIds.includes(m.id));
|
|
if (search.trim()) {
|
|
const q = search.toLowerCase();
|
|
items = items.filter((m) => m.title.toLowerCase().includes(q));
|
|
}
|
|
return items;
|
|
}
|
|
return [];
|
|
})();
|
|
|
|
$: cols = Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP)));
|
|
|
|
// Reset visible count whenever the filtered set changes (filter/search/tab switch)
|
|
$: { filtered; renderVisible = $settings.renderLimit ?? 48; }
|
|
|
|
$: visibleManga = filtered.slice(0, renderVisible);
|
|
$: hasMore = filtered.length > renderVisible;
|
|
$: remainingCount = filtered.length - renderVisible;
|
|
|
|
function loadMore() {
|
|
renderVisible += $settings.renderLimit ?? 48;
|
|
}
|
|
|
|
// Merged pool for folder resolution: library manga first (instant), then any
|
|
// non-library manga from the unfiltered fetch. This means Completed and other
|
|
// folders whose manga are saved to the library render immediately without
|
|
// waiting for the allMangaUnfiltered fetch to complete.
|
|
$: folderPool = (() => {
|
|
const seen = new Set(allManga.map(m => m.id));
|
|
return [...allManga, ...allMangaUnfiltered.filter(m => !seen.has(m.id))];
|
|
})();
|
|
|
|
$: counts = {
|
|
library: allManga.length,
|
|
downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length,
|
|
...$settings.folders.reduce((a, f) => ({
|
|
...a,
|
|
[f.id]: folderPool.filter((m) => f.mangaIds.includes(m.id)).length,
|
|
}), {} as Record<string, number>),
|
|
};
|
|
|
|
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.clear(CACHE_KEYS.LIBRARY);
|
|
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
|
}
|
|
|
|
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); }
|
|
}
|
|
|
|
function openCtx(e: MouseEvent, m: Manga) {
|
|
e.preventDefault();
|
|
ctx = { x: e.clientX, y: e.clientY, manga: m };
|
|
}
|
|
|
|
function buildCtxItems(m: Manga): MenuEntry[] {
|
|
const mangaFolders = getMangaFolders(m.id);
|
|
const folderEntries: MenuEntry[] = $settings.folders.map((f) => {
|
|
const inFolder = mangaFolders.some((mf) => mf.id === f.id);
|
|
return {
|
|
label: inFolder ? `Remove from ${f.name}` : `Add to ${f.name}`,
|
|
icon: Folder,
|
|
onClick: () => inFolder ? removeMangaFromFolder(f.id, m.id) : assignMangaToFolder(f.id, m.id),
|
|
};
|
|
});
|
|
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: "Delete all downloads",
|
|
icon: Trash,
|
|
danger: true,
|
|
disabled: !(m.downloadCount && m.downloadCount > 0),
|
|
onClick: () => deleteAllDownloads(m),
|
|
},
|
|
...(folderEntries.length ? [{ separator: true } as MenuEntry, ...folderEntries] : []),
|
|
{ separator: true },
|
|
{
|
|
label: "New folder",
|
|
icon: FolderSimplePlus,
|
|
onClick: () => {
|
|
const name = prompt("Folder name:");
|
|
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
function buildEmptyCtx(): MenuEntry[] {
|
|
return [{
|
|
label: "New folder",
|
|
icon: FolderSimplePlus,
|
|
onClick: () => { const name = prompt("Folder name:"); if (name?.trim()) addFolder(name.trim()); },
|
|
}];
|
|
}
|
|
|
|
onMount(() => {
|
|
const ro = new ResizeObserver(([e]) => containerWidth = e.contentRect.width);
|
|
ro.observe(scrollEl);
|
|
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, loadData);
|
|
return () => { ro.disconnect(); unsub(); };
|
|
});
|
|
</script>
|
|
|
|
<div
|
|
class="root"
|
|
role="presentation"
|
|
bind:this={scrollEl}
|
|
on:contextmenu={(e) => {
|
|
if ((e.target as HTMLElement).closest("button")) return;
|
|
e.preventDefault();
|
|
emptyCtx = { x: e.clientX, y: e.clientY };
|
|
}}
|
|
>
|
|
{#if $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" on:click={() => retryCount++}>Retry</button>
|
|
</div>
|
|
{:else}
|
|
<div class="header">
|
|
<div class="header-left">
|
|
<span class="heading">Library</span>
|
|
<div class="tabs">
|
|
{#each [["library","Saved"], ["downloaded","Downloaded"]] as [f, label]}
|
|
<button class="tab" class:active={$libraryFilter === f} on:click={() => libraryFilter.set(f)}>
|
|
{#if f === "library"}<Books size={11} weight="bold" />
|
|
{:else if f === "downloaded"}<DownloadSimple size={11} weight="bold" />{/if}
|
|
{label}
|
|
<span class="tab-count">{counts[f] ?? 0}</span>
|
|
</button>
|
|
{/each}
|
|
{#each $settings.folders.filter((f) => f.showTab) as folder}
|
|
<button class="tab" class:active={$libraryFilter === folder.id} on:click={() => libraryFilter.set(folder.id)}>
|
|
<Folder size={11} weight="bold" />
|
|
{folder.name}
|
|
<span class="tab-count">{counts[folder.id] ?? 0}</span>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
</div>
|
|
<div class="search-wrap">
|
|
<MagnifyingGlass size={13} class="search-icon" weight="light" />
|
|
<input class="search" placeholder="Search" bind:value={search} />
|
|
</div>
|
|
</div>
|
|
|
|
{#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)}
|
|
<button
|
|
class="card"
|
|
on:click={() => activeManga.set(m)}
|
|
on:contextmenu={(e) => openCtx(e, m)}
|
|
>
|
|
<div class="cover-wrap">
|
|
<img
|
|
src={thumbUrl(m.thumbnailUrl)} alt={m.title}
|
|
class="cover"
|
|
style="object-fit:{$settings.libraryCropCovers ? 'cover' : 'contain'}"
|
|
loading="lazy" decoding="async"
|
|
/>
|
|
{#if m.downloadCount}<span class="badge-dl">{m.downloadCount}</span>{/if}
|
|
{#if m.unreadCount}<span class="badge-unread">{m.unreadCount}</span>{/if}
|
|
</div>
|
|
<p class="title">{m.title}</p>
|
|
</button>
|
|
{/each}
|
|
</div>
|
|
{#if hasMore}
|
|
<div class="load-more-row">
|
|
<button class="load-more-btn" on:click={loadMore}>
|
|
Show {Math.min(remainingCount, $settings.renderLimit ?? 48)} more
|
|
<span class="load-more-count">({remainingCount} remaining)</span>
|
|
</button>
|
|
</div>
|
|
{/if}
|
|
{/if}
|
|
{/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;
|
|
padding: var(--sp-5) var(--sp-6);
|
|
overflow-y: auto; height: 100%;
|
|
animation: fadeIn 0.14s ease both;
|
|
will-change: scroll-position;
|
|
-webkit-overflow-scrolling: touch;
|
|
}
|
|
|
|
.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;
|
|
}
|
|
@keyframes branchGrow {
|
|
to { stroke-dashoffset: 0; }
|
|
}
|
|
|
|
.header {
|
|
position: relative; z-index: 1;
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
margin-bottom: var(--sp-4); gap: var(--sp-4); flex-wrap: wrap;
|
|
}
|
|
.header-left { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }
|
|
|
|
.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;
|
|
}
|
|
.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); }
|
|
.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); }
|
|
|
|
.grid {
|
|
position: relative; z-index: 1;
|
|
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:hover .cover { filter: brightness(1.07); }
|
|
.card:hover .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);
|
|
}
|
|
.cover { width: 100%; height: 100%; transition: filter var(--t-base); will-change: filter; }
|
|
|
|
.badge-dl {
|
|
position: absolute; bottom: var(--sp-1); right: var(--sp-1);
|
|
min-width: 18px; height: 18px; padding: 0 3px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-size: 10px; font-weight: bold;
|
|
background: var(--accent-dim); color: var(--accent-fg);
|
|
border-radius: var(--radius-sm); border: 1px solid var(--accent-muted);
|
|
}
|
|
.badge-unread {
|
|
position: absolute; top: var(--sp-1); left: var(--sp-1);
|
|
min-width: 18px; height: 18px; padding: 0 4px;
|
|
display: flex; align-items: center; justify-content: center;
|
|
font-size: 10px; font-weight: bold;
|
|
background: var(--bg-void); color: var(--text-primary);
|
|
border-radius: var(--radius-sm); border: 1px solid var(--border-strong);
|
|
}
|
|
|
|
.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: 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);
|
|
}
|
|
.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);
|
|
}
|
|
</style>
|