Chore: Restructure Repository for SvelteKit

This commit is contained in:
Youwes09
2026-05-22 04:04:59 -05:00
parent bf071dcfc7
commit 8cef74bb98
266 changed files with 5093 additions and 396 deletions
@@ -0,0 +1,187 @@
<script lang="ts">
import { Download, CheckCircle, Circle, CircleNotch, Trash } from "phosphor-svelte";
import ContextMenu from "@shared/ui/ContextMenu.svelte";
import type { MenuEntry } from "@shared/ui/ContextMenu.svelte";
import { longPress } from "@core/ui/touchscreen";
import type { Chapter } from "@types";
interface Props {
pageChapters: Chapter[];
sortedChapters: Chapter[];
viewMode: "list" | "grid";
loadingChapters: boolean;
selectedIds: Set<number>;
enqueueing: Set<number>;
chapterPage: number;
totalPages: number;
scrollEl?: HTMLDivElement | null;
onOpen: (ch: Chapter, inProgress: boolean) => void;
onToggleSelect: (id: number, e: MouseEvent | KeyboardEvent) => void;
onEnqueue: (ch: Chapter, e: MouseEvent) => void;
onDeleteDownload:(id: number) => void;
onPageChange: (page: number) => void;
buildCtxItems: (ch: Chapter, idx: number) => MenuEntry[];
}
let {
pageChapters, sortedChapters, viewMode, loadingChapters,
selectedIds, enqueueing, chapterPage, totalPages,
scrollEl = $bindable(null),
onOpen, onToggleSelect, onEnqueue, onDeleteDownload,
onPageChange, buildCtxItems,
}: Props = $props();
let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null);
const hasSelection = $derived(selectedIds.size > 0);
function chapterLongPress(node: HTMLElement, param: [Chapter, number]) {
const [ch, idx] = param;
return longPress(node, {
onLongPress(e) { ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx }; },
});
}
function formatDate(ts: string | null | undefined): string {
if (!ts) return "";
const n = Number(ts);
const d = new Date(n > 1e10 ? n : n * 1000);
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}
</script>
<div class={viewMode === "grid" ? "ch-grid" : "ch-list"} bind:this={scrollEl}>
{#if loadingChapters && sortedChapters.length === 0}
{#if viewMode === "grid"}
{#each Array(24) as _}<div class="grid-cell-skeleton skeleton"></div>{/each}
{:else}
{#each Array(8) as _}
<div class="row-skeleton">
<div class="skeleton sk-line" style="width:55%;height:12px"></div>
<div class="skeleton sk-line" style="width:25%;height:11px"></div>
</div>
{/each}
{/if}
{:else if viewMode === "grid"}
{#each sortedChapters as ch, i}
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
{@const isGridSelected = selectedIds.has(ch.id)}
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:grid-selected={isGridSelected}
use:chapterLongPress={[ch, i]}
onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, inProgress)}
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
title={ch.name}
>{#if isGridSelected}<span class="grid-cell-check"></span>{/if}
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
{#if ch.isDownloaded}<span class="grid-cell-dl" title="Downloaded"></span>{/if}
{#if ch.isRead}<span class="grid-cell-dot"></span>{/if}
{#if enqueueing.has(ch.id)}<span class="grid-cell-spinner"><CircleNotch size={10} weight="light" class="anim-spin" /></span>{/if}
</button>
{/each}
{:else}
{#each pageChapters as ch}
{@const idxInSorted = sortedChapters.indexOf(ch)}
{@const isSelected = selectedIds.has(ch.id)}
{@const chInProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
<div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} class:ch-selected={isSelected}
use:chapterLongPress={[ch, idxInSorted]}
onclick={(e) => hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, chInProgress)}
onkeydown={(e) => e.key === "Enter" && (hasSelection ? onToggleSelect(ch.id, e) : onOpen(ch, chInProgress))}
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}>
<button class="ch-check" class:ch-check-visible={hasSelection} onclick={(e) => onToggleSelect(ch.id, e)} title="Select">
{#if isSelected}<CheckCircle size={15} weight="fill" />{:else}<Circle size={15} weight="light" />{/if}
</button>
<div class="ch-left">
<span class="ch-name">{ch.name}</span>
<div class="ch-meta">
{#if ch.scanlator}<span class="ch-meta-item">{ch.scanlator}</span>{/if}
{#if ch.uploadDate}<span class="ch-meta-item">{formatDate(ch.uploadDate)}</span>{/if}
{#if ch.lastPageRead && ch.lastPageRead > 0 && !ch.isRead}<span class="ch-meta-item">p.{ch.lastPageRead}</span>{/if}
</div>
</div>
<div class="ch-right">
{#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
{#if ch.isDownloaded}
<div class="ch-dl-wrap">
<Download size={13} weight="fill" class="ch-dl-icon" />
<button class="dl-btn dl-btn-delete" onclick={(e) => { e.stopPropagation(); onDeleteDownload(ch.id); }} title="Delete download"><Trash size={13} weight="light" /></button>
</div>
{:else if enqueueing.has(ch.id)}
<CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" />
{:else}
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); onEnqueue(ch, e); }} title="Download"><Download size={13} weight="light" /></button>
{/if}
</div>
</div>
{/each}
{/if}
</div>
{#if totalPages > 1}
<div class="pagination-bottom">
<button class="page-btn" onclick={() => onPageChange(Math.max(1, chapterPage - 1))} disabled={chapterPage === 1}>← Prev</button>
<span class="page-num">{chapterPage} / {totalPages}</span>
<button class="page-btn" onclick={() => onPageChange(Math.min(totalPages, chapterPage + 1))} disabled={chapterPage === totalPages}>Next →</button>
</div>
{/if}
{#if ctx}
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.chapter, ctx.idx)} onClose={() => ctx = null} />
{/if}
<style>
.ch-list { flex: 1; overflow-y: auto; }
.ch-grid { flex: 1; overflow-y: auto; display: grid; grid-template-columns: repeat(auto-fill, minmax(42px, 1fr)); gap: 4px; padding: var(--sp-3); align-content: start; }
.ch-row { display: flex; align-items: center; padding: 10px var(--sp-4); border-bottom: 1px solid var(--border-dim); cursor: pointer; transition: background var(--t-fast); gap: var(--sp-3); }
.ch-row:hover { background: var(--bg-raised); }
.ch-row.read { opacity: 0.45; }
.ch-left { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 3px; }
.ch-name { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.ch-meta { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.ch-meta-item { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.ch-right { display: flex; align-items: center; gap: var(--sp-1); flex-shrink: 0; }
:global(.read-icon) { color: var(--text-faint); }
:global(.enqueue-icon) { color: var(--text-faint); }
.dl-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); opacity: 0; }
.ch-row:hover .dl-btn { opacity: 1; }
.dl-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.dl-btn-delete { color: var(--color-error) !important; opacity: 0; }
.ch-row:hover .dl-btn-delete { opacity: 1; }
.dl-btn-delete:hover { background: var(--color-error-bg) !important; }
.ch-dl-wrap { position: relative; display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; }
:global(.ch-dl-icon) { color: var(--text-faint); transition: opacity var(--t-fast); }
.ch-row:hover .ch-dl-wrap :global(.ch-dl-icon) { opacity: 0; }
.ch-dl-wrap .dl-btn-delete { position: absolute; inset: 0; opacity: 0; }
.ch-row:hover .ch-dl-wrap .dl-btn-delete { opacity: 1; }
.ch-check { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; flex-shrink: 0; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; opacity: 0; transition: opacity var(--t-fast), color var(--t-fast); padding: 0; }
.ch-row:hover .ch-check { opacity: 1; }
.ch-check-visible { opacity: 1 !important; }
.ch-selected { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
.ch-selected .ch-check { color: var(--accent-fg); opacity: 1; }
.row-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); padding: 12px var(--sp-4); border-bottom: 1px solid var(--border-dim); }
.grid-cell { display: flex; align-items: center; justify-content: center; aspect-ratio: 1; border-radius: var(--radius-sm); background: var(--bg-raised); border: 1px solid var(--border-dim); font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); cursor: pointer; position: relative; transition: background var(--t-fast), border-color var(--t-fast); }
.grid-cell:hover { background: var(--bg-overlay); border-color: var(--border-strong); }
.grid-cell.read { background: var(--color-read); color: var(--text-faint); border-color: transparent; }
.grid-cell.in-progress { border-color: var(--accent-dim); color: var(--accent-fg); }
.grid-cell-num { font-size: 10px; }
.grid-cell-dot { position: absolute; bottom: 3px; right: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--text-faint); }
.grid-cell-dl { position: absolute; top: 3px; left: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent-fg); }
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
.grid-selected { background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; }
.grid-cell-check { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; font-size: 14px; color: var(--accent-fg); pointer-events: none; }
.pagination-bottom { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.page-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
.page-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
.page-btn:disabled { opacity: 0.3; cursor: default; }
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
</style>
@@ -0,0 +1,632 @@
<script lang="ts">
import {
Download, CheckCircle, Circle, SortAscending, SortDescending,
CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus,
Trash, DownloadSimple, X, MagnifyingGlass, Funnel, Check,
} from "phosphor-svelte";
import type { Chapter, Category } from "@types";
import type { ChapterSortMode, ChapterSortDir } from "../lib/chapterList";
import { updateSettings } from "@store/state.svelte";
interface ContinueChapter {
chapter: Chapter;
type: "start" | "continue" | "reread";
resumePage: number | null;
}
interface Props {
chapters: Chapter[];
sortedChapters: Chapter[];
sortMode: ChapterSortMode;
sortDir: ChapterSortDir;
viewMode: "list" | "grid";
chapterPage: number;
totalPages: number;
downloadedCount: number;
totalCount: number;
deletingAll: boolean;
hasSelection: boolean;
selectedCount: number;
continueChapter: ContinueChapter | null;
availableScanlators: string[];
scanlatorFilter: string[];
scanlatorBlacklist: string[];
scanlatorForce: boolean;
allCategories: Category[];
mangaCategories: Category[];
catsLoading: boolean;
onViewModeToggle: () => void;
onPageChange: (page: number) => void;
onDownloadSelected: () => void;
onDeleteSelected: () => void;
onMarkSelectedRead: (isRead: boolean) => void;
onClearSelection: () => void;
onEnqueueNext: (n: number) => void;
onEnqueueMultiple: (ids: number[]) => void;
onDeleteAll: () => void;
onRefresh: () => void;
onToggleCategory: (cat: Category) => void;
onCreateCategory: (name: string) => void;
onSetScanlatorFilter: (v: string[]) => void;
onSetScanlatorBlacklist: (v: string[]) => void;
onSetScanlatorForce: (v: boolean) => void;
refreshing: boolean;
}
let {
chapters, sortedChapters, sortMode, sortDir, viewMode,
chapterPage, totalPages, downloadedCount, totalCount, deletingAll,
hasSelection, selectedCount, continueChapter,
availableScanlators, scanlatorFilter, scanlatorBlacklist, scanlatorForce,
allCategories, mangaCategories, catsLoading,
onViewModeToggle, onPageChange, onDownloadSelected, onDeleteSelected,
onMarkSelectedRead, onClearSelection, onEnqueueNext, onEnqueueMultiple,
onDeleteAll, onRefresh, onToggleCategory, onCreateCategory,
onSetScanlatorFilter, onSetScanlatorBlacklist, onSetScanlatorForce,
refreshing,
}: Props = $props();
let sortMenuOpen: boolean = $state(false);
let jumpOpen: boolean = $state(false);
let jumpInput: string = $state("");
let scanFilterOpen: boolean = $state(false);
let scanTab: "prefer" | "block" = $state("prefer");
let dlOpen: boolean = $state(false);
let showRange: boolean = $state(false);
let rangeFrom: string = $state("");
let rangeTo: string = $state("");
let folderPickerOpen: boolean = $state(false);
let folderCreating: boolean = $state(false);
let folderNewName: string = $state("");
let dlDropRef: HTMLDivElement | undefined = $state();
let folderPickerRef: HTMLDivElement | undefined = $state();
const hasFolders = $derived(mangaCategories.filter(c => c.id !== 0).length > 0);
const jumpChapter = $derived.by(() => {
const q = jumpInput.trim().toLowerCase();
if (!q) return null;
const num = parseFloat(q);
if (!isNaN(num)) return sortedChapters.find(c => c.chapterNumber === num) ?? null;
return sortedChapters.find(c => c.name.toLowerCase().includes(q)) ?? null;
});
function focusOnMount(node: HTMLElement) { node.focus(); }
function doJump() {
if (!jumpChapter) return;
const pageIdx = sortedChapters.indexOf(jumpChapter);
if (pageIdx >= 0) onPageChange(Math.floor(pageIdx / 25) + 1);
jumpOpen = false; jumpInput = "";
}
function enqueueRange() {
const from = parseFloat(rangeFrom), to = parseFloat(rangeTo);
if (isNaN(from) || isNaN(to)) return;
const lo = Math.min(from, to), hi = Math.max(from, to);
onEnqueueMultiple(sortedChapters.filter(c => c.chapterNumber >= lo && c.chapterNumber <= hi && !c.isDownloaded).map(c => c.id));
}
function submitNewFolder() {
const name = folderNewName.trim();
if (!name) return;
onCreateCategory(name);
folderNewName = ""; folderCreating = false;
}
$effect(() => {
if (dlOpen) { setTimeout(() => document.addEventListener("mousedown", handleDlOutside, true), 0); }
else document.removeEventListener("mousedown", handleDlOutside, true);
});
$effect(() => {
if (folderPickerOpen) { setTimeout(() => document.addEventListener("mousedown", handleFolderOutside, true), 0); }
else document.removeEventListener("mousedown", handleFolderOutside, true);
});
$effect(() => {
if (!scanFilterOpen) return;
function onOutside(e: MouseEvent) {
if (!(e.target as HTMLElement).closest(".scan-filter-wrap")) scanFilterOpen = false;
}
setTimeout(() => document.addEventListener("mousedown", onOutside, true), 0);
return () => document.removeEventListener("mousedown", onOutside, true);
});
function handleDlOutside(e: MouseEvent) { if (dlDropRef && !dlDropRef.contains(e.target as Node)) dlOpen = false; }
function handleFolderOutside(e: MouseEvent) { if (folderPickerRef && !folderPickerRef.contains(e.target as Node)) { folderPickerOpen = false; folderCreating = false; folderNewName = ""; } }
</script>
<div class="list-header">
<div class="list-header-left">
{#if hasSelection}
<span class="sel-count">{selectedCount} selected</span>
<button class="sel-action-btn" onclick={onDownloadSelected} title="Download selected"><Download size={13} weight="light" /></button>
<button class="sel-action-btn sel-action-danger" onclick={onDeleteSelected} title="Delete selected downloads"><Trash size={13} weight="light" /></button>
<button class="sel-action-btn" onclick={() => onMarkSelectedRead(true)} title="Mark selected as read"><CheckCircle size={13} weight="light" /></button>
<button class="sel-action-btn" onclick={() => onMarkSelectedRead(false)} title="Mark selected as unread"><Circle size={13} weight="light" /></button>
<button class="sel-action-btn" onclick={onClearSelection} title="Clear selection"><X size={13} weight="light" /></button>
{:else}
<div class="sort-wrap">
<button class="sort-btn" onclick={() => sortMenuOpen = !sortMenuOpen}>
{#if sortDir === "desc"}<SortDescending size={14} weight="light" />{:else}<SortAscending size={14} weight="light" />{/if}
{{ source: "Source order", chapterNumber: "Ch. number", uploadDate: "Upload date" }[sortMode]}
<CaretDown size={10} weight="light" />
</button>
{#if sortMenuOpen}
<div class="sort-menu" role="presentation" onmouseleave={() => sortMenuOpen = false}>
{#each [["source","Source order"],["chapterNumber","Chapter number"],["uploadDate","Upload date"]] as [val, label]}
<button class="sort-option" class:active={sortMode === val}
onclick={() => { updateSettings({ chapterSortMode: val as any }); onPageChange(1); sortMenuOpen = false; }}>
{label}
</button>
{/each}
<div class="sort-divider"></div>
<button class="sort-option" onclick={() => { updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" }); onPageChange(1); sortMenuOpen = false; }}>
{sortDir === "desc" ? "↑ Ascending" : "↓ Descending"}
</button>
</div>
{/if}
</div>
<button class="icon-btn" class:active={viewMode === "grid"} onclick={onViewModeToggle} title={viewMode === "list" ? "Grid view" : "List view"}>
{#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
</button>
{/if}
</div>
<div class="list-header-right">
<div class="jump-wrap">
<button class="icon-btn" class:active={jumpOpen} onclick={() => { jumpOpen = !jumpOpen; jumpInput = ""; }} title="Jump to chapter">
<MagnifyingGlass size={14} weight="light" />
</button>
{#if jumpOpen}
<div class="jump-popover">
<input class="jump-input" placeholder="Chapter # or name…" bind:value={jumpInput} use:focusOnMount
onkeydown={(e) => { if (e.key === "Enter") doJump(); if (e.key === "Escape") { jumpOpen = false; jumpInput = ""; } }} />
{#if jumpChapter}
<button class="jump-go" onclick={doJump}>Go · {jumpChapter.name}</button>
{:else if jumpInput.trim()}
<p class="jump-none">No match</p>
{/if}
</div>
{/if}
</div>
{#if availableScanlators.length > 1}
<div class="scan-filter-wrap">
<button class="icon-btn" class:active={scanlatorFilter.length > 0 || scanlatorBlacklist.length > 0} onclick={() => scanFilterOpen = !scanFilterOpen} title="Filter by scanlator">
<Funnel size={14} weight={scanlatorFilter.length > 0 || scanlatorBlacklist.length > 0 ? "fill" : "light"} />
</button>
{#if scanFilterOpen}
<div class="scan-filter-panel" role="menu">
<div class="scan-filter-header">
<div class="scan-filter-tabs">
<button class="scan-filter-tab" class:scan-filter-tab-active={scanTab === "prefer"} onclick={() => scanTab = "prefer"}>Prefer</button>
<button class="scan-filter-tab" class:scan-filter-tab-active={scanTab === "block"} onclick={() => scanTab = "block"}>Block</button>
</div>
{#if scanTab === "prefer" && scanlatorFilter.length > 0}
<button class="scan-filter-clear" onclick={() => { onSetScanlatorFilter([]); onSetScanlatorForce(false); onPageChange(1); }}>Clear</button>
{:else if scanTab === "block" && scanlatorBlacklist.length > 0}
<button class="scan-filter-clear" onclick={() => { onSetScanlatorBlacklist([]); onPageChange(1); }}>Clear</button>
{/if}
</div>
<div class="scan-filter-divider"></div>
{#if scanTab === "prefer"}
<div class="scan-filter-force-row">
<span class="scan-filter-force-label" title="Hide chapters with no preferred group match, rather than falling back to any available group.">Enforce</span>
<button class="scan-force-toggle" class:scan-force-on={scanlatorForce}
onclick={() => { onSetScanlatorForce(!scanlatorForce); onPageChange(1); }}>
{scanlatorForce ? "On" : "Off"}
</button>
</div>
<div class="scan-filter-divider"></div>
{#each availableScanlators as s}
<button class="scan-filter-item" class:scan-filter-item-active={scanlatorFilter.includes(s)} role="menuitem"
onclick={() => { onSetScanlatorFilter(scanlatorFilter.includes(s) ? scanlatorFilter.filter(x => x !== s) : [...scanlatorFilter, s]); onPageChange(1); }}>
<span class="scan-filter-check" class:scan-filter-check-on={scanlatorFilter.includes(s)}>
{#if scanlatorFilter.includes(s)}<Check size={9} weight="bold" />{/if}
</span>
{s}
</button>
{/each}
{:else}
{#each availableScanlators as s}
<button class="scan-filter-item" class:scan-filter-item-active={scanlatorBlacklist.includes(s)} class:scan-filter-item-block={scanlatorBlacklist.includes(s)} role="menuitem"
onclick={() => { onSetScanlatorBlacklist(scanlatorBlacklist.includes(s) ? scanlatorBlacklist.filter(x => x !== s) : [...scanlatorBlacklist, s]); onPageChange(1); }}>
<span class="scan-filter-check" class:scan-filter-check-block={scanlatorBlacklist.includes(s)}>
{#if scanlatorBlacklist.includes(s)}<X size={9} weight="bold" />{/if}
</span>
{s}
</button>
{/each}
{/if}
</div>
{/if}
</div>
{/if}
<button class="icon-btn" onclick={onRefresh} disabled={refreshing}>
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
</button>
<div class="fp-wrap" bind:this={folderPickerRef}>
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
</button>
{#if folderPickerOpen}
<div class="fp-menu">
{#if catsLoading}
<p class="fp-empty">Loading…</p>
{:else if allCategories.length === 0 && !folderCreating}
<p class="fp-empty">No folders yet</p>
{/if}
{#each allCategories as cat}
{@const isIn = mangaCategories.some(c => c.id === cat.id)}
<button class="fp-item" class:fp-item-active={isIn} onclick={() => onToggleCategory(cat)}>
<span class="fp-check">{isIn ? "✓" : ""}</span>{cat.name}
</button>
{/each}
<div class="fp-div"></div>
{#if folderCreating}
<div class="fp-create">
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName} use:focusOnMount
onkeydown={(e) => { if (e.key === "Enter") submitNewFolder(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} />
<button class="fp-confirm" onclick={submitNewFolder} disabled={!folderNewName.trim()}>Add</button>
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}><X size={12} weight="light" /></button>
</div>
{:else}
<button class="fp-new" onclick={() => folderCreating = true}>+ New folder</button>
{/if}
</div>
{/if}
</div>
{#if chapters.length > 0}
<div class="dl-wrap" bind:this={dlDropRef}>
<button class="icon-btn dl-unified-btn" class:active={dlOpen} class:dl-has-count={downloadedCount > 0} onclick={() => dlOpen = !dlOpen} title="Download options">
<Download size={13} weight={downloadedCount > 0 ? "fill" : "light"} />
{#if downloadedCount > 0}<span class="dl-unified-count">{downloadedCount}</span>{/if}
</button>
{#if dlOpen}
<div class="dl-dropdown">
{#if downloadedCount > 0}
<p class="dl-section-label">{downloadedCount} / {totalCount} downloaded</p>
<div class="dl-divider"></div>
{/if}
{#if continueChapter}
{@const contIdx = sortedChapters.indexOf(continueChapter.chapter)}
{#if contIdx >= 0}
<p class="dl-section-label">From Ch.{continueChapter.chapter.chapterNumber}</p>
<div class="dl-next-row">
{#each [5, 10, 25] as n}
{@const avail = sortedChapters.slice(contIdx, contIdx + n).filter(c => !c.isDownloaded).length}
<button class="dl-next-btn" disabled={avail === 0} onclick={() => { onEnqueueNext(n); dlOpen = false; }}>
<span>Next {n}</span><span class="dl-next-sub">{avail} new</span>
</button>
{/each}
</div>
<div class="dl-divider"></div>
{/if}
{/if}
{#if !showRange}
<button class="dl-item" onclick={() => showRange = true}>
<span>Custom range…</span><span class="dl-item-sub">Enter chapter numbers</span>
</button>
{:else}
<div class="dl-range-row">
<button class="dl-range-back" onclick={() => showRange = false}></button>
<input class="dl-range-input" placeholder="From" bind:value={rangeFrom} onkeydown={(e) => e.key === "Enter" && enqueueRange()} use:focusOnMount />
<span class="dl-range-sep"></span>
<input class="dl-range-input" placeholder="To" bind:value={rangeTo} onkeydown={(e) => e.key === "Enter" && enqueueRange()} />
<button class="dl-range-go" disabled={!rangeFrom.trim() || !rangeTo.trim()} onclick={enqueueRange}>Go</button>
</div>
{/if}
<div class="dl-divider"></div>
<button class="dl-item" onclick={() => { onEnqueueMultiple(sortedChapters.filter(c => !c.isRead && !c.isDownloaded).map(c => c.id)); dlOpen = false; }}>
<span>Unread chapters</span><span class="dl-item-sub">{sortedChapters.filter(c => !c.isRead && !c.isDownloaded).length} remaining</span>
</button>
<button class="dl-item" onclick={() => { onEnqueueMultiple(sortedChapters.filter(c => !c.isDownloaded).map(c => c.id)); dlOpen = false; }}>
<span>Download all</span><span class="dl-item-sub">{sortedChapters.filter(c => !c.isDownloaded).length} not downloaded</span>
</button>
{#if downloadedCount > 0}
<div class="dl-divider"></div>
<button class="dl-item dl-item-danger" onclick={() => { onDeleteAll(); dlOpen = false; }} disabled={deletingAll}>
<span>{deletingAll ? "Deleting…" : "Delete all downloads"}</span>
<span class="dl-item-sub">{downloadedCount} downloaded</span>
</button>
{/if}
</div>
{/if}
</div>
{/if}
{#if totalPages > 1}
<div class="pagination">
<button class="page-btn" onclick={() => onPageChange(Math.max(1, chapterPage - 1))} disabled={chapterPage === 1}></button>
<span class="page-num">{chapterPage} / {totalPages}</span>
<button class="page-btn" onclick={() => onPageChange(Math.min(totalPages, chapterPage + 1))} disabled={chapterPage === totalPages}></button>
</div>
{/if}
</div>
</div>
<style>
.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); }
.sort-wrap { position: relative; }
.sort-menu {
position: absolute; top: calc(100% + 4px); left: 0; min-width: 160px;
background: var(--bg-raised); border: 1px solid var(--border-base);
border-radius: var(--radius-md); padding: var(--sp-1); z-index: 200;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
animation: scaleIn 0.1s ease both; transform-origin: top left;
}
.sort-option {
display: block; width: 100%; padding: 6px var(--sp-3); border-radius: var(--radius-sm);
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary);
background: none; border: none; cursor: pointer; text-align: left;
transition: background var(--t-fast), color var(--t-fast);
}
.sort-option:hover { background: var(--bg-overlay); color: var(--text-primary); }
.sort-option.active { color: var(--accent-fg); }
.sort-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
.icon-btn {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); color: var(--text-muted);
background: none; cursor: pointer;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.icon-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.icon-btn:disabled { opacity: 0.3; cursor: default; }
.jump-wrap { position: relative; }
.jump-popover {
position: absolute; top: calc(100% + 4px); right: 0; width: 220px;
background: var(--bg-raised); border: 1px solid var(--border-base);
border-radius: var(--radius-md); padding: var(--sp-2); z-index: 200;
box-shadow: 0 8px 24px rgba(0,0,0,0.5);
animation: scaleIn 0.1s ease both; transform-origin: top right;
display: flex; flex-direction: column; gap: var(--sp-1);
}
.jump-input {
width: 100%; background: var(--bg-overlay); border: 1px solid var(--border-strong);
border-radius: var(--radius-sm); padding: 5px 9px;
font-size: var(--text-xs); color: var(--text-secondary); outline: none;
transition: border-color var(--t-base);
}
.jump-input:focus { border-color: var(--border-focus); }
.jump-go {
width: 100%; padding: 6px var(--sp-2); border-radius: var(--radius-sm);
background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
cursor: pointer; text-align: left; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
transition: background var(--t-fast), border-color var(--t-fast);
}
.jump-go:hover { background: var(--accent); border-color: var(--accent); color: var(--accent-contrast, #fff); }
.jump-none { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: 4px var(--sp-1); letter-spacing: var(--tracking-wide); }
.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); }
.dl-wrap { position: relative; }
.dl-dropdown {
position: absolute; top: calc(100% + 4px); right: 0; min-width: 220px;
background: var(--bg-raised); border: 1px solid var(--border-base);
border-radius: var(--radius-lg); padding: var(--sp-1); z-index: 200;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
animation: scaleIn 0.1s ease both; transform-origin: top right;
}
.dl-section-label {
padding: 6px var(--sp-3) 4px; font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase;
}
.dl-next-row { display: flex; gap: 4px; padding: 2px var(--sp-2) var(--sp-2); }
.dl-next-btn {
flex: 1; display: flex; align-items: center; justify-content: center; gap: 5px;
padding: 5px 6px; border-radius: var(--radius-md); border: 1px solid var(--border-dim);
background: var(--bg-overlay); color: var(--text-secondary);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); cursor: pointer;
transition: background var(--t-fast), border-color var(--t-fast), color var(--t-fast);
}
.dl-next-btn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.dl-next-btn:disabled { opacity: 0.3; cursor: default; }
.dl-next-sub { font-size: var(--text-2xs); color: var(--text-faint); }
.dl-divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) var(--sp-2); }
.dl-item {
display: flex; flex-direction: column; align-items: flex-start; gap: 2px;
width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md);
font-size: var(--text-sm); color: var(--text-secondary); background: none; border: none;
cursor: pointer; text-align: left; transition: background var(--t-fast), color var(--t-fast);
}
.dl-item:hover:not(:disabled) { background: var(--bg-overlay); color: var(--text-primary); }
.dl-item:disabled { opacity: 0.3; cursor: default; }
.dl-item.dl-item-danger { color: var(--color-error); }
.dl-item.dl-item-danger:hover:not(:disabled) { background: var(--color-error-bg); }
.dl-item-sub { font-size: var(--text-xs); color: var(--text-faint); }
.dl-range-row { display: flex; align-items: center; gap: 4px; padding: 7px var(--sp-2); }
.dl-range-back {
display: flex; align-items: center; justify-content: center;
width: 20px; height: 20px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 14px; cursor: pointer;
}
.dl-range-back:hover { color: var(--text-muted); background: var(--bg-overlay); }
.dl-range-input {
flex: 1; min-width: 0; padding: 4px 8px; background: var(--bg-overlay);
border: 1px solid var(--border-strong); border-radius: var(--radius-sm);
color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs);
outline: none; text-align: center;
}
.dl-range-input:focus { border-color: var(--border-focus); }
.dl-range-sep { color: var(--text-faint); font-size: var(--text-xs); }
.dl-range-go {
padding: 4px 10px; border-radius: var(--radius-sm);
border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg);
font-family: var(--font-ui); font-size: var(--text-xs); cursor: pointer;
}
.dl-range-go:disabled { opacity: 0.3; cursor: default; }
.dl-unified-btn { gap: 5px; padding: 0 8px; width: auto; min-width: 28px; }
.dl-unified-count {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
color: var(--text-faint); transition: color var(--t-base);
}
.dl-unified-btn:hover .dl-unified-count,
.dl-unified-btn.active .dl-unified-count { color: var(--text-secondary); }
.dl-unified-btn.dl-has-count { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.dl-unified-btn.dl-has-count .dl-unified-count { color: var(--accent-fg); opacity: 0.8; }
.dl-unified-btn.dl-has-count:hover { background: var(--accent-muted); border-color: var(--accent); opacity: 0.9; }
.dl-unified-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.pagination { display: flex; align-items: center; gap: var(--sp-2); }
.page-btn {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
color: var(--text-faint); background: none; cursor: pointer;
transition: color var(--t-base), border-color var(--t-base);
}
.page-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
.page-btn:disabled { opacity: 0.3; cursor: default; }
.page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.sel-count {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted);
letter-spacing: var(--tracking-wide); padding: 0 var(--sp-1);
}
.sel-action-btn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer;
transition: color var(--t-base), background var(--t-base), border-color var(--t-base);
}
.sel-action-btn:hover { color: var(--text-primary); background: var(--bg-raised); border-color: var(--border-strong); }
.sel-action-danger { color: var(--color-error) !important; }
.sel-action-danger:hover { background: var(--color-error-bg) !important; border-color: var(--color-error) !important; }
.scan-filter-wrap { position: relative; }
.scan-filter-panel {
position: absolute; top: calc(100% + 6px); right: 0; z-index: 200; min-width: 200px;
background: var(--bg-raised); border: 1px solid var(--border-base);
border-radius: var(--radius-lg); padding: var(--sp-1);
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
animation: scaleIn 0.1s ease both; transform-origin: top right;
}
.scan-filter-header { display: flex; align-items: center; justify-content: space-between; padding: 4px 8px 6px; }
.scan-filter-clear {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
color: var(--text-faint); background: none; border: none; cursor: pointer; padding: 0;
transition: color var(--t-base);
}
.scan-filter-clear:hover { color: var(--color-error); }
.scan-filter-divider { height: 1px; background: var(--border-dim); margin: 0 2px 4px; }
.scan-filter-item {
display: flex; align-items: center; gap: var(--sp-2); width: 100%;
padding: 7px 10px; border-radius: var(--radius-sm); border: none; background: transparent;
color: var(--text-muted); font-family: var(--font-ui); font-size: var(--text-xs);
cursor: pointer; text-align: left;
transition: background var(--t-base), color var(--t-base);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.scan-filter-item:hover { background: var(--bg-overlay); color: var(--text-primary); }
.scan-filter-item-active { color: var(--accent-fg); background: var(--accent-muted); }
.scan-filter-item-active:hover { background: var(--accent-dim); }
.scan-filter-tabs {
display: flex; gap: 2px; background: var(--bg-overlay);
border: 1px solid var(--border-base); border-radius: var(--radius-sm); padding: 2px;
}
.scan-filter-tab {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 2px 8px; border-radius: 2px; border: none; background: none;
color: var(--text-faint); cursor: pointer; transition: color var(--t-fast), background var(--t-fast);
}
.scan-filter-tab:hover { color: var(--text-muted); }
.scan-filter-tab.scan-filter-tab-active { background: var(--bg-surface); color: var(--text-primary); box-shadow: 0 1px 3px rgba(0,0,0,0.3); }
.scan-filter-force-row { display: flex; align-items: center; justify-content: space-between; padding: 5px 10px; }
.scan-filter-force-label {
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted);
letter-spacing: var(--tracking-wide); cursor: default;
text-decoration: underline; text-decoration-style: dotted;
text-decoration-color: var(--border-strong); text-underline-offset: 3px;
}
.scan-force-toggle {
font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim);
background: none; color: var(--text-faint); cursor: pointer;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.scan-force-toggle:hover { color: var(--text-muted); border-color: var(--border-strong); }
.scan-force-toggle.scan-force-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.scan-filter-check {
width: 13px; height: 13px; border-radius: 2px; border: 1px solid var(--border-strong);
background: transparent; flex-shrink: 0;
display: flex; align-items: center; justify-content: center; color: var(--bg-base);
transition: background var(--t-base), border-color var(--t-base);
}
.scan-filter-check-on { background: var(--accent); border-color: var(--accent); }
.scan-filter-check-block { background: var(--color-error); border-color: var(--color-error); }
.scan-filter-item-block { color: var(--color-error) !important; background: color-mix(in srgb, var(--color-error) 8%, transparent) !important; }
.scan-filter-item-block:hover { background: color-mix(in srgb, var(--color-error) 14%, transparent) !important; }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
@@ -0,0 +1,765 @@
<script lang="ts">
import { onMount, untrack } from "svelte";
import { gql } from "@api/client";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import {
CheckCircle, Circle, ArrowFatLinesUp, ArrowFatLinesDown,
ArrowFatLineUp, ArrowFatLineDown, Download, Trash, DownloadSimple, CheckSquare,
} from "phosphor-svelte";
import { GET_MANGA, GET_ALL_MANGA, GET_CATEGORIES } from "@api/queries/manga";
import { GET_CHAPTERS } from "@api/queries/chapters";
import { UPDATE_MANGA, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "@api/mutations/manga";
import { FETCH_CHAPTERS, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS } from "@api/mutations/chapters";
import { downloadStore } from "@features/downloads/store/downloadState.svelte";
import { cache, CACHE_KEYS, recordSourceAccess } from "@core/cache";
import {
store, addToast, openReader, setActiveManga,
addBookmark, acknowledgeUpdate,
checkAndMarkCompleted as storeCheckAndMarkCompleted,
clearMarkersForManga,
saveScroll, getScroll,
} from "@store/state.svelte";
import { trackingState } from "@features/tracking/store/trackingState.svelte";
import type { MangaPrefs } from "@store/state.svelte";
import { DEFAULT_MANGA_PREFS } from "@store/state.svelte";
import type { Manga, Chapter, Category } from "@types";
import ContextMenu from "@shared/ui/ContextMenu.svelte";
import type { MenuEntry } from "@shared/ui/ContextMenu.svelte";
import MigrateModal from "../panels/MigrateModal.svelte";
import TrackingPanel from "../panels/TrackingPanel.svelte";
import AutomationPanel from "../panels/AutomationPanel.svelte";
import MarkersPanel from "../panels/MarkersPanel.svelte";
import CoverPickerPanel from "../panels/CoverPickerPanel.svelte";
import SeriesLinkPanel from "../panels/SeriesLinkPanel.svelte";
import SeriesHeader from "./SeriesHeader.svelte";
import SeriesActions from "./SeriesActions.svelte";
import ChapterList from "./ChapterList.svelte";
import { buildChapterList, chaptersAscending } from "../lib/chapterList";
import { getPref, setPref } from "../lib/mangaPrefs";
import { autoLinkLibrary } from "@core/cover/autoLink";
const CHAPTERS_PER_PAGE = 25;
const MANGA_TTL_MS = 5 * 60 * 1000;
const CHAPTER_TTL_MS = 2 * 60 * 1000;
const mangaStore: Map<number, { data: Manga; fetchedAt: number }> = new Map();
const chapterStore: Map<number, { data: Chapter[]; fetchedAt: number }> = new Map();
let manga: Manga | null = $state(null);
let chapters: Chapter[] = $state([]);
let loadingManga: boolean = $state(false);
let loadingChapters: boolean = $state(true);
let enqueueing: Set<number> = $state(new Set());
let togglingLibrary: boolean = $state(false);
let chapterPage: number = $state(1);
let viewMode: "list" | "grid" = $state("list");
let deletingAll: boolean = $state(false);
let refreshing: boolean = $state(false);
let selectedIds: Set<number> = $state(new Set());
let migrateOpen: boolean = $state(false);
let autoOpen: boolean = $state(false);
let trackingOpen: boolean = $state(false);
let markersOpen: boolean = $state(false);
let linkPickerOpen: boolean = $state(false);
let coverPickerOpen: boolean = $state(false);
let allMangaForLink: Manga[] = $state([]);
let loadingLinkList: boolean = $state(false);
let mangaCategories: Category[] = $state([]);
let allCategories: Category[] = $state([]);
let catsLoading: boolean = $state(false);
let mangaAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null;
let loadingFor: number | null = null;
let _prevChapterIds: Set<number> = new Set();
const get = <K extends keyof MangaPrefs>(key: K) =>
store.activeManga ? getPref(store.activeManga.id, key) : DEFAULT_MANGA_PREFS[key];
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => {
if (store.activeManga) setPref(store.activeManga.id, key, value);
};
const hasSelection = $derived(selectedIds.size > 0);
const sortDir = $derived(store.settings.chapterSortDir);
const sortMode = $derived(store.settings.chapterSortMode ?? "source");
const scanlatorFilter = $derived((get("scanlatorFilter") ?? []) as string[]);
const scanlatorBlacklist = $derived((get("scanlatorBlacklist") ?? []) as string[]);
const scanlatorForce = $derived((get("scanlatorForce") ?? false) as boolean);
const currentPrefs = $derived({
sortMode,
sortDir,
preferredScanlator: get("preferredScanlator") as string,
scanlatorFilter: scanlatorFilter as string[],
scanlatorBlacklist: scanlatorBlacklist as string[],
scanlatorForce: scanlatorForce as boolean,
});
const availableScanlators = $derived(
[...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))]
.sort((a, b) => a.localeCompare(b))
);
const sortedChapters = $derived(buildChapterList(chapters, currentPrefs));
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
const readCount = $derived(sortedChapters.filter(c => c.isRead).length);
const totalCount = $derived(sortedChapters.length);
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0);
const downloadedCount = $derived(chapters.filter(c => c.isDownloaded).length);
const continueChapter = $derived((() => {
if (!sortedChapters.length) return null;
const asc = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
const anyRead = asc.some(c => c.isRead);
const bookmark = store.activeManga
? store.bookmarks.find(b => b.mangaId === store.activeManga!.id)
: null;
const bookmarkedCh = bookmark ? asc.find(c => c.id === bookmark.chapterId) : null;
if (bookmarkedCh && !bookmarkedCh.isRead) {
return { chapter: bookmarkedCh, type: (anyRead ? "continue" : "start") as const, resumePage: bookmark!.pageNumber };
}
const inProgress = asc.find(c => !c.isRead && (c.lastPageRead ?? 0) > 0);
const firstUnread = asc.find(c => !c.isRead);
const target = inProgress ?? firstUnread;
if (target) {
return { chapter: target, type: (anyRead ? "continue" : "start") as const, resumePage: null };
}
return { chapter: asc[0], type: "reread" as const, resumePage: null };
})());
const hasAnyAutomation = $derived(
get("autoDownload") ||
(get("downloadAhead") as number) > 0 ||
(get("maxKeepChapters") as number) > 0 ||
get("deleteOnRead") ||
get("pauseUpdates") ||
get("refreshInterval") !== "global" ||
!!(get("preferredScanlator") as string)
);
const linkedIds = $derived(
store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : []
);
function focusOnMount(node: HTMLElement) { node.focus(); }
function clearSelection() { selectedIds = new Set(); }
function toggleSelect(id: number, e: MouseEvent | KeyboardEvent) {
e.stopPropagation();
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id); else next.add(id);
selectedIds = next;
}
function applyChapters(nodes: Chapter[]) {
if (get("autoDownload") && _prevChapterIds.size > 0) {
const filtered = buildChapterList(nodes, currentPrefs);
const newChapters = filtered.filter(c => !_prevChapterIds.has(c.id) && !c.isDownloaded);
if (newChapters.length) enqueueMultiple(newChapters.map(c => c.id));
}
_prevChapterIds = new Set(nodes.map(c => c.id));
chapters = nodes;
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
}
function loadCategories(mangaId: number) {
catsLoading = true;
gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES)
.then(d => {
allCategories = d.categories.nodes.filter(c => c.id !== 0);
mangaCategories = allCategories.filter(c => c.mangas?.nodes.some(m => m.id === mangaId));
})
.catch(console.error)
.finally(() => { catsLoading = false; });
}
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
const mangaStatus = manga?.status;
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA, mangaStatus);
if (chaps.length && mangaStatus !== "ONGOING") {
const allRead = chaps.every(c => c.isRead);
const completed = allCategories.find(c => c.name === "Completed");
if (completed) {
const inCompleted = mangaCategories.some(c => c.id === completed.id);
if (allRead && !inCompleted) mangaCategories = [...mangaCategories, completed];
else if (!allRead && inCompleted) mangaCategories = mangaCategories.filter(c => c.id !== completed.id);
}
}
}
function loadManga(id: number) {
mangaAbort?.abort();
const ctrl = new AbortController();
mangaAbort = ctrl; loadingFor = id;
const cached = mangaStore.get(id);
if (cached) {
manga = cached.data; loadingManga = false;
if (Date.now() - cached.fetchedAt < MANGA_TTL_MS) return;
gql<{ manga: Manga }>(GET_MANGA, { id }, ctrl.signal).then(d => {
if (ctrl.signal.aborted || loadingFor !== id) return;
mangaStore.set(id, { data: d.manga, fetchedAt: Date.now() });
manga = d.manga;
if (d.manga.source?.id) recordSourceAccess(d.manga.source.id);
}).catch(() => {});
return;
}
loadingManga = true;
gql<{ manga: Manga }>(GET_MANGA, { id }, ctrl.signal).then(d => {
if (ctrl.signal.aborted || loadingFor !== id) return;
mangaStore.set(id, { data: d.manga, fetchedAt: Date.now() });
manga = d.manga;
if (d.manga.source?.id) recordSourceAccess(d.manga.source.id);
}).catch(() => {}).finally(() => { if (!ctrl.signal.aborted && loadingFor === id) loadingManga = false; });
}
function loadChapters(id: number) {
chapterAbort?.abort();
const ctrl = new AbortController();
chapterAbort = ctrl;
const cached = chapterStore.get(id);
if (cached) {
applyChapters(cached.data); loadingChapters = false;
if (Date.now() - cached.fetchedAt < CHAPTER_TTL_MS) return;
gql(FETCH_CHAPTERS, { mangaId: id }, ctrl.signal)
.then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, ctrl.signal))
.then(d => {
if (ctrl.signal.aborted || loadingFor !== id) return;
chapterStore.set(id, { data: d.chapters.nodes, fetchedAt: Date.now() });
applyChapters(d.chapters.nodes);
}).catch(() => {});
return;
}
chapters = []; loadingChapters = true;
gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, ctrl.signal).then(d => {
if (ctrl.signal.aborted || loadingFor !== id) return;
applyChapters(d.chapters.nodes); loadingChapters = false;
return gql(FETCH_CHAPTERS, { mangaId: id }, ctrl.signal)
.then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id }, ctrl.signal))
.then(fresh => {
if (ctrl.signal.aborted || loadingFor !== id) return;
chapterStore.set(id, { data: fresh.chapters.nodes, fetchedAt: Date.now() });
applyChapters(fresh.chapters.nodes);
});
}).catch(() => { if (!ctrl.signal.aborted) loadingChapters = false; });
}
async function syncTrackersIntoChapters(mangaId: number, chaps: Chapter[]) {
if (!store.settings.trackerSyncBack) return;
const records = trackingState.recordsFor(mangaId);
if (!records.length) return;
for (const record of records) {
try {
const { markedIds } = await trackingState.syncFromRemote(mangaId, record, chaps, currentPrefs);
if (markedIds.length > 0) {
const idSet = new Set(markedIds);
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead: true } : c);
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
}
} catch {}
}
}
$effect(() => {
const m = store.activeManga;
const shouldAutoLink = store.settings.autoLinkOnOpen;
if (m) untrack(() => {
acknowledgeUpdate(m.id);
loadManga(m.id);
loadChapters(m.id);
loadCategories(m.id);
trackingState.loadForManga(m.id).then(() => {
syncTrackersIntoChapters(m.id, chapters);
});
if (shouldAutoLink) {
if (allMangaForLink.length) {
autoLinkLibrary(m, allMangaForLink)
.then(n => { if (n > 0) addToast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); });
} else {
loadingLinkList = true;
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
.then(d => {
allMangaForLink = d.mangas.nodes;
return autoLinkLibrary(m, d.mangas.nodes);
})
.then(n => { if (n > 0) addToast({ kind: "success", title: "Series linked", body: `${n} new link${n === 1 ? "" : "s"} found` }); })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
}
}
});
});
let prevChapterId: number | null = null;
$effect(() => {
const wasOpen = prevChapterId !== null;
prevChapterId = store.activeChapter?.id ?? null;
if (wasOpen && !store.activeChapter && store.activeManga) {
const id = store.activeManga.id;
untrack(() => { loadChapters(id); cache.clear(CACHE_KEYS.LIBRARY); });
}
});
async function toggleLibrary() {
if (!manga) return;
togglingLibrary = true;
const next = !manga.inLibrary;
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error);
manga = { ...manga, inLibrary: next };
if (mangaStore.has(manga.id)) { const e = mangaStore.get(manga.id)!; mangaStore.set(manga.id, { ...e, data: manga }); }
cache.clear(CACHE_KEYS.LIBRARY);
togglingLibrary = false;
}
async function reloadChapters(id: number) {
const d = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: id });
chapterStore.set(id, { data: d.chapters.nodes, fetchedAt: Date.now() });
applyChapters(d.chapters.nodes);
}
async function enqueue(ch: Chapter, e: MouseEvent) {
e.stopPropagation();
enqueueing = new Set(enqueueing).add(ch.id);
await downloadStore.enqueue(ch.id);
addToast({ kind: "download", title: "Download queued", body: ch.name });
enqueueing.delete(ch.id); enqueueing = new Set(enqueueing);
if (store.activeManga) reloadChapters(store.activeManga.id);
}
async function enqueueMultiple(chapterIds: number[]) {
if (!chapterIds.length) return;
for (const id of chapterIds) {
const allowed = await downloadStore.enqueue(id);
if (!allowed) return;
}
addToast({ kind: "download", title: "Download queued", body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added` });
if (store.activeManga) reloadChapters(store.activeManga.id);
}
async function markRead(chapterId: number, isRead: boolean) {
const mangaId = store.activeManga?.id;
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
if (mangaId) {
chapterStore.set(mangaId, { data: chapters, fetchedAt: Date.now() });
checkAndMarkCompleted(mangaId, chapters);
const ch = chapters.find(c => c.id === chapterId);
if (ch) {
if (isRead) {
await trackingState.updateFromRead(mangaId, ch, chapters, currentPrefs);
} else {
await trackingState.updateFromUnread(mangaId, chapters, currentPrefs);
}
}
}
if (isRead) {
if (get("deleteOnRead")) {
const ch = chapters.find(c => c.id === chapterId);
if (ch?.isDownloaded) {
const delayMs = (get("deleteDelayHours") as number) * 60 * 60 * 1000;
if (delayMs === 0) deleteDownloaded(chapterId);
else setTimeout(() => deleteDownloaded(chapterId), delayMs);
}
}
const ahead = get("downloadAhead") as number;
if (ahead > 0) {
const idx = sortedChapters.findIndex(c => c.id === chapterId);
if (idx >= 0) {
const toQueue = sortedChapters.slice(idx + 1, idx + 1 + ahead).filter(c => !c.isDownloaded).map(c => c.id);
if (toQueue.length) enqueueMultiple(toQueue);
}
}
}
}
async function markBulk(ids: number[], isRead: boolean) {
if (!ids.length) return;
const mangaId = store.activeManga?.id;
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 (mangaId) {
chapterStore.set(mangaId, { data: chapters, fetchedAt: Date.now() });
checkAndMarkCompleted(mangaId, chapters);
if (isRead) {
const ascending = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
const lastInBatch = ascending.filter(c => idSet.has(c.id)).at(-1);
if (lastInBatch) await trackingState.updateFromRead(mangaId, lastInBatch, chapters, currentPrefs);
} else {
await trackingState.updateFromUnread(mangaId, chapters, currentPrefs);
}
}
if (isRead) {
if (get("deleteOnRead")) {
const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.isDownloaded);
if (toDelete.length) {
const delayMs = (get("deleteDelayHours") as number) * 60 * 60 * 1000;
const doDelete = async () => {
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: toDelete }).catch(console.error);
chapters = chapters.map(c => toDelete.includes(c.id) ? { ...c, isDownloaded: false } : c);
if (mangaId) chapterStore.set(mangaId, { data: chapters, fetchedAt: Date.now() });
};
if (delayMs === 0) doDelete();
else setTimeout(doDelete, delayMs);
}
}
}
}
async function deleteSelected() {
const ids = [...selectedIds].filter(id => chapters.find(c => c.id === id)?.isDownloaded);
if (ids.length) {
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
chapters = chapters.map(c => ids.includes(c.id) ? { ...c, isDownloaded: false } : c);
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
}
clearSelection();
}
async function downloadSelected() { await enqueueMultiple([...selectedIds].filter(id => !chapters.find(c => c.id === id)?.isDownloaded)); clearSelection(); }
async function markSelectedRead(isRead: boolean) { await markBulk([...selectedIds], isRead); clearSelection(); }
const markAboveRead = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => !c.isRead).map(c => c.id), true);
const markBelowRead = (i: number) => markBulk(sortedChapters.slice(i).filter(c => !c.isRead).map(c => c.id), true);
const markAboveUnread = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => c.isRead).map(c => c.id), false);
const markBelowUnread = (i: number) => markBulk(sortedChapters.slice(i).filter(c => c.isRead).map(c => c.id), false);
async function deleteDownloaded(chapterId: number) {
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error);
chapters = chapters.map(c => c.id === chapterId ? { ...c, isDownloaded: false } : c);
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
}
async function deleteAllDownloads() {
const ids = chapters.filter(c => c.isDownloaded).map(c => c.id);
if (!ids.length) return;
deletingAll = true;
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
chapters = chapters.map(c => ({ ...c, isDownloaded: false }));
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
deletingAll = false;
}
async function refreshChapters() {
if (!store.activeManga || refreshing) return;
refreshing = true;
chapterStore.delete(store.activeManga.id);
gql(FETCH_CHAPTERS, { mangaId: store.activeManga.id })
.then(() => reloadChapters(store.activeManga!.id))
.then(() => addToast({ kind: "success", title: "Chapters refreshed", body: `${chapters.length} chapter${chapters.length !== 1 ? "s" : ""} available` }))
.catch(e => addToast({ kind: "error", title: "Refresh failed", body: e?.message }))
.finally(() => refreshing = false);
}
function buildCtxItems(ch: Chapter, idx: number): MenuEntry[] {
const above = sortedChapters.slice(0, idx + 1), below = sortedChapters.slice(idx), 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) },
{ label: "Select", icon: CheckSquare, onClick: () => { const next = new Set(selectedIds); next.add(ch.id); selectedIds = next; } },
{ separator: true },
{ label: "Mark above as read", icon: ArrowFatLinesUp, onClick: () => markAboveRead(idx), disabled: above.filter(c => !c.isRead).length === 0 },
{ label: "Mark above as unread", icon: ArrowFatLineUp, onClick: () => markAboveUnread(idx), disabled: above.filter(c => c.isRead).length === 0 },
{ separator: true },
{ label: "Mark below as read", icon: ArrowFatLinesDown, onClick: () => markBelowRead(idx), disabled: idx === last || below.filter(c => !c.isRead).length === 0 },
{ label: "Mark below as unread", icon: ArrowFatLineDown, 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) : downloadStore.enqueue(ch.id) },
{ 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 enqueueNext(n: number) {
if (!continueChapter) return;
const idx = sortedChapters.indexOf(continueChapter.chapter);
if (idx < 0) return;
enqueueMultiple(sortedChapters.slice(idx, idx + n).filter(c => !c.isDownloaded).map(c => c.id));
}
function openReaderWithAhead(ch: Chapter, inProgress: boolean) {
const ascList = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
const type = inProgress ? "continue" : undefined;
const resumePage = inProgress ? ch.lastPageRead ?? null : null;
const ahead = get("downloadAhead") as number;
if (ahead > 0) {
const idx = ascList.indexOf(ch);
if (idx >= 0) {
const toQueue = ascList.slice(idx + 1, idx + 1 + ahead).filter(c => !c.isDownloaded).map(c => c.id);
if (toQueue.length) enqueueMultiple(toQueue);
}
}
if (type === "continue" && resumePage && resumePage > 1) {
const existing = store.bookmarks.find(b => b.chapterId === ch.id);
if (!existing || existing.pageNumber < resumePage) {
addBookmark({
mangaId: store.activeManga!.id,
mangaTitle: store.activeManga!.title,
thumbnailUrl: store.activeManga!.thumbnailUrl,
chapterId: ch.id,
chapterName: ch.name,
pageNumber: resumePage,
});
}
}
openReader(ch, ascList);
}
function handleContinue(cc: typeof continueChapter) {
if (!cc) return;
const ascList = [...sortedChapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
const ahead = get("downloadAhead") as number;
if (ahead > 0) {
const idx = ascList.indexOf(cc.chapter);
if (idx >= 0) {
const toQueue = ascList.slice(idx + 1, idx + 1 + ahead).filter(c => !c.isDownloaded).map(c => c.id);
if (toQueue.length) enqueueMultiple(toQueue);
}
}
if (cc.type === "continue" && cc.resumePage && cc.resumePage > 1) {
const existing = store.bookmarks.find(b => b.chapterId === cc.chapter.id);
if (!existing || existing.pageNumber < cc.resumePage) {
addBookmark({
mangaId: store.activeManga!.id,
mangaTitle: store.activeManga!.title,
thumbnailUrl: store.activeManga!.thumbnailUrl,
chapterId: cc.chapter.id,
chapterName: cc.chapter.name,
pageNumber: cc.resumePage,
});
}
}
openReader(cc.chapter, ascList);
}
async function openLinkPicker() {
linkPickerOpen = true;
if (allMangaForLink.length) return;
loadingLinkList = true;
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
.then(d => { allMangaForLink = d.mangas.nodes; })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
}
function closeLinkPicker() { linkPickerOpen = false; }
async function openCoverPicker() {
coverPickerOpen = true;
if (allMangaForLink.length) return;
loadingLinkList = true;
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
.then(d => { allMangaForLink = d.mangas.nodes; })
.catch(console.error)
.finally(() => { loadingLinkList = false; });
}
async function toggleCategory(cat: Category) {
if (!store.activeManga) return;
const inCat = mangaCategories.some(c => c.id === cat.id);
try {
await gql(UPDATE_MANGA_CATEGORIES, {
mangaId: store.activeManga.id,
addTo: inCat ? [] : [cat.id],
removeFrom: inCat ? [cat.id] : [],
});
if (!inCat && !manga?.inLibrary) {
await gql(UPDATE_MANGA, { id: store.activeManga.id, inLibrary: true }).catch(console.error);
if (manga) manga = { ...manga, inLibrary: true };
}
mangaCategories = inCat ? mangaCategories.filter(c => c.id !== cat.id) : [...mangaCategories, cat];
} catch (e) { console.error(e); }
}
async function createCategory(name: string) {
if (!name || !store.activeManga) return;
try {
const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name });
const cat = res.createCategory.category;
await gql(UPDATE_MANGA_CATEGORIES, { mangaId: store.activeManga.id, addTo: [cat.id], removeFrom: [] });
if (!manga?.inLibrary) {
await gql(UPDATE_MANGA, { id: store.activeManga.id, inLibrary: true }).catch(console.error);
if (manga) manga = { ...manga, inLibrary: true };
}
allCategories = [...allCategories, cat];
mangaCategories = [...mangaCategories, cat];
} catch (e) { console.error(e); }
}
let chapterListEl: HTMLDivElement | null = $state(null);
let prevMangaId: number | null = null;
$effect(() => {
const mangaId = store.activeManga?.id ?? null;
if (mangaId === prevMangaId) return;
if (chapterListEl && prevMangaId !== null) saveScroll(`series:${prevMangaId}`, chapterListEl.scrollTop);
prevMangaId = mangaId;
if (chapterListEl && mangaId !== null) {
const saved = getScroll(`series:${mangaId}`);
chapterListEl.scrollTo({ top: saved });
}
});
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
</script>
{#if store.activeManga}
<div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}>
<SeriesHeader
{manga}
{loadingManga}
{totalCount}
{readCount}
{progressPct}
{downloadedCount}
{deletingAll}
{continueChapter}
{hasAnyAutomation}
{markersOpen}
{linkedIds}
{allMangaForLink}
{loadingLinkList}
{mangaCategories}
{togglingLibrary}
onRead={handleContinue}
onToggleLibrary={toggleLibrary}
onDeleteAll={deleteAllDownloads}
onMigrateOpen={() => migrateOpen = true}
onTrackingOpen={() => trackingOpen = true}
onAutoOpen={() => autoOpen = true}
onMarkersToggle={() => markersOpen = !markersOpen}
onLinkPickerOpen={openLinkPicker}
onCoverPickerOpen={openCoverPicker}
/>
<div class="list-wrap">
<SeriesActions
{chapters}
{sortedChapters}
{sortMode}
{sortDir}
{viewMode}
{chapterPage}
{totalPages}
{downloadedCount}
{totalCount}
{deletingAll}
{hasSelection}
selectedCount={selectedIds.size}
{continueChapter}
{availableScanlators}
{scanlatorFilter}
{scanlatorBlacklist}
{scanlatorForce}
{allCategories}
{mangaCategories}
{catsLoading}
{refreshing}
onViewModeToggle={() => viewMode = viewMode === "list" ? "grid" : "list"}
onPageChange={(p) => chapterPage = p}
onDownloadSelected={downloadSelected}
onDeleteSelected={deleteSelected}
onMarkSelectedRead={markSelectedRead}
onClearSelection={clearSelection}
onEnqueueNext={enqueueNext}
onEnqueueMultiple={enqueueMultiple}
onDeleteAll={deleteAllDownloads}
onRefresh={refreshChapters}
onToggleCategory={toggleCategory}
onCreateCategory={createCategory}
onSetScanlatorFilter={(v) => set("scanlatorFilter", v)}
onSetScanlatorBlacklist={(v) => set("scanlatorBlacklist", v)}
onSetScanlatorForce={(v) => set("scanlatorForce", v)}
/>
<ChapterList
{pageChapters}
{sortedChapters}
{viewMode}
{loadingChapters}
{selectedIds}
{enqueueing}
{chapterPage}
{totalPages}
bind:scrollEl={chapterListEl}
onOpen={openReaderWithAhead}
onToggleSelect={toggleSelect}
onEnqueue={enqueue}
onDeleteDownload={deleteDownloaded}
onPageChange={(p) => chapterPage = p}
{buildCtxItems}
/>
</div>
</div>
{#if migrateOpen && manga}
<MigrateModal
{manga}
currentChapters={chapters}
onClose={() => migrateOpen = false}
onMigrated={(newManga) => { setActiveManga(newManga); migrateOpen = false; cache.clear(CACHE_KEYS.LIBRARY); }}
/>
{/if}
{#if trackingOpen && store.activeManga}
<TrackingPanel mangaId={store.activeManga.id} mangaTitle={store.activeManga.title} onClose={() => trackingOpen = false} />
{/if}
{#if autoOpen && store.activeManga}
<AutomationPanel mangaId={store.activeManga.id} manga={store.activeManga} onClose={() => autoOpen = false} />
{/if}
{#if markersOpen && store.activeManga}
<div class="markers-panel-overlay" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) markersOpen = false; }}>
<div class="markers-panel-drawer">
<MarkersPanel mangaId={store.activeManga.id} {chapters} onClose={() => markersOpen = false} />
</div>
</div>
{/if}
{#if coverPickerOpen && store.activeManga}
<CoverPickerPanel
manga={manga ?? store.activeManga}
allManga={allMangaForLink}
onClose={() => coverPickerOpen = false}
/>
{/if}
{#if linkPickerOpen && store.activeManga}
<SeriesLinkPanel
manga={manga ?? store.activeManga}
allManga={allMangaForLink}
onClose={closeLinkPicker}
/>
{/if}
{/if}
<style>
.root {
display: flex;
height: 100%;
overflow: hidden;
animation: fadeIn 0.14s ease both;
}
.list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.markers-panel-overlay {
position: fixed; inset: 0; z-index: var(--z-settings);
display: flex; align-items: stretch; justify-content: flex-start;
animation: fadeIn 0.12s ease both;
}
.markers-panel-drawer {
width: 280px; max-width: 90vw;
background: var(--bg-surface); border-right: 1px solid var(--border-base);
box-shadow: 4px 0 24px rgba(0,0,0,0.4);
display: flex; flex-direction: column;
animation: drawerIn 0.18s cubic-bezier(0.16,1,0.3,1) both;
}
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
@keyframes drawerIn { from { opacity: 0; transform: translateX(-12px) } to { opacity: 1; transform: translateX(0) } }
</style>
@@ -0,0 +1,386 @@
<script lang="ts">
import {
ArrowLeft, BookmarkSimple, ArrowSquareOut, Play, CaretDown,
Eye, ArrowsClockwise, LinkSimpleHorizontalBreak, ChartLineUp,
MapPin, Gear, Trash, Image,
} from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { resolvedCover } from "@core/cover/coverResolver";
import type { Manga, Chapter, Category } from "@types";
import type { MangaPrefs } from "@store/state.svelte";
import { store, setActiveManga, setGenreFilter, setNavPage, setPreviewManga, linkManga, unlinkManga } from "@store/state.svelte";
interface ContinueChapter {
chapter: Chapter;
type: "start" | "continue" | "reread";
resumePage: number | null;
}
interface Props {
manga: Manga | null;
loadingManga: boolean;
totalCount: number;
readCount: number;
progressPct: number;
downloadedCount: number;
deletingAll: boolean;
continueChapter: ContinueChapter | null;
hasAnyAutomation: boolean;
markersOpen: boolean;
linkedIds: number[];
allMangaForLink: Manga[];
loadingLinkList: boolean;
mangaCategories: Category[];
onRead: (ch: ContinueChapter) => void;
onToggleLibrary: () => void;
onDeleteAll: () => void;
onMigrateOpen: () => void;
onTrackingOpen: () => void;
onAutoOpen: () => void;
onMarkersToggle: () => void;
onLinkPickerOpen: () => void;
onCoverPickerOpen: () => void;
togglingLibrary: boolean;
}
let {
manga, loadingManga, totalCount, readCount, progressPct,
downloadedCount, deletingAll, continueChapter, hasAnyAutomation,
markersOpen, linkedIds, allMangaForLink, loadingLinkList,
mangaCategories,
onRead, onToggleLibrary, onDeleteAll, onMigrateOpen,
onTrackingOpen, onAutoOpen, onMarkersToggle, onLinkPickerOpen,
onCoverPickerOpen, togglingLibrary,
}: Props = $props();
let manageOpen: boolean = $state(false);
let genresExpanded: boolean = $state(false);
let altOpen: boolean = $state(false);
const statusLabel = $derived(
manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null
);
const markerCount = $derived(
store.activeManga ? store.getMarkersForManga(store.activeManga.id).length : 0
);
const hasCoverOverride = $derived(
!!store.settings.mangaPrefs?.[store.activeManga!.id]?.coverUrl
);
const altTitles = $derived(
(manga as any)?.alternativeTitles ?? (manga as any)?.altTitles ?? []
);
</script>
<div class="sidebar">
<button class="back" onclick={() => setActiveManga(null)}>
<ArrowLeft size={13} weight="light" /> Back
</button>
<button class="cover-wrap" onclick={() => setPreviewManga(manga)}>
<Thumbnail src={resolvedCover(store.activeManga!.id, store.activeManga!.thumbnailUrl)} alt={store.activeManga!.title} class="cover" />
</button>
{#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}
<div class="badges">
{#if statusLabel}
<span class="badge" class:badge-ongoing={manga?.status === "ONGOING"} class:badge-ended={manga?.status !== "ONGOING"}>{statusLabel}</span>
{/if}
{#if manga?.source?.displayName ?? (manga as any)?.source?.name}
<span class="badge badge-source">{manga?.source?.displayName ?? (manga as any)?.source?.name}</span>
{/if}
</div>
{#if altTitles.length > 0}
<div class="alttitles-section">
<button class="row-toggle" onclick={() => altOpen = !altOpen}>
<span>Also known as</span>
<CaretDown size={10} weight="light" style="transform:{altOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease;flex-shrink:0" />
</button>
{#if altOpen}
<div class="alttitles-list">
{#each altTitles as t}
<p class="alttitle">{t}</p>
{/each}
</div>
{/if}
</div>
{/if}
{#if manga?.genre?.length}
<div class="genres">
{#each (genresExpanded ? manga.genre : manga.genre.slice(0, 3)) as g}
<button class="genre" onclick={() => { setGenreFilter(g); setNavPage("search"); setActiveManga(null); }}>{g}</button>
{/each}
{#if manga.genre.length > 3}
<button class="genre-toggle" onclick={() => genresExpanded = !genresExpanded}>
{genresExpanded ? "less" : `+${manga.genre.length - 3}`}
</button>
{/if}
</div>
{/if}
{#if manga?.description}
<div class="desc-wrap">
<p class="desc">{manga.description}</p>
<button class="expand-toggle" onclick={() => setPreviewManga(manga)}>Read more</button>
</div>
{/if}
</div>
{/if}
<div class="cta-section">
{#if continueChapter}
<button class="read-btn" onclick={() => onRead(continueChapter!)}>
<Play size={12} weight="fill" />
{continueChapter.type === "reread" ? "Read again"
: continueChapter.type === "start" ? "Start reading"
: `Continue · Ch.${continueChapter.chapter.chapterNumber}${continueChapter.resumePage ? ` p.${continueChapter.resumePage}` : ""}`}
</button>
{/if}
<div class="actions">
<button class="library-btn" class:active={manga?.inLibrary} onclick={onToggleLibrary} disabled={togglingLibrary || loadingManga}>
<BookmarkSimple size={13} weight={manga?.inLibrary ? "fill" : "light"} />
{manga?.inLibrary ? "In Library" : "Add to Library"}
</button>
{#if manga?.realUrl}
<a href={manga.realUrl} target="_blank" rel="noreferrer" class="external-link">
<ArrowSquareOut size={13} weight="light" />
</a>
{/if}
</div>
</div>
{#if totalCount > 0}
<div class="progress-section">
<div class="progress-header">
<span class="progress-label">{readCount} / {totalCount} read</span>
<span class="progress-pct">{Math.round(progressPct)}%</span>
</div>
<div class="progress-track"><div class="progress-fill" style="width:{progressPct}%"></div></div>
</div>
{/if}
{#if !loadingManga && manga}
<div class="details-section">
<button class="details-toggle" onclick={() => manageOpen = !manageOpen}>
<span>Manage</span>
<CaretDown size={11} weight="light" style="transform:{manageOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease" />
</button>
{#if manageOpen}
<div class="details-body">
<div class="detail-actions">
<button class="detail-action-btn" onclick={() => setPreviewManga(manga)}>
<Eye size={12} weight="light" /> Preview
</button>
<button class="detail-action-btn" onclick={onMigrateOpen}>
<ArrowsClockwise size={12} weight="light" /> Switch Source
</button>
<button class="detail-action-btn" class:detail-action-active={linkedIds.length > 0} onclick={onLinkPickerOpen}>
<LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} />
Series Link{linkedIds.length > 0 ? ` (${linkedIds.length})` : ""}
</button>
<button class="detail-action-btn" class:detail-action-active={hasCoverOverride} onclick={onCoverPickerOpen}>
<Image size={12} weight={hasCoverOverride ? "fill" : "light"} /> Cover Image
</button>
<button class="detail-action-btn" onclick={onTrackingOpen}>
<ChartLineUp size={12} weight="light" /> Tracking
</button>
<button class="detail-action-btn" class:detail-action-active={markersOpen} onclick={onMarkersToggle}>
<MapPin size={12} weight={markersOpen ? "fill" : "light"} />
Markers{markerCount > 0 ? ` (${markerCount})` : ""}
</button>
{#if manga?.inLibrary}
<button class="detail-action-btn" class:detail-action-active={hasAnyAutomation} onclick={onAutoOpen}>
<Gear size={12} weight={hasAnyAutomation ? "fill" : "light"} /> Automation
</button>
{/if}
{#if downloadedCount > 0}
<button class="detail-action-btn detail-action-danger" onclick={onDeleteAll} disabled={deletingAll}>
<Trash size={12} weight="light" /> {deletingAll ? "Deleting…" : `Delete Downloads (${downloadedCount})`}
</button>
{/if}
</div>
</div>
{/if}
</div>
{/if}
</div>
<style>
.sidebar {
width: 240px;
flex-shrink: 0;
padding: var(--sp-5);
border-right: 1px solid var(--border-dim);
overflow-y: auto;
scrollbar-width: none;
display: flex;
flex-direction: column;
gap: var(--sp-4);
background: var(--bg-base);
}
.sidebar::-webkit-scrollbar { display: none; }
.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;
cursor: pointer; transition: opacity var(--t-base);
padding: 0;
}
.cover-wrap:hover { opacity: 0.88; }
:global(.cover) { width: 100%; height: 100%; object-fit: cover; }
.meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
.sk-line { border-radius: var(--radius-sm); }
.meta { display: flex; flex-direction: column; gap: var(--sp-3); }
.title {
font-size: var(--text-base); font-weight: var(--weight-medium);
color: var(--text-primary); line-height: var(--leading-snug);
letter-spacing: var(--tracking-tight);
}
.byline { font-size: var(--text-xs); color: var(--text-muted); font-family: var(--font-ui); }
.badges { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.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;
}
.badge-ongoing { background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); }
.badge-ended { background: var(--bg-raised); color: var(--text-faint); border: 1px solid var(--border-dim); }
.badge-source {
background: var(--bg-raised); color: var(--text-faint); border: 1px solid var(--border-dim);
text-transform: none; letter-spacing: var(--tracking-normal);
}
.alttitles-section { display: flex; flex-direction: column; gap: var(--sp-1); }
.row-toggle {
display: flex; align-items: center; justify-content: space-between; width: 100%;
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
letter-spacing: var(--tracking-wide); padding: 2px 0;
transition: color var(--t-base);
}
.row-toggle:hover { color: var(--text-muted); }
.alttitles-list { display: flex; flex-direction: column; gap: 3px; padding-top: var(--sp-1); }
.alttitle {
font-size: var(--text-2xs); color: var(--text-faint); font-family: var(--font-ui);
line-height: var(--leading-snug); padding-left: var(--sp-1);
border-left: 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: var(--sp-1); }
.desc {
font-size: var(--text-xs); color: var(--text-muted); line-height: var(--leading-base);
display: -webkit-box; -webkit-line-clamp: 4; -webkit-box-orient: vertical; overflow: hidden;
}
.expand-toggle {
font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint);
letter-spacing: var(--tracking-wide); align-self: flex-start;
transition: color var(--t-base);
}
.expand-toggle:hover { color: var(--accent-fg); }
.cta-section { display: flex; flex-direction: column; gap: var(--sp-2); }
.read-btn {
display: flex; align-items: center; justify-content: center; gap: var(--sp-2);
width: 100%; padding: 9px var(--sp-3); border-radius: var(--radius-md);
background: var(--accent); border: 1px solid var(--accent);
color: var(--accent-contrast, #fff); font-size: var(--text-xs); font-family: var(--font-ui);
letter-spacing: var(--tracking-wide); cursor: pointer; transition: opacity var(--t-base);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.read-btn:hover { opacity: 0.88; }
.actions { display: flex; align-items: center; gap: var(--sp-2); }
.library-btn {
display: flex; align-items: center; gap: var(--sp-2);
font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide);
padding: 5px 10px; border-radius: var(--radius-md); border: 1px solid var(--border-strong);
color: var(--text-muted); background: var(--bg-raised);
transition: border-color var(--t-base), color var(--t-base), background var(--t-base); flex: 1;
}
.library-btn:hover { border-color: var(--accent); color: var(--accent-fg); }
.library-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.library-btn:disabled { opacity: 0.4; cursor: default; }
.external-link {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: var(--radius-md);
border: 1px solid var(--border-dim); color: var(--text-faint); flex-shrink: 0;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.external-link:hover { color: var(--text-muted); border-color: var(--border-strong); }
.progress-section { display: flex; flex-direction: column; gap: var(--sp-1); }
.progress-header { display: flex; justify-content: space-between; align-items: center; }
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.progress-pct { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
.progress-track { height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
.details-section { display: flex; flex-direction: column; gap: 2px; }
.details-toggle {
display: flex; align-items: center; justify-content: space-between;
font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint);
letter-spacing: var(--tracking-wide); padding: 4px 0; transition: color var(--t-base);
}
.details-toggle:hover { color: var(--text-muted); }
.details-body { display: flex; flex-direction: column; gap: var(--sp-2); padding-top: var(--sp-2); }
.detail-actions { display: flex; flex-direction: column; gap: var(--sp-1); padding-top: var(--sp-1); }
.detail-action-btn {
display: flex; align-items: center; gap: var(--sp-2); width: 100%;
padding: 6px var(--sp-2); border-radius: var(--radius-md);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
color: var(--text-faint); background: none; border: 1px solid var(--border-dim); cursor: pointer;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.detail-action-btn:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.detail-action-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.detail-action-active:hover { color: var(--accent-fg); border-color: var(--accent); }
.detail-action-danger { color: var(--color-error); }
.detail-action-danger:hover:not(:disabled) { background: var(--color-error-bg); border-color: var(--color-error); color: var(--color-error); }
.detail-action-danger:disabled { opacity: 0.4; cursor: default; }
</style>
+10
View File
@@ -0,0 +1,10 @@
export { default as SeriesDetail } from "./components/SeriesDetail.svelte";
export { default as SeriesHeader } from "./components/SeriesHeader.svelte";
export { default as SeriesActions } from "./components/SeriesActions.svelte";
export { default as ChapterList } from "./components/ChapterList.svelte";
export { default as AutomationPanel } from "./panels/AutomationPanel.svelte";
export { default as MarkersPanel } from "./panels/MarkersPanel.svelte";
export { default as MigrateModal } from "./panels/MigrateModal.svelte";
export { default as TrackingPanel } from "./panels/TrackingPanel.svelte";
export { buildChapterList, chaptersAscending } from "./lib/chapterList";
export type { ChapterDisplayPrefs, ChapterSortMode, ChapterSortDir } from "./lib/chapterList";
+79
View File
@@ -0,0 +1,79 @@
import type { Chapter } from "@types";
export type ChapterSortMode = "source" | "chapterNumber" | "uploadDate";
export type ChapterSortDir = "asc" | "desc";
export interface ChapterDisplayPrefs {
sortMode?: ChapterSortMode;
sortDir?: ChapterSortDir;
preferredScanlator?: string;
scanlatorFilter?: string[];
scanlatorBlacklist?: string[];
scanlatorForce?: boolean;
}
function sortByMode(a: Chapter, b: Chapter, mode: ChapterSortMode): number {
if (mode === "chapterNumber") return a.chapterNumber - b.chapterNumber;
if (mode === "uploadDate") return Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0);
return a.sourceOrder - b.sourceOrder;
}
export function buildChapterList(chapters: Chapter[], prefs: ChapterDisplayPrefs = {}): Chapter[] {
const {
sortMode = "source",
sortDir = "asc",
preferredScanlator = "",
scanlatorFilter = [],
scanlatorBlacklist = [],
scanlatorForce = false,
} = prefs;
let base = [...chapters];
if (scanlatorBlacklist.length > 0) {
base = base.filter(c => !scanlatorBlacklist.includes(c.scanlator ?? ""));
}
base.sort((a, b) => sortByMode(a, b, sortMode));
if (preferredScanlator) {
const pref: Chapter[] = [], rest: Chapter[] = [];
for (const c of base) (c.scanlator === preferredScanlator ? pref : rest).push(c);
base = [...pref, ...rest];
}
if (scanlatorFilter.length > 0) {
const seen = new Map<number, Chapter>();
for (const ch of base) {
const existing = seen.get(ch.chapterNumber);
if (!existing) {
if (!scanlatorForce || scanlatorFilter.includes(ch.scanlator ?? "")) {
seen.set(ch.chapterNumber, ch);
}
} else {
const np = scanlatorFilter.indexOf(ch.scanlator ?? "");
const op = scanlatorFilter.indexOf(existing.scanlator ?? "");
if (np !== -1 && (op === -1 || np < op)) seen.set(ch.chapterNumber, ch);
}
}
base = [...seen.values()].sort((a, b) => sortByMode(a, b, sortMode));
}
return sortDir === "desc" ? base.reverse() : base;
}
export function chaptersAscending(chapters: Chapter[]): Chapter[] {
return [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
}
export function buildReaderChapterList(
chapters: Chapter[],
prefs: Pick<ChapterDisplayPrefs, "preferredScanlator" | "scanlatorFilter"> | undefined,
): Chapter[] {
return buildChapterList(chapters, {
sortMode: "source",
sortDir: "asc",
preferredScanlator: prefs?.preferredScanlator,
scanlatorFilter: prefs?.scanlatorFilter,
});
}
+17
View File
@@ -0,0 +1,17 @@
import { store, updateSettings } from "@store/state.svelte";
import { DEFAULT_MANGA_PREFS } from "@store/state.svelte";
import type { MangaPrefs } from "@store/state.svelte";
export function getPref<K extends keyof MangaPrefs>(mangaId: number, key: K): MangaPrefs[K] {
const prefs = store.settings.mangaPrefs?.[mangaId] ?? {};
return (prefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
}
export function setPref<K extends keyof MangaPrefs>(mangaId: number, key: K, value: MangaPrefs[K]) {
updateSettings({
mangaPrefs: {
...store.settings.mangaPrefs,
[mangaId]: { ...(store.settings.mangaPrefs?.[mangaId] ?? {}), [key]: value },
},
});
}
@@ -0,0 +1,327 @@
<script lang="ts">
import { X } from "phosphor-svelte";
import { getPref, setPref } from "../lib/mangaPrefs";
import { store } from "@store/state.svelte";
import { resolvedCover } from "@core/cover/coverResolver";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { MangaPrefs } from "@store/state.svelte";
import type { Manga } from "@types/index";
let { mangaId, manga: mangaProp = null, onClose }: {
mangaId: number;
manga?: Manga | null;
onClose: () => void;
} = $props();
const DOWNLOAD_AHEAD_OPTIONS = [
{ value: 0, label: "Off" },
{ value: 2, label: "2" },
{ value: 5, label: "5" },
{ value: 10, label: "10" },
];
const MAX_KEEP_OPTIONS = [
{ value: 0, label: "Off" },
{ value: 5, label: "5" },
{ value: 10, label: "10" },
{ value: 25, label: "25" },
];
const DELETE_DELAY_OPTIONS = [
{ value: 0, label: "Now" },
{ value: 24, label: "1 day" },
{ value: 168, label: "1 week" },
];
const REFRESH_INTERVAL_OPTIONS = [
{ value: "global", label: "Default" },
{ value: "daily", label: "Daily" },
{ value: "weekly", label: "Weekly" },
{ value: "manual", label: "Manual" },
];
const defaults = $derived(store.settings.automationDefaults);
function get<K extends keyof MangaPrefs>(key: K): MangaPrefs[K] {
const pref = getPref(mangaId, key);
if (pref !== undefined) return pref;
return (defaults as MangaPrefs | undefined)?.[key] ?? getPref(mangaId, key);
}
const set = <K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) => setPref(mangaId, key, value);
const manga = $derived(store.library?.find(m => m.id === mangaId) ?? mangaProp);
const coverSrc = $derived(manga ? resolvedCover(manga.id, manga.thumbnailUrl) : null);
function onBackdrop(e: MouseEvent) {
if (e.target === e.currentTarget) onClose();
}
</script>
<div class="backdrop" role="presentation" tabindex="-1" onmousedown={onBackdrop}>
<div class="modal" role="dialog" aria-modal="true" aria-label="Automation">
<div class="cover-col">
{#if coverSrc}
<div class="cover-wrap">
<Thumbnail src={coverSrc} alt={manga?.title} class="cover" />
</div>
{:else}
<div class="cover-placeholder"></div>
{/if}
</div>
<div class="content">
<div class="content-header">
<div class="title-block">
<span class="title">{manga?.title ?? "Automation"}</span>
<span class="subtitle">Per-series rules</span>
</div>
<button class="close-btn" onclick={onClose} aria-label="Close">
<X size={16} weight="light" />
</button>
</div>
<div class="content-body">
<p class="section-label">Downloads</p>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Auto-download new chapters</span>
<span class="auto-desc">Queue new chapters when this series refreshes</span>
</div>
<button
role="switch"
aria-checked={get("autoDownload")}
aria-label="Auto-download new chapters"
class="auto-toggle"
class:auto-toggle-on={get("autoDownload")}
onclick={() => set("autoDownload", !get("autoDownload"))}
><span class="auto-toggle-thumb"></span></button>
</div>
<div class="auto-row auto-row-col">
<div class="auto-info">
<span class="auto-label">Download ahead</span>
<span class="auto-desc">Pre-fetch chapters while reading</span>
</div>
<div class="auto-chip-group">
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={get("downloadAhead") === opt.value}
onclick={() => set("downloadAhead", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
<div class="auto-row auto-row-col">
<div class="auto-info">
<span class="auto-label">Max chapters to keep</span>
<span class="auto-desc">Delete oldest downloads when limit is exceeded</span>
</div>
<div class="auto-chip-group">
{#each MAX_KEEP_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={get("maxKeepChapters") === opt.value}
onclick={() => set("maxKeepChapters", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
<div class="divider"></div>
<p class="section-label">On Read</p>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Delete after reading</span>
<span class="auto-desc">Remove download when chapter is marked read</span>
</div>
<button
role="switch"
aria-checked={get("deleteOnRead")}
aria-label="Delete after reading"
class="auto-toggle"
class:auto-toggle-on={get("deleteOnRead")}
onclick={() => set("deleteOnRead", !get("deleteOnRead"))}
><span class="auto-toggle-thumb"></span></button>
</div>
{#if get("deleteOnRead")}
<div class="auto-row auto-row-sub">
<span class="auto-label">Delete delay</span>
<div class="auto-chip-group">
{#each DELETE_DELAY_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={get("deleteDelayHours") === opt.value}
onclick={() => set("deleteDelayHours", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
{/if}
<div class="divider"></div>
<p class="section-label">Updates</p>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Pause updates</span>
<span class="auto-desc">Skip this series during global refresh</span>
</div>
<button
role="switch"
aria-checked={get("pauseUpdates")}
aria-label="Pause updates"
class="auto-toggle"
class:auto-toggle-on={get("pauseUpdates")}
onclick={() => set("pauseUpdates", !get("pauseUpdates"))}
><span class="auto-toggle-thumb"></span></button>
</div>
<div class="auto-row auto-row-col">
<div class="auto-info">
<span class="auto-label">Refresh interval</span>
<span class="auto-desc">How often to check for new chapters</span>
</div>
<div class="auto-chip-group">
{#each REFRESH_INTERVAL_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={get("refreshInterval") === opt.value}
onclick={() => set("refreshInterval", opt.value as MangaPrefs["refreshInterval"])}
>{opt.label}</button>
{/each}
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0; z-index: 300;
background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.1s ease both;
}
.modal {
display: flex; flex-direction: row;
width: 600px; max-width: calc(100vw - var(--sp-6));
height: 480px; max-height: 85vh;
background: var(--bg-surface); border: 1px solid var(--border-base);
border-radius: var(--radius-xl); overflow: hidden;
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.15s ease both;
}
.cover-col {
width: 200px; flex-shrink: 0;
background: var(--bg-raised);
border-right: 1px solid var(--border-dim);
display: flex; flex-direction: column;
padding: var(--sp-4);
overflow: hidden;
}
.cover-wrap { position: relative; width: 100%; flex: 1; min-height: 0; }
:global(.cover) {
position: absolute; inset: 0;
width: 100%; height: 100%;
object-fit: cover; object-position: center top;
border-radius: var(--radius-md);
border: 1px solid var(--border-dim);
display: block;
}
.cover-placeholder {
position: absolute; inset: 0;
background: var(--bg-overlay);
border-radius: var(--radius-md);
}
.content {
flex: 1; min-width: 0;
display: flex; flex-direction: column;
overflow: hidden;
border-left: 1px solid var(--border-dim);
}
.content-header {
display: flex; align-items: flex-start; justify-content: space-between;
gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.title-block { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); }
.title {
font-size: var(--text-base); font-weight: var(--weight-medium);
color: var(--text-primary); letter-spacing: var(--tracking-tight);
line-height: var(--leading-tight);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.subtitle {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide);
}
.close-btn {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; flex-shrink: 0;
border-radius: var(--radius-sm); color: var(--text-faint);
background: none; border: none; cursor: pointer;
transition: color var(--t-base), background var(--t-base);
}
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.content-body {
flex: 1; overflow-y: auto; scrollbar-width: none;
display: flex; flex-direction: column; gap: var(--sp-3);
padding: var(--sp-5) var(--sp-6);
}
.content-body::-webkit-scrollbar { display: none; }
.section-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-widest); color: var(--text-faint);
text-transform: uppercase; margin: 0;
}
.divider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; }
.auto-row { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.auto-row-col { flex-direction: column; align-items: flex-start; gap: var(--sp-2); }
.auto-row-sub { padding-left: var(--sp-3); border-left: 2px solid var(--border-dim); }
.auto-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.auto-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.auto-desc { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); }
.auto-toggle { width: 28px; height: 16px; border-radius: var(--radius-full); border: 1px solid var(--border-strong); background: var(--bg-overlay); cursor: pointer; padding: 0; flex-shrink: 0; position: relative; transition: background var(--t-base), border-color var(--t-base); }
.auto-toggle-on { background: var(--accent); border-color: var(--accent); }
.auto-toggle-thumb { position: absolute; top: 1px; left: 1px; width: 12px; height: 12px; border-radius: 50%; background: var(--text-faint); transition: transform var(--t-base), background var(--t-base); }
.auto-toggle-on .auto-toggle-thumb { transform: translateX(12px); background: var(--bg-base); }
.auto-chip-group { display: flex; flex-direction: row; gap: 4px; flex-wrap: wrap; }
.auto-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.auto-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.auto-chip-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
@@ -0,0 +1,212 @@
<script lang="ts">
import { X, CaretLeft, CaretRight, CircleNotch } from "phosphor-svelte";
import { setPref } from "@features/series/lib/mangaPrefs";
import { coverCandidatesSync, dedupeByImage } from "@core/cover/coverResolver";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import type { Manga } from "@types";
interface Props {
manga: Manga;
allManga: Manga[];
onClose: () => void;
}
let { manga, allManga, onClose }: Props = $props();
type MangaWithTitle = Manga & { title: string };
const mangaById = $derived(new Map(allManga.map(m => [m.id, m as MangaWithTitle])));
const syncCandidates = $derived(
coverCandidatesSync(manga.id, manga.title, manga.thumbnailUrl, mangaById)
);
let candidates = $state<typeof syncCandidates>([]);
let hashingDone = $state(false);
let index = $state(0);
$effect(() => {
const snap = syncCandidates;
candidates = [];
hashingDone = false;
index = 0;
dedupeByImage(snap).then(merged => {
candidates = merged;
index = Math.max(0, merged.findIndex(c => c.isActive));
hashingDone = true;
});
});
const current = $derived(candidates[index]);
function prev() { index = (index - 1 + candidates.length) % candidates.length; }
function next() { index = (index + 1) % candidates.length; }
function confirm() {
if (!current) return;
if (current.mangaId === manga.id) setPref(manga.id, "coverUrl", undefined as any);
else setPref(manga.id, "coverUrl", current.url);
onClose();
}
function onKeydown(e: KeyboardEvent) {
if (e.key === "ArrowLeft") { e.preventDefault(); prev(); }
if (e.key === "ArrowRight") { e.preventDefault(); next(); }
if (e.key === "Enter") { e.preventDefault(); confirm(); }
if (e.key === "Escape") onClose();
}
</script>
<div
class="backdrop"
role="button"
tabindex="-1"
aria-label="Close cover picker"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div class="modal" role="dialog" aria-label="Choose cover image" tabindex="-1" onkeydown={onKeydown}>
<div class="header">
<span class="title">Cover Image</span>
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
</div>
{#if !hashingDone}
<div class="loading">
<CircleNotch size={24} weight="light" class="anim-spin" style="color:var(--text-faint)" />
</div>
{:else}
<div class="stage">
<button class="arrow" onclick={prev} disabled={candidates.length <= 1} aria-label="Previous">
<CaretLeft size={18} weight="bold" />
</button>
<div class="cover-wrap">
{#if current}
<Thumbnail src={current.url} alt="" class="cover-img" />
{/if}
</div>
<button class="arrow" onclick={next} disabled={candidates.length <= 1} aria-label="Next">
<CaretRight size={18} weight="bold" />
</button>
</div>
{#if candidates.length > 1}
<div class="filmstrip">
{#each candidates as c, i (c.url)}
<button
class="film-thumb"
class:film-active={i === index}
onclick={() => index = i}
aria-label="Cover {i + 1}"
>
<Thumbnail src={c.url} alt="" class="film-img" />
</button>
{/each}
</div>
{/if}
{/if}
<div class="footer">
<button class="confirm-btn" onclick={confirm}>Use this cover</button>
</div>
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.72);
z-index: calc(var(--z-settings) + 2);
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.1s ease both;
}
.modal {
width: min(380px, calc(100vw - 48px));
display: flex; flex-direction: column;
background: var(--bg-surface);
border: 1px solid var(--border-base); border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.14s ease both;
outline: none;
}
.header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0; gap: var(--sp-2);
}
.title {
font-size: var(--text-sm); font-weight: var(--weight-medium);
color: var(--text-secondary); flex: 1;
}
.close-btn {
display: flex; align-items: center; justify-content: center;
width: 26px; height: 26px; border-radius: var(--radius-sm);
color: var(--text-faint); background: none; border: none; cursor: pointer;
transition: color var(--t-base), background var(--t-base);
}
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.stage {
display: flex; align-items: center; justify-content: center;
gap: var(--sp-3);
padding: var(--sp-5) var(--sp-4) var(--sp-4);
}
.cover-wrap {
flex: 1; max-width: 200px; aspect-ratio: 2/3;
border-radius: var(--radius-md); overflow: hidden;
border: 1px solid var(--border-dim);
background: var(--bg-raised);
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
}
:global(.cover-img) { width: 100%; height: 100%; object-fit: cover; display: block; }
.arrow {
display: flex; align-items: center; justify-content: center;
width: 36px; height: 36px; flex-shrink: 0;
border-radius: var(--radius-full);
border: 1px solid var(--border-dim);
background: var(--bg-raised);
color: var(--text-muted);
cursor: pointer;
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
}
.arrow:hover:not(:disabled) { color: var(--text-primary); border-color: var(--border-strong); background: var(--bg-overlay); }
.arrow:disabled { opacity: 0.2; cursor: default; }
.filmstrip {
display: flex; gap: var(--sp-2); align-items: center; justify-content: center;
padding: 0 var(--sp-4) var(--sp-4);
overflow-x: auto; scrollbar-width: none;
}
.filmstrip::-webkit-scrollbar { display: none; }
.film-thumb {
flex-shrink: 0; width: 44px; aspect-ratio: 2/3;
border-radius: var(--radius-sm); overflow: hidden;
border: 2px solid transparent;
background: var(--bg-raised);
cursor: pointer; padding: 0;
opacity: 0.5;
transition: border-color var(--t-base), opacity var(--t-base);
}
.film-thumb:hover { opacity: 0.8; }
.film-active { border-color: var(--accent); opacity: 1; }
:global(.film-img) { width: 100%; height: 100%; object-fit: cover; display: block; }
.footer { padding: 0 var(--sp-4) var(--sp-4); flex-shrink: 0; }
.confirm-btn {
width: 100%; padding: 9px;
border-radius: var(--radius-md);
background: var(--accent); border: 1px solid var(--accent);
color: var(--accent-contrast, #fff);
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
cursor: pointer;
transition: opacity var(--t-base);
}
.confirm-btn:hover { opacity: 0.88; }
.loading { display: flex; align-items: center; justify-content: center; padding: var(--sp-10) 0; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
@keyframes pulse { 0%, 100% { opacity: 0.4 } 50% { opacity: 1 } }
</style>
@@ -0,0 +1,198 @@
<script lang="ts">
import { X, MapPin, Trash, PencilSimple, Check } from "phosphor-svelte";
import { store, removeMarker, updateMarker, openReader } from "@store/state.svelte";
import type { MarkerEntry, MarkerColor } from "@store/state.svelte";
import type { Chapter } from "@types";
interface Props {
mangaId: number;
chapters: Chapter[];
onClose: () => void;
}
let { mangaId, chapters, onClose }: Props = $props();
const COLOR_HEX: Record<MarkerColor, string> = {
yellow: "#c4a94a",
red: "#c47a7a",
blue: "#7a9ec4",
green: "#7aab7a",
purple: "#a07ac4",
};
const markers = $derived(store.getMarkersForManga(mangaId));
const grouped = $derived.by(() => {
const map = new Map<number, MarkerEntry[]>();
for (const m of markers) {
if (!map.has(m.chapterId)) map.set(m.chapterId, []);
map.get(m.chapterId)!.push(m);
}
const entries = [...map.entries()].map(([chapterId, items]) => ({
chapterId,
chapterName: items[0].chapterName,
items: [...items].sort((a, b) => a.pageNumber - b.pageNumber),
}));
const chapterOrder = new Map(chapters.map((c, i) => [c.id, i]));
entries.sort((a, b) => (chapterOrder.get(a.chapterId) ?? 9999) - (chapterOrder.get(b.chapterId) ?? 9999));
return entries;
});
let editingId: string = $state("");
let editNote: string = $state("");
let editColor: MarkerColor = $state("yellow");
function startEdit(m: MarkerEntry) {
editingId = m.id;
editNote = m.note;
editColor = m.color;
}
function commitEdit() {
if (!editingId) return;
updateMarker(editingId, { note: editNote.trim(), color: editColor });
editingId = "";
}
function jumpToMarker(m: MarkerEntry) {
const chapter = chapters.find(c => c.id === m.chapterId);
if (!chapter) return;
const chaptersAsc = [...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder);
openReader(chapter, chaptersAsc);
}
function formatDate(ts: number): string {
return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
</script>
<div class="panel">
<div class="panel-header">
<div class="panel-title">
<MapPin size={13} weight="fill" />
<span>Markers</span>
{#if markers.length > 0}
<span class="count">{markers.length}</span>
{/if}
</div>
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
</div>
<div class="panel-body">
{#if grouped.length === 0}
<div class="empty">
<MapPin size={22} weight="light" style="color:var(--text-faint);opacity:0.4" />
<p>No markers yet</p>
<p class="empty-sub">Mark pages while reading with the marker button or keybind</p>
</div>
{:else}
{#each grouped as group}
<div class="group">
<div class="group-header">
<span class="group-name">{group.chapterName}</span>
<span class="group-count">{group.items.length}</span>
</div>
{#each group.items as m (m.id)}
<div class="marker-row" class:editing={editingId === m.id}>
<div class="marker-dot" style="background:{COLOR_HEX[m.color]}"></div>
<div class="marker-body">
{#if editingId === m.id}
<div class="edit-wrap">
<div class="color-row">
{#each Object.entries(COLOR_HEX) as [c, hex]}
<button
class="color-swatch"
class:color-active={editColor === c}
style="background:{hex}"
onclick={() => editColor = c as MarkerColor}
title={c}
></button>
{/each}
</div>
<textarea
class="edit-input"
rows={3}
bind:value={editNote}
placeholder="Add a note…"
onkeydown={(e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); commitEdit(); } if (e.key === "Escape") editingId = ""; }}
></textarea>
<div class="edit-actions">
<button class="edit-save" onclick={commitEdit}><Check size={12} weight="bold" /> Save</button>
<button class="edit-cancel" onclick={() => editingId = ""}>Cancel</button>
</div>
</div>
{:else}
<button class="marker-jump" onclick={() => jumpToMarker(m)}>
<span class="page-label">p.{m.pageNumber}</span>
{#if m.note}
<span class="marker-note">{m.note}</span>
{:else}
<span class="marker-note marker-note-empty">No note</span>
{/if}
<span class="marker-date">{formatDate(m.updatedAt ?? m.createdAt)}</span>
</button>
<div class="marker-actions">
<button class="marker-action-btn" onclick={() => startEdit(m)} title="Edit"><PencilSimple size={11} weight="light" /></button>
<button class="marker-action-btn danger" onclick={() => removeMarker(m.id)} title="Delete"><Trash size={11} weight="light" /></button>
</div>
{/if}
</div>
</div>
{/each}
</div>
{/each}
{/if}
</div>
</div>
<style>
.panel { display: flex; flex-direction: column; height: 100%; overflow: hidden; }
.panel-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.panel-title { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); }
.count { background: var(--bg-overlay); border: 1px solid var(--border-dim); border-radius: var(--radius-full); font-size: var(--text-2xs); padding: 0 5px; color: var(--text-faint); }
.close-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.panel-body { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: var(--sp-1); }
.empty { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); padding: var(--sp-8) var(--sp-4); text-align: center; }
.empty p { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.empty-sub { font-size: var(--text-2xs) !important; opacity: 0.7; max-width: 180px; line-height: var(--leading-snug); }
.group { display: flex; flex-direction: column; gap: 2px; }
.group-header { display: flex; align-items: center; justify-content: space-between; padding: 6px var(--sp-2) 4px; }
.group-name { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.group-count { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); opacity: 0.6; flex-shrink: 0; }
.marker-row { display: flex; align-items: flex-start; gap: var(--sp-2); padding: 5px var(--sp-2); border-radius: var(--radius-md); transition: background var(--t-fast); }
.marker-row:hover { background: var(--bg-raised); }
.marker-row.editing { background: var(--bg-raised); }
.marker-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; margin-top: 5px; }
.marker-body { flex: 1; min-width: 0; display: flex; align-items: flex-start; gap: var(--sp-1); }
.marker-jump { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 2px; text-align: left; background: none; border: none; padding: 0; cursor: pointer; }
.page-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); }
.marker-note { font-size: var(--text-xs); color: var(--text-secondary); line-height: var(--leading-snug); white-space: pre-wrap; word-break: break-word; }
.marker-note-empty { color: var(--text-faint); font-style: italic; }
.marker-date { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.marker-actions { display: flex; flex-direction: column; gap: 2px; flex-shrink: 0; opacity: 0; transition: opacity var(--t-fast); }
.marker-row:hover .marker-actions { opacity: 1; }
.marker-action-btn { display: flex; align-items: center; justify-content: center; width: 20px; height: 20px; border-radius: var(--radius-sm); color: var(--text-faint); transition: color var(--t-base), background var(--t-base); }
.marker-action-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.marker-action-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
.edit-wrap { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); }
.color-row { display: flex; gap: 5px; }
.color-swatch { width: 14px; height: 14px; border-radius: 50%; border: 2px solid transparent; cursor: pointer; transition: border-color var(--t-fast), transform var(--t-fast); flex-shrink: 0; }
.color-swatch:hover { transform: scale(1.15); }
.color-active { border-color: var(--text-primary) !important; }
.edit-input { width: 100%; background: var(--bg-overlay); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 6px 8px; font-size: var(--text-xs); color: var(--text-secondary); outline: none; resize: none; font-family: inherit; line-height: var(--leading-snug); transition: border-color var(--t-base); }
.edit-input:focus { border-color: var(--border-focus); }
.edit-actions { display: flex; align-items: center; gap: var(--sp-2); }
.edit-save { display: flex; align-items: center; gap: 4px; padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: filter var(--t-fast); }
.edit-save:hover { filter: brightness(1.15); }
.edit-cancel { padding: 4px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
.edit-cancel:hover { color: var(--text-muted); border-color: var(--border-strong); }
</style>
@@ -0,0 +1,533 @@
<script lang="ts">
import { X, MagnifyingGlass, CircleNotch, ArrowRight, Check, Warning, Sparkle } from "phosphor-svelte";
import { untrack } from "svelte";
import { gql } from "@api/client";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { resolvedCover } from "@core/cover/coverResolver";
import { GET_SOURCES } from "@api/queries/extensions";
import { UPDATE_MANGA } from "@api/mutations/manga";
import { FETCH_SOURCE_MANGA } from "@api/mutations/downloads";
import { FETCH_CHAPTERS, UPDATE_CHAPTERS_PROGRESS } from "@api/mutations/chapters";
import { store } from "@store/state.svelte";
import type { Manga, Chapter, Source } from "@types";
interface Props {
manga: Manga;
currentChapters: Chapter[];
onClose: () => void;
onMigrated: (newManga: Manga) => void;
}
let { manga, currentChapters, onClose, onMigrated }: Props = $props();
type Step = "source" | "search" | "confirm";
const STEPS = ["source", "search", "confirm"] as const satisfies Step[];
interface Match {
manga: Manga;
chapters: Chapter[];
readCount: number;
similarity: number;
}
function titleSimilarity(a: string, b: string): number {
const norm = (s: string) =>
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
const wordsA = new Set(norm(a));
const wordsB = new Set(norm(b));
if (wordsA.size === 0 || wordsB.size === 0) return 0;
const intersection = [...wordsA].filter(w => wordsB.has(w)).length;
return intersection / new Set([...wordsA, ...wordsB]).size;
}
function onKey(e: KeyboardEvent) { if (e.key === "Escape") onClose(); }
let step: Step = $state("source");
let sources: Source[] = $state([]);
let loadingSources = $state(true);
let selectedSource: Source | null = $state(null);
let selectedLang = $state("all");
let langStripEl: HTMLDivElement | undefined = $state();
const stepIdx = $derived(STEPS.indexOf(step));
const availableLangs = $derived.by(() => {
const langs = Array.from(new Set<string>(sources.map(s => s.lang))).sort();
const en = langs.indexOf("en");
if (en > 0) { langs.splice(en, 1); langs.unshift("en"); }
return langs;
});
const hasMultipleLangs = $derived(availableLangs.length > 1);
const visibleSources = $derived.by(() => {
if (selectedLang !== "all") return sources.filter(s => s.lang === selectedLang);
const map = new Map<string, Source>();
for (const s of sources) {
const existing = map.get(s.name);
if (!existing || s.lang < existing.lang) map.set(s.name, s);
}
return Array.from(map.values());
});
let query = $state(untrack(() => manga.title));
let results: { manga: Manga; similarity: number }[] = $state([]);
let searching = $state(false);
let selectedMatch: Match | null = $state(null);
let loadingMatchId: number | null = $state(null);
let migrating = $state(false);
let error: string | null = $state(null);
const readCount = $derived(currentChapters.filter(c => c.isRead).length);
const totalCount = $derived(currentChapters.length);
const chapterDiff = $derived(selectedMatch ? selectedMatch.chapters.length - totalCount : 0);
function scrollLangStrip(dir: -1 | 1) {
if (!langStripEl) return;
const chips = Array.from(langStripEl.children) as HTMLElement[];
const viewEnd = langStripEl.scrollLeft + langStripEl.clientWidth;
if (dir === 1) {
const next = chips.find(c => c.offsetLeft + c.offsetWidth > viewEnd + 2);
if (next) langStripEl.scrollTo({ left: next.offsetLeft, behavior: "smooth" });
} else {
const prev = [...chips].reverse().find(c => c.offsetLeft < langStripEl!.scrollLeft - 2);
if (prev) langStripEl.scrollTo({ left: prev.offsetLeft + prev.offsetWidth - langStripEl.clientWidth, behavior: "smooth" });
}
}
$effect(() => {
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then(d => {
const filtered = d.sources.nodes.filter(s => s.id !== "0" && s.id !== manga.source?.id);
sources = filtered;
const prefLang = store?.settings?.preferredExtensionLang ?? "";
const langs = new Set(filtered.map(s => s.lang));
if (prefLang && langs.has(prefLang) && langs.size > 1) selectedLang = prefLang;
})
.catch(console.error)
.finally(() => { loadingSources = false; });
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
});
async function searchSource(src: Source, q: string) {
if (!src || !q.trim()) return;
searching = true; results = []; error = null;
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
source: src.id, type: "SEARCH", page: 1, query: q.trim(),
});
results = d.fetchSourceManga.mangas
.map(m => ({ manga: m, similarity: titleSimilarity(manga.title, m.title) }))
.sort((a, b) => b.similarity - a.similarity);
} catch (e: any) {
error = e.message;
} finally {
searching = false;
}
}
function pickSource(src: Source) {
selectedSource = src;
step = "search";
searchSource(src, query);
}
async function selectMatch(m: Manga, similarity: number) {
loadingMatchId = m.id; error = null;
try {
const d = await gql<{ fetchChapters: { chapters: Chapter[] } }>(FETCH_CHAPTERS, { mangaId: m.id });
const chapters = d.fetchChapters.chapters;
const matchReadCount = chapters.filter(c => {
const old = currentChapters.find(o => Math.abs(o.chapterNumber - c.chapterNumber) < 0.01);
return old?.isRead;
}).length;
selectedMatch = { manga: m, chapters, readCount: matchReadCount, similarity };
step = "confirm";
} catch (e: any) {
error = e.message;
} finally {
loadingMatchId = null;
}
}
async function migrate() {
if (!selectedMatch) return;
migrating = true; error = null;
try {
const { manga: newManga, chapters: newChapters } = selectedMatch;
const oldByNum = new Map(currentChapters.map(c => [Math.round(c.chapterNumber * 100), c]));
const toMarkRead: number[] = [];
const toMarkBookmarked: number[] = [];
const progressUpdates: { id: number; lastPageRead: number }[] = [];
for (const nc of newChapters) {
const old = oldByNum.get(Math.round(nc.chapterNumber * 100));
if (!old) continue;
if (old.isRead) toMarkRead.push(nc.id);
if (old.isBookmarked) toMarkBookmarked.push(nc.id);
if ((old.lastPageRead ?? 0) > 0 && !old.isRead)
progressUpdates.push({ id: nc.id, lastPageRead: old.lastPageRead! });
}
if (toMarkRead.length) await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkRead, isRead: true });
if (toMarkBookmarked.length) await gql(UPDATE_CHAPTERS_PROGRESS, { ids: toMarkBookmarked, isBookmarked: true });
for (const { id, lastPageRead } of progressUpdates)
await gql(UPDATE_CHAPTERS_PROGRESS, { ids: [id], lastPageRead });
await gql(UPDATE_MANGA, { id: newManga.id, inLibrary: true });
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false });
onMigrated({ ...newManga, inLibrary: true });
} catch (e: any) {
error = e.message;
migrating = false;
}
}
</script>
<!-- svelte-ignore a11y_click_events_have_key_events a11y_no_static_element_interactions -->
<div class="overlay" onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="modal">
<div class="modal-header">
<div class="manga-context">
<div class="manga-context-cover">
<Thumbnail src={resolvedCover(manga.id, manga.thumbnailUrl)} alt={manga.title} class="ctx-cover" />
</div>
<div class="manga-context-info">
<span class="modal-eyebrow">Migrate source</span>
<span class="modal-title">{manga.title}</span>
{#if manga.source?.displayName}
<span class="modal-source">{manga.source.displayName}</span>
{/if}
</div>
</div>
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
</div>
<div class="steps">
{#each STEPS as st, i}
<div class="step" class:step-active={step === st} class:step-done={i < stepIdx}>
<span class="step-dot">
{#if i < stepIdx}<Check size={9} weight="bold" />{:else}{i + 1}{/if}
</span>
<span class="step-label">
{st === "source" ? "Pick source" : st === "search" ? (selectedSource?.displayName ?? "Search") : "Confirm"}
</span>
</div>
{/each}
</div>
<div class="body">
{#if step === "source"}
{#if loadingSources}
<div class="centered">
<CircleNotch size={16} weight="light" class="anim-spin" style="color:var(--text-faint)" />
</div>
{:else if sources.length === 0}
<div class="centered"><span class="hint">No other sources installed.</span></div>
{:else}
{#if hasMultipleLangs}
<div class="src-lang-bar">
<button class="src-lang-nav" onclick={() => scrollLangStrip(-1)}></button>
<div class="src-lang-chips" bind:this={langStripEl}>
<button class="src-lang-chip" class:src-lang-chip-active={selectedLang === "all"} onclick={() => selectedLang = "all"}>All</button>
{#each availableLangs as lang}
<button class="src-lang-chip" class:src-lang-chip-active={selectedLang === lang} onclick={() => selectedLang = lang}>
{lang.toUpperCase()}
</button>
{/each}
</div>
<button class="src-lang-nav" onclick={() => scrollLangStrip(1)}></button>
</div>
{/if}
<div class="source-list">
{#each visibleSources as src}
<button class="source-row" class:source-row-active={selectedSource?.id === src.id} onclick={() => pickSource(src)}>
<div class="source-icon-wrap">
<Thumbnail src={src.iconUrl} alt={src.name} class="source-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
</div>
<div class="source-info">
<span class="source-name">{src.displayName}</span>
<span class="source-meta">{src.lang.toUpperCase()}{src.isNsfw ? " · NSFW" : ""}</span>
</div>
<ArrowRight size={13} weight="light" class="source-arrow" />
</button>
{/each}
</div>
{/if}
{:else if step === "search"}
<div class="search-step">
{#if selectedSource}
<div class="search-context">
<div class="source-icon-wrap" style="width:20px;height:20px;border-radius:var(--radius-sm)">
<Thumbnail src={selectedSource.iconUrl} alt={selectedSource.name} class="search-context-icon" onerror={(e) => (e.target as HTMLImageElement).style.display = "none"} />
</div>
<span class="search-context-name">{selectedSource.displayName}</span>
<button class="search-context-change" onclick={() => { step = "source"; results = []; }}>Change</button>
</div>
{/if}
<div class="search-row">
<div class="search-bar">
<MagnifyingGlass size={13} weight="light" class="search-icon" />
<input
class="search-input"
bind:value={query}
onkeydown={(e) => e.key === "Enter" && selectedSource && searchSource(selectedSource, query)}
placeholder="Search title…"
autofocus />
</div>
<button class="search-btn"
onclick={() => selectedSource && searchSource(selectedSource, query)}
disabled={searching || !selectedSource}>
{#if searching}
<CircleNotch size={13} weight="light" class="anim-spin" />
{:else}
<MagnifyingGlass size={12} weight="bold" /> Search
{/if}
</button>
</div>
{#if error}<p class="error"><Warning size={13} weight="light" /> {error}</p>{/if}
<div class="results">
{#if searching}
{#each Array(5) as _}
<div class="sk-result">
<div class="skeleton sk-cover"></div>
<div class="sk-meta">
<div class="skeleton sk-line" style="width:60%"></div>
<div class="skeleton sk-line" style="width:35%"></div>
</div>
</div>
{/each}
{:else}
{#each results as { manga: m, similarity }, idx}
<button class="result-row" onclick={() => selectMatch(m, similarity)} disabled={loadingMatchId !== null}>
<div class="result-cover-wrap">
<Thumbnail src={resolvedCover(m.id, m.thumbnailUrl)} alt={m.title} class="result-cover" />
</div>
<div class="result-info">
<span class="result-title">{m.title}</span>
<div class="result-meta">
{#if idx === 0 && similarity > 0.5}
<span class="best-match-badge"><Sparkle size={9} weight="fill" /> Best match</span>
{/if}
<span class="sim-bar"><span class="sim-fill" style="width:{Math.round(similarity * 100)}%"></span></span>
<span class="sim-label">{Math.round(similarity * 100)}%</span>
</div>
</div>
{#if loadingMatchId === m.id}
<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint);flex-shrink:0" />
{:else}
<ArrowRight size={13} weight="light" style="color:var(--text-faint);flex-shrink:0;opacity:0.4" />
{/if}
</button>
{/each}
{#if results.length === 0 && !error && !searching}
<div class="centered">
<span class="hint">{query ? "No results — try a different title." : "Enter a title to search."}</span>
</div>
{/if}
{/if}
</div>
</div>
{:else if step === "confirm" && selectedMatch}
<div class="confirm-step">
<div class="confirm-row">
<div class="confirm-manga">
<div class="confirm-cover-wrap">
<Thumbnail src={resolvedCover(manga.id, manga.thumbnailUrl)} alt={manga.title} class="confirm-cover" />
</div>
<p class="confirm-title">{manga.title}</p>
<p class="confirm-source">{manga.source?.displayName ?? "Unknown"}</p>
<span class="confirm-tag">Current</span>
</div>
<div class="confirm-arrow-wrap">
<ArrowRight size={18} weight="light" style="color:var(--text-faint)" />
</div>
<div class="confirm-manga">
<div class="confirm-cover-wrap">
<Thumbnail src={resolvedCover(selectedMatch.manga.id, selectedMatch.manga.thumbnailUrl)} alt={selectedMatch.manga.title} class="confirm-cover" />
</div>
<p class="confirm-title">{selectedMatch.manga.title}</p>
<p class="confirm-source">{selectedSource?.displayName ?? "Unknown"}</p>
<span class="confirm-tag confirm-tag-new">New</span>
</div>
</div>
<div class="confirm-stats">
<div class="stat-row">
<span class="stat-label">Title match</span>
<span class="stat-val"
class:stat-good={selectedMatch.similarity > 0.7}
class:stat-warn={selectedMatch.similarity > 0.4 && selectedMatch.similarity <= 0.7}
class:stat-bad={selectedMatch.similarity <= 0.4}>
{Math.round(selectedMatch.similarity * 100)}%
</span>
</div>
<div class="stat-row">
<span class="stat-label">Chapters on new source</span>
<span class="stat-val" class:stat-warn={chapterDiff < -5}>
{selectedMatch.chapters.length}
{#if chapterDiff !== 0}
<span class="chapter-diff">{chapterDiff > 0 ? `+${chapterDiff}` : chapterDiff} vs current</span>
{/if}
</span>
</div>
<div class="stat-row">
<span class="stat-label">Read progress to carry</span>
<span class="stat-val">{selectedMatch.readCount} / {readCount}</span>
</div>
</div>
{#if chapterDiff < -5}
<div class="warn-box">
<Warning size={13} weight="light" />
New source has {Math.abs(chapterDiff)} fewer chapters — some content may be missing.
</div>
{/if}
<p class="confirm-note">The current entry will be removed from your library. Downloads are not transferred.</p>
{#if error}<p class="error"><Warning size={13} weight="light" /> {error}</p>{/if}
<div class="confirm-actions">
<button class="back-btn" onclick={() => step = "search"} disabled={migrating}>Back</button>
<button class="migrate-btn" onclick={migrate} disabled={migrating}>
{#if migrating}
<CircleNotch size={13} weight="light" class="anim-spin" /> Migrating…
{:else}
<Check size={13} weight="bold" /> Migrate
{/if}
</button>
</div>
</div>
{/if}
</div>
</div>
</div>
<style>
.overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.6); display: flex; align-items: center; justify-content: center; z-index: 200; animation: fadeIn 0.1s ease both; }
.modal { background: var(--bg-base); border: 1px solid var(--border-base); border-radius: var(--radius-xl); width: 520px; max-height: 82vh; display: flex; flex-direction: column; overflow: hidden; box-shadow: 0 8px 40px rgba(0,0,0,0.5); }
.modal-header { display: flex; align-items: flex-start; justify-content: space-between; gap: var(--sp-3); padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.manga-context { display: flex; align-items: center; gap: var(--sp-3); min-width: 0; }
.manga-context-cover { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; flex-shrink: 0; background: var(--bg-raised); border: 1px solid var(--border-dim); }
:global(.ctx-cover) { width: 100%; height: 100%; object-fit: cover; }
.manga-context-info { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.modal-eyebrow { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.modal-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.close-btn { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-md); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; margin-top: 2px; }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.steps { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-3) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.step { display: flex; align-items: center; gap: var(--sp-2); opacity: 0.35; transition: opacity var(--t-base); }
.step + .step::before { content: ""; color: var(--text-faint); margin-right: var(--sp-1); font-size: var(--text-sm); opacity: 0.5; }
.step-active { opacity: 1; }
.step-done { opacity: 0.55; }
.step-dot { width: 18px; height: 18px; border-radius: 50%; background: var(--bg-raised); border: 1px solid var(--border-base); display: flex; align-items: center; justify-content: center; font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); flex-shrink: 0; }
.step-active .step-dot { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.step-label { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); }
.step-active .step-label { color: var(--text-secondary); }
.body { flex: 1; overflow: hidden; display: flex; flex-direction: column; min-height: 0; }
.centered { flex: 1; display: flex; align-items: center; justify-content: center; padding: var(--sp-8); }
.hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.src-lang-bar { display: flex; align-items: center; gap: var(--sp-1); padding: var(--sp-2); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
.src-lang-nav { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; flex-shrink: 0; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); font-size: 15px; line-height: 1; cursor: pointer; transition: color var(--t-base), background var(--t-base); }
.src-lang-nav:hover { color: var(--text-muted); background: var(--bg-raised); }
.src-lang-chips { display: flex; align-items: center; gap: var(--sp-1); flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; }
.src-lang-chips::-webkit-scrollbar { display: none; }
.src-lang-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; flex-shrink: 0; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.src-lang-chip:hover { color: var(--text-muted); background: var(--bg-raised); }
.src-lang-chip-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.source-list { flex: 1; overflow-y: auto; padding: var(--sp-2); display: flex; flex-direction: column; gap: 1px; }
.source-row { display: flex; align-items: center; gap: var(--sp-3); padding: 8px var(--sp-3); border-radius: var(--radius-md); border: 1px solid transparent; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast), border-color var(--t-fast); }
.source-row:hover { background: var(--bg-raised); border-color: var(--border-dim); }
.source-row-active { background: var(--accent-muted); border-color: var(--accent-dim); }
.source-icon-wrap { width: 28px; height: 28px; border-radius: var(--radius-md); overflow: hidden; flex-shrink: 0; background: var(--bg-raised); }
:global(.source-icon) { width: 100%; height: 100%; object-fit: cover; }
.source-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; }
.source-name { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.source-meta { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
:global(.source-arrow) { color: var(--text-faint); opacity: 0; transition: opacity var(--t-base); flex-shrink: 0; }
.source-row:hover :global(.source-arrow) { opacity: 1; }
.search-step { flex: 1; overflow: hidden; display: flex; flex-direction: column; gap: var(--sp-3); padding: var(--sp-3) var(--sp-4); }
.search-context { display: flex; align-items: center; gap: var(--sp-2); padding: 6px var(--sp-3); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); flex-shrink: 0; }
:global(.search-context-icon) { width: 100%; height: 100%; object-fit: cover; }
.search-context-name { flex: 1; font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.search-context-change { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--accent-fg); background: none; border: none; cursor: pointer; padding: 0; flex-shrink: 0; opacity: 0.8; transition: opacity var(--t-base); }
.search-context-change:hover { opacity: 1; }
.search-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.search-bar { flex: 1; display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 0 var(--sp-3) 0 var(--sp-2); transition: border-color var(--t-base); }
.search-bar:focus-within { border-color: var(--border-strong); }
:global(.search-icon) { color: var(--text-faint); flex-shrink: 0; }
.search-input { flex: 1; background: none; border: none; outline: none; color: var(--text-primary); font-size: var(--text-sm); padding: 7px 0; }
.search-input::placeholder { color: var(--text-faint); }
.search-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 12px; border-radius: var(--radius-md); background: var(--accent-muted); color: var(--accent-fg); border: 1px solid var(--accent-dim); cursor: pointer; flex-shrink: 0; display: flex; align-items: center; gap: var(--sp-1); transition: filter var(--t-base); }
.search-btn:hover:not(:disabled) { filter: brightness(1.1); }
.search-btn:disabled { opacity: 0.4; cursor: default; }
.results { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: 1px; }
.result-row { display: flex; align-items: center; gap: var(--sp-3); padding: 6px var(--sp-2); border-radius: var(--radius-md); border: none; background: none; text-align: left; width: 100%; cursor: pointer; transition: background var(--t-fast); }
.result-row:hover:not(:disabled) { background: var(--bg-raised); }
.result-row:disabled { opacity: 0.5; cursor: default; }
.result-cover-wrap { width: 36px; height: 54px; border-radius: var(--radius-sm); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
:global(.result-cover) { width: 100%; height: 100%; object-fit: cover; }
.result-info { flex: 1; display: flex; flex-direction: column; gap: 4px; overflow: hidden; min-width: 0; }
.result-title { font-size: var(--text-sm); color: var(--text-secondary); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.result-meta { display: flex; align-items: center; gap: var(--sp-2); }
.best-match-badge { display: inline-flex; align-items: center; gap: 3px; font-family: var(--font-ui); font-size: 9px; letter-spacing: var(--tracking-wide); text-transform: uppercase; color: var(--accent-fg); background: var(--accent-muted); border: 1px solid var(--accent-dim); padding: 1px 5px; border-radius: var(--radius-sm); flex-shrink: 0; }
.sim-bar { width: 40px; height: 3px; background: var(--bg-overlay); border-radius: var(--radius-full); overflow: hidden; flex-shrink: 0; }
.sim-fill { display: block; height: 100%; background: var(--accent); border-radius: var(--radius-full); }
.sim-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
.sk-result { display: flex; align-items: center; gap: var(--sp-3); padding: 6px var(--sp-2); }
.sk-cover { width: 36px; height: 54px; border-radius: var(--radius-sm); flex-shrink: 0; }
.sk-meta { flex: 1; display: flex; flex-direction: column; gap: var(--sp-2); }
.sk-line { height: 12px; border-radius: var(--radius-sm); }
.confirm-step { flex: 1; overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); padding: var(--sp-4) var(--sp-5); }
.confirm-row { display: flex; align-items: flex-start; justify-content: center; gap: var(--sp-3); }
.confirm-manga { display: flex; flex-direction: column; align-items: center; gap: var(--sp-2); flex: 1; max-width: 150px; }
.confirm-cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); }
:global(.confirm-cover) { width: 100%; height: 100%; object-fit: cover; }
.confirm-title { font-size: var(--text-xs); color: var(--text-secondary); text-align: center; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; line-height: var(--leading-snug); }
.confirm-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
.confirm-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); text-transform: uppercase; padding: 2px 7px; border-radius: var(--radius-full); background: var(--bg-raised); border: 1px solid var(--border-dim); color: var(--text-faint); }
.confirm-tag-new { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
.confirm-arrow-wrap { display: flex; align-items: center; padding-top: 48px; flex-shrink: 0; }
.confirm-stats { display: flex; flex-direction: column; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: var(--sp-3) var(--sp-4); }
.stat-row { display: flex; justify-content: space-between; align-items: center; }
.stat-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-muted); letter-spacing: var(--tracking-wide); }
.stat-val { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); }
.stat-good { color: var(--color-success) !important; }
.stat-warn { color: #d97706 !important; }
.stat-bad { color: var(--color-error) !important; }
.chapter-diff { font-family: var(--font-ui); font-size: var(--text-2xs); color: #d97706; letter-spacing: var(--tracking-wide); margin-left: var(--sp-2); }
.warn-box { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3); background: rgba(217,119,6,0.08); border: 1px solid rgba(217,119,6,0.25); border-radius: var(--radius-md); font-size: var(--text-xs); color: #d97706; line-height: var(--leading-snug); flex-shrink: 0; }
.confirm-note { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-base); }
.confirm-actions { display: flex; justify-content: flex-end; gap: var(--sp-2); flex-shrink: 0; margin-top: auto; }
.back-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 10px; border-radius: var(--radius-md); background: none; color: var(--text-muted); border: 1px solid var(--border-dim); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.back-btn:hover:not(:disabled) { color: var(--text-secondary); border-color: var(--border-strong); background: var(--bg-raised); }
.back-btn:disabled { opacity: 0.4; cursor: default; }
.migrate-btn { display: flex; align-items: center; gap: var(--sp-2); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 16px; border-radius: var(--radius-md); background: var(--accent-dim); border: 1px solid var(--accent); color: var(--accent-fg); cursor: pointer; transition: background var(--t-base), border-color var(--t-base); }
.migrate-btn:hover:not(:disabled) { background: var(--accent-muted); border-color: var(--accent-bright); }
.migrate-btn:disabled { opacity: 0.5; cursor: default; }
.error { display: flex; align-items: center; gap: var(--sp-2); font-size: var(--text-xs); color: var(--color-error); padding: var(--sp-2) var(--sp-3); background: rgba(180,60,60,0.08); border-radius: var(--radius-md); border: 1px solid rgba(180,60,60,0.2); flex-shrink: 0; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style>
@@ -0,0 +1,237 @@
<script lang="ts">
import { X, LinkSimple, LinkBreak, Sparkle } from "phosphor-svelte";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { store, linkManga, unlinkManga } from "@store/state.svelte";
import type { Manga } from "@types";
interface Props {
manga: Manga;
allManga: Manga[];
onClose: () => void;
}
let { manga, allManga, onClose }: Props = $props();
let query = $state("");
function titleSimilarity(a: string, b: string): number {
const norm = (s: string) =>
s.toLowerCase().replace(/[^a-z0-9\s]/g, "").split(/\s+/).filter(Boolean);
const wa = new Set(norm(a));
const wb = new Set(norm(b));
if (!wa.size || !wb.size) return 0;
const intersection = [...wa].filter(w => wb.has(w)).length;
return intersection / new Set([...wa, ...wb]).size;
}
const linkedIds = $derived(store.settings.mangaLinks?.[manga.id] ?? []);
const others = $derived(allManga.filter(m => m.id !== manga.id));
const suggestions = $derived.by(() => {
if (linkedIds.length === others.length) return [];
return others
.filter(m => !linkedIds.includes(m.id))
.map(m => ({ manga: m, score: titleSimilarity(manga.title, m.title) }))
.filter(r => r.score >= 0.65)
.sort((a, b) => b.score - a.score)
.slice(0, 8);
});
const searchResults = $derived.by(() => {
const q = query.trim().toLowerCase();
if (!q) return [];
return others
.filter(m => m.title.toLowerCase().includes(q))
.slice(0, 30);
});
const linked = $derived(
others.filter(m => linkedIds.includes(m.id))
);
function toggle(other: Manga) {
if (linkedIds.includes(other.id)) unlinkManga(manga.id, other.id);
else linkManga(manga.id, other.id);
}
</script>
<div
class="backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}
onkeydown={(e) => e.key === "Escape" && onClose()}
>
<div class="modal" role="dialog" aria-label="Link as same series">
<div class="header">
<span class="title">Link as same series</span>
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
</div>
<p class="hint">Linked entries share covers and are merged in search. Click a linked entry to unlink.</p>
<div class="search-wrap">
<input class="search" placeholder="Search your library…" bind:value={query} />
</div>
<div class="list">
{#if query.trim()}
{#if searchResults.length === 0}
<p class="empty">No results</p>
{:else}
{#each searchResults as m (m.id)}
{@const isLinked = linkedIds.includes(m.id)}
<button class="row" class:row-linked={isLinked} onclick={() => toggle(m)}>
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="thumb" />
<div class="info">
<span class="manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="source">{m.source.displayName}</span>{/if}
</div>
<span class="row-icon">{#if isLinked}<LinkBreak size={14} />{:else}<LinkSimple size={14} />{/if}</span>
</button>
{/each}
{/if}
{:else}
{#if linked.length > 0}
<p class="section-label">Linked</p>
{#each linked as m (m.id)}
<button class="row row-linked" onclick={() => toggle(m)}>
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="thumb" />
<div class="info">
<span class="manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="source">{m.source.displayName}</span>{/if}
</div>
<span class="row-icon"><LinkBreak size={14} /></span>
</button>
{/each}
{/if}
{#if suggestions.length > 0}
<p class="section-label">
<Sparkle size={10} weight="fill" style="color:var(--accent)" />
Suggested
</p>
{#each suggestions as { manga: m, score } (m.id)}
<button class="row" onclick={() => toggle(m)}>
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="thumb" />
<div class="info">
<span class="manga-title">{m.title}</span>
{#if m.source?.displayName}<span class="source">{m.source.displayName}</span>{/if}
</div>
<span class="sim-bar">
<span class="sim-fill" style="width:{Math.round(score * 100)}%"></span>
</span>
<span class="row-icon"><LinkSimple size={14} /></span>
</button>
{/each}
{/if}
{#if linked.length === 0 && suggestions.length === 0}
<p class="empty">No suggestions — search your library above.</p>
{/if}
{/if}
</div>
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0;
background: rgba(0,0,0,0.72);
z-index: var(--z-settings);
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.12s ease both;
}
.modal {
width: min(460px, calc(100vw - 48px));
max-height: 70vh;
display: flex; flex-direction: column;
background: var(--bg-surface);
border: 1px solid var(--border-base); border-radius: var(--radius-xl);
overflow: hidden;
box-shadow: 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.14s ease both;
}
.header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.title {
font-size: var(--text-sm); font-weight: var(--weight-medium);
color: var(--text-secondary);
}
.close-btn {
display: flex; align-items: center; justify-content: center;
width: 28px; height: 28px; border-radius: var(--radius-sm);
color: var(--text-faint); background: none; border: none; cursor: pointer;
transition: color var(--t-base), background var(--t-base);
}
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.hint {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); letter-spacing: var(--tracking-wide);
line-height: var(--leading-snug);
padding: var(--sp-3) var(--sp-5) 0;
flex-shrink: 0;
}
.search-wrap {
padding: var(--sp-3) var(--sp-4);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0;
}
.search {
width: 100%; background: var(--bg-raised);
border: 1px solid var(--border-dim); border-radius: var(--radius-md);
padding: 6px 10px; color: var(--text-primary);
font-size: var(--text-sm); outline: none;
transition: border-color var(--t-base);
}
.search:focus { border-color: var(--border-strong); }
.list {
flex: 1; overflow-y: auto;
padding: var(--sp-2);
scrollbar-width: none;
}
.list::-webkit-scrollbar { display: none; }
.section-label {
display: flex; align-items: center; gap: var(--sp-1);
font-family: var(--font-ui); font-size: 9px;
color: var(--text-faint); letter-spacing: var(--tracking-wider);
text-transform: uppercase;
padding: var(--sp-3) var(--sp-3) var(--sp-1);
}
.empty {
font-family: var(--font-ui); font-size: var(--text-xs);
color: var(--text-faint); padding: var(--sp-4) var(--sp-3);
text-align: center; letter-spacing: var(--tracking-wide);
}
.row {
display: flex; align-items: center; gap: var(--sp-3);
width: 100%; padding: 8px var(--sp-3);
border-radius: var(--radius-md); border: none;
background: none; text-align: left; cursor: pointer;
transition: background var(--t-fast);
}
.row:hover { background: var(--bg-raised); }
.row-linked { background: var(--accent-muted) !important; }
:global(.thumb) { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
.info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
.manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.sim-bar {
width: 36px; height: 3px;
background: var(--bg-overlay); border-radius: var(--radius-full);
overflow: hidden; flex-shrink: 0;
}
.sim-fill { display: block; height: 100%; background: var(--accent); border-radius: var(--radius-full); }
.row-icon { display: flex; align-items: center; color: var(--text-faint); flex-shrink: 0; opacity: 0.6; transition: opacity var(--t-base); }
.row:hover .row-icon { opacity: 1; }
.row-linked .row-icon { color: var(--accent-fg); opacity: 1; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
@@ -0,0 +1,653 @@
<script lang="ts">
import { CircleNotch, X, MagnifyingGlass, ArrowSquareOut, Lock, LockOpen, ArrowsClockwise, ArrowLineDown, CalendarBlank } from "phosphor-svelte";
import { gql } from "@api/client";
import Thumbnail from "@shared/manga/Thumbnail.svelte";
import { GET_TRACKERS, GET_MANGA_TRACK_RECORDS, SEARCH_TRACKER } from "@api/queries/tracking";
import { BIND_TRACK, UPDATE_TRACK, UNBIND_TRACK, FETCH_TRACK } from "@api/mutations/tracking";
import { GET_CHAPTERS } from "@api/queries/chapters";
import { addToast, store } from "@store/state.svelte";
import { syncBackFromTracker } from "@features/tracking/lib/trackingSync";
import type { Tracker, TrackRecord, TrackSearch } from "@types";
import type { Chapter } from "@types/index";
let { mangaId, mangaTitle, onClose }: {
mangaId: number;
mangaTitle: string;
onClose: () => void;
} = $props();
type TabId = "records" | number;
let trackers: Tracker[] = $state([]);
let records: TrackRecord[] = $state([]);
let loading: boolean = $state(true);
let activeTab: TabId = $state("records");
let searchQuery: string = $state("");
let searchResults: TrackSearch[] = $state([]);
let searching: boolean = $state(false);
let searchInited: Set<number> = $state(new Set());
let binding: boolean = $state(false);
let updatingRecord: number | null = $state(null);
let syncing: number | null = $state(null);
let applyingRecord: number | null = $state(null);
let editingId: number | null = $state(null);
let chapterDraft: number = $state(0);
let startDraft: string = $state("");
let finishDraft: string = $state("");
let confirmUnbindId: number | null = $state(null);
const loggedInTrackers = $derived(trackers.filter(t => t.isLoggedIn));
function autoFocus(node: HTMLElement) { setTimeout(() => node.focus(), 50); }
async function load() {
loading = true;
try {
const [tRes, rRes] = await Promise.all([
gql<{ trackers: { nodes: Tracker[] } }>(GET_TRACKERS),
gql<{ manga: { trackRecords: { nodes: TrackRecord[] } } }>(GET_MANGA_TRACK_RECORDS, { mangaId }),
]);
trackers = tRes.trackers.nodes;
records = rRes.manga.trackRecords.nodes;
if (store.settings.trackerSyncBack && records.length > 0) {
await Promise.all(records.map(r => applyToLibrary(r, true)));
}
} catch (e: any) {
addToast({ kind: "error", title: "Failed to load tracking", body: e?.message });
} finally {
loading = false;
}
}
$effect(() => { load(); });
$effect(() => {
const tab = activeTab;
if (typeof tab !== "number") return;
if (searchInited.has(tab)) return;
searchQuery = mangaTitle;
searchInited = new Set([...searchInited, tab]);
doSearch(tab, mangaTitle);
});
function trackerFor(id: number) { return trackers.find(t => t.id === id); }
function recordFor(trackerId: number) { return records.find(r => r.trackerId === trackerId); }
let searchTimer: ReturnType<typeof setTimeout>;
function onSearchInput() {
clearTimeout(searchTimer);
if (typeof activeTab !== "number") return;
const tid = activeTab;
if (!searchQuery.trim()) { searchResults = []; return; }
searchTimer = setTimeout(() => doSearch(tid, searchQuery), 400);
}
async function doSearch(trackerId: number, query: string) {
if (!query.trim()) return;
searching = true; searchResults = [];
try {
const res = await gql<{ searchTracker: { trackSearches: TrackSearch[] } }>(
SEARCH_TRACKER, { trackerId, query: query.trim() }
);
searchResults = res.searchTracker.trackSearches;
} catch (e: any) {
addToast({ kind: "error", title: "Search failed", body: e?.message });
} finally {
searching = false;
}
}
async function bind(result: TrackSearch) {
if (typeof activeTab !== "number") return;
binding = true;
try {
const existing = recordFor(activeTab);
if (existing) {
await gql(UNBIND_TRACK, { recordId: existing.id });
records = records.filter(r => r.id !== existing.id);
}
const res = await gql<{ bindTrack: { trackRecord: TrackRecord } }>(
BIND_TRACK, { mangaId, trackerId: activeTab, remoteId: result.remoteId }
);
const newRecord = res.bindTrack.trackRecord;
records = [...records, newRecord];
activeTab = "records";
addToast({ kind: "success", title: "Now tracking", body: result.title });
if (store.settings.trackerSyncBack) await applyToLibrary(newRecord, true);
} catch (e: any) {
addToast({ kind: "error", title: "Failed to bind", body: e?.message });
} finally {
binding = false;
}
}
async function unbind(record: TrackRecord) {
updatingRecord = record.id;
confirmUnbindId = null;
try {
await gql(UNBIND_TRACK, { recordId: record.id });
records = records.filter(r => r.id !== record.id);
addToast({ kind: "info", title: "Unlinked from " + trackerFor(record.trackerId)?.name });
} catch (e: any) {
addToast({ kind: "error", title: "Failed to unlink", body: e?.message });
} finally {
updatingRecord = null;
}
}
function patchRecord(updated: Partial<TrackRecord> & { id: number }) {
records = records.map(r => r.id === updated.id ? { ...r, ...updated } : r);
}
async function updateStatus(record: TrackRecord, status: number) {
updatingRecord = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, status });
patchRecord(res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { updatingRecord = null; }
}
async function updateScore(record: TrackRecord, scoreString: string) {
updatingRecord = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, scoreString });
patchRecord(res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { updatingRecord = null; }
}
async function togglePrivate(record: TrackRecord) {
updatingRecord = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, { recordId: record.id, private: !record.private });
patchRecord(res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { updatingRecord = null; }
}
async function syncRecord(record: TrackRecord) {
syncing = record.id;
try {
const res = await gql<{ fetchTrack: { trackRecord: TrackRecord } }>(FETCH_TRACK, { recordId: record.id });
patchRecord(res.fetchTrack.trackRecord);
addToast({ kind: "success", title: "Synced from tracker" });
} catch (e: any) {
addToast({ kind: "error", title: "Sync failed", body: e?.message });
} finally { syncing = null; }
}
function openChapterEditor(record: TrackRecord) {
editingId = record.id;
chapterDraft = record.lastChapterRead;
startDraft = record.startDate ?? "";
finishDraft = record.finishDate ?? "";
}
function cancelEditor() { editingId = null; }
async function applyToLibrary(record: TrackRecord, silent = false) {
applyingRecord = record.id;
try {
const chapRes = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId });
const prefs = store.settings.mangaPrefs?.[mangaId] ?? {};
const marked = await syncBackFromTracker(
[record], chapRes.chapters.nodes,
{
threshold: store.settings.trackerSyncBackThreshold ?? null,
respectScanlatorFilter: store.settings.trackerRespectScanlatorFilter ?? true,
chapterPrefs: prefs,
},
(query, vars) => gql(query, vars),
);
if (!silent) {
if (marked.length > 0) addToast({ kind: "success", title: `${marked.length} chapter${marked.length !== 1 ? "s" : ""} marked read` });
else addToast({ kind: "info", title: "Already up to date" });
}
} catch (e: any) {
addToast({ kind: "error", title: "Apply failed", body: e?.message });
} finally { applyingRecord = null; }
}
async function submitChapter(record: TrackRecord) {
const tracker = trackerFor(record.trackerId);
const val = Math.max(0, chapterDraft);
const sd = tracker?.supportsReadingDates ? (startDraft.trim() || undefined) : undefined;
const fd = tracker?.supportsReadingDates ? (finishDraft.trim() || undefined) : undefined;
editingId = null;
const chapterChanged = val !== record.lastChapterRead;
const startChanged = sd !== (record.startDate ?? undefined);
const finishChanged = fd !== (record.finishDate ?? undefined);
if (!chapterChanged && !startChanged && !finishChanged) return;
updatingRecord = record.id;
try {
const res = await gql<{ updateTrack: { trackRecord: TrackRecord } }>(UPDATE_TRACK, {
recordId: record.id,
lastChapterRead: chapterChanged ? val : undefined,
startDate: sd,
finishDate: fd,
});
patchRecord(res.updateTrack.trackRecord);
} catch (e: any) {
addToast({ kind: "error", title: "Update failed", body: e?.message });
} finally { updatingRecord = null; }
}
</script>
<svelte:window onkeydown={(e) => { if (e.key === "Escape") { if (confirmUnbindId !== null) { confirmUnbindId = null; } else if (editingId !== null) { editingId = null; } else { onClose(); } } }} />
<div class="backdrop" role="presentation" onclick={(e) => { if (e.target === e.currentTarget) onClose(); }}>
<div class="modal" role="dialog" aria-label="Tracking">
<div class="modal-header">
<div class="header-left">
<span class="modal-title">Tracking</span>
<span class="modal-subtitle">{mangaTitle}</span>
</div>
<button class="close-btn" onclick={onClose}><X size={14} weight="light" /></button>
</div>
{#if loading}
<div class="state-body">
<CircleNotch size={18} weight="light" class="anim-spin" style="color:var(--text-faint)" />
</div>
{:else if loggedInTrackers.length === 0}
<div class="state-body">
<p class="state-text">No trackers connected.</p>
<p class="state-hint">Go to Settings → Tracking to log in.</p>
</div>
{:else}
<div class="tabs">
<button class="tab" class:tab-active={activeTab === "records"} onclick={() => activeTab = "records"}>
My List
{#if records.length > 0}<span class="tab-badge">{records.length}</span>{/if}
</button>
{#each loggedInTrackers as t}
{@const rec = recordFor(t.id)}
<button class="tab" class:tab-active={activeTab === t.id} onclick={() => { activeTab = t.id; searchResults = []; }}>
<Thumbnail src={t.icon} alt={t.name} class="tab-icon" />
{t.name}
{#if rec}<span class="tab-dot"></span>{/if}
</button>
{/each}
</div>
{#if activeTab === "records"}
<div class="tab-body">
{#if records.length === 0}
<div class="state-body">
<p class="state-text">Not tracking yet.</p>
<p class="state-hint">Click a tracker tab above to search and link it.</p>
</div>
{:else}
{#each records as record (record.id)}
{@const tracker = trackerFor(record.trackerId)}
{@const isBusy = updatingRecord === record.id}
{@const isEdit = editingId === record.id}
{@const pct = record.totalChapters > 0 ? Math.min(100, (record.lastChapterRead / record.totalChapters) * 100) : null}
{@const canUnlink = !tracker || tracker.supportsTrackDeletion !== false}
<div class="record-card" class:record-busy={isBusy}>
<div class="record-head">
<div class="record-source">
{#if tracker}<Thumbnail src={tracker.icon} alt={tracker.name} class="record-icon" />{/if}
<span class="record-source-name">{tracker?.name ?? "Tracker"}</span>
{#if record.remoteUrl}
<a href={record.remoteUrl} target="_blank" rel="noreferrer" class="record-external" title="Open on {tracker?.name}">
<ArrowSquareOut size={10} weight="light" />
</a>
{/if}
</div>
<div class="record-actions">
{#if tracker?.supportsPrivateTracking}
<button
class="pill-btn"
class:pill-btn-on={record.private}
title={record.private ? "Private" : "Public"}
disabled={isBusy}
onclick={() => togglePrivate(record)}
>
{#if record.private}<Lock size={9} weight="fill" />{:else}<LockOpen size={9} weight="light" />{/if}
{record.private ? "Private" : "Public"}
</button>
{/if}
<button class="icon-action" title="Sync from tracker" disabled={syncing === record.id} onclick={() => syncRecord(record)}>
<ArrowsClockwise size={12} weight="light" class={syncing === record.id ? "anim-spin" : ""} />
</button>
{#if store.settings.trackerSyncBack}
<button class="icon-action" title="Apply to library" disabled={applyingRecord === record.id} onclick={() => applyToLibrary(record)}>
<ArrowLineDown size={12} weight="light" class={applyingRecord === record.id ? "anim-spin" : ""} />
</button>
{/if}
{#if canUnlink}
<button class="icon-action icon-action-danger" title="Unlink" disabled={isBusy} onclick={() => confirmUnbindId = record.id}>
<X size={11} weight="bold" />
</button>
{/if}
</div>
</div>
<div class="record-body">
<div class="record-selects">
<select class="field-select" value={record.status} disabled={isBusy}
onchange={(e) => updateStatus(record, parseInt((e.target as HTMLSelectElement).value))}>
{#each (tracker?.statuses ?? []) as s}
<option value={s.value}>{s.name}</option>
{/each}
</select>
{#if (tracker?.scores ?? []).length > 0}
<select class="field-select score-select" value={record.displayScore} disabled={isBusy}
onchange={(e) => updateScore(record, (e.target as HTMLSelectElement).value)}>
{#each (tracker?.scores ?? []) as s}
<option value={s}> {s}</option>
{/each}
</select>
{/if}
</div>
{#if isEdit}
<div class="editor">
<div class="editor-row">
<span class="editor-label">Chapter read</span>
<div class="editor-input-row">
<input
type="number" class="editor-input"
min="0" max={record.totalChapters > 0 ? record.totalChapters : undefined}
step="0.5" bind:value={chapterDraft}
onkeydown={(e) => { if (e.key === "Enter") submitChapter(record); if (e.key === "Escape") cancelEditor(); }}
use:autoFocus
/>
{#if record.totalChapters > 0}
<span class="editor-total">/ {record.totalChapters}</span>
{/if}
</div>
</div>
{#if record.totalChapters > 0}
<input type="range" class="chapter-slider" min="0" max={record.totalChapters} step="1" bind:value={chapterDraft} />
{/if}
{#if tracker?.supportsReadingDates}
<div class="date-row">
<div class="date-field">
<CalendarBlank size={11} weight="light" class="date-icon" />
<span class="editor-label">Started</span>
<input type="date" class="date-input" bind:value={startDraft} />
</div>
<div class="date-field">
<CalendarBlank size={11} weight="light" class="date-icon" />
<span class="editor-label">Finished</span>
<input type="date" class="date-input" bind:value={finishDraft} />
</div>
</div>
{/if}
<div class="editor-actions">
<button class="editor-cancel" onclick={cancelEditor}>Cancel</button>
<button class="editor-save" onclick={() => submitChapter(record)}>Save</button>
</div>
</div>
{:else}
<button class="progress-row" onclick={() => openChapterEditor(record)} disabled={isBusy}>
<div class="progress-labels">
<span class="progress-text">
{#if record.totalChapters > 0}Ch. {record.lastChapterRead} / {record.totalChapters}
{:else if record.lastChapterRead > 0}Ch. {record.lastChapterRead} read
{:else}Set progress…{/if}
</span>
{#if record.startDate || record.finishDate}
<span class="progress-dates">
{#if record.startDate}{record.startDate}{/if}
{#if record.startDate && record.finishDate}{/if}
{#if record.finishDate}{record.finishDate}{/if}
</span>
{/if}
</div>
<span class="progress-edit-hint">Edit</span>
</button>
{#if pct !== null}
<div class="progress-track">
<div class="progress-fill" style="width:{pct}%"></div>
</div>
{/if}
{/if}
</div>
</div>
{/each}
{/if}
</div>
{:else}
{@const tracker = trackerFor(activeTab as number)}
{@const boundRecord = recordFor(activeTab as number)}
<div class="search-bar">
<MagnifyingGlass size={13} weight="light" class="search-icon" />
<input
class="search-input"
placeholder="Search {tracker?.name}…"
bind:value={searchQuery}
oninput={onSearchInput}
onkeydown={(e) => e.key === "Enter" && doSearch(activeTab as number, searchQuery)}
use:autoFocus
/>
{#if searching}<CircleNotch size={13} weight="light" class="anim-spin" style="color:var(--text-faint)" />{/if}
</div>
<div class="search-results">
{#if searching && searchResults.length === 0}
<div class="state-body"><p class="state-hint">Searching…</p></div>
{:else if !searching && searchQuery.trim() && searchResults.length === 0}
<div class="state-body"><p class="state-text">No results for "{searchQuery}"</p></div>
{:else if !searchQuery.trim()}
<div class="state-body"><p class="state-hint">Type a title to search</p></div>
{:else}
{#each searchResults as result (result.trackerId + ":" + result.remoteId)}
{@const isBound = boundRecord?.remoteId === result.remoteId}
<button
class="result-row"
class:result-bound={isBound}
onclick={() => isBound ? unbind(boundRecord!) : bind(result)}
disabled={binding}
>
{#if result.coverUrl}
<img src={result.coverUrl} alt={result.title} class="result-cover" loading="lazy"
onerror={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
{:else}
<div class="result-cover result-cover-empty"></div>
{/if}
<div class="result-info">
<span class="result-title">{result.title}</span>
<div class="result-meta">
{#if result.publishingType}<span class="result-tag">{result.publishingType}</span>{/if}
{#if result.publishingStatus}<span class="result-tag">{result.publishingStatus}</span>{/if}
{#if result.totalChapters > 0}<span class="result-tag">{result.totalChapters} ch</span>{/if}
</div>
{#if result.summary}
<p class="result-summary">{result.summary.slice(0,140)}{result.summary.length > 140 ? "…" : ""}</p>
{/if}
</div>
<span class="result-action" class:result-action-on={isBound}>
{isBound ? "✓ Linked" : "Link"}
</span>
</button>
{/each}
{/if}
</div>
{/if}
{/if}
</div>
</div>
{#if confirmUnbindId !== null}
{@const rec = records.find(r => r.id === confirmUnbindId)}
{@const trk = rec ? trackerFor(rec.trackerId) : null}
<div class="confirm-backdrop" role="button" tabindex="-1" aria-label="Cancel" onclick={() => confirmUnbindId = null} onkeydown={(e) => { if (e.key === 'Escape') confirmUnbindId = null; }}>
<div class="confirm-modal" role="dialog" tabindex="-1" onclick={(e) => e.stopPropagation()} onkeydown={(e) => e.stopPropagation()}>
<p class="confirm-title">Unlink from {trk?.name ?? "tracker"}?</p>
<p class="confirm-body">Your progress on {trk?.name} is unaffected.</p>
<div class="confirm-row">
<button class="confirm-cancel" onclick={() => confirmUnbindId = null}>Cancel</button>
<button class="confirm-ok" onclick={() => rec && unbind(rec)}>Unlink</button>
</div>
</div>
</div>
{/if}
<style>
.backdrop {
position: fixed; inset: 0; background: rgba(0,0,0,0.68);
z-index: var(--z-settings);
display: flex; align-items: center; justify-content: center;
backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px);
animation: fadeIn 0.12s ease both;
}
.modal {
width: min(520px, calc(100vw - 40px));
max-height: min(640px, calc(100vh - 72px));
display: flex; flex-direction: column;
background: var(--bg-surface); border: 1px solid var(--border-base);
border-radius: var(--radius-xl); overflow: hidden;
box-shadow: 0 0 0 1px rgba(255,255,255,0.04) inset, 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.15s ease both;
}
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-4) var(--sp-4) var(--sp-5);
border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.header-left { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.modal-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.close-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
.state-body { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); padding: var(--sp-10) var(--sp-5); flex: 1; }
.state-text { font-size: var(--text-sm); color: var(--text-muted); }
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; }
.tabs { display: flex; align-items: center; gap: 1px; padding: 0 var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; overflow-x: auto; scrollbar-width: none; }
.tabs::-webkit-scrollbar { display: none; }
.tab { display: flex; align-items: center; gap: 6px; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 10px 8px 9px; color: var(--text-faint); background: none; border: none; border-bottom: 2px solid transparent; cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base); margin-bottom: -1px; }
.tab:hover { color: var(--text-muted); }
.tab-active { color: var(--text-secondary); border-bottom-color: var(--accent); }
:global(.tab-icon) { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; }
.tab-badge { font-size: 10px; padding: 0 4px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 16px; text-align: center; line-height: 16px; }
.tab-active .tab-badge { background: var(--accent-muted); color: var(--accent-fg); }
.tab-dot { width: 5px; height: 5px; border-radius: 50%; background: var(--accent); flex-shrink: 0; }
.tab-body { flex: 1; overflow-y: auto; padding: var(--sp-3); scrollbar-width: none; display: flex; flex-direction: column; gap: var(--sp-2); }
.tab-body::-webkit-scrollbar { display: none; }
.record-card { display: flex; flex-direction: column; border-radius: var(--radius-lg); border: 1px solid var(--border-dim); background: var(--bg-raised); overflow: hidden; transition: border-color var(--t-base); }
.record-card:hover { border-color: var(--border-strong); }
.record-busy { opacity: 0.45; pointer-events: none; }
.record-head { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-2); padding: var(--sp-3) var(--sp-3) 0; }
.record-source { display: flex; align-items: center; gap: 6px; }
:global(.record-icon) { width: 14px; height: 14px; border-radius: 2px; flex-shrink: 0; object-fit: contain; opacity: 0.7; }
.record-source-name { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.record-external { display: flex; align-items: center; color: var(--text-faint); transition: color var(--t-base); }
.record-external:hover { color: var(--accent-fg); }
.record-actions { display: flex; align-items: center; gap: 2px; }
.pill-btn { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-full); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base), border-color var(--t-base); }
.pill-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); }
.pill-btn-on { color: var(--accent-fg); background: var(--accent-muted); border-color: var(--accent-dim); }
.pill-btn:disabled { opacity: 0.35; cursor: default; }
.icon-action { display: flex; align-items: center; justify-content: center; width: 24px; height: 24px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.icon-action:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-overlay); }
.icon-action-danger:hover:not(:disabled) { color: var(--color-error); background: color-mix(in srgb, var(--color-error) 10%, transparent); }
.icon-action:disabled { opacity: 0.3; cursor: default; }
.record-body { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-2) var(--sp-3) var(--sp-3); }
.record-selects { display: flex; gap: var(--sp-2); }
.field-select { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 5px 22px 5px 9px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-surface); color: var(--text-muted); outline: none; cursor: pointer; flex: 1; min-width: 0; appearance: none; -webkit-appearance: none; background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='8' height='5' viewBox='0 0 8 5'%3E%3Cpath d='M1 1l3 3 3-3' stroke='%23555' stroke-width='1.3' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); background-repeat: no-repeat; background-position: right 7px center; transition: border-color var(--t-base), color var(--t-base); }
.field-select:hover:not(:disabled) { border-color: var(--border-strong); color: var(--text-secondary); }
.field-select:focus { border-color: var(--accent-dim); }
.field-select:disabled { opacity: 0.35; cursor: default; }
.field-select option { background: var(--bg-surface); color: var(--text-secondary); }
.score-select { flex: 0 0 auto; min-width: 76px; }
.progress-row { display: flex; align-items: center; justify-content: space-between; width: 100%; padding: 6px 8px; border-radius: var(--radius-sm); border: 1px solid transparent; background: none; cursor: pointer; text-align: left; transition: background var(--t-fast), border-color var(--t-fast); }
.progress-row:hover:not(:disabled) { background: var(--bg-overlay); border-color: var(--border-dim); }
.progress-row:disabled { cursor: default; }
.progress-labels { display: flex; flex-direction: column; gap: 1px; }
.progress-text { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.progress-dates { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); opacity: 0.7; }
.progress-edit-hint { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); opacity: 0; letter-spacing: var(--tracking-wide); transition: opacity var(--t-fast); }
.progress-row:hover:not(:disabled) .progress-edit-hint { opacity: 0.5; }
.progress-track { height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; }
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
.editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-surface); }
.editor-row { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.editor-label { font-family: var(--font-ui); font-size: 10px; color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; }
.editor-input-row { display: flex; align-items: center; gap: var(--sp-2); }
.editor-input { width: 60px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; transition: border-color var(--t-base); }
.editor-input:focus { border-color: var(--accent); }
.editor-total { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); }
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
.date-row { display: flex; gap: var(--sp-3); padding-top: var(--sp-1); border-top: 1px solid var(--border-dim); }
.date-field { display: flex; align-items: center; gap: 5px; flex: 1; }
:global(.date-icon) { color: var(--text-faint); flex-shrink: 0; }
.date-input { flex: 1; min-width: 0; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-sm); padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-muted); outline: none; transition: border-color var(--t-base); }
.date-input:focus { border-color: var(--accent-dim); }
.editor-actions { display: flex; gap: var(--sp-2); justify-content: flex-end; padding-top: var(--sp-1); }
.editor-save { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
.editor-save:hover { filter: brightness(1.15); }
.editor-cancel { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); }
.editor-cancel:hover { color: var(--text-muted); }
.search-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
:global(.search-icon) { color: var(--text-faint); flex-shrink: 0; }
.search-input { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); }
.search-input::placeholder { color: var(--text-faint); }
.search-results { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
.search-results::-webkit-scrollbar { display: none; }
.result-row { display: flex; align-items: flex-start; gap: var(--sp-3); width: 100%; padding: var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
.result-row:hover:not(:disabled) { background: var(--bg-raised); }
.result-row:disabled { opacity: 0.4; cursor: default; }
.result-bound { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
.result-cover { width: 40px; height: 56px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); flex-shrink: 0; }
.result-cover-empty { background: var(--bg-raised); }
.result-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 4px; padding-top: 2px; }
.result-title { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); text-align: left; }
.result-meta { display: flex; flex-wrap: wrap; gap: 4px; }
.result-tag { font-family: var(--font-ui); font-size: 10px; letter-spacing: var(--tracking-wide); padding: 1px 5px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); }
.result-summary { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-align: left; }
.result-action { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 3px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); flex-shrink: 0; align-self: center; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.result-row:hover:not(:disabled) .result-action { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; }
.confirm-backdrop { position: fixed; inset: 0; z-index: calc(var(--z-settings) + 1); background: rgba(0,0,0,0.45); backdrop-filter: blur(2px); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.1s ease both; }
.confirm-modal { background: var(--bg-surface); border: 1px solid var(--border-dim); border-radius: var(--radius-xl); padding: var(--sp-5); width: 260px; display: flex; flex-direction: column; gap: var(--sp-3); box-shadow: 0 16px 48px rgba(0,0,0,0.5); animation: scaleIn 0.15s ease both; }
.confirm-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-primary); margin: 0; }
.confirm-body { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); line-height: 1.5; margin: 0; letter-spacing: var(--tracking-wide); }
.confirm-row { display: flex; gap: var(--sp-2); }
.confirm-cancel { flex: 1; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 0; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-muted); cursor: pointer; transition: border-color var(--t-base), color var(--t-base); }
.confirm-cancel:hover { border-color: var(--border-strong); color: var(--text-primary); }
.confirm-ok { flex: 1; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 7px 0; border-radius: var(--radius-md); border: 1px solid color-mix(in srgb, var(--color-error) 30%, transparent); background: color-mix(in srgb, var(--color-error) 8%, transparent); color: var(--color-error); cursor: pointer; transition: filter var(--t-base); }
.confirm-ok:hover { filter: brightness(1.2); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>