chore: ported over basics

This commit is contained in:
Youwes09
2026-03-18 23:05:32 -05:00
parent 697116b630
commit 73b73e85d7
11 changed files with 2086 additions and 35 deletions
+1
View File
@@ -11,6 +11,7 @@
"dependencies": { "dependencies": {
"@tauri-apps/api": "^2.0.0", "@tauri-apps/api": "^2.0.0",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"phosphor-svelte": "^3.1.0",
"svelte-spa-router": "^4.0.1" "svelte-spa-router": "^4.0.1"
}, },
"devDependencies": { "devDependencies": {
+27
View File
@@ -14,6 +14,9 @@ importers:
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
phosphor-svelte:
specifier: ^3.1.0
version: 3.1.0(svelte@5.54.0)(vite@5.4.21)
svelte-spa-router: svelte-spa-router:
specifier: ^4.0.1 specifier: ^4.0.1
version: 4.0.2 version: 4.0.2
@@ -505,6 +508,9 @@ packages:
esrap@2.2.4: esrap@2.2.4:
resolution: {integrity: sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==} resolution: {integrity: sha512-suICpxAmZ9A8bzJjEl/+rLJiDKC0X4gYWUxT6URAWBLvlXmtbZd5ySMu/N2ZGEtMCAmflUDPSehrP9BQcsGcSg==}
estree-walker@3.0.3:
resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==}
fill-range@7.1.1: fill-range@7.1.1:
resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
engines: {node: '>=8'} engines: {node: '>=8'}
@@ -601,6 +607,15 @@ packages:
resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==}
engines: {node: '>=0.10.0'} engines: {node: '>=0.10.0'}
phosphor-svelte@3.1.0:
resolution: {integrity: sha512-nldtxx+XCgNREvrb7O5xgDsefytXpSkPTx8Rnu3f2qQCUZLDV1rLxYSd2Jcwckuo9lZB1qKMqGR17P4UDC0PrA==}
peerDependencies:
svelte: ^5.0.0 || ^5.0.0-next.96
vite: '>=5'
peerDependenciesMeta:
vite:
optional: true
picocolors@1.1.1: picocolors@1.1.1:
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
@@ -1088,6 +1103,10 @@ snapshots:
'@jridgewell/sourcemap-codec': 1.5.5 '@jridgewell/sourcemap-codec': 1.5.5
'@typescript-eslint/types': 8.57.1 '@typescript-eslint/types': 8.57.1
estree-walker@3.0.3:
dependencies:
'@types/estree': 1.0.8
fill-range@7.1.1: fill-range@7.1.1:
dependencies: dependencies:
to-regex-range: 5.0.1 to-regex-range: 5.0.1
@@ -1169,6 +1188,14 @@ snapshots:
path-is-absolute@1.0.1: {} path-is-absolute@1.0.1: {}
phosphor-svelte@3.1.0(svelte@5.54.0)(vite@5.4.21):
dependencies:
estree-walker: 3.0.3
magic-string: 0.30.21
svelte: 5.54.0
optionalDependencies:
vite: 5.4.21
picocolors@1.1.1: {} picocolors@1.1.1: {}
picomatch@2.3.1: {} picomatch@2.3.1: {}
+3 -3
View File
@@ -14,7 +14,7 @@
import Settings from "./components/settings/Settings.svelte"; import Settings from "./components/settings/Settings.svelte";
import TitleBar from "./components/layout/TitleBar.svelte"; import TitleBar from "./components/layout/TitleBar.svelte";
import Toaster from "./components/layout/Toaster.svelte"; import Toaster from "./components/layout/Toaster.svelte";
import SplashScreen, { EXIT_MS } from "./components/layout/SplashScreen.svelte"; import SplashScreen from "./components/layout/SplashScreen.svelte";
const MAX_ATTEMPTS = 30; const MAX_ATTEMPTS = 30;
@@ -128,7 +128,7 @@
{#if devSplash} {#if devSplash}
<SplashScreen mode="idle" showFps showCards={$settings.splashCards ?? true} <SplashScreen mode="idle" showFps showCards={$settings.splashCards ?? true}
onDismiss={() => setTimeout(() => devSplash = false, EXIT_MS + 20)} /> onDismiss={() => setTimeout(() => devSplash = false, 340)} />
{:else if !appReady} {:else if !appReady}
<SplashScreen mode="loading" ringFull={serverProbeOk} {failed} <SplashScreen mode="loading" ringFull={serverProbeOk} {failed}
showCards={$settings.splashCards ?? true} showCards={$settings.splashCards ?? true}
@@ -138,7 +138,7 @@
<div class="root"> <div class="root">
{#if idle && !$activeChapter} {#if idle && !$activeChapter}
<SplashScreen mode="idle" showCards={$settings.splashCards ?? true} <SplashScreen mode="idle" showCards={$settings.splashCards ?? true}
onDismiss={() => setTimeout(() => idle = false, EXIT_MS + 20)} /> onDismiss={() => setTimeout(() => idle = false, 340)} />
{/if} {/if}
{#if !$activeChapter}<TitleBar />{/if} {#if !$activeChapter}<TitleBar />{/if}
<div class="content"> <div class="content">
+283 -1
View File
@@ -1 +1,283 @@
<div>History.svelte</div> <script lang="ts">
import { derived } from "svelte/store";
import { ClockCounterClockwise, Trash, MagnifyingGlass, Play, Books } from "phosphor-svelte";
import { thumbUrl } from "../../lib/client";
import { history, settings, activeManga, activeChapter, activeChapterList, openReader } from "../../store";
import type { HistoryEntry } from "../../store";
let search = "";
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" });
}
function dayLabel(ts: number): string {
const d = new Date(ts), now = new Date();
if (d.toDateString() === now.toDateString()) return "Today";
const yest = new Date(now); yest.setDate(now.getDate() - 1);
if (d.toDateString() === yest.toDateString()) return "Yesterday";
return d.toLocaleDateString("en-US", { weekday: "long", month: "long", day: "numeric" });
}
function formatReadTime(m: number): string {
if (m < 1) return "< 1 min";
if (m < 60) return `${m} min`;
const h = Math.floor(m / 60), r = m % 60;
return r === 0 ? `${h}h` : `${h}h ${r}m`;
}
const SESSION_GAP_MS = 30 * 60 * 1000;
interface Session {
mangaId: number; mangaTitle: string; thumbnailUrl: string;
latestChapterId: number; latestChapterName: string; latestPageNumber: number;
firstChapterName: string; chapterCount: number; readAt: number;
}
function buildSessions(entries: HistoryEntry[]): Session[] {
if (!entries.length) return [];
const sessions: Session[] = [];
let i = 0;
while (i < entries.length) {
const anchor = entries[i];
const group: HistoryEntry[] = [anchor];
let j = i + 1;
while (j < entries.length) {
const next = entries[j];
if (next.mangaId === anchor.mangaId && anchor.readAt - next.readAt <= SESSION_GAP_MS) { group.push(next); j++; }
else break;
}
const latest = group[0], oldest = group[group.length - 1];
sessions.push({
mangaId: latest.mangaId, mangaTitle: latest.mangaTitle, thumbnailUrl: latest.thumbnailUrl,
latestChapterId: latest.chapterId, latestChapterName: latest.chapterName,
latestPageNumber: latest.pageNumber, firstChapterName: oldest.chapterName,
chapterCount: group.length, readAt: latest.readAt,
});
i = j;
}
return sessions;
}
$: filtered = search.trim()
? $history.filter((e) => e.mangaTitle.toLowerCase().includes(search.toLowerCase()) || e.chapterName.toLowerCase().includes(search.toLowerCase()))
: $history;
$: sessions = buildSessions(filtered);
$: groups = (() => {
const map = new Map<string, Session[]>();
for (const s of sessions) {
const l = dayLabel(s.readAt);
if (!map.has(l)) map.set(l, []);
map.get(l)!.push(s);
}
return Array.from(map.entries()).map(([label, items]) => ({ label, items }));
})();
$: stats = $history.length ? {
uniqueChapters: new Set($history.map((e) => e.chapterId)).size,
uniqueManga: new Set($history.map((e) => e.mangaId)).size,
estimatedMinutes: Math.round(new Set($history.map((e) => e.chapterId)).size * 4.5),
} : null;
function resume(session: Session) {
const ch = $activeChapterList.find((c) => c.id === session.latestChapterId);
if (ch && $activeChapterList.length > 0) openReader(ch, $activeChapterList);
else activeManga.set({ id: session.mangaId, title: session.mangaTitle, thumbnailUrl: session.thumbnailUrl } as any);
}
</script>
<div class="root">
<div class="header">
<h1 class="heading">History</h1>
<div class="header-right">
<div class="search-wrap">
<MagnifyingGlass size={12} class="search-icon" weight="light" />
<input class="search" placeholder="Search history…" bind:value={search} />
{#if search}
<button class="search-clear" on:click={() => search = ""}>×</button>
{/if}
</div>
{#if $history.length > 0}
<button class="clear-btn" on:click={() => history.set([])} title="Clear all history">
<Trash size={14} weight="light" />
</button>
{/if}
</div>
</div>
{#if stats}
<div class="stats-bar">
<span class="stat-item">
<span class="stat-val">{stats.uniqueChapters}</span>
<span class="stat-label">chapters read</span>
</span>
<span class="stat-div"></span>
<span class="stat-item">
<span class="stat-val">{stats.uniqueManga}</span>
<span class="stat-label">series</span>
</span>
<span class="stat-div"></span>
<span class="stat-item">
<span class="stat-val">{formatReadTime(stats.estimatedMinutes)}</span>
<span class="stat-label">est. read time</span>
</span>
</div>
{/if}
{#if $history.length === 0}
<div class="empty">
<ClockCounterClockwise size={32} weight="light" class="empty-icon" />
<p class="empty-text">No reading history yet</p>
<p class="empty-hint">Chapters you read will appear here</p>
</div>
{:else if sessions.length === 0}
<div class="empty">
<Books size={28} weight="light" class="empty-icon" />
<p class="empty-text">No results for "{search}"</p>
</div>
{:else}
<div class="list">
{#each groups as { label, items }}
<div class="group">
<p class="group-label">{label}</p>
{#each items as session}
<button class="row" on:click={() => resume(session)}>
<div class="thumb-wrap">
<img src={thumbUrl(session.thumbnailUrl)} alt={session.mangaTitle} class="thumb" />
{#if session.chapterCount > 1}
<span class="session-badge">{session.chapterCount}</span>
{/if}
</div>
<div class="info">
<span class="manga-title">{session.mangaTitle}</span>
<span class="chapter-name">
{#if session.chapterCount > 1}
<span class="chapter-range">
{session.firstChapterName}
<span class="range-sep"></span>
{session.latestChapterName}
</span>
{:else}
{session.latestChapterName}
{#if session.latestPageNumber > 1}
<span class="page-badge">p.{session.latestPageNumber}</span>
{/if}
{/if}
</span>
</div>
<span class="time">{timeAgo(session.readAt)}</span>
<Play size={12} weight="fill" class="play-icon" />
</button>
{/each}
</div>
{/each}
</div>
{/if}
</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-5) var(--sp-6) var(--sp-3); flex-shrink: 0;
border-bottom: 1px solid var(--border-dim);
}
.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-right { display: flex; align-items: center; gap: var(--sp-2); }
.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 28px 5px 26px;
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); }
.search-clear {
position: absolute; right: 7px; color: var(--text-faint); font-size: 14px; line-height: 1;
background: none; border: none; cursor: pointer; padding: 2px; transition: color var(--t-base);
}
.search-clear:hover { color: var(--text-muted); }
.clear-btn {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: var(--radius-md);
color: var(--text-faint); transition: color var(--t-base), background var(--t-base);
}
.clear-btn:hover { color: var(--color-error); background: var(--color-error-bg); }
.stats-bar {
display: flex; align-items: center; gap: var(--sp-3);
padding: var(--sp-2) var(--sp-6); border-bottom: 1px solid var(--border-dim);
flex-shrink: 0; background: var(--bg-raised);
}
.stat-item { display: flex; align-items: baseline; gap: 5px; }
.stat-val { font-family: var(--font-ui); font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--accent-fg); }
.stat-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.stat-div { width: 1px; height: 12px; background: var(--border-dim); flex-shrink: 0; }
.list { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-6); }
.group { margin-bottom: var(--sp-5); }
.group-label {
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
letter-spacing: var(--tracking-wider); text-transform: uppercase;
margin-bottom: var(--sp-2); padding: 0 var(--sp-1);
}
.row {
display: flex; align-items: center; gap: var(--sp-3);
width: 100%; padding: 8px var(--sp-2); 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);
}
.row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.row:hover :global(.play-icon) { opacity: 1; }
.thumb-wrap { position: relative; flex-shrink: 0; }
.thumb {
width: 36px; height: 52px; border-radius: var(--radius-sm); object-fit: cover;
display: block; background: var(--bg-raised); border: 1px solid var(--border-dim);
}
.session-badge {
position: absolute; bottom: -4px; right: -6px;
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
font-family: var(--font-ui); font-size: 9px; font-weight: 600;
padding: 1px 4px; border-radius: 6px; line-height: 1.4; pointer-events: none;
}
.info { flex: 1; display: flex; flex-direction: column; gap: 3px; overflow: hidden; min-width: 0; }
.manga-title {
font-size: var(--text-base); font-weight: var(--weight-medium);
color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.chapter-name {
font-size: var(--text-sm); color: var(--text-muted);
display: flex; align-items: center; gap: var(--sp-1); min-width: 0;
}
.chapter-range {
display: flex; align-items: center; gap: 5px;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis; color: var(--text-muted);
}
.range-sep { color: var(--text-faint); font-size: 10px; flex-shrink: 0; }
.page-badge { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.time { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; white-space: nowrap; }
:global(.play-icon) { color: var(--text-faint); flex-shrink: 0; opacity: 0; transition: opacity var(--t-base); }
.empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); }
:global(.empty-icon) { color: var(--text-faint); }
.empty-text { font-size: var(--text-base); color: var(--text-muted); }
.empty-hint { font-size: var(--text-sm); color: var(--text-faint); }
</style>
+11 -17
View File
@@ -1,14 +1,15 @@
<script lang="ts"> <script lang="ts">
import { Books, MagnifyingGlass, ClockCounterClockwise, Compass, DownloadSimple, PuzzlePiece, GearSix } from "phosphor-svelte";
import { navPage, activeManga, activeSource, libraryFilter, genreFilter, settingsOpen } from "../../store"; import { navPage, activeManga, activeSource, libraryFilter, genreFilter, settingsOpen } from "../../store";
import type { NavPage } from "../../store"; import type { NavPage } from "../../store";
const TABS: { id: NavPage; label: string; path: string }[] = [ const TABS: { id: NavPage; label: string; icon: any }[] = [
{ id: "library", label: "Library", path: "M12 2H4a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h8M12 2h8a2 2 0 0 1 2 2v16a2 2 0 0 1-2 2h-8M12 2v20" }, { id: "library", label: "Library", icon: Books },
{ id: "search", label: "Search", path: "M21 21l-4.35-4.35M17 11A6 6 0 1 1 5 11a6 6 0 0 1 12 0z" }, { id: "search", label: "Search", icon: MagnifyingGlass },
{ id: "history", label: "History", path: "M12 8v4l3 3M3.05 11a9 9 0 1 0 .5-3" }, { id: "history", label: "History", icon: ClockCounterClockwise },
{ id: "explore", label: "Explore", path: "M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm0 0v20M2 12h20" }, { id: "explore", label: "Explore", icon: Compass },
{ id: "downloads", label: "Downloads", path: "M12 3v13M7 11l5 5 5-5M5 21h14" }, { id: "downloads", label: "Downloads", icon: DownloadSimple },
{ id: "extensions", label: "Extensions", path: "M12 2l2 7h7l-5.5 4 2 7L12 16l-5.5 4 2-7L3 9h7z" }, { id: "extensions", label: "Extensions", icon: PuzzlePiece },
]; ];
function navigate(id: NavPage) { function navigate(id: NavPage) {
@@ -28,26 +29,19 @@
<aside class="root"> <aside class="root">
<button class="logo" on:click={goHome} title="Go to Library" aria-label="Go to Library"> <button class="logo" on:click={goHome} title="Go to Library" aria-label="Go to Library">
<div class="logo-icon" /> <div class="logo-icon"></div>
</button> </button>
<nav class="nav"> <nav class="nav">
{#each TABS as tab} {#each TABS as tab}
<button class="tab" class:active={$navPage === tab.id} <button class="tab" class:active={$navPage === tab.id}
title={tab.label} on:click={() => navigate(tab.id)}> title={tab.label} on:click={() => navigate(tab.id)}>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" <svelte:component this={tab.icon} size={18} weight="light" />
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d={tab.path} />
</svg>
</button> </button>
{/each} {/each}
</nav> </nav>
<div class="bottom"> <div class="bottom">
<button class="settings-btn" on:click={() => settingsOpen.set(true)} title="Settings"> <button class="settings-btn" on:click={() => settingsOpen.set(true)} title="Settings">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" <GearSix size={18} weight="light" />
stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M12 15a3 3 0 1 0 0-6 3 3 0 0 0 0 6z" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
</button> </button>
</div> </div>
</aside> </aside>
+368 -9
View File
@@ -1,12 +1,371 @@
<script lang="ts"> <script lang="ts">
export const EXIT_MS = 320; import { onMount, onDestroy } from "svelte";
export let mode: "loading" | "idle" = "loading"; import { getCurrentWindow } from "@tauri-apps/api/window";
export let ringFull = false; import logoUrl from "../../assets/moku-icon.svg";
export let failed = false;
export let showCards = true; export let mode: "loading" | "idle" = "loading";
export let showFps = false; export let ringFull: boolean = false;
export let onReady: (() => void) | undefined = undefined; export let failed: boolean = false;
export let onRetry: (() => void) | undefined = undefined; export let showCards: boolean = true;
export let showFps: boolean = false;
export let onReady: (() => void) | undefined = undefined;
export let onRetry: (() => void) | undefined = undefined;
export let onDismiss: (() => void) | undefined = undefined; export let onDismiss: (() => void) | undefined = undefined;
const EXIT_MS = 320;
let dots = "";
let ringProg = 0.025;
let exiting = false;
let exitLock = false;
let canvas: HTMLCanvasElement;
let fpsEl: HTMLSpanElement;
function triggerExit(cb?: () => void) {
if (exitLock) return;
exitLock = true;
exiting = true;
setTimeout(() => cb?.(), EXIT_MS);
}
$: if (ringFull) {
ringProg = 1;
setTimeout(() => triggerExit(onReady), 650);
}
const dotsInterval = setInterval(() => {
dots = dots.length >= 3 ? "" : dots + ".";
}, 420);
onMount(() => {
if (mode === "idle" && onDismiss) {
const handler = () => triggerExit(onDismiss);
const t = setTimeout(() => {
window.addEventListener("keydown", handler, { once: true });
window.addEventListener("mousedown", handler, { once: true });
window.addEventListener("touchstart", handler, { once: true });
}, 200);
return () => {
clearTimeout(t);
window.removeEventListener("keydown", handler);
window.removeEventListener("mousedown", handler);
window.removeEventListener("touchstart", handler);
};
}
});
onDestroy(() => clearInterval(dotsInterval));
// ── canvas animation ────────────────────────────────────────────────────────
interface CardDef {
cx: number; w: number; h: number; lines: number; alpha: number;
speed: number; cycleSec: number; phase: number; travel: number;
yStart: number; angleStart: number; tilt: number;
}
interface CardTrig { cosA: number; sinA: number; tiltRad: number; }
const LAYER_CFG = [
{ wMin: 26, wMax: 40, speedMin: 30, speedMax: 50, alpha: 0.22 },
{ wMin: 38, wMax: 56, speedMin: 52, speedMax: 80, alpha: 0.35 },
{ wMin: 54, wMax: 76, speedMin: 85, speedMax: 120, alpha: 0.50 },
] as const;
const BUF = 80, COLS = 14;
function hash(n: number): number {
let x = Math.imul(n ^ (n >>> 16), 0x45d9f3b);
x = Math.imul(x ^ (x >>> 16), 0x45d9f3b);
return ((x ^ (x >>> 16)) >>> 0) / 0xffffffff;
}
function buildCards(vw: number, vh: number) {
const cards: CardDef[] = [], laneW = vw / COLS;
for (let layer = 0; layer < 3; layer++) {
const cfg = LAYER_CFG[layer];
for (let col = 0; col < COLS; col++) {
const seed = col * 31 + layer * 97 + 7;
const w = cfg.wMin + hash(seed + 1) * (cfg.wMax - cfg.wMin);
const h = w * 1.44;
const speed = cfg.speedMin + hash(seed + 5) * (cfg.speedMax - cfg.speedMin);
const travel = vh + h + BUF;
cards.push({
cx: (col + 0.5) * laneW + (hash(seed + 2) * 2 - 1) * Math.max(0, (laneW - w) / 2 - 2),
w, h,
lines: 1 + Math.floor(hash(seed + 7) * 3),
alpha: cfg.alpha, speed,
cycleSec: travel / speed,
phase: ((col / COLS) + hash(seed + 6) * 0.6 + layer * 0.23) % 1,
travel,
yStart: vh + h / 2 + BUF / 2,
angleStart: hash(seed + 3) * 50 - 25,
tilt: (hash(seed + 4) * 2 - 1) * 18,
});
}
}
const trigs: CardTrig[] = cards.map(c => ({
cosA: Math.cos(c.angleStart * (Math.PI / 180)),
sinA: Math.sin(c.angleStart * (Math.PI / 180)),
tiltRad: c.tilt * (Math.PI / 180),
}));
return { cards, trigs };
}
function rrect(ctx: CanvasRenderingContext2D, x: number, y: number, w: number, h: number, r: number) {
ctx.beginPath();
ctx.moveTo(x + r, y);
ctx.lineTo(x + w - r, y); ctx.arcTo(x + w, y, x + w, y + r, r);
ctx.lineTo(x + w, y + h - r); ctx.arcTo(x + w, y + h, x + w - r, y + h, r);
ctx.lineTo(x + r, y + h); ctx.arcTo(x, y + h, x, y + h - r, r);
ctx.lineTo(x, y + r); ctx.arcTo(x, y, x + r, y, r);
ctx.closePath();
}
const STAMP_PAD = 6;
function buildStamp(c: CardDef, dpr: number): HTMLCanvasElement {
const oc = document.createElement("canvas");
oc.width = Math.round(Math.ceil(c.w + STAMP_PAD * 2) * dpr);
oc.height = Math.round(Math.ceil(c.h + STAMP_PAD * 2) * dpr);
const ctx = oc.getContext("2d")!;
ctx.scale(dpr, dpr);
const x0 = STAMP_PAD, y0 = STAMP_PAD;
const coverH = (c.w * 0.72) * 1.05;
const lineY0 = y0 + 3 + coverH + 5;
ctx.fillStyle = "rgba(0,0,0,0.5)";
rrect(ctx, x0 + 2, y0 + 2, c.w, c.h, 4); ctx.fill();
ctx.fillStyle = "rgba(255,255,255,0.07)";
rrect(ctx, x0, y0, c.w, c.h, 4); ctx.fill();
ctx.strokeStyle = "rgba(255,255,255,0.75)";
ctx.lineWidth = 1.2;
rrect(ctx, x0, y0, c.w, c.h, 4); ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,0.15)";
rrect(ctx, x0 + 3, y0 + 3, c.w - 6, coverH, 3); ctx.fill();
ctx.fillStyle = "rgba(255,255,255,0.08)";
rrect(ctx, x0 + 3, y0 + 3, (c.w - 6) * 0.45, coverH, 3); ctx.fill();
for (let li = 0; li < c.lines; li++) {
ctx.fillStyle = li === 0 ? "rgba(255,255,255,0.35)" : "rgba(255,255,255,0.20)";
ctx.fillRect(x0 + 4, lineY0 + li * 8, (c.w - 8) * (li === 0 ? 0.78 : 0.52), li === 0 ? 3 : 2);
}
return oc;
}
function buildVignette(vw: number, vh: number, dpr: number): HTMLCanvasElement {
const oc = document.createElement("canvas");
oc.width = Math.round(vw * dpr);
oc.height = Math.round(vh * dpr);
const ctx = oc.getContext("2d")!;
ctx.scale(dpr, dpr);
const g = ctx.createRadialGradient(vw / 2, vh / 2, 0, vw / 2, vh / 2, Math.max(vw, vh) * 0.65);
g.addColorStop(0.15, "rgba(0,0,0,0)");
g.addColorStop(1, "rgba(0,0,0,0.82)");
ctx.fillStyle = g;
ctx.fillRect(0, 0, vw, vh);
return oc;
}
function drawFrame(
ctx: CanvasRenderingContext2D, t: number, cw: number, ch: number, dpr: number,
cards: CardDef[], trigs: CardTrig[], stamps: HTMLCanvasElement[], vignette: HTMLCanvasElement,
) {
ctx.clearRect(0, 0, cw, ch);
for (let i = 0; i < cards.length; i++) {
const c = cards[i];
const p = ((t / c.cycleSec) + c.phase) % 1;
const alpha = p < 0.07 ? (p / 0.07) * c.alpha : p > 0.86 ? ((1 - p) / 0.14) * c.alpha : c.alpha;
if (alpha < 0.005) continue;
const cy = c.yStart - p * c.travel;
const tg = trigs[i];
const delta = tg.tiltRad * p;
const cos = tg.cosA * Math.cos(delta) - tg.sinA * Math.sin(delta);
const sin = tg.sinA * Math.cos(delta) + tg.cosA * Math.sin(delta);
ctx.globalAlpha = alpha;
ctx.setTransform(cos * dpr, sin * dpr, -sin * dpr, cos * dpr, c.cx * dpr, cy * dpr);
const sw = stamps[i].width / dpr, sh = stamps[i].height / dpr;
ctx.drawImage(stamps[i], -sw / 2, -sh / 2, sw, sh);
}
ctx.setTransform(1, 0, 0, 1, 0, 0);
ctx.globalAlpha = 1;
ctx.drawImage(vignette, 0, 0, cw, ch);
}
// ── FPS counter ─────────────────────────────────────────────────────────────
let fps = 0, fpsFrames = 0, fpsLast = 0;
function tickFps(now: number) {
fpsFrames++;
if (now - fpsLast >= 500) {
fps = Math.round(fpsFrames / ((now - fpsLast) / 1000));
fpsFrames = 0; fpsLast = now;
if (fpsEl) fpsEl.textContent = `${fps} fps`;
}
}
// ── canvas mount ─────────────────────────────────────────────────────────────
function mountCanvas(el: HTMLCanvasElement) {
const win = getCurrentWindow();
const ctx = el.getContext("2d")!;
interface RenderState {
cards: CardDef[]; trigs: CardTrig[];
stamps: HTMLCanvasElement[]; vignette: HTMLCanvasElement;
CW: number; CH: number; scale: number;
}
let live: RenderState | null = null;
let lastLogW = 0, lastLogH = 0, lastScale = 0, buildGen = 0;
async function syncSize() {
const gen = ++buildGen;
const [phys, scale] = await Promise.all([win.innerSize(), win.scaleFactor()]);
if (gen !== buildGen) return;
const logW = phys.width / scale, logH = phys.height / scale;
if (logW === lastLogW && logH === lastLogH && scale === lastScale) return;
lastLogW = logW; lastLogH = logH; lastScale = scale;
const built = buildCards(logW, logH);
const stamps = built.cards.map(c => buildStamp(c, scale));
const vig = buildVignette(logW, logH, scale);
el.width = phys.width;
el.height = phys.height;
live = { cards: built.cards, trigs: built.trigs, stamps, vignette: vig, CW: phys.width, CH: phys.height, scale };
}
const ro = new ResizeObserver(() => syncSize());
ro.observe(el);
syncSize();
let raf = 0, t0 = -1;
function frame(now: number) {
raf = requestAnimationFrame(frame);
if (!live) return;
if (t0 < 0) t0 = now;
if (showFps) tickFps(now);
const { cards, trigs, stamps, vignette, CW, CH, scale } = live;
drawFrame(ctx, (now - t0) / 1000, CW, CH, scale, cards, trigs, stamps, vignette);
}
raf = requestAnimationFrame(frame);
return () => { cancelAnimationFrame(raf); ro.disconnect(); };
}
// ── ring ─────────────────────────────────────────────────────────────────────
$: ringR = 44;
$: ringPad = 8;
$: ringSize = (ringR + ringPad) * 2;
$: ringC = ringR + ringPad;
$: ringCirc = 2 * Math.PI * ringR;
$: ringArc = ringCirc * Math.min(Math.max(ringProg, 0.025), 0.999);
$: ringTop = -((ringSize - 80) / 2);
$: ringLeft = -((ringSize - 80) / 2);
</script> </script>
<div>SplashScreen stub</div>
<div
class="splash"
class:exiting
style="cursor: {mode === 'idle' ? 'pointer' : 'default'}"
>
{#if showCards}
<canvas
style="position:absolute;inset:0;pointer-events:none;width:100%;height:100%"
use:mountCanvas
></canvas>
{#if showFps}
<span bind:this={fpsEl} style="position:absolute;top:8px;right:8px;font-family:var(--font-ui);font-size:10px;color:var(--text-faint);z-index:2;pointer-events:none"></span>
{/if}
{/if}
{#if mode === "idle"}
<div style="z-index:1;display:flex;flex-direction:column;align-items:center">
<div style="position:relative;width:128px;height:128px;margin-bottom:32px">
<div class="logo-glow"></div>
<img src={logoUrl} alt="Moku" class="logo-breathe" style="width:128px;height:128px;border-radius:28px;display:block;position:relative" />
</div>
<p class="hint">press any key to continue</p>
</div>
{:else}
<div style="position:relative;width:80px;height:80px;margin-bottom:20px;z-index:1">
{#if !failed}
<svg
width={ringSize} height={ringSize}
style="position:absolute;pointer-events:none;top:{ringTop}px;left:{ringLeft}px"
>
<circle cx={ringC} cy={ringC} r={ringR} fill="none" stroke="var(--border-base)" stroke-width="2" />
<circle
cx={ringC} cy={ringC} r={ringR}
fill="none"
stroke="var(--accent)"
stroke-width="2"
stroke-linecap="round"
stroke-dasharray="{ringArc} {ringCirc}"
transform="rotate(-90 {ringC} {ringC})"
style="transition: stroke-dasharray 0.4s cubic-bezier(0.4,0,0.2,1)"
/>
</svg>
{/if}
<img src={logoUrl} alt="Moku" style="width:80px;height:80px;border-radius:18px;display:block" />
</div>
<p class="title-label">moku</p>
<div style="z-index:1;display:flex;flex-direction:column;align-items:center;gap:8px">
{#if failed}
<p style="font-family:var(--font-ui);font-size:11px;color:var(--color-error);letter-spacing:0.1em;margin:0">
Could not reach Suwayomi
</p>
<p style="font-family:var(--font-ui);font-size:10px;color:var(--text-faint);letter-spacing:0.05em;margin:0;text-align:center;max-width:240px;line-height:1.6">
Make sure tachidesk-server is on your PATH
</p>
<button class="retry-btn" on:click={onRetry}>Retry</button>
{:else}
<p style="font-family:var(--font-ui);font-size:10px;color:var(--text-faint);letter-spacing:0.12em;margin:0;min-width:160px;text-align:center">
{ringFull ? "Ready" : `Initializing server${dots}`}
</p>
{/if}
</div>
{/if}
</div>
<style>
.splash {
position: fixed; inset: 0; z-index: 9999;
background: var(--bg-base); overflow: hidden;
display: flex; flex-direction: column;
align-items: center; justify-content: center;
animation: spIn 0.35s cubic-bezier(0,0,0.2,1) both;
}
.splash.exiting {
animation: spOut 320ms cubic-bezier(0.4,0,1,1) both;
}
@keyframes spIn { from { opacity:0; transform:scale(1.015) } to { opacity:1; transform:scale(1) } }
@keyframes spOut { from { opacity:1; transform:scale(1) } to { opacity:0; transform:scale(0.96) } }
@keyframes logoBreathe {
0%,100% { transform:scale(1); filter:drop-shadow(0 0 0px rgba(255,255,255,0)) }
50% { transform:scale(1.04);filter:drop-shadow(0 0 18px rgba(255,255,255,0.12)) }
}
@keyframes hintFade { 0%,100% { opacity:0.35 } 50% { opacity:0.7 } }
.logo-glow {
position: absolute; inset: -20px; border-radius: 50%;
background: radial-gradient(circle, rgba(255,255,255,0.06) 0%, transparent 70%);
animation: logoBreathe 4s ease-in-out infinite;
}
.logo-breathe { animation: logoBreathe 4s ease-in-out infinite; }
.hint {
font-family: var(--font-ui); font-size: 10px; color: var(--text-faint);
letter-spacing: 0.22em; text-transform: uppercase;
margin: 0; user-select: none;
animation: hintFade 3.5s ease-in-out infinite;
}
.title-label {
font-family: var(--font-ui); font-size: 11px; font-weight: 500;
letter-spacing: 0.26em; text-transform: uppercase;
color: var(--text-secondary); margin: 0 0 8px; z-index: 1; user-select: none;
}
.retry-btn {
margin-top: 4px; padding: 5px 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: 11px; letter-spacing: 0.08em;
}
</style>
+453 -1
View File
@@ -1 +1,453 @@
<div>Library.svelte</div> <script lang="ts">
import { onMount, onDestroy } from "svelte";
import { MagnifyingGlass, Books, DownloadSimple, X, Folder, FolderSimplePlus, Trash } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import { GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
import { settings, activeManga, libraryFilter, libraryTagFilter, 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[] = [];
let loading = true;
let error: string | null = null;
let retryCount = 0;
let search = "";
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 = nodes; error = null; })
.catch((e) => error = e.message)
.finally(() => loading = false);
}
$: {
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 === "all" || f === "library" || f === "downloaded";
$: filtered = (() => {
let items = allManga;
if ($libraryFilter === "library") items = items.filter((m) => m.inLibrary);
else if ($libraryFilter === "downloaded") items = items.filter((m) => (m.downloadCount ?? 0) > 0);
else if (!isBuiltin($libraryFilter)) {
const folder = $settings.folders.find((f) => f.id === $libraryFilter);
if (folder) items = items.filter((m) => folder.mangaIds.includes(m.id));
}
if ($libraryTagFilter.length)
items = items.filter((m) => $libraryTagFilter.every((t) => (m.genre ?? []).includes(t)));
if (search.trim()) {
const q = search.toLowerCase();
items = items.filter((m) => m.title.toLowerCase().includes(q));
}
return items;
})();
$: cols = Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP)));
$: counts = {
all: allManga.length,
library: allManga.filter((m) => m.inLibrary).length,
downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length,
...$settings.folders.reduce((a, f) => ({ ...a, [f.id]: allManga.filter((m) => f.mangaIds.includes(m.id)).length }), {}),
};
$: allTags = [...new Set(allManga.filter((m) => m.inLibrary).flatMap((m) => m.genre ?? []))].sort();
async function removeFromLibrary(manga: Manga) {
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
allManga = allManga.map((m) => m.id === manga.id ? { ...m, inLibrary: false } : m);
cache.clear(CACHE_KEYS.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()); },
}];
}
function toggleTag(tag: string) {
libraryTagFilter.update((t) => t.includes(tag) ? t.filter((x) => x !== tag) : [...t, tag]);
}
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"
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"], ["all","All"]] 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 allTags.length > 0}
<div class="tag-panel">
{#if $libraryTagFilter.length > 0}
<button class="tag-clear" on:click={() => libraryTagFilter.set([])}>
<X size={11} weight="bold" /> Clear
</button>
{/if}
{#each allTags as tag}
<button class="tag-chip" class:active={$libraryTagFilter.includes(tag)} on:click={() => toggleTag(tag)}>
{tag}
</button>
{/each}
</div>
{/if}
{#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."
: !isBuiltin($libraryFilter) ? "No manga in this folder yet. Right-click manga to assign them."
: "No manga found."}
</div>
{:else}
<div class="grid" style="--cols:{cols}">
{#each filtered 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}
{/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); }
.tag-panel {
position: relative; z-index: 1;
display: flex; flex-wrap: wrap; gap: var(--sp-1);
margin-bottom: var(--sp-3);
}
.tag-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);
color: var(--text-faint); cursor: pointer;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.tag-chip:hover { color: var(--text-muted); border-color: var(--border-strong); }
.tag-chip.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.tag-clear {
display: flex; align-items: center; gap: 4px;
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(--color-error);
color: var(--color-error); cursor: pointer;
transition: background var(--t-base);
}
.tag-clear:hover { background: var(--color-error-bg); }
.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); }
.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>
+801 -1
View File
@@ -1 +1,801 @@
<div>SeriesDetail.svelte</div> <script lang="ts">
import { onMount, onDestroy } from "svelte";
import {
ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle,
ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending,
CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X,
} from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client";
import {
GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD,
UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS,
ENQUEUE_CHAPTERS_DOWNLOAD,
} from "../../lib/queries";
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
import { settings, activeManga, activeChapter, genreFilter, navPage, addToast, updateSettings, addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders, openReader } from "../../store";
import type { Manga, Chapter } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
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 = null;
let chapters: Chapter[] = [];
let loadingManga = false;
let loadingChapters = true;
let enqueueing: Set<number> = new Set();
let dlOpen = false;
let detailsOpen = false;
let togglingLibrary = false;
let chapterPage = 1;
let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = null;
let jumpOpen = false;
let jumpInput = "";
let viewMode: "list" | "grid" = "list";
let deletingAll = false;
let refreshing = false;
let descExpanded = false;
let genresExpanded = false;
let folderPickerOpen = false;
let folderCreating = false;
let folderNewName = "";
let rangeFrom = "";
let rangeTo = "";
let showRange = false;
let dlDropRef: HTMLDivElement;
let folderPickerRef: HTMLDivElement;
let mangaAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null;
let loadingFor: number | null = null;
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" });
}
function applyChapters(nodes: Chapter[]) {
chapters = nodes;
}
$: sortDir = $settings.chapterSortDir;
$: sortedChapters = sortDir === "desc" ? [...chapters].reverse() : [...chapters];
$: totalPages = Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE);
$: pageChapters = sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE);
$: readCount = chapters.filter((c) => c.isRead).length;
$: totalCount = chapters.length;
$: progressPct = totalCount > 0 ? (readCount / totalCount) * 100 : 0;
$: downloadedCount = chapters.filter((c) => c.isDownloaded).length;
$: continueChapter = (() => {
if (!chapters.length) return null;
const asc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
const anyRead = asc.some((c) => c.isRead);
const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0);
if (inProgress) return { chapter: inProgress, type: "continue" as const };
const firstUnread = asc.find((c) => !c.isRead);
if (firstUnread) return { chapter: firstUnread, type: (anyRead ? "continue" : "start") as const };
return { chapter: asc[0], type: "reread" as const };
})();
$: statusLabel = manga?.status
? manga.status.charAt(0) + manga.status.slice(1).toLowerCase()
: null;
$: assignedFolders = $activeManga ? getMangaFolders($activeManga.id) : [];
$: hasFolders = assignedFolders.length > 0;
function loadManga(id: number) {
mangaAbort?.abort();
const ctrl = new AbortController();
mangaAbort = ctrl;
loadingFor = id;
const cached = mangaStore.get(id);
const now = Date.now();
if (cached) {
manga = cached.data;
loadingManga = false;
if (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);
const now = Date.now();
if (cached) {
applyChapters(cached.data);
loadingChapters = false;
if (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; });
}
$: if ($activeManga) { loadManga($activeManga.id); loadChapters($activeManga.id); }
let prevChapterId: number | null = null;
$: {
const wasOpen = prevChapterId !== null;
prevChapterId = $activeChapter?.id ?? null;
if (wasOpen && !$activeChapter && $activeManga) {
loadChapters($activeManga.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 ($activeManga) reloadChapters($activeManga.id);
}
async function enqueueMultiple(chapterIds: number[]) {
if (!chapterIds.length) return;
await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds }).catch(console.error);
addToast({ kind: "download", title: "Download queued", body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added` });
if ($activeManga) reloadChapters($activeManga.id);
}
async function markRead(chapterId: number, isRead: boolean) {
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
chapters = chapters.map((c) => c.id === chapterId ? { ...c, isRead } : c);
if ($activeManga) chapterStore.set($activeManga.id, { data: chapters, fetchedAt: Date.now() });
}
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 ($activeManga) chapterStore.set($activeManga.id, { data: chapters, fetchedAt: Date.now() });
}
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 ($activeManga) chapterStore.set($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 ($activeManga) chapterStore.set($activeManga.id, { data: chapters, fetchedAt: Date.now() });
deletingAll = false;
}
async function refreshChapters() {
if (!$activeManga || refreshing) return;
refreshing = true;
chapterStore.delete($activeManga.id);
gql(FETCH_CHAPTERS, { mangaId: $activeManga.id })
.then(() => reloadChapters($activeManga!.id))
.then(() => addToast({ kind: "success", title: "Chapters refreshed" }))
.catch((e) => addToast({ kind: "error", title: "Refresh failed", body: e?.message }))
.finally(() => refreshing = false);
}
function buildCtxItems(ch: Chapter, idx: number): MenuEntry[] {
const above = sortedChapters.slice(0, idx + 1);
const below = sortedChapters.slice(idx);
const last = sortedChapters.length - 1;
return [
{ label: ch.isRead ? "Mark as unread" : "Mark as read", icon: ch.isRead ? Circle : CheckCircle, onClick: () => markRead(ch.id, !ch.isRead) },
{ separator: true },
{ label: "Mark above as read", icon: CheckCircle, onClick: () => markAboveRead(idx), disabled: idx === 0 || above.filter((c) => !c.isRead).length === 0 },
{ label: "Mark above as unread", icon: Circle, onClick: () => markAboveUnread(idx), disabled: idx === 0 || above.filter((c) => c.isRead).length === 0 },
{ separator: true },
{ label: "Mark below as read", icon: CheckCircle, onClick: () => markBelowRead(idx), disabled: idx === last || below.filter((c) => !c.isRead).length === 0 },
{ label: "Mark below as unread", icon: Circle, onClick: () => markBelowUnread(idx), disabled: idx === last || below.filter((c) => c.isRead).length === 0 },
{ separator: true },
{ label: ch.isDownloaded ? "Delete download" : "Download", icon: ch.isDownloaded ? Trash : 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", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx, idx + 5).filter((c) => !c.isDownloaded).map((c) => c.id)) },
{ label: "Download all from here", icon: DownloadSimple, onClick: () => enqueueMultiple(sortedChapters.slice(idx).filter((c) => !c.isDownloaded).map((c) => c.id)) },
];
}
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 = ""; }
}
$: if (dlOpen) { setTimeout(() => document.addEventListener("mousedown", handleDlOutside, true), 0); }
else { document.removeEventListener("mousedown", handleDlOutside, true); }
$: if (folderPickerOpen){ setTimeout(() => document.addEventListener("mousedown", handleFolderOutside, true), 0); }
else { document.removeEventListener("mousedown", handleFolderOutside, true); }
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 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);
enqueueMultiple(sortedChapters.filter((c) => c.chapterNumber >= lo && c.chapterNumber <= hi && !c.isDownloaded).map((c) => c.id));
}
function createFolder() {
const name = folderNewName.trim();
if (!name || !$activeManga) return;
const id = addFolder(name);
assignMangaToFolder(id, $activeManga.id);
folderNewName = ""; folderCreating = false;
}
onDestroy(() => { mangaAbort?.abort(); chapterAbort?.abort(); });
</script>
{#if $activeManga}
<div class="root" on:contextmenu|preventDefault>
<!-- Sidebar -->
<div class="sidebar">
<button class="back" on:click={() => activeManga.set(null)}>
<ArrowLeft size={13} weight="light" /> Back
</button>
<div class="cover-wrap">
<img src={thumbUrl($activeManga.thumbnailUrl)} alt={$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, 5)) as g}
<button class="genre" on:click={() => { genreFilter.set(g); navPage.set("explore"); activeManga.set(null); }}>{g}</button>
{/each}
{#if manga.genre.length > 5}
<button class="genre-toggle" on:click={() => genresExpanded = !genresExpanded}>
{genresExpanded ? "less" : `+${manga.genre.length - 5}`}
</button>
{/if}
</div>
{/if}
{#if manga?.description}
<div class="desc-wrap">
<p class="desc" class:expanded={descExpanded}>{manga.description}</p>
{#if manga.description.length > 120}
<button class="desc-toggle" on:click={() => descExpanded = !descExpanded}>{descExpanded ? "Less" : "More"}</button>
{/if}
</div>
{/if}
</div>
{/if}
{#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}
<div class="actions">
<button class="library-btn" class:active={manga?.inLibrary} on:click={toggleLibrary} 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>
{#if continueChapter}
<button class="read-btn" on:click={() => openReader(continueChapter!.chapter, sortedChapters)}>
<Play size={12} weight="fill" />
{continueChapter.type === "continue"
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}`
: continueChapter.type === "reread" ? "Read again" : "Start reading"}
</button>
{/if}
<p class="chapter-count">{totalCount} {totalCount === 1 ? "chapter" : "chapters"}{readCount > 0 ? ` · ${readCount} read` : ""}</p>
{#if !loadingManga && manga?.source}
<div class="details-section">
<button class="details-toggle" on:click={() => detailsOpen = !detailsOpen}>
<span>Details</span>
<CaretDown size={11} weight="light" style="transform:{detailsOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease" />
</button>
{#if detailsOpen}
<div class="details-body">
<div class="detail-row"><span class="detail-key">Source</span><span class="detail-val">{manga.source.displayName}</span></div>
{#if manga.status}<div class="detail-row"><span class="detail-key">Status</span><span class="detail-val">{statusLabel}</span></div>{/if}
{#if manga.author}<div class="detail-row"><span class="detail-key">Author</span><span class="detail-val">{manga.author}</span></div>{/if}
{#if manga.artist && manga.artist !== manga.author}<div class="detail-row"><span class="detail-key">Artist</span><span class="detail-val">{manga.artist}</span></div>{/if}
<button class="migrate-btn" on:click={() => {}}>
<ArrowsClockwise size={12} weight="light" /> Switch source
</button>
{#if downloadedCount > 0}
<button class="delete-all-btn" on:click={deleteAllDownloads} disabled={deletingAll}>
<Trash size={12} weight="light" /> {deletingAll ? "Deleting…" : `Delete downloads (${downloadedCount})`}
</button>
{/if}
</div>
{/if}
</div>
{/if}
</div>
<!-- Chapter list -->
<div class="list-wrap">
<div class="list-header">
<div class="list-header-left">
<button class="sort-btn" on:click={() => { updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" }); chapterPage = 1; }}>
{#if sortDir === "desc"}<SortDescending size={14} weight="light" />{:else}<SortAscending size={14} weight="light" />{/if}
{sortDir === "desc" ? "Newest first" : "Oldest first"}
</button>
<button class="icon-btn" class:active={viewMode === "grid"} on:click={() => viewMode = viewMode === "list" ? "grid" : "list"}>
{#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
</button>
</div>
<div class="list-header-right">
<button class="icon-btn" on:click={refreshChapters} 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} on:click={() => folderPickerOpen = !folderPickerOpen}>
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
</button>
{#if folderPickerOpen}
<div class="fp-menu">
{#if $settings.folders.length === 0 && !folderCreating}
<p class="fp-empty">No folders yet</p>
{/if}
{#each $settings.folders as folder}
{@const isIn = $activeManga ? folder.mangaIds.includes($activeManga.id) : false}
<button class="fp-item" class:fp-item-active={isIn}
on:click={() => $activeManga && (isIn ? removeMangaFromFolder(folder.id, $activeManga.id) : assignMangaToFolder(folder.id, $activeManga.id))}>
<span class="fp-check">{isIn ? "✓" : ""}</span>{folder.name}
</button>
{/each}
<div class="fp-div"></div>
{#if folderCreating}
<div class="fp-create">
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName}
on:keydown={(e) => { if (e.key === "Enter") createFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }}
use:focus />
<button class="fp-confirm" on:click={createFolder} disabled={!folderNewName.trim()}>Add</button>
<button class="fp-cancel" on:click={() => { folderCreating = false; folderNewName = ""; }}>
<X size={12} weight="light" />
</button>
</div>
{:else}
<button class="fp-new" on:click={() => folderCreating = true}>+ New folder</button>
{/if}
</div>
{/if}
</div>
<!-- Jump to chapter -->
{#if chapters.length > 1}
<div class="jump-wrap">
{#if !jumpOpen}
<button class="jump-toggle" on:click={() => { jumpOpen = true; jumpInput = ""; }}>Go to</button>
{:else}
<div class="jump-row">
<input class="jump-input" type="text" placeholder="Ch. #" bind:value={jumpInput}
use:focus
on:keydown={(e) => {
if (e.key === "Escape") { jumpOpen = false; return; }
if (e.key === "Enter") {
const num = parseFloat(jumpInput);
if (!isNaN(num)) {
const target = sortedChapters.find((c) => c.chapterNumber === num)
?? sortedChapters.reduce((best, c) => Math.abs(c.chapterNumber - num) < Math.abs(best.chapterNumber - num) ? c : best, sortedChapters[0]);
if (target) openReader(target, sortedChapters);
}
jumpOpen = false;
}
}}
/>
<button class="jump-cancel" on:click={() => jumpOpen = false}></button>
</div>
{/if}
</div>
{/if}
<!-- Download dropdown -->
{#if chapters.length > 0}
<div class="dl-wrap" bind:this={dlDropRef}>
<button class="icon-btn" on:click={() => dlOpen = !dlOpen}>
<Download size={13} weight="light" />
</button>
{#if dlOpen}
<div class="dl-dropdown">
{#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} on:click={() => { enqueueNext(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" on:click={() => 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" on:click={() => showRange = false}></button>
<input class="dl-range-input" placeholder="From" bind:value={rangeFrom} on:keydown={(e) => e.key === "Enter" && enqueueRange()} use:focus />
<span class="dl-range-sep"></span>
<input class="dl-range-input" placeholder="To" bind:value={rangeTo} on:keydown={(e) => e.key === "Enter" && enqueueRange()} />
<button class="dl-range-go" disabled={!rangeFrom.trim() || !rangeTo.trim()} on:click={enqueueRange}>Go</button>
</div>
{/if}
<div class="dl-divider"></div>
<button class="dl-item" on:click={() => { enqueueMultiple(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" on:click={() => { enqueueMultiple(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" on:click={() => { deleteAllDownloads(); 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}
{#if totalPages > 1}
<div class="pagination">
<button class="page-btn" on:click={() => chapterPage = Math.max(1, chapterPage - 1)} disabled={chapterPage === 1}></button>
<span class="page-num">{chapterPage} / {totalPages}</span>
<button class="page-btn" on:click={() => chapterPage = Math.min(totalPages, chapterPage + 1)} disabled={chapterPage === totalPages}></button>
</div>
{/if}
</div>
</div>
<div class={viewMode === "grid" ? "ch-grid" : "ch-list"}>
{#if loadingChapters && chapters.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}
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:bookmarked={ch.isBookmarked}
on:click={() => openReader(ch, sortedChapters)}
on:contextmenu={(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.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)}
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead}
on:click={() => openReader(ch, sortedChapters)}
on:keydown={(e) => e.key === "Enter" && openReader(ch, sortedChapters)}
on:contextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}>
<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}
<button class="dl-btn" on:click|stopPropagation={() => deleteDownloaded(ch.id)}><Trash size={13} weight="light" /></button>
{:else if enqueueing.has(ch.id)}
<CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" />
{:else}
<button class="dl-btn" on:click|stopPropagation={(e) => enqueue(ch, e)}><Download size={13} weight="light" /></button>
{/if}
</div>
</div>
{/each}
{/if}
</div>
{#if totalPages > 1}
<div class="pagination-bottom">
<button class="page-btn" on:click={() => chapterPage = Math.max(1, chapterPage - 1)} disabled={chapterPage === 1}> Prev</button>
<span class="page-num">{chapterPage} / {totalPages}</span>
<button class="page-btn" on:click={() => chapterPage = Math.min(totalPages, chapterPage + 1)} disabled={chapterPage === totalPages}>Next </button>
</div>
{/if}
</div>
</div>
{#if ctx}
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.chapter, ctx.idx)} onClose={() => ctx = null} />
{/if}
{/if}
<script context="module">
function focus(node: HTMLElement) { node.focus(); }
</script>
<style>
.root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
/* Sidebar */
.sidebar {
width: 200px; 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; }
.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-wrap { display: flex; flex-direction: column; gap: 2px; }
.desc { font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base); display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; }
.desc.expanded { -webkit-line-clamp: unset; display: block; overflow: visible; }
.desc-toggle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); background: none; border: none; padding: 0; cursor: pointer; opacity: 0.7; transition: opacity var(--t-base); }
.desc-toggle:hover { opacity: 1; }
.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; }
.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); }
.external-link:hover { color: var(--text-muted); border-color: var(--border-strong); }
.read-btn { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); background: var(--accent-dim); border: 1px solid var(--accent); color: var(--accent-fg); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); cursor: pointer; transition: background var(--t-base), border-color var(--t-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.read-btn:hover { background: var(--accent-muted); border-color: var(--accent-bright); }
.chapter-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; }
.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-row { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-2); }
.detail-key { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.detail-val { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); text-align: right; }
.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: 5px var(--sp-2); border-radius: var(--radius-md); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; }
.delete-all-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); }
.delete-all-btn:hover:not(:disabled) { color: var(--color-error); border-color: var(--color-error); background: var(--color-error-bg); }
.delete-all-btn:disabled { opacity: 0.4; cursor: default; }
/* Chapter list */
.list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.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-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); }
.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; }
/* 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); }
/* Jump */
.jump-wrap { position: relative; }
.jump-toggle { 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); }
.jump-toggle:hover { color: var(--text-muted); border-color: var(--border-strong); }
.jump-row { display: flex; align-items: center; gap: 4px; }
.jump-input { width: 64px; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); outline: none; }
.jump-input:focus { border-color: var(--border-focus); }
.jump-cancel { font-size: 12px; color: var(--text-faint); padding: 2px 4px; border-radius: var(--radius-sm); transition: color var(--t-base); }
.jump-cancel:hover { color: var(--text-muted); }
/* 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; }
/* Pagination */
.pagination, .pagination-bottom { display: flex; align-items: center; gap: var(--sp-2); }
.pagination-bottom { justify-content: center; 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); }
/* Chapter list/grid */
.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); }
.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-spinner { position: absolute; top: 2px; right: 2px; }
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
</style>
+135
View File
@@ -0,0 +1,135 @@
<script lang="ts">
import { onMount, onDestroy } from "svelte";
export interface MenuItem {
label: string;
icon?: any;
onClick: () => void;
danger?: boolean;
disabled?: boolean;
separator?: never;
}
export interface MenuSeparator { separator: true }
export type MenuEntry = MenuItem | MenuSeparator;
export let x: number;
export let y: number;
export let items: MenuEntry[];
export let onClose: () => void;
let focused = -1;
let el: HTMLDivElement;
const actionable = items
.map((_, i) => i)
.filter((i) => !("separator" in items[i]) && !(items[i] as MenuItem).disabled);
$: if (actionable.length) focused = actionable[0];
function getPos() {
const zoom = parseFloat(document.documentElement.style.zoom || "100") / 100 || 1;
const menuW = 200, menuH = items.length * 34;
const vw = window.innerWidth / zoom, vh = window.innerHeight / zoom;
const sx = x / zoom, sy = y / zoom;
return {
left: Math.max(4, sx + menuW > vw ? sx - menuW : sx),
top: Math.max(4, sy + menuH > vh ? sy - menuH : sy),
};
}
function onMouseDown(e: MouseEvent) {
if (el && !el.contains(e.target as Node)) onClose();
}
function onKey(e: KeyboardEvent) {
if (e.key === "Escape") { e.stopPropagation(); onClose(); return; }
if (e.key === "ArrowDown") {
e.preventDefault();
const cur = actionable.indexOf(focused);
focused = actionable[(cur + 1) % actionable.length] ?? actionable[0];
return;
}
if (e.key === "ArrowUp") {
e.preventDefault();
const cur = actionable.indexOf(focused);
focused = actionable[(cur - 1 + actionable.length) % actionable.length] ?? actionable[0];
return;
}
if (e.key === "Enter" && focused >= 0) {
e.preventDefault();
const item = items[focused] as MenuItem;
if (item && !item.disabled) { item.onClick(); onClose(); }
}
}
onMount(() => {
document.addEventListener("mousedown", onMouseDown, true);
document.addEventListener("keydown", onKey, true);
});
onDestroy(() => {
document.removeEventListener("mousedown", onMouseDown, true);
document.removeEventListener("keydown", onKey, true);
});
$: pos = getPos();
</script>
<div bind:this={el} class="menu" style="left:{pos.left}px;top:{pos.top}px"
on:contextmenu|preventDefault>
{#each items as item, i}
{#if "separator" in item}
<div class="sep"></div>
{:else}
{@const mi = item as MenuItem}
<button
class="item"
class:danger={mi.danger}
class:disabled={mi.disabled}
class:focused={focused === i}
disabled={mi.disabled}
on:click={() => { if (!mi.disabled) { mi.onClick(); onClose(); } }}
on:mouseenter={() => { if (!mi.disabled) focused = i; }}
on:mouseleave={() => focused = -1}
>
<span class="icon" class:icon-danger={mi.danger}>
{#if mi.icon}<svelte:component this={mi.icon} size={13} weight="light" />{/if}
</span>
<span class="label">{mi.label}</span>
</button>
{/if}
{/each}
</div>
<style>
.menu {
position: fixed; z-index: 200;
background: var(--bg-raised);
border: 1px solid var(--border-base);
border-radius: var(--radius-lg);
padding: var(--sp-1); min-width: 190px;
box-shadow: 0 0 0 1px rgba(0,0,0,0.08), 0 4px 12px rgba(0,0,0,0.35), 0 16px 40px rgba(0,0,0,0.25);
animation: scaleIn 0.1s ease both;
transform-origin: top left;
}
.item {
display: flex; align-items: center; gap: var(--sp-2);
width: 100%; padding: 5px var(--sp-2);
border-radius: var(--radius-md);
font-size: var(--text-sm); color: var(--text-secondary);
text-align: left; cursor: pointer;
transition: background var(--t-fast), color var(--t-fast);
background: none; border: none; outline: none;
}
.item:hover:not(.disabled), .item.focused:not(.disabled) { background: var(--bg-overlay); color: var(--text-primary); }
.item.danger { color: var(--color-error); }
.item.danger:hover:not(.disabled), .item.danger.focused:not(.disabled) { background: var(--color-error-bg); }
.item.disabled { opacity: 0.3; cursor: default; pointer-events: none; }
.icon {
display: flex; align-items: center; justify-content: center;
width: 18px; height: 18px; flex-shrink: 0;
color: var(--text-faint); border-radius: var(--radius-sm);
}
.icon-danger { color: var(--color-error); opacity: 0.7; }
.label { flex: 1; line-height: 1.3; }
.sep { height: 1px; background: var(--border-dim); margin: 3px var(--sp-1); }
</style>
+2 -3
View File
@@ -1,6 +1,5 @@
import { mount } from "svelte";
import App from "./App.svelte"; import App from "./App.svelte";
import "./styles/global.css"; import "./styles/global.css";
const app = new App({ target: document.getElementById("app")! }); mount(App, { target: document.getElementById("app")! });
export default app;
+2
View File
@@ -35,6 +35,7 @@ export interface Settings {
preferredExtensionLang: string; keybinds: Keybinds; idleTimeoutMin?: number; preferredExtensionLang: string; keybinds: Keybinds; idleTimeoutMin?: number;
splashCards?: boolean; storageLimitGb: number | null; folders: Folder[]; splashCards?: boolean; storageLimitGb: number | null; folders: Folder[];
markReadOnNext: boolean; readerDebounceMs: number; theme: Theme; markReadOnNext: boolean; readerDebounceMs: number; theme: Theme;
libraryBranches: boolean;
} }
export const DEFAULT_SETTINGS: Settings = { export const DEFAULT_SETTINGS: Settings = {
@@ -48,6 +49,7 @@ export const DEFAULT_SETTINGS: Settings = {
autoStartServer: true, preferredExtensionLang: "en", keybinds: DEFAULT_KEYBINDS, autoStartServer: true, preferredExtensionLang: "en", keybinds: DEFAULT_KEYBINDS,
idleTimeoutMin: 5, splashCards: true, storageLimitGb: null, folders: [], idleTimeoutMin: 5, splashCards: true, storageLimitGb: null, folders: [],
markReadOnNext: true, readerDebounceMs: 120, theme: "dark", markReadOnNext: true, readerDebounceMs: 120, theme: "dark",
libraryBranches: true,
}; };
function loadPersisted() { function loadPersisted() {