Feat: Automation Panel (WIP) & SeriesDetail Additions

This commit is contained in:
Youwes09
2026-04-02 00:56:27 -05:00
parent a62512bf42
commit f49f7e7ac1
8 changed files with 962 additions and 583 deletions
+3
View File
@@ -16,6 +16,7 @@ Minor Revisions:
Priority Bugs: Priority Bugs:
- Cache ALL Cover Pictures & Details for Manga in Library - Cache ALL Cover Pictures & Details for Manga in Library
- Fix Library Build not Updating
General/Misc Bugs: General/Misc Bugs:
@@ -29,6 +30,8 @@ General/Misc Bugs:
In-Progress: In-Progress:
- Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching) - Enable Cloudflare Bypass (Suwayomi Config) (Requires Patching)
- Patch Migrate Modal to Fill Language Options, not Limit to 7-9.
+112 -21
View File
@@ -27,6 +27,14 @@
"Magic","Music","Cooking","Medical","Military","Harem","Ecchi", "Magic","Music","Cooking","Medical","Military","Harem","Ecchi",
]; ];
const MANGA_STATUSES: { value: string; label: string }[] = [
{ value: "ONGOING", label: "Ongoing" },
{ value: "COMPLETED", label: "Completed" },
{ value: "HIATUS", label: "Hiatus" },
{ value: "ABANDONED", label: "Abandoned" },
{ value: "UNKNOWN", label: "Unknown" },
];
async function runConcurrent<T>(items: T[], fn: (item: T) => Promise<void>, signal: AbortSignal): Promise<void> { async function runConcurrent<T>(items: T[], fn: (item: T) => Promise<void>, signal: AbortSignal): Promise<void> {
let i = 0; let i = 0;
async function worker() { async function worker() {
@@ -44,10 +52,27 @@
return tags.every((t) => genres.includes(t.toLowerCase())); return tags.every((t) => genres.includes(t.toLowerCase()));
} }
function buildGenreFilter(tags: string[], mode: TagMode): Record<string, unknown> { function buildTagFilter(
if (tags.length === 0) return {}; tags: string[],
if (mode === "AND") return { and: tags.map((t) => ({ genre: { includesInsensitive: t } })) }; mode: TagMode,
return { or: tags.map((t) => ({ genre: { includesInsensitive: t } })) }; statuses: string[],
): Record<string, unknown> {
const genrePart: Record<string, unknown> | null =
tags.length === 0 ? null :
mode === "AND"
? { and: tags.map((t) => ({ genre: { includesInsensitive: t } })) }
: { or: tags.map((t) => ({ genre: { includesInsensitive: t } })) };
const statusPart: Record<string, unknown> | null =
statuses.length === 0 ? null :
statuses.length === 1
? { status: { equalTo: statuses[0] } }
: { or: statuses.map((s) => ({ status: { equalTo: s } })) };
if (!genrePart && !statusPart) return {};
if (genrePart && !statusPart) return genrePart;
if (!genrePart && statusPart) return statusPart;
return { and: [genrePart, statusPart] };
} }
const MANGAS_BY_GENRE = ` const MANGAS_BY_GENRE = `
@@ -168,6 +193,7 @@
const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading)); const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading));
let tag_activeTags: string[] = $state([]); let tag_activeTags: string[] = $state([]);
let tag_activeStatuses: string[] = $state([]);
let tag_tagMode: TagMode = $state("AND"); let tag_tagMode: TagMode = $state("AND");
let tag_tagFilter = $state(""); let tag_tagFilter = $state("");
@@ -191,7 +217,7 @@
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES; return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES;
}); });
const tag_hasActiveTags = $derived(tag_activeTags.length > 0); const tag_hasActiveTags = $derived(tag_activeTags.length > 0 || tag_activeStatuses.length > 0);
const tag_localIds = $derived(new Set(tag_localResults.map((m) => m.id))); const tag_localIds = $derived(new Set(tag_localResults.map((m) => m.id)));
const tag_mergedResults = $derived(dedupeMangaByTitle(dedupeMangaById( const tag_mergedResults = $derived(dedupeMangaByTitle(dedupeMangaById(
tag_searchSources tag_searchSources
@@ -202,9 +228,10 @@
const tag_sourceHasMore = $derived(tag_searchSources && [...tag_srcNextPage.values()].some((p) => p > 0)); const tag_sourceHasMore = $derived(tag_searchSources && [...tag_srcNextPage.values()].some((p) => p > 0));
$effect(() => { $effect(() => {
const _activeTags = tag_activeTags; const _activeTags = tag_activeTags;
const _tagMode = tag_tagMode; const _tagMode = tag_tagMode;
untrack(() => tagFetchLocal(_activeTags, _tagMode)); const _activeStatuses = tag_activeStatuses;
untrack(() => tagFetchLocal(_activeTags, _tagMode, _activeStatuses));
}); });
let tag_autoSearchFired = $state(false); let tag_autoSearchFired = $state(false);
@@ -223,8 +250,8 @@
} }
}); });
async function tagFetchLocal(activeTags: string[], tagMode: TagMode) { async function tagFetchLocal(activeTags: string[], tagMode: TagMode, activeStatuses: string[]) {
if (activeTags.length === 0) { if (activeTags.length === 0 && activeStatuses.length === 0) {
tag_localResults = []; tag_totalCount = 0; tag_localHasNext = false; tag_localOffset = 0; tag_localResults = []; tag_totalCount = 0; tag_localHasNext = false; tag_localOffset = 0;
return; return;
} }
@@ -235,7 +262,7 @@
tag_loadingLocal = true; tag_loadingLocal = true;
gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean }; totalCount: number } }>( gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean }; totalCount: number } }>(
MANGAS_BY_GENRE, MANGAS_BY_GENRE,
{ filter: buildGenreFilter(activeTags, tagMode), first: (store.settings.renderLimit ?? 48), offset: 0 }, { filter: buildTagFilter(activeTags, tagMode, activeStatuses), first: (store.settings.renderLimit ?? 48), offset: 0 },
ctrl.signal, ctrl.signal,
).then((d) => { ).then((d) => {
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
@@ -279,7 +306,8 @@
const matching = (activeTags.length > 1 const matching = (activeTags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, activeTags)) ? result.mangas.filter((m) => matchesAllTags(m, activeTags))
: result.mangas : result.mangas
).filter((m) => !shouldHideNsfw(m, store.settings)); ).filter((m) => !shouldHideNsfw(m, store.settings))
.filter((m) => tag_activeStatuses.length === 0 || tag_activeStatuses.includes(m.status ?? "UNKNOWN"));
if (matching.length > 0) { if (matching.length > 0) {
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks); tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks);
tag_loadingSourceSearch = false; tag_loadingSourceSearch = false;
@@ -298,11 +326,12 @@
try { try {
const d = await gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean } } }>( const d = await gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean } } }>(
MANGAS_BY_GENRE, MANGAS_BY_GENRE,
{ filter: buildGenreFilter(tag_activeTags, tag_tagMode), first: (store.settings.renderLimit ?? 48), offset: tag_localOffset }, { filter: buildTagFilter(tag_activeTags, tag_tagMode, tag_activeStatuses), first: (store.settings.renderLimit ?? 48), offset: tag_localOffset },
ctrl.signal, ctrl.signal,
); );
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings); const nsfwFilter = (m: Manga) => !shouldHideNsfw(m, store.settings);
tag_localResults = [...tag_localResults, ...d.mangas.nodes.filter(nsfwFilter)];
tag_localHasNext = d.mangas.pageInfo.hasNextPage; tag_localHasNext = d.mangas.pageInfo.hasNextPage;
tag_localOffset += (store.settings.renderLimit ?? 48); tag_localOffset += (store.settings.renderLimit ?? 48);
} catch (e: any) { } catch (e: any) {
@@ -341,7 +370,8 @@
const matching = (tag_activeTags.length > 1 const matching = (tag_activeTags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, tag_activeTags)) ? result.mangas.filter((m) => matchesAllTags(m, tag_activeTags))
: result.mangas : result.mangas
).filter((m) => !shouldHideNsfw(m, store.settings)); ).filter((m) => !shouldHideNsfw(m, store.settings))
.filter((m) => tag_activeStatuses.length === 0 || tag_activeStatuses.includes(m.status ?? "UNKNOWN"));
if (matching.length > 0) { if (matching.length > 0) {
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks); tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), store.settings.mangaLinks);
} }
@@ -359,6 +389,14 @@
: [...tag_activeTags, tag]; : [...tag_activeTags, tag];
} }
function tagToggleStatus(status: string) {
tag_srcNextPage = new Map();
tag_sourceResults = [];
tag_activeStatuses = tag_activeStatuses.includes(status)
? tag_activeStatuses.filter((s) => s !== status)
: [...tag_activeStatuses, status];
}
function tagToggleSearchSources() { function tagToggleSearchSources() {
tag_searchSources = !tag_searchSources; tag_searchSources = !tag_searchSources;
if (tag_searchSources && tag_activeTags.length > 0 && !loadingSources) { if (tag_searchSources && tag_activeTags.length > 0 && !loadingSources) {
@@ -678,13 +716,26 @@
<input <input
bind:value={tag_tagFilter} bind:value={tag_tagFilter}
class="splitSearchInput" class="splitSearchInput"
placeholder="Filter tags…" placeholder="Filter genres…"
/> />
{#if tag_tagFilter} {#if tag_tagFilter}
<button class="splitSearchClear" title="Clear" onclick={() => (tag_tagFilter = "")}>×</button> <button class="splitSearchClear" title="Clear" onclick={() => (tag_tagFilter = "")}>×</button>
{/if} {/if}
</div> </div>
<div class="splitList"> <div class="splitList">
<div class="splitSectionLabel">Status</div>
{#each MANGA_STATUSES as { value, label } (value)}
<button
class="splitItem"
class:splitItemActive={tag_activeStatuses.includes(value)}
onclick={() => tagToggleStatus(value)}
>
<span class="splitItemLabel">{label}</span>
{#if tag_activeStatuses.includes(value)}<span class="tagCheckMark"></span>{/if}
</button>
{/each}
<div class="splitSectionLabel splitSectionLabelSpaced">Genre</div>
{#each tag_filteredGenres as tag (tag)} {#each tag_filteredGenres as tag (tag)}
<button <button
class="splitItem" class="splitItem"
@@ -696,7 +747,7 @@
</button> </button>
{/each} {/each}
{#if tag_filteredGenres.length === 0} {#if tag_filteredGenres.length === 0}
<p class="splitEmpty">No matching tags</p> <p class="splitEmpty">No matching genres</p>
{/if} {/if}
</div> </div>
</div> </div>
@@ -709,12 +760,18 @@
<path d="M224,104H200l8-48a8,8,0,0,0-15.79-2.67L183.79,104H136l8-48a8,8,0,0,0-15.79-2.67L119.79,104H72a8,8,0,0,0,0,16h45.33L105.6,200H56a8,8,0,0,0,0,16H103l-8,48a8,8,0,0,0,15.79,2.67L119.21,216H168l-8,48a8,8,0,0,0,15.79,2.67L184.21,216H232a8,8,0,0,0,0-16H186.67l11.73-80H224a8,8,0,0,0,0-16Zm-69.33,96H101.6L113.33,120h53.07Z"/> <path d="M224,104H200l8-48a8,8,0,0,0-15.79-2.67L183.79,104H136l8-48a8,8,0,0,0-15.79-2.67L119.79,104H72a8,8,0,0,0,0,16h45.33L105.6,200H56a8,8,0,0,0,0,16H103l-8,48a8,8,0,0,0,15.79,2.67L119.21,216H168l-8,48a8,8,0,0,0,15.79,2.67L184.21,216H232a8,8,0,0,0,0-16H186.67l11.73-80H224a8,8,0,0,0,0-16Zm-69.33,96H101.6L113.33,120h53.07Z"/>
</svg> </svg>
<p class="emptyText">Browse by tag</p> <p class="emptyText">Browse by tag</p>
<p class="emptyHint">Select one or more genre tags to find matching manga.</p> <p class="emptyHint">Select a status or genre to find matching manga.</p>
</div> </div>
{:else} {:else}
<div class="tagActiveBar"> <div class="tagActiveBar">
<div class="tagPillRow"> <div class="tagPillRow">
{#each tag_activeStatuses as status (status)}
<span class="tagPill tagPillStatus">
{MANGA_STATUSES.find((s) => s.value === status)?.label ?? status}
<button class="tagPillRemove" title="Remove {status}" onclick={() => tagToggleStatus(status)}>×</button>
</span>
{/each}
{#each tag_activeTags as tag (tag)} {#each tag_activeTags as tag (tag)}
<span class="tagPill"> <span class="tagPill">
{tag} {tag}
@@ -752,13 +809,27 @@
</svg> </svg>
Sources Sources
</button> </button>
<button class="tagClearAll" onclick={() => (tag_activeTags = [])}>Clear all</button> <button
class="tagClearAll"
onclick={() => {
tag_activeTags = [];
tag_activeStatuses = [];
tag_srcNextPage = new Map();
tag_sourceResults = [];
}}
>Clear all</button>
</div> </div>
</div> </div>
<div class="splitContentHeader"> <div class="splitContentHeader">
<span class="splitContentTitle"> <span class="splitContentTitle">
{tag_activeTags.length === 1 ? tag_activeTags[0] : `${tag_activeTags.length} tags (${tag_tagMode})`} {#if tag_activeStatuses.length > 0 && tag_activeTags.length === 0}
{tag_activeStatuses.map((s) => MANGA_STATUSES.find((x) => x.value === s)?.label ?? s).join(" · ")}
{:else if tag_activeTags.length === 1 && tag_activeStatuses.length === 0}
{tag_activeTags[0]}
{:else}
{[...tag_activeStatuses.map((s) => MANGA_STATUSES.find((x) => x.value === s)?.label ?? s), ...tag_activeTags].join(` ${tag_tagMode} `)}
{/if}
{#if tag_searchSources} {#if tag_searchSources}
<span style="margin-left:6px;font-weight:400;opacity:0.55;font-size:0.9em">+ sources</span> <span style="margin-left:6px;font-weight:400;opacity:0.55;font-size:0.9em">+ sources</span>
{/if} {/if}
@@ -834,7 +905,7 @@
</div> </div>
{:else} {:else}
<div class="empty"> <div class="empty">
<p class="emptyText">No results for {tag_activeTags.join(` ${tag_tagMode} `)}</p> <p class="emptyText">No results</p>
<p class="emptyHint"> <p class="emptyHint">
{#if tag_searchSources} {#if tag_searchSources}
Try OR mode or broader tags. Try OR mode or broader tags.
@@ -1429,6 +1500,21 @@
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: var(--border-dim) transparent; scrollbar-color: var(--border-dim) transparent;
} }
.splitSectionLabel {
font-family: var(--font-ui);
font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider);
text-transform: uppercase;
color: var(--text-faint);
padding: var(--sp-2) var(--sp-3) var(--sp-1);
pointer-events: none;
user-select: none;
}
.splitSectionLabelSpaced {
margin-top: var(--sp-2);
border-top: 1px solid var(--border-dim);
padding-top: var(--sp-3);
}
.splitItem { .splitItem {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1547,8 +1633,13 @@
letter-spacing: var(--tracking-wide); letter-spacing: var(--tracking-wide);
color: var(--accent-fg); color: var(--accent-fg);
} }
.tagPillStatus {
background: color-mix(in srgb, var(--color-info, #4a90d9) 12%, transparent);
border-color: color-mix(in srgb, var(--color-info, #4a90d9) 30%, transparent);
color: var(--color-info, #4a90d9);
}
.tagPillRemove { .tagPillRemove {
color: var(--accent-fg); color: currentColor;
opacity: 0.6; opacity: 0.6;
font-size: 13px; font-size: 13px;
line-height: 1; line-height: 1;
+287 -267
View File
@@ -1,15 +1,18 @@
<script lang="ts"> <script lang="ts">
import { onMount, untrack } from "svelte"; import { onMount, untrack } from "svelte";
import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp } from "phosphor-svelte"; import { ArrowLeft, BookmarkSimple, Download, CheckCircle, Circle, ArrowSquareOut, CircleNotch, Play, SortAscending, SortDescending, CaretDown, ArrowsClockwise, List, SquaresFour, FolderSimplePlus, Trash, DownloadSimple, X, LinkSimpleHorizontalBreak, ChartLineUp, MagnifyingGlass, Gear, Eye } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, GET_ALL_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries"; import { GET_MANGA, GET_CHAPTERS, FETCH_CHAPTERS, ENQUEUE_DOWNLOAD, UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, GET_ALL_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries";
import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache"; import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache";
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util"; import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
import { store, addToast, updateSettings, openReader, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga, checkAndMarkCompleted as storeCheckAndMarkCompleted } from "../../store/state.svelte"; import { store, addToast, updateSettings, openReader, setActiveManga, setGenreFilter, setNavPage, linkManga, unlinkManga, setPreviewManga, checkAndMarkCompleted as storeCheckAndMarkCompleted } from "../../store/state.svelte";
import type { MangaPrefs } from "../../store/state.svelte";
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
import type { Manga, Chapter, Category } from "../../lib/types"; import type { Manga, Chapter, Category } from "../../lib/types";
import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte"; import ContextMenu, { type MenuEntry } from "../shared/ContextMenu.svelte";
import MigrateModal from "./MigrateModal.svelte"; import MigrateModal from "./MigrateModal.svelte";
import TrackingPanel from "../shared/TrackingPanel.svelte"; import TrackingPanel from "../shared/TrackingPanel.svelte";
import AutomationPanel from "../shared/AutomationPanel.svelte";
const CHAPTERS_PER_PAGE = 25; const CHAPTERS_PER_PAGE = 25;
const MANGA_TTL_MS = 5 * 60 * 1000; const MANGA_TTL_MS = 5 * 60 * 1000;
@@ -18,124 +21,81 @@
const mangaStore: Map<number, { data: Manga; fetchedAt: number }> = new Map(); const mangaStore: Map<number, { data: Manga; fetchedAt: number }> = new Map();
const chapterStore: Map<number, { data: Chapter[]; fetchedAt: number }> = new Map(); const chapterStore: Map<number, { data: Chapter[]; fetchedAt: number }> = new Map();
let manga: Manga | null = $state(null); let manga: Manga | null = $state(null);
let chapters: Chapter[] = $state([]); let chapters: Chapter[] = $state([]);
let loadingManga: boolean = $state(false); let loadingManga: boolean = $state(false);
let loadingChapters: boolean = $state(true); let loadingChapters: boolean = $state(true);
let enqueueing: Set<number> = $state(new Set()); let enqueueing: Set<number> = $state(new Set());
let dlOpen: boolean = $state(false); let dlOpen: boolean = $state(false);
let detailsOpen: boolean = $state(false); let manageOpen: boolean = $state(false);
let togglingLibrary: boolean = $state(false); let togglingLibrary: boolean = $state(false);
let chapterPage: number = $state(1); let chapterPage: number = $state(1);
let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null); let ctx: { x: number; y: number; chapter: Chapter; idx: number } | null = $state(null);
let jumpOpen: boolean = $state(false); let jumpOpen: boolean = $state(false);
let jumpInput: string = $state(""); let jumpInput: string = $state("");
let viewMode: "list" | "grid" = $state("list"); let viewMode: "list" | "grid" = $state("list");
let deletingAll: boolean = $state(false); let deletingAll: boolean = $state(false);
let refreshing: boolean = $state(false); let refreshing: boolean = $state(false);
let genresExpanded: boolean = $state(false); let genresExpanded: boolean = $state(false);
let folderPickerOpen: boolean = $state(false); let folderPickerOpen: boolean = $state(false);
let folderCreating: boolean = $state(false); let folderCreating: boolean = $state(false);
let folderNewName: string = $state(""); let folderNewName: string = $state("");
let mangaCategories: Category[] = $state([]); let mangaCategories: Category[] = $state([]);
let allCategories: Category[] = $state([]); let allCategories: Category[] = $state([]);
let catsLoading: boolean = $state(false); let catsLoading: boolean = $state(false);
let rangeFrom: string = $state(""); let rangeFrom: string = $state("");
let rangeTo: string = $state(""); let rangeTo: string = $state("");
let showRange: boolean = $state(false); let showRange: boolean = $state(false);
let migrateOpen: boolean = $state(false); let migrateOpen: boolean = $state(false);
let dlDropRef: HTMLDivElement | undefined = $state(); let autoOpen: boolean = $state(false);
let folderPickerRef: HTMLDivElement | undefined = $state(); let trackingOpen: boolean = $state(false);
let linkPickerOpen: boolean = $state(false);
let linkSearch: string = $state("");
let allMangaForLink: Manga[] = $state([]);
let loadingLinkList: boolean = $state(false);
let selectedIds: Set<number> = $state(new Set());
let sortMenuOpen: boolean = $state(false);
let dlDropRef: HTMLDivElement | undefined = $state();
let folderPickerRef: HTMLDivElement | undefined = $state();
let mangaAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null;
let loadingFor: number | null = null;
let _prevChapterIds: Set<number> = new Set();
// Series link state function focusOnMount(node: HTMLElement) { node.focus(); }
let linkPickerOpen: boolean = $state(false);
let linkSearch: string = $state("");
let allMangaForLink: Manga[] = $state([]);
let loadingLinkList: boolean = $state(false);
// Tracking modal const mangaPrefs = $derived.by((): Partial<MangaPrefs> => {
let trackingOpen: boolean = $state(false); if (!store.activeManga) return {};
return store.settings.mangaPrefs?.[store.activeManga.id] ?? {};
});
function getPref<K extends keyof MangaPrefs>(key: K): MangaPrefs[K] {
return (mangaPrefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
}
// Multi-select
let selectedIds: Set<number> = $state(new Set());
const hasSelection = $derived(selectedIds.size > 0); const hasSelection = $derived(selectedIds.size > 0);
function toggleSelect(id: number, e: MouseEvent | KeyboardEvent) { const sortDir = $derived(store.settings.chapterSortDir);
e.stopPropagation(); const sortMode = $derived(store.settings.chapterSortMode ?? "source");
const next = new Set(selectedIds);
if (next.has(id)) next.delete(id); else next.add(id);
selectedIds = next;
}
function clearSelection() { selectedIds = new Set(); }
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() {
const ids = [...selectedIds].filter(id => !chapters.find(c => c.id === id)?.isDownloaded);
await enqueueMultiple(ids);
clearSelection();
}
async function markSelectedRead(isRead: boolean) {
await markBulk([...selectedIds], isRead);
clearSelection();
}
let mangaAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null;
let loadingFor: number | null = null;
function formatDate(ts: string | null | undefined): string {
if (!ts) return "";
const n = Number(ts);
const d = new Date(n > 1e10 ? n : n * 1000);
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}
function applyChapters(nodes: Chapter[]) {
chapters = nodes;
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
}
const sortDir = $derived(store.settings.chapterSortDir);
const sortMode = $derived(store.settings.chapterSortMode ?? "source");
let sortMenuOpen = $state(false);
const sortedChapters = $derived.by(() => { const sortedChapters = $derived.by(() => {
const base = [...chapters]; const base = [...chapters];
if (sortMode === "chapterNumber") { if (sortMode === "chapterNumber") base.sort((a, b) => a.chapterNumber - b.chapterNumber);
base.sort((a, b) => a.chapterNumber - b.chapterNumber); else if (sortMode === "uploadDate") base.sort((a, b) => Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0));
} else if (sortMode === "uploadDate") { else base.sort((a, b) => a.sourceOrder - b.sourceOrder);
base.sort((a, b) => Number(a.uploadDate ?? 0) - Number(b.uploadDate ?? 0));
} else {
base.sort((a, b) => a.sourceOrder - b.sourceOrder);
}
return sortDir === "desc" ? base.reverse() : base; return sortDir === "desc" ? base.reverse() : base;
}); });
/** const chaptersAsc = $derived([...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder));
* Chapter list in canonical reading order (ch1 -> ch2 -> ch3). const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
* Always passed to openReader so the Reader's idx-based prev/next const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
* navigation is direction-independent of the user's display sort. const readCount = $derived(chapters.filter(c => c.isRead).length);
*/ const totalCount = $derived(chapters.length);
const chaptersAsc = $derived( const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0);
[...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
);
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
const pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
const readCount = $derived(chapters.filter(c => c.isRead).length);
const totalCount = $derived(chapters.length);
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0);
const downloadedCount = $derived(chapters.filter(c => c.isDownloaded).length); const downloadedCount = $derived(chapters.filter(c => c.isDownloaded).length);
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null);
const assignedFolders = $derived(mangaCategories.filter(c => c.id !== 0));
const hasFolders = $derived(assignedFolders.length > 0);
const continueChapter = $derived((() => { const continueChapter = $derived((() => {
if (!chapters.length) return null; if (!chapters.length) return null;
@@ -148,9 +108,71 @@
return { chapter: asc[0], type: "reread" as const }; return { chapter: asc[0], type: "reread" as const };
})()); })());
const statusLabel = $derived(manga?.status ? manga.status.charAt(0) + manga.status.slice(1).toLowerCase() : null); const jumpChapter = $derived.by(() => {
const assignedFolders = $derived(mangaCategories.filter(c => c.id !== 0)); const q = jumpInput.trim().toLowerCase();
const hasFolders = $derived(assignedFolders.length > 0); 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;
});
const hasAnyAutomation = $derived(
getPref("autoDownload") ||
getPref("downloadAhead") > 0 ||
getPref("maxKeepChapters") > 0 ||
getPref("deleteOnRead") ||
getPref("pauseUpdates") ||
getPref("refreshInterval") !== "global" ||
!!getPref("preferredScanlator")
);
const linkedIds = $derived(
store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : []
);
const linkPickerResults = $derived.by(() => {
const id = store.activeManga?.id;
const others = allMangaForLink.filter(m => m.id !== id);
const q = linkSearch.trim().toLowerCase();
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
const linked = filtered.filter(m => linkedIds.includes(m.id));
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
return [...linked, ...rest];
});
function doJump() {
if (!jumpChapter) return;
const pageIdx = sortedChapters.indexOf(jumpChapter);
if (pageIdx >= 0) chapterPage = Math.floor(pageIdx / CHAPTERS_PER_PAGE) + 1;
jumpOpen = false;
jumpInput = "";
}
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 clearSelection() { selectedIds = new Set(); }
function formatDate(ts: string | null | undefined): string {
if (!ts) return "";
const n = Number(ts);
const d = new Date(n > 1e10 ? n : n * 1000);
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
}
function applyChapters(nodes: Chapter[]) {
if (getPref("autoDownload") && _prevChapterIds.size > 0) {
const newChapters = nodes.filter(c => !_prevChapterIds.has(c.id) && !c.isDownloaded);
if (newChapters.length) enqueueMultiple(newChapters.map(c => c.id));
}
_prevChapterIds = new Set(nodes.map(c => c.id));
chapters = nodes;
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
}
function loadCategories(mangaId: number) { function loadCategories(mangaId: number) {
catsLoading = true; catsLoading = true;
@@ -165,7 +187,6 @@
async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) { async function checkAndMarkCompleted(mangaId: number, chaps: Chapter[]) {
await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA); await storeCheckAndMarkCompleted(mangaId, chaps, allCategories, gql, UPDATE_MANGA_CATEGORIES, UPDATE_MANGA);
// Sync local mangaCategories state after the mutation
if (chaps.length) { if (chaps.length) {
const allRead = chaps.every(c => c.isRead); const allRead = chaps.every(c => c.isRead);
const completed = allCategories.find(c => c.name === "Completed"); const completed = allCategories.find(c => c.name === "Completed");
@@ -249,6 +270,18 @@
} }
}); });
$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);
});
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 = ""; } }
async function toggleLibrary() { async function toggleLibrary() {
if (!manga) return; if (!manga) return;
togglingLibrary = true; togglingLibrary = true;
@@ -286,6 +319,14 @@
await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error); await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error);
chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c); chapters = chapters.map(c => c.id === chapterId ? { ...c, isRead } : c);
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); } if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
if (isRead && getPref("deleteOnRead")) {
const ch = chapters.find(c => c.id === chapterId);
if (ch?.isDownloaded) {
const delayMs = getPref("deleteDelayHours") * 60 * 60 * 1000;
if (delayMs === 0) deleteDownloaded(chapterId);
else setTimeout(() => deleteDownloaded(chapterId), delayMs);
}
}
} }
async function markBulk(ids: number[], isRead: boolean) { async function markBulk(ids: number[], isRead: boolean) {
@@ -294,6 +335,40 @@
const idSet = new Set(ids); const idSet = new Set(ids);
chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c); chapters = chapters.map(c => idSet.has(c.id) ? { ...c, isRead } : c);
if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); } if (store.activeManga) { chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() }); checkAndMarkCompleted(store.activeManga.id, chapters); }
if (isRead && getPref("deleteOnRead")) {
const toDelete = ids.filter(id => chapters.find(c => c.id === id)?.isDownloaded);
if (toDelete.length) {
const delayMs = getPref("deleteDelayHours") * 60 * 60 * 1000;
const doDelete = async () => {
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: toDelete }).catch(console.error);
chapters = chapters.map(c => toDelete.includes(c.id) ? { ...c, isDownloaded: false } : c);
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
};
if (delayMs === 0) doDelete();
else setTimeout(doDelete, delayMs);
}
}
}
async function deleteSelected() {
const ids = [...selectedIds].filter(id => chapters.find(c => c.id === id)?.isDownloaded);
if (ids.length) {
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error);
chapters = chapters.map(c => ids.includes(c.id) ? { ...c, isDownloaded: false } : c);
if (store.activeManga) chapterStore.set(store.activeManga.id, { data: chapters, fetchedAt: Date.now() });
}
clearSelection();
}
async function downloadSelected() {
const ids = [...selectedIds].filter(id => !chapters.find(c => c.id === id)?.isDownloaded);
await enqueueMultiple(ids);
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 markAboveRead = (i: number) => markBulk(sortedChapters.slice(0, i + 1).filter(c => !c.isRead).map(c => c.id), true);
@@ -346,18 +421,6 @@
]; ];
} }
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 = ""; } }
$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);
});
function enqueueNext(n: number) { function enqueueNext(n: number) {
if (!continueChapter) return; if (!continueChapter) return;
const idx = sortedChapters.indexOf(continueChapter.chapter); const idx = sortedChapters.indexOf(continueChapter.chapter);
@@ -394,29 +457,21 @@
addTo: inCat ? [] : [cat.id], addTo: inCat ? [] : [cat.id],
removeFrom: inCat ? [cat.id] : [], removeFrom: inCat ? [cat.id] : [],
}); });
mangaCategories = inCat mangaCategories = inCat ? mangaCategories.filter(c => c.id !== cat.id) : [...mangaCategories, cat];
? mangaCategories.filter(c => c.id !== cat.id)
: [...mangaCategories, cat];
} catch (e) { console.error(e); } } catch (e) { console.error(e); }
} }
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); }); function openReaderWithAhead(ch: Chapter, list: Chapter[]) {
const ahead = getPref("downloadAhead");
// ── Series link ──────────────────────────────────────────────────────────── if (ahead > 0) {
const idx = list.indexOf(ch);
const linkedIds = $derived( if (idx >= 0) {
store.activeManga ? (store.settings.mangaLinks?.[store.activeManga.id] ?? []) : [] const toQueue = list.slice(idx + 1, idx + 1 + ahead).filter(c => !c.isDownloaded).map(c => c.id);
); if (toQueue.length) enqueueMultiple(toQueue);
}
const linkPickerResults = $derived.by(() => { }
const id = store.activeManga?.id; openReader(ch, list);
const others = allMangaForLink.filter(m => m.id !== id); }
const q = linkSearch.trim().toLowerCase();
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
const linked = filtered.filter(m => linkedIds.includes(m.id));
const rest = filtered.filter(m => !linkedIds.includes(m.id)).slice(0, 30);
return [...linked, ...rest];
});
async function openLinkPicker() { async function openLinkPicker() {
linkPickerOpen = true; linkSearch = ""; linkPickerOpen = true; linkSearch = "";
@@ -435,23 +490,22 @@
if (linkedIds.includes(other.id)) unlinkManga(store.activeManga.id, other.id); if (linkedIds.includes(other.id)) unlinkManga(store.activeManga.id, other.id);
else linkManga(store.activeManga.id, other.id); else linkManga(store.activeManga.id, other.id);
} }
onMount(() => () => { mangaAbort?.abort(); chapterAbort?.abort(); });
</script> </script>
{#if store.activeManga} {#if store.activeManga}
<div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}> <div class="root" role="presentation" oncontextmenu={(e) => e.preventDefault()}>
<!-- ── Sidebar ────────────────────────────────────────────────────────── -->
<div class="sidebar"> <div class="sidebar">
<button class="back" onclick={() => setActiveManga(null)}> <button class="back" onclick={() => setActiveManga(null)}>
<ArrowLeft size={13} weight="light" /> Back <ArrowLeft size={13} weight="light" /> Back
</button> </button>
<!-- Zone 1: Cover -->
<div class="cover-wrap"> <div class="cover-wrap">
<img src={thumbUrl(store.activeManga.thumbnailUrl)} alt={store.activeManga.title} class="cover" /> <img src={thumbUrl(store.activeManga.thumbnailUrl)} alt={store.activeManga.title} class="cover" />
</div> </div>
<!-- Zone 2: Meta -->
{#if loadingManga} {#if loadingManga}
<div class="meta-skeleton"> <div class="meta-skeleton">
<div class="skeleton sk-line" style="width:90%;height:14px"></div> <div class="skeleton sk-line" style="width:90%;height:14px"></div>
@@ -484,10 +538,9 @@
</div> </div>
{/if} {/if}
<!-- Zone 3: Primary CTA + library action -->
<div class="cta-section"> <div class="cta-section">
{#if continueChapter} {#if continueChapter}
<button class="read-btn" onclick={() => openReader(continueChapter!.chapter, chaptersAsc)}> <button class="read-btn" onclick={() => openReaderWithAhead(continueChapter!.chapter, chaptersAsc)}>
<Play size={12} weight="fill" /> <Play size={12} weight="fill" />
{continueChapter.type === "continue" {continueChapter.type === "continue"
? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}` ? `Continue · Ch.${continueChapter.chapter.chapterNumber}${(continueChapter.chapter.lastPageRead ?? 0) > 0 ? ` p.${continueChapter.chapter.lastPageRead}` : ""}`
@@ -507,7 +560,6 @@
</div> </div>
</div> </div>
<!-- Zone 4: Progress -->
{#if totalCount > 0} {#if totalCount > 0}
<div class="progress-section"> <div class="progress-section">
<div class="progress-header"> <div class="progress-header">
@@ -518,38 +570,33 @@
</div> </div>
{/if} {/if}
<!-- Zone 5: Details accordion (source info + all secondary actions) --> {#if !loadingManga && manga}
{#if !loadingManga && manga?.source}
<div class="details-section"> <div class="details-section">
<button class="details-toggle" onclick={() => detailsOpen = !detailsOpen}> <button class="details-toggle" onclick={() => manageOpen = !manageOpen}>
<span>Details</span> <span>Manage</span>
<CaretDown size={11} weight="light" style="transform:{detailsOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease" /> <CaretDown size={11} weight="light" style="transform:{manageOpen ? 'rotate(180deg)' : 'rotate(0)'};transition:transform 0.15s ease" />
</button> </button>
{#if detailsOpen} {#if manageOpen}
<div class="details-body"> <div class="details-body">
<div class="detail-row"><span class="detail-key">Source</span><span class="detail-val">{manga.source.displayName}</span></div>
{#if manga.status}<div class="detail-row"><span class="detail-key">Status</span><span class="detail-val">{statusLabel}</span></div>{/if}
{#if manga.author}<div class="detail-row"><span class="detail-key">Author</span><span class="detail-val">{manga.author}</span></div>{/if}
{#if manga.artist && manga.artist !== manga.author}<div class="detail-row"><span class="detail-key">Artist</span><span class="detail-val">{manga.artist}</span></div>{/if}
<div class="detail-actions"> <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={() => migrateOpen = true}> <button class="detail-action-btn" onclick={() => migrateOpen = true}>
<ArrowsClockwise size={12} weight="light" /> Switch Source <ArrowsClockwise size={12} weight="light" /> Switch Source
</button> </button>
<button <button class="detail-action-btn" class:detail-action-active={linkedIds.length > 0} onclick={openLinkPicker}>
class="detail-action-btn"
class:detail-action-active={linkedIds.length > 0}
onclick={openLinkPicker}
>
<LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} /> <LinkSimpleHorizontalBreak size={12} weight={linkedIds.length > 0 ? "fill" : "light"} />
Series Link{linkedIds.length > 0 ? ` (${linkedIds.length})` : ""} Series Link{linkedIds.length > 0 ? ` (${linkedIds.length})` : ""}
</button> </button>
<button <button class="detail-action-btn" onclick={() => trackingOpen = true}>
class="detail-action-btn"
onclick={() => trackingOpen = true}
>
<ChartLineUp size={12} weight="light" /> Tracking <ChartLineUp size={12} weight="light" /> Tracking
</button> </button>
{#if manga?.inLibrary}
<button class="detail-action-btn" class:detail-action-active={hasAnyAutomation} onclick={() => autoOpen = true}>
<Gear size={12} weight={hasAnyAutomation ? "fill" : "light"} /> Automation
</button>
{/if}
{#if downloadedCount > 0} {#if downloadedCount > 0}
<button class="detail-action-btn detail-action-danger" onclick={deleteAllDownloads} disabled={deletingAll}> <button class="detail-action-btn detail-action-danger" onclick={deleteAllDownloads} disabled={deletingAll}>
<Trash size={12} weight="light" /> {deletingAll ? "Deleting…" : `Delete Downloads (${downloadedCount})`} <Trash size={12} weight="light" /> {deletingAll ? "Deleting…" : `Delete Downloads (${downloadedCount})`}
@@ -562,61 +609,65 @@
{/if} {/if}
</div> </div>
<!-- ── Chapter list ───────────────────────────────────────────────────── -->
<div class="list-wrap"> <div class="list-wrap">
<div class="list-header"> <div class="list-header">
<div class="list-header-left"> <div class="list-header-left">
{#if hasSelection} {#if hasSelection}
<span class="sel-count">{selectedIds.size} selected</span> <span class="sel-count">{selectedIds.size} selected</span>
<button class="sel-action-btn" onclick={downloadSelected} title="Download selected"> <button class="sel-action-btn" onclick={downloadSelected} title="Download selected"><Download size={13} weight="light" /></button>
<Download size={13} weight="light" /> <button class="sel-action-btn sel-action-danger" onclick={deleteSelected} title="Delete selected downloads"><Trash size={13} weight="light" /></button>
</button> <button class="sel-action-btn" onclick={() => markSelectedRead(true)} title="Mark selected as read"><CheckCircle size={13} weight="light" /></button>
<button class="sel-action-btn sel-action-danger" onclick={deleteSelected} title="Delete selected downloads"> <button class="sel-action-btn" onclick={() => markSelectedRead(false)} title="Mark selected as unread"><Circle size={13} weight="light" /></button>
<Trash size={13} weight="light" /> <button class="sel-action-btn" onclick={clearSelection} title="Clear selection"><X size={13} weight="light" /></button>
</button>
<button class="sel-action-btn" onclick={() => markSelectedRead(true)} title="Mark selected as read">
<CheckCircle size={13} weight="light" />
</button>
<button class="sel-action-btn" onclick={() => markSelectedRead(false)} title="Mark selected as unread">
<Circle size={13} weight="light" />
</button>
<button class="sel-action-btn" onclick={clearSelection} title="Clear selection">
<X size={13} weight="light" />
</button>
{:else} {:else}
<div class="sort-wrap"> <div class="sort-wrap">
<button class="sort-btn" onclick={() => sortMenuOpen = !sortMenuOpen}> <button class="sort-btn" onclick={() => sortMenuOpen = !sortMenuOpen}>
{#if sortDir === "desc"}<SortDescending size={14} weight="light" />{:else}<SortAscending size={14} weight="light" />{/if} {#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]} {{ source: "Source order", chapterNumber: "Ch. number", uploadDate: "Upload date" }[sortMode]}
<CaretDown size={10} weight="light" /> <CaretDown size={10} weight="light" />
</button> </button>
{#if sortMenuOpen} {#if sortMenuOpen}
<div class="sort-menu" role="presentation" onmouseleave={() => sortMenuOpen = false}> <div class="sort-menu" role="presentation" onmouseleave={() => sortMenuOpen = false}>
{#each [["source","Source order"],["chapterNumber","Chapter number"],["uploadDate","Upload date"]] as [val, label]} {#each [["source","Source order"],["chapterNumber","Chapter number"],["uploadDate","Upload date"]] as [val, label]}
<button class="sort-option" class:active={sortMode === val} <button class="sort-option" class:active={sortMode === val}
onclick={() => { updateSettings({ chapterSortMode: val as any }); chapterPage = 1; sortMenuOpen = false; }}> onclick={() => { updateSettings({ chapterSortMode: val as any }); chapterPage = 1; sortMenuOpen = false; }}>
{label} {label}
</button>
{/each}
<div class="sort-divider"></div>
<button class="sort-option" onclick={() => { updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" }); chapterPage = 1; sortMenuOpen = false; }}>
{sortDir === "desc" ? "↑ Ascending" : "↓ Descending"}
</button> </button>
{/each} </div>
<div class="sort-divider"></div> {/if}
<button class="sort-option" onclick={() => { updateSettings({ chapterSortDir: sortDir === "desc" ? "asc" : "desc" }); chapterPage = 1; sortMenuOpen = false; }}> </div>
{sortDir === "desc" ? "↑ Ascending" : "↓ Descending"} <button class="icon-btn" class:active={viewMode === "grid"} onclick={() => viewMode = viewMode === "list" ? "grid" : "list"} title={viewMode === "list" ? "Grid view" : "List view"}>
</button> {#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
</div> </button>
{/if}
</div>
<!-- View toggle: icon reflects current state -->
<button class="icon-btn" class:active={viewMode === "grid"} onclick={() => viewMode = viewMode === "list" ? "grid" : "list"} title={viewMode === "list" ? "Grid view" : "List view"}>
{#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
</button>
{/if} {/if}
</div> </div>
<div class="list-header-right"> <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>
<button class="icon-btn" onclick={refreshChapters} disabled={refreshing}> <button class="icon-btn" onclick={refreshChapters} disabled={refreshing}>
<ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} /> <ArrowsClockwise size={14} weight="light" class={refreshing ? "anim-spin" : ""} />
</button> </button>
<!-- Category picker -->
<div class="fp-wrap" bind:this={folderPickerRef}> <div class="fp-wrap" bind:this={folderPickerRef}>
<button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}> <button class="icon-btn" class:active={hasFolders} onclick={() => folderPickerOpen = !folderPickerOpen}>
<FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} /> <FolderSimplePlus size={14} weight={hasFolders ? "fill" : "light"} />
@@ -637,12 +688,10 @@
<div class="fp-div"></div> <div class="fp-div"></div>
{#if folderCreating} {#if folderCreating}
<div class="fp-create"> <div class="fp-create">
<input class="fp-input" placeholder="Folder name…" bind:value={folderNewName} <input class="fp-input" placeholder="Folder name…" bind:value={folderNewName} use:focusOnMount
onkeydown={(e) => { if (e.key === "Enter") createCategory(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} use:focusOnMount /> onkeydown={(e) => { if (e.key === "Enter") createCategory(); if (e.key === "Escape") { folderCreating = false; folderNewName = ""; } }} />
<button class="fp-confirm" onclick={createCategory} disabled={!folderNewName.trim()}>Add</button> <button class="fp-confirm" onclick={createCategory} disabled={!folderNewName.trim()}>Add</button>
<button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}> <button class="fp-cancel" onclick={() => { folderCreating = false; folderNewName = ""; }}><X size={12} weight="light" /></button>
<X size={12} weight="light" />
</button>
</div> </div>
{:else} {:else}
<button class="fp-new" onclick={() => folderCreating = true}>+ New folder</button> <button class="fp-new" onclick={() => folderCreating = true}>+ New folder</button>
@@ -651,7 +700,6 @@
{/if} {/if}
</div> </div>
<!-- Download dropdown -->
{#if chapters.length > 0} {#if chapters.length > 0}
<div class="dl-wrap" bind:this={dlDropRef}> <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"> <button class="icon-btn dl-unified-btn" class:active={dlOpen} class:dl-has-count={downloadedCount > 0} onclick={() => dlOpen = !dlOpen} title="Download options">
@@ -711,7 +759,7 @@
</div> </div>
{/if} {/if}
{#if totalPages >= 1} {#if totalPages > 1}
<div class="pagination"> <div class="pagination">
<button class="page-btn" onclick={() => chapterPage = Math.max(1, chapterPage - 1)} disabled={chapterPage === 1}></button> <button class="page-btn" onclick={() => chapterPage = Math.max(1, chapterPage - 1)} disabled={chapterPage === 1}></button>
<span class="page-num">{chapterPage} / {totalPages}</span> <span class="page-num">{chapterPage} / {totalPages}</span>
@@ -731,8 +779,9 @@
{:else if viewMode === "grid"} {:else if viewMode === "grid"}
{#each sortedChapters as ch, i} {#each sortedChapters as ch, i}
{@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0} {@const inProgress = !ch.isRead && (ch.lastPageRead ?? 0) > 0}
<button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} {@const isGridSelected = selectedIds.has(ch.id)}
onclick={() => openReader(ch, chaptersAsc)} <button class="grid-cell" class:read={ch.isRead} class:in-progress={inProgress} class:grid-selected={isGridSelected}
onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, chaptersAsc)}
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }} oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: i }; }}
title={ch.name}> title={ch.name}>
<span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span> <span class="grid-cell-num">{ch.chapterNumber % 1 === 0 ? ch.chapterNumber.toFixed(0) : ch.chapterNumber}</span>
@@ -745,14 +794,10 @@
{#each pageChapters as ch} {#each pageChapters as ch}
{@const idxInSorted = sortedChapters.indexOf(ch)} {@const idxInSorted = sortedChapters.indexOf(ch)}
{@const isSelected = selectedIds.has(ch.id)} {@const isSelected = selectedIds.has(ch.id)}
<div role="button" tabindex="0" <div role="button" tabindex="0" class="ch-row" class:read={ch.isRead} class:ch-selected={isSelected}
class="ch-row" onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, chaptersAsc)}
class:read={ch.isRead} onkeydown={(e) => e.key === "Enter" && (hasSelection ? toggleSelect(ch.id, e) : openReaderWithAhead(ch, chaptersAsc))}
class:ch-selected={isSelected}
onclick={(e) => hasSelection ? toggleSelect(ch.id, e) : openReader(ch, chaptersAsc)}
onkeydown={(e) => e.key === "Enter" && (hasSelection ? toggleSelect(ch.id, e) : openReader(ch, chaptersAsc))}
oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}> oncontextmenu={(e) => { e.preventDefault(); ctx = { x: e.clientX, y: e.clientY, chapter: ch, idx: idxInSorted }; }}>
<!-- Checkbox shown when selection active, or on hover -->
<button class="ch-check" class:ch-check-visible={hasSelection} onclick={(e) => toggleSelect(ch.id, e)} title="Select"> <button class="ch-check" class:ch-check-visible={hasSelection} onclick={(e) => toggleSelect(ch.id, e)} title="Select">
{#if isSelected}<CheckCircle size={15} weight="fill" />{:else}<Circle size={15} weight="light" />{/if} {#if isSelected}<CheckCircle size={15} weight="fill" />{:else}<Circle size={15} weight="light" />{/if}
</button> </button>
@@ -768,15 +813,11 @@
{#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if} {#if ch.isRead}<CheckCircle size={14} weight="light" class="read-icon" />{/if}
{#if ch.isDownloaded} {#if ch.isDownloaded}
<span class="ch-dl-dot" title="Downloaded"></span> <span class="ch-dl-dot" title="Downloaded"></span>
<button class="dl-btn dl-btn-delete" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }} title="Delete download"> <button class="dl-btn dl-btn-delete" onclick={(e) => { e.stopPropagation(); deleteDownloaded(ch.id); }} title="Delete download"><Trash size={13} weight="light" /></button>
<Trash size={13} weight="light" />
</button>
{:else if enqueueing.has(ch.id)} {:else if enqueueing.has(ch.id)}
<CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" /> <CircleNotch size={14} weight="light" class="anim-spin enqueue-icon" />
{:else} {:else}
<button class="dl-btn" onclick={(e) => { e.stopPropagation(); enqueue(ch, e); }} title="Download"> <button class="dl-btn" onclick={(e) => { e.stopPropagation(); enqueue(ch, e); }} title="Download"><Download size={13} weight="light" /></button>
<Download size={13} weight="light" />
</button>
{/if} {/if}
</div> </div>
</div> </div>
@@ -784,7 +825,7 @@
{/if} {/if}
</div> </div>
{#if totalPages >= 1} {#if totalPages > 1}
<div class="pagination-bottom"> <div class="pagination-bottom">
<button class="page-btn" onclick={() => chapterPage = Math.max(1, chapterPage - 1)} disabled={chapterPage === 1}>← Prev</button> <button class="page-btn" onclick={() => chapterPage = Math.max(1, chapterPage - 1)} disabled={chapterPage === 1}>← Prev</button>
<span class="page-num">{chapterPage} / {totalPages}</span> <span class="page-num">{chapterPage} / {totalPages}</span>
@@ -808,20 +849,17 @@
{/if} {/if}
{#if trackingOpen && store.activeManga} {#if trackingOpen && store.activeManga}
<TrackingPanel <TrackingPanel mangaId={store.activeManga.id} mangaTitle={store.activeManga.title} onClose={() => trackingOpen = false} />
mangaId={store.activeManga.id} {/if}
mangaTitle={store.activeManga.title}
onClose={() => trackingOpen = false} {#if autoOpen && store.activeManga}
/> <AutomationPanel mangaId={store.activeManga.id} {chapters} onClose={() => autoOpen = false} />
{/if} {/if}
{#if linkPickerOpen} {#if linkPickerOpen}
<div <div class="link-backdrop" role="presentation"
class="link-backdrop"
role="presentation"
onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }} onclick={(e) => { if (e.target === e.currentTarget) closeLinkPicker(); }}
onkeydown={(e) => e.key === "Escape" && closeLinkPicker()} onkeydown={(e) => e.key === "Escape" && closeLinkPicker()}>
>
<div class="link-modal"> <div class="link-modal">
<div class="link-header"> <div class="link-header">
<span class="link-title">Link as same series</span> <span class="link-title">Link as same series</span>
@@ -859,16 +897,13 @@
<style> <style>
.root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; } .root { display: flex; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
/* ── Sidebar ─────────────────────────────────────────────────────────────── */
.sidebar { width: 240px; flex-shrink: 0; padding: var(--sp-5); border-right: 1px solid var(--border-dim); overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); background: var(--bg-base); } .sidebar { width: 240px; flex-shrink: 0; padding: var(--sp-5); border-right: 1px solid var(--border-dim); overflow-y: auto; display: flex; flex-direction: column; gap: var(--sp-4); background: var(--bg-base); }
.back { display: flex; align-items: center; gap: var(--sp-2); color: var(--text-muted); font-size: var(--text-xs); font-family: var(--font-ui); letter-spacing: var(--tracking-wide); text-transform: uppercase; transition: color var(--t-base); } .back { 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); } .back:hover { color: var(--text-secondary); }
/* Zone 1: Cover */
.cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; } .cover-wrap { width: 100%; aspect-ratio: 2/3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); flex-shrink: 0; }
.cover { width: 100%; height: 100%; object-fit: cover; } .cover { width: 100%; height: 100%; object-fit: cover; }
/* Zone 2: Meta */
.meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); } .meta-skeleton { display: flex; flex-direction: column; gap: var(--sp-2); }
.sk-line { border-radius: var(--radius-sm); } .sk-line { border-radius: var(--radius-sm); }
.meta { display: flex; flex-direction: column; gap: var(--sp-3); } .meta { display: flex; flex-direction: column; gap: var(--sp-3); }
@@ -882,10 +917,8 @@
.genre:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .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 { 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); } .genre-toggle:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
/* Description clamped — no expand in 240px sidebar */
.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; } .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; }
/* Zone 3: CTA */
.cta-section { display: flex; flex-direction: column; gap: var(--sp-2); } .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 { 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; } .read-btn:hover { opacity: 0.88; }
@@ -897,7 +930,6 @@
.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 { 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); } .external-link:hover { color: var(--text-muted); border-color: var(--border-strong); }
/* Zone 4: Progress */
.progress-section { display: flex; flex-direction: column; gap: var(--sp-1); } .progress-section { display: flex; flex-direction: column; gap: var(--sp-1); }
.progress-header { display: flex; justify-content: space-between; align-items: center; } .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-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
@@ -905,14 +937,10 @@
.progress-track { height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; } .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; } .progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.4s ease; }
/* Zone 5: Details accordion */
.details-section { display: flex; flex-direction: column; gap: 2px; } .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 { 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-toggle:hover { color: var(--text-muted); }
.details-body { display: flex; flex-direction: column; gap: var(--sp-2); padding-top: var(--sp-2); } .details-body { display: flex; flex-direction: column; gap: var(--sp-2); padding-top: var(--sp-2); }
.detail-row { display: flex; justify-content: space-between; align-items: baseline; gap: var(--sp-2); }
.detail-key { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.detail-val { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); text-align: right; }
.detail-actions { display: flex; flex-direction: column; gap: var(--sp-1); padding-top: var(--sp-1); } .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 { 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-btn:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
@@ -922,7 +950,6 @@
.detail-action-danger:hover:not(:disabled) { background: var(--color-error-bg); border-color: var(--color-error); 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; } .detail-action-danger:disabled { opacity: 0.4; cursor: default; }
/* ── Series link modal ───────────────────────────────────────────────────── */
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.12s ease both; } .link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.12s ease both; }
.link-modal { width: min(460px, calc(100vw - 48px)); max-height: 70vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; } .link-modal { width: min(460px, calc(100vw - 48px)); max-height: 70vh; display: flex; flex-direction: column; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; box-shadow: 0 24px 64px rgba(0,0,0,0.6); animation: scaleIn 0.14s ease both; }
.link-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; } .link-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; }
@@ -946,7 +973,6 @@
.link-status { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); } .link-status { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); flex-shrink: 0; padding: 2px 8px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); }
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
/* ── Chapter list ────────────────────────────────────────────────────────── */
.list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; } .list-wrap { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.list-header { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); flex-wrap: wrap; } .list-header { 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); } .list-header-left, .list-header-right { display: flex; align-items: center; gap: var(--sp-1); }
@@ -963,7 +989,14 @@
.icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .icon-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.icon-btn:disabled { opacity: 0.3; cursor: default; } .icon-btn:disabled { opacity: 0.3; cursor: default; }
/* ── Folder picker ───────────────────────────────────────────────────────── */ .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-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-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-empty { padding: var(--sp-2) var(--sp-3); font-size: var(--text-xs); color: var(--text-faint); }
@@ -982,7 +1015,6 @@
.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 { 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); } .fp-new:hover { color: var(--text-secondary); background: var(--bg-overlay); }
/* ── Download dropdown ───────────────────────────────────────────────────── */
.dl-wrap { position: relative; } .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-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-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; }
@@ -1007,7 +1039,6 @@
.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 { 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-range-go:disabled { opacity: 0.3; cursor: default; }
/* ── Pagination ──────────────────────────────────────────────────────────── */
.pagination, .pagination-bottom { display: flex; align-items: center; gap: var(--sp-2); } .pagination, .pagination-bottom { display: flex; align-items: center; gap: var(--sp-2); }
.pagination-bottom { justify-content: center; padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; } .pagination-bottom { justify-content: center; padding: var(--sp-3); border-top: 1px solid var(--border-dim); flex-shrink: 0; }
.page-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 10px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); color: var(--text-faint); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base); } .page-btn { 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); }
@@ -1015,7 +1046,6 @@
.page-btn:disabled { opacity: 0.3; cursor: default; } .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); } .page-num { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
/* ── Chapter rows ────────────────────────────────────────────────────────── */
.ch-list { flex: 1; overflow-y: auto; } .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-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 { 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); }
@@ -1041,14 +1071,12 @@
.grid-cell-spinner { position: absolute; top: 2px; right: 2px; } .grid-cell-spinner { position: absolute; top: 2px; right: 2px; }
.grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); } .grid-cell-skeleton { aspect-ratio: 1; border-radius: var(--radius-sm); }
/* ── Multi-select action bar ─────────────────────────────────────────────── */
.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-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 { 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-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 { color: var(--color-error) !important; }
.sel-action-danger:hover { background: var(--color-error-bg) !important; border-color: var(--color-error) !important; } .sel-action-danger:hover { background: var(--color-error-bg) !important; border-color: var(--color-error) !important; }
/* ── Download unified button ─────────────────────────────────────────────── */
.dl-unified-btn { gap: 5px; padding: 0 8px; width: auto; min-width: 28px; } .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-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:hover .dl-unified-count,
@@ -1058,30 +1086,22 @@
.dl-unified-btn.dl-has-count:hover { background: var(--accent-muted); border-color: var(--accent); opacity: 0.9; } .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); } .dl-unified-btn.active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
/* ── Chapter row selection ───────────────────────────────────────────────── */
.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-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-row:hover .ch-check { opacity: 1; }
.ch-check-visible { opacity: 1 !important; } .ch-check-visible { opacity: 1 !important; }
.ch-selected { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; } .ch-selected { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
.ch-selected .ch-check { color: var(--accent-fg); opacity: 1; } .ch-selected .ch-check { color: var(--accent-fg); opacity: 1; }
/* ── Red trash for downloaded chapters ───────────────────────────────────── */
.dl-btn-delete { color: var(--color-error) !important; opacity: 0; } .dl-btn-delete { color: var(--color-error) !important; opacity: 0; }
.ch-row:hover .dl-btn-delete { opacity: 1; } .ch-row:hover .dl-btn-delete { opacity: 1; }
.dl-btn-delete:hover { background: var(--color-error-bg) !important; } .dl-btn-delete:hover { background: var(--color-error-bg) !important; }
/* ── Persistent downloaded dot in list rows ──────────────────────────────── */
.ch-dl-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-fg); flex-shrink: 0; opacity: 0.7; transition: opacity var(--t-fast); } .ch-dl-dot { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-fg); flex-shrink: 0; opacity: 0.7; transition: opacity var(--t-fast); }
.ch-row:hover .ch-dl-dot { opacity: 0; } .ch-row:hover .ch-dl-dot { opacity: 0; }
/* ── Grid cell selection + downloaded dot ────────────────────────────────── */
.grid-selected { background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; } .grid-selected { background: var(--accent-muted) !important; border-color: var(--accent-dim) !important; }
.grid-cell-dl { position: absolute; top: 3px; left: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent-fg); } .grid-cell-dl { position: absolute; top: 3px; left: 3px; width: 4px; height: 4px; border-radius: 50%; background: var(--accent-fg); }
@keyframes fadeIn { from { opacity: 0 } to { 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) } } @keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style> </style>
<script module>
function focusOnMount(node: HTMLElement) { node.focus(); }
</script>
+56 -47
View File
@@ -493,41 +493,51 @@
.header-top { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6) var(--sp-3); } .header-top { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-4) var(--sp-6) var(--sp-3); }
.heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; } .heading { font-family: var(--font-ui); font-size: var(--text-xs); font-weight: var(--weight-normal); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.header-actions { display: flex; align-items: center; gap: var(--sp-2); } .header-actions { display: flex; align-items: center; gap: 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 { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); border: none; color: var(--text-faint); background: none; cursor: pointer; transition: 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:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-raised); }
.icon-btn:disabled { opacity: 0.3; cursor: default; } .icon-btn:disabled { opacity: 0.3; cursor: default; }
/* ── Tracker tabs ───────────────────────────────────────────────────────── */ /* ── Tracker tabs ───────────────────────────────────────────────────────── */
.tracker-tabs { display: flex; align-items: center; gap: 2px; padding: var(--sp-2) var(--sp-4); overflow-x: auto; scrollbar-width: none; } .tracker-tabs { display: flex; align-items: center; gap: 1px; padding: 0 var(--sp-5); overflow-x: auto; scrollbar-width: none; }
.tracker-tabs::-webkit-scrollbar { display: none; } .tracker-tabs::-webkit-scrollbar { display: none; }
.tracker-tab { display: flex; align-items: center; gap: var(--sp-2); padding: 4px 10px; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); background: none; border: none; border-radius: var(--radius-md); cursor: pointer; white-space: nowrap; transition: color var(--t-base), background var(--t-base); } .tracker-tab {
display: flex; align-items: center; gap: var(--sp-2);
padding: 9px 10px 8px;
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
color: var(--text-faint); background: none; border: none;
border-bottom: 2px solid transparent;
border-radius: 0; cursor: pointer; white-space: nowrap;
transition: color var(--t-base), border-color var(--t-base);
margin-bottom: -1px;
}
.tracker-tab:hover { color: var(--text-muted); } .tracker-tab:hover { color: var(--text-muted); }
.tab-active { background: var(--accent-muted); color: var(--accent-fg) !important; border: 1px solid var(--accent-dim); } .tab-active { color: var(--text-secondary) !important; border-bottom-color: var(--accent); }
.tab-tracker-icon { width: 14px; height: 14px; border-radius: 2px; object-fit: contain; } .tab-tracker-icon { width: 13px; height: 13px; border-radius: 2px; object-fit: contain; opacity: 0.85; }
.tab-count { font-size: 10px; padding: 1px 5px; border-radius: var(--radius-full); background: var(--bg-overlay); color: var(--text-faint); min-width: 18px; text-align: center; } .tab-count { 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-count { background: var(--accent-muted); color: var(--accent-fg); }
/* ── Filter bar ─────────────────────────────────────────────────────────── */ /* ── Filter bar ─────────────────────────────────────────────────────────── */
.filter-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-5); border-top: 1px solid var(--border-dim); } .filter-bar { display: flex; align-items: center; gap: var(--sp-3); padding: var(--sp-2) var(--sp-5); border-top: 1px solid var(--border-dim); }
.search-wrap { display: flex; align-items: center; gap: var(--sp-2); flex: 1; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 5px 10px; } .search-wrap { display: flex; align-items: center; gap: var(--sp-2); flex: 1; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 4px 10px; }
:global(.search-ico) { color: var(--text-faint); flex-shrink: 0; } :global(.search-ico) { color: var(--text-faint); flex-shrink: 0; }
.filter-search { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; } .filter-search { flex: 1; background: none; border: none; outline: none; font-size: var(--text-sm); color: var(--text-primary); min-width: 0; }
.filter-search::placeholder { color: var(--text-faint); } .filter-search::placeholder { color: var(--text-faint); }
.filter-right { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; } .filter-right { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.filter-select { .filter-select {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 5px 28px 5px 10px; border-radius: var(--radius-md); padding: 4px 24px 4px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-dim); background: var(--bg-raised); border: 1px solid var(--border-dim); background: var(--bg-raised);
color: var(--text-muted); outline: none; cursor: pointer; color: var(--text-faint); outline: none; cursor: pointer;
appearance: none; -webkit-appearance: none; appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 8px center; background-repeat: no-repeat; background-position: right 7px center;
transition: border-color var(--t-base), color var(--t-base); transition: border-color var(--t-base), color var(--t-base);
} }
.filter-select:hover { border-color: var(--border-strong); color: var(--text-secondary); } .filter-select:hover { border-color: var(--border-strong); color: var(--text-muted); }
.filter-select option { background: var(--bg-surface); color: var(--text-secondary); } .filter-select option { background: var(--bg-surface); color: var(--text-secondary); }
/* ── Body ───────────────────────────────────────────────────────────────── */ /* ── Body ───────────────────────────────────────────────────────────────── */
.page-body { flex: 1; overflow-y: auto; padding: var(--sp-4) var(--sp-5); scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; } .page-body { flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-5); scrollbar-width: thin; scrollbar-color: var(--border-strong) transparent; }
/* ── States ─────────────────────────────────────────────────────────────── */ /* ── States ─────────────────────────────────────────────────────────────── */
.state-center { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-3); height: 100%; padding: var(--sp-10); text-align: center; } .state-center { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-3); height: 100%; padding: var(--sp-10); text-align: center; }
@@ -535,29 +545,28 @@
.state-text { font-size: var(--text-sm); color: var(--text-muted); } .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); } .state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); } .state-error { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--color-error); letter-spacing: var(--tracking-wide); }
.retry-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 6px 16px; border-radius: var(--radius-md); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); } .retry-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 14px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); }
.retry-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); } .retry-btn:hover { color: var(--accent-fg); border-color: var(--accent-dim); }
/* ── Records list ───────────────────────────────────────────────────────── */ /* ── Records list ───────────────────────────────────────────────────────── */
.records-list { display: flex; flex-direction: column; gap: var(--sp-2); } .records-list { display: flex; flex-direction: column; gap: 2px; }
.record-card { .record-card {
display: flex; align-items: flex-start; gap: var(--sp-4); display: flex; align-items: flex-start; gap: var(--sp-4);
padding: var(--sp-3) var(--sp-4); padding: var(--sp-3) var(--sp-3);
border-radius: var(--radius-md); border-radius: var(--radius-md);
border: 1px solid var(--border-dim); background: none;
background: var(--bg-raised); transition: background var(--t-fast), opacity var(--t-base);
transition: border-color var(--t-base), opacity var(--t-base);
} }
.record-card:hover { border-color: var(--border-strong); } .record-card:hover { background: var(--bg-raised); }
.record-busy { opacity: 0.5; pointer-events: none; } .record-busy { opacity: 0.4; pointer-events: none; }
/* Cover */ /* Cover */
.record-cover-wrap { position: relative; flex-shrink: 0; cursor: pointer; } .record-cover-wrap { position: relative; flex-shrink: 0; cursor: pointer; }
.record-cover { width: 48px; height: 68px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); display: block; } .record-cover { width: 44px; height: 62px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); display: block; transition: opacity var(--t-fast); }
.record-cover-empty { background: var(--bg-overlay); } .record-cover-empty { background: var(--bg-overlay); }
.record-cover-wrap:hover .record-cover { opacity: 0.8; } .record-cover-wrap:hover .record-cover { opacity: 0.75; }
.record-tracker-badge { position: absolute; bottom: -4px; right: -4px; width: 16px; height: 16px; border-radius: 3px; border: 1px solid var(--bg-raised); object-fit: contain; background: var(--bg-raised); } .record-tracker-badge { position: absolute; bottom: -3px; right: -3px; width: 14px; height: 14px; border-radius: 2px; border: 1px solid var(--bg-base); object-fit: contain; background: var(--bg-raised); }
/* Body */ /* Body */
.record-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-2); } .record-body { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-2); }
@@ -566,9 +575,9 @@
.record-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color var(--t-base); } .record-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; transition: color var(--t-base); }
.record-titles:hover .record-title { color: var(--accent-fg); } .record-titles:hover .record-title { color: var(--accent-fg); }
.record-local-title { 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; } .record-local-title { 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; }
.record-header-actions { display: flex; align-items: center; gap: 2px; flex-shrink: 0; } .record-header-actions { display: flex; align-items: center; gap: 1px; flex-shrink: 0; }
.record-tracker-label { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 2px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); margin-right: var(--sp-1); } .record-tracker-label { display: flex; align-items: center; gap: 4px; font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); padding: 2px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-overlay); margin-right: var(--sp-1); }
.record-tracker-label-icon { width: 12px; height: 12px; border-radius: 2px; object-fit: contain; } .record-tracker-label-icon { width: 11px; height: 11px; border-radius: 2px; object-fit: contain; }
.card-icon-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; text-decoration: none; transition: color var(--t-base), background var(--t-base); } .card-icon-btn { display: flex; align-items: center; justify-content: center; width: 22px; height: 22px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; text-decoration: none; transition: color var(--t-base), background var(--t-base); }
.card-icon-btn:hover { color: var(--text-muted); background: var(--bg-overlay); } .card-icon-btn:hover { color: var(--text-muted); background: var(--bg-overlay); }
.card-icon-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); } .card-icon-btn.danger:hover { color: var(--color-error); background: var(--color-error-bg); }
@@ -577,24 +586,24 @@
/* Controls */ /* Controls */
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; } .record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.record-select { .record-select {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 3px 26px 3px 8px; border-radius: var(--radius-sm); padding: 3px 22px 3px 7px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); background: var(--bg-overlay); border: 1px solid transparent; background: var(--bg-overlay);
color: var(--text-secondary); outline: none; cursor: pointer; color: var(--text-faint); outline: none; cursor: pointer;
appearance: none; -webkit-appearance: none; appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 7px center; background-repeat: no-repeat; background-position: right 6px center;
transition: border-color var(--t-base); transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
} }
.record-select:hover:not(:disabled) { border-color: var(--accent-dim); } .record-select:hover:not(:disabled) { border-color: var(--border-dim); color: var(--text-secondary); background: var(--bg-raised); }
.record-select:disabled { opacity: 0.4; cursor: default; } .record-select:disabled { opacity: 0.35; cursor: default; }
.record-select option { background: var(--bg-surface); color: var(--text-secondary); } .record-select option { background: var(--bg-surface); color: var(--text-secondary); }
.record-select-score { max-width: 90px; } .record-select-score { max-width: 86px; }
.private-badge { display: flex; align-items: center; color: var(--text-faint); padding: 2px 4px; } .private-badge { display: flex; align-items: center; color: var(--text-faint); padding: 2px 4px; }
/* Progress */ /* Progress */
.record-progress { display: flex; align-items: center; gap: var(--sp-3); } .record-progress { display: flex; align-items: center; gap: var(--sp-3); }
.progress-track { flex: 1; height: 3px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; max-width: 160px; } .progress-track { flex: 1; height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; max-width: 140px; }
.progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; } .progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
.progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; } .progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); white-space: nowrap; }
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 2px 4px; margin: -2px -4px; transition: background var(--t-fast); } .record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 2px 4px; margin: -2px -4px; transition: background var(--t-fast); }
@@ -604,21 +613,21 @@
.record-progress.clickable:hover .progress-edit-hint { opacity: 1; } .record-progress.clickable:hover .progress-edit-hint { opacity: 1; }
/* Chapter editor */ /* Chapter editor */
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--accent-dim); background: var(--bg-overlay); } .chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); }
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); } .chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); } .chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); }
.chapter-input { width: 72px; background: var(--bg-raised); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; appearance: none; -webkit-appearance: none; -moz-appearance: textfield; } .chapter-input { width: 72px; background: var(--bg-surface); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 4px 8px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; appearance: none; -webkit-appearance: none; -moz-appearance: textfield; }
.chapter-input:focus { border-color: var(--accent); } .chapter-input:focus { border-color: var(--accent); }
.chapter-input::-webkit-outer-spin-button, .chapter-input::-webkit-outer-spin-button,
.chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; } .chapter-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
.chapter-total { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .chapter-total { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 4px; } .chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
.chapter-editor-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; } .chapter-editor-actions { display: flex; align-items: center; gap: var(--sp-2); justify-content: flex-end; }
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 4px 14px; border-radius: var(--radius-sm); border: 1px solid var(--accent); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); } .chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
.chapter-save-btn:hover { filter: brightness(1.15); } .chapter-save-btn:hover { filter: brightness(1.15); }
.chapter-cancel-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); background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base); } .chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); }
.chapter-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); } .chapter-cancel-btn:hover { color: var(--text-muted); }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } } @keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
</style> </style>
@@ -0,0 +1,321 @@
<script lang="ts">
import { X } from "phosphor-svelte";
import { store, updateSettings } from "../../store/state.svelte";
import { DEFAULT_MANGA_PREFS } from "../../store/state.svelte";
import type { MangaPrefs } from "../../store/state.svelte";
import type { Chapter } from "../../lib/types";
let { mangaId, chapters, onClose }: {
mangaId: number;
chapters: Chapter[];
onClose: () => void;
} = $props();
// ── Prefs helpers ──────────────────────────────────────────────────────────
const mangaPrefs = $derived(
(store.settings.mangaPrefs?.[mangaId] ?? {}) as Partial<MangaPrefs>
);
function getPref<K extends keyof MangaPrefs>(key: K): MangaPrefs[K] {
return (mangaPrefs[key] ?? DEFAULT_MANGA_PREFS[key]) as MangaPrefs[K];
}
function setPref<K extends keyof MangaPrefs>(key: K, value: MangaPrefs[K]) {
updateSettings({
mangaPrefs: {
...store.settings.mangaPrefs,
[mangaId]: { ...(store.settings.mangaPrefs?.[mangaId] ?? {}), [key]: value },
},
});
}
// ── Scanlator list — derived from loaded chapters ──────────────────────────
const scanlators = $derived(
[...new Set(chapters.map(c => c.scanlator).filter((s): s is string => !!s?.trim()))]
.sort((a, b) => a.localeCompare(b))
);
// ── Options ────────────────────────────────────────────────────────────────
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" },
];
// ── Backdrop close ─────────────────────────────────────────────────────────
function onBackdrop(e: MouseEvent) {
if (e.target === e.currentTarget) onClose();
}
</script>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div class="backdrop" onmousedown={onBackdrop}>
<div class="modal" role="dialog" aria-modal="true" aria-label="Automation">
<!-- Header -->
<div class="modal-header">
<div class="header-left">
<span class="modal-title">Automation</span>
<span class="modal-subtitle">Per-series rules</span>
</div>
<button class="close-btn" onclick={onClose} aria-label="Close"><X size={16} weight="light" /></button>
</div>
<!-- Body -->
<div class="modal-body">
<!-- ── Downloads ────────────────────────────────────────────────────── -->
<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={getPref("autoDownload")}
class="auto-toggle"
class:auto-toggle-on={getPref("autoDownload")}
onclick={() => setPref("autoDownload", !getPref("autoDownload"))}
><span class="auto-toggle-thumb"></span></button>
</div>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Download ahead</span>
<span class="auto-desc">Pre-fetch chapters while reading</span>
</div>
<div class="auto-chip-group">
{#each DOWNLOAD_AHEAD_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={getPref("downloadAhead") === opt.value}
onclick={() => setPref("downloadAhead", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Max chapters to keep</span>
<span class="auto-desc">Delete oldest downloads when limit is exceeded</span>
</div>
<div class="auto-chip-group">
{#each MAX_KEEP_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={getPref("maxKeepChapters") === opt.value}
onclick={() => setPref("maxKeepChapters", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
<div class="divider"></div>
<!-- ── On Read ──────────────────────────────────────────────────────── -->
<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={getPref("deleteOnRead")}
class="auto-toggle"
class:auto-toggle-on={getPref("deleteOnRead")}
onclick={() => setPref("deleteOnRead", !getPref("deleteOnRead"))}
><span class="auto-toggle-thumb"></span></button>
</div>
{#if getPref("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={getPref("deleteDelayHours") === opt.value}
onclick={() => setPref("deleteDelayHours", opt.value)}
>{opt.label}</button>
{/each}
</div>
</div>
{/if}
<div class="divider"></div>
<!-- ── Updates ─────────────────────────────────────────────────────── -->
<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={getPref("pauseUpdates")}
class="auto-toggle"
class:auto-toggle-on={getPref("pauseUpdates")}
onclick={() => setPref("pauseUpdates", !getPref("pauseUpdates"))}
><span class="auto-toggle-thumb"></span></button>
</div>
<div class="auto-row">
<div class="auto-info">
<span class="auto-label">Refresh interval</span>
<span class="auto-desc">How often to check for new chapters</span>
</div>
<div class="auto-chip-group">
{#each REFRESH_INTERVAL_OPTIONS as opt}
<button
class="auto-chip"
class:auto-chip-on={getPref("refreshInterval") === opt.value}
onclick={() => setPref("refreshInterval", opt.value as MangaPrefs["refreshInterval"])}
>{opt.label}</button>
{/each}
</div>
</div>
{#if scanlators.length > 1}
<div class="divider"></div>
<!-- ── Scanlator ──────────────────────────────────────────────────── -->
<p class="section-label">Scanlator</p>
<div class="auto-row auto-row-align-start">
<div class="auto-info">
<span class="auto-label">Preferred scanlator</span>
<span class="auto-desc">Prioritise this group's chapters in the list</span>
</div>
<div class="scanlator-list">
<button
class="auto-chip scanlator-chip"
class:auto-chip-on={!getPref("preferredScanlator")}
onclick={() => setPref("preferredScanlator", "")}
>Any</button>
{#each scanlators as s}
<button
class="auto-chip scanlator-chip"
class:auto-chip-on={getPref("preferredScanlator") === s}
onclick={() => setPref("preferredScanlator", getPref("preferredScanlator") === s ? "" : s)}
title={s}
>{s}</button>
{/each}
</div>
</div>
{/if}
</div>
</div>
</div>
<style>
.backdrop {
position: fixed; inset: 0; z-index: 300;
background: rgba(0,0,0,0.6);
display: flex; align-items: center; justify-content: center;
animation: fadeIn 0.1s ease both;
}
.modal {
width: 420px; max-width: calc(100vw - var(--sp-6));
max-height: 80vh;
display: flex; flex-direction: column;
background: var(--bg-surface); border: 1px solid var(--border-base);
border-radius: var(--radius-xl); overflow: hidden;
box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6);
animation: scaleIn 0.15s ease both;
}
/* Header */
.modal-header {
display: flex; align-items: center; justify-content: space-between;
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
}
.header-left { display: flex; flex-direction: column; gap: 2px; }
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); }
.modal-subtitle { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.close-btn { display: flex; align-items: center; justify-content: center; width: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
/* Body */
.modal-body {
flex: 1; overflow-y: auto; scrollbar-width: none;
display: flex; flex-direction: column; gap: var(--sp-3);
padding: var(--sp-4) var(--sp-5);
}
.modal-body::-webkit-scrollbar { display: none; }
/* Section labels */
.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; }
/* Rows — mirrors SeriesDetail auto-row */
.auto-row {
display: flex; align-items: center; justify-content: space-between;
gap: var(--sp-3);
}
.auto-row-align-start { align-items: flex-start; }
.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); }
/* Toggle */
.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); }
/* Chips */
.auto-chip-group { display: flex; flex-direction: row; gap: 4px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.auto-chip { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 2px 7px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: none; color: var(--text-faint); cursor: pointer; white-space: nowrap; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.auto-chip:hover { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-raised); }
.auto-chip-on { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
/* Scanlator list */
.scanlator-list { display: flex; flex-direction: row; gap: 4px; flex-wrap: wrap; justify-content: flex-end; max-width: 220px; }
.scanlator-chip { max-width: 160px; overflow: hidden; text-overflow: ellipsis; }
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
</style>
+14 -13
View File
@@ -393,19 +393,18 @@
{#if !loadingDetail} {#if !loadingDetail}
<div class="meta-table"> <div class="meta-table">
{#if displayManga?.author}<div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga.author}</span></div>{/if} <div class="meta-grid">
{#if displayManga?.artist && displayManga.artist !== displayManga.author}<div class="meta-row"><span class="meta-key">Artist</span><span class="meta-val">{displayManga.artist}</span></div>{/if} <div class="meta-col">
{#if statusLabel}<div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel}</span></div>{/if} <div class="meta-row"><span class="meta-key">Status</span><span class="meta-val">{statusLabel ?? "N/A"}</span></div>
{#if displayManga?.source}<div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga.source.displayName}</span></div>{/if} <div class="meta-row"><span class="meta-key">Source</span><span class="meta-val">{displayManga?.source?.displayName ?? "N/A"}</span></div>
{#if !loadingChapters && scanlators.length > 0}<div class="meta-row"><span class="meta-key">{scanlators.length === 1 ? "Scanlator" : "Scanlators"}</span><span class="meta-val">{scanlators.join(", ")}</span></div>{/if} <div class="meta-row"><span class="meta-key">Link</span>{#if displayManga?.realUrl}<a href={displayManga.realUrl} target="_blank" rel="noreferrer" class="meta-link">Open <ArrowSquareOut size={11} weight="light" /></a>{:else}<span class="meta-val">N/A</span>{/if}</div>
{#if !loadingChapters && firstUpload && lastUpload}
<div class="meta-row">
<span class="meta-key">Published</span>
<span class="meta-val">{firstUpload.getTime() === lastUpload.getTime() ? formatDate(firstUpload) : `${formatDate(firstUpload)} ${formatDate(lastUpload)}`}</span>
</div> </div>
{/if} <div class="meta-col">
{#if !loadingChapters && downloadedCount > 0}<div class="meta-row"><span class="meta-key">Downloaded</span><span class="meta-val">{downloadedCount} / {totalCount} chapters</span></div>{/if} <div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga?.author ?? "N/A"}</span></div>
{#if displayManga?.realUrl}<div class="meta-row"><span class="meta-key">Link</span><a href={displayManga.realUrl} target="_blank" rel="noreferrer" class="meta-link">Open <ArrowSquareOut size={11} weight="light" /></a></div>{/if} <div class="meta-row"><span class="meta-key">Artist</span><span class="meta-val">{displayManga?.artist && displayManga.artist !== displayManga.author ? displayManga.artist : (displayManga?.author ?? "N/A")}</span></div>
<div class="meta-row"><span class="meta-key">Scanlator</span><span class="meta-val">{!loadingChapters && scanlators.length > 0 ? scanlators[0] : "N/A"}</span></div>
</div>
</div>
</div> </div>
{/if} {/if}
</div> </div>
@@ -528,9 +527,11 @@
.genre-tag { 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: var(--bg-raised); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); } .genre-tag { 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: var(--bg-raised); color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.genre-tag:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .genre-tag:hover { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
.meta-table { display: flex; flex-direction: column; gap: 1px; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); } .meta-table { display: flex; flex-direction: column; gap: 1px; border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); }
.meta-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0 var(--sp-4); }
.meta-col { display: flex; flex-direction: column; }
.meta-row { display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; } .meta-row { display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; }
.meta-key { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; min-width: 56px; flex-shrink: 0; } .meta-key { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-transform: uppercase; min-width: 56px; flex-shrink: 0; }
.meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); } .meta-val { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); } .meta-link { display: inline-flex; align-items: center; gap: 4px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; transition: opacity var(--t-base); }
.meta-link:hover { opacity: 0.75; } .meta-link:hover { opacity: 0.75; }
.link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: calc(var(--z-settings) + 1); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.1s ease both; } .link-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.65); z-index: calc(var(--z-settings) + 1); display: flex; align-items: center; justify-content: center; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); animation: fadeIn 0.1s ease both; }
+70 -48
View File
@@ -517,8 +517,8 @@
animation: fadeIn 0.12s ease both; animation: fadeIn 0.12s ease both;
} }
.modal { .modal {
width: min(580px, calc(100vw - 48px)); width: min(560px, calc(100vw - 48px));
max-height: min(680px, calc(100vh - 80px)); max-height: min(660px, calc(100vh - 80px));
display: flex; flex-direction: column; display: flex; flex-direction: column;
background: var(--bg-surface); border: 1px solid var(--border-base); background: var(--bg-surface); border: 1px solid var(--border-base);
border-radius: var(--radius-xl); overflow: hidden; border-radius: var(--radius-xl); overflow: hidden;
@@ -532,9 +532,9 @@
padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; padding: var(--sp-4) var(--sp-5); border-bottom: 1px solid var(--border-dim); flex-shrink: 0;
} }
.header-left { display: flex; flex-direction: column; gap: 2px; min-width: 0; } .header-left { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.modal-title { font-size: var(--text-base); font-weight: var(--weight-medium); color: var(--text-primary); letter-spacing: var(--tracking-tight); } .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-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .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: 28px; height: 28px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; } .close-btn { display: flex; align-items: center; justify-content: center; width: 26px; height: 26px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; transition: color var(--t-base), background var(--t-base); flex-shrink: 0; }
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); } .close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
/* States */ /* States */
@@ -544,71 +544,93 @@
.state-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); text-align: center; } .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 */ /* Tabs */
.tabs { display: flex; align-items: center; gap: 2px; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; overflow-x: auto; scrollbar-width: none; } .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; } .tabs::-webkit-scrollbar { display: none; }
.tab { display: flex; align-items: center; gap: var(--sp-2); position: relative; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 10px; border-radius: var(--radius-sm); color: var(--text-faint); background: none; border: none; cursor: pointer; white-space: nowrap; transition: color var(--t-base), background var(--t-base); } .tab {
.tab:hover { color: var(--text-muted); background: var(--bg-raised); } display: flex; align-items: center; gap: var(--sp-2); position: relative;
.tab-active { color: var(--text-secondary); background: var(--bg-raised); } font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
.tab-icon { width: 14px; height: 14px; border-radius: 2px; object-fit: contain; } padding: 10px 10px 9px; color: var(--text-faint);
.tab-badge { font-size: 10px; padding: 0 5px; border-radius: var(--radius-full); background: var(--accent-dim); color: var(--accent-fg); min-width: 16px; text-align: center; } background: none; border: none; border-bottom: 2px solid transparent;
.tab-dot { position: absolute; top: 4px; right: 4px; width: 5px; height: 5px; border-radius: 50%; background: var(--accent); } 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); }
.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; }
/* Records */ /* Records */
.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 { flex: 1; overflow-y: auto; padding: var(--sp-3) var(--sp-4); scrollbar-width: none; display: flex; flex-direction: column; gap: 2px; }
.tab-body::-webkit-scrollbar { display: none; } .tab-body::-webkit-scrollbar { display: none; }
.record-row { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-4); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-raised); transition: opacity var(--t-base); }
.record-busy { opacity: 0.5; pointer-events: none; } .record-row {
display: flex; flex-direction: column; gap: var(--sp-3);
padding: var(--sp-3) var(--sp-4);
border-radius: var(--radius-md);
background: var(--bg-raised);
transition: opacity var(--t-base);
}
.record-row:hover { background: var(--bg-overlay); }
.record-busy { opacity: 0.45; pointer-events: none; }
.record-identity { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; } .record-identity { display: flex; align-items: center; gap: var(--sp-2); min-width: 0; }
.record-tracker-icon { width: 16px; height: 16px; border-radius: 3px; flex-shrink: 0; object-fit: contain; } .record-tracker-icon { width: 14px; height: 14px; border-radius: 2px; flex-shrink: 0; object-fit: contain; opacity: 0.8; }
.record-title { display: inline-flex; align-items: center; gap: 3px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; transition: opacity var(--t-base); } .record-title { display: inline-flex; align-items: center; gap: 3px; font-size: var(--text-sm); color: var(--accent-fg); text-decoration: none; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; transition: opacity var(--t-base); }
.record-title:hover { opacity: 0.75; } .record-title:hover { opacity: 0.75; }
.record-title-plain { font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; } .record-title-plain { font-size: var(--text-sm); color: var(--text-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; flex: 1; }
.record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; } .record-controls { display: flex; align-items: center; gap: var(--sp-2); flex-wrap: wrap; }
.record-select { .record-select {
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide);
padding: 4px 28px 4px 8px; border-radius: var(--radius-sm); padding: 3px 22px 3px 8px; border-radius: var(--radius-sm);
border: 1px solid var(--border-strong); background: var(--bg-overlay); border: 1px solid transparent; background: var(--bg-overlay);
color: var(--text-secondary); outline: none; cursor: pointer; color: var(--text-faint); outline: none; cursor: pointer;
appearance: none; -webkit-appearance: none; appearance: none; -webkit-appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23888' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6' viewBox='0 0 10 6'%3E%3Cpath d='M1 1l4 4 4-4' stroke='%23555' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E");
background-repeat: no-repeat; background-position: right 8px center; background-repeat: no-repeat; background-position: right 6px center;
transition: border-color var(--t-base); transition: border-color var(--t-base), color var(--t-base), background var(--t-base);
} }
.record-select:hover:not(:disabled) { border-color: var(--accent-dim); } .record-select:hover:not(:disabled) { border-color: var(--border-dim); color: var(--text-secondary); background: var(--bg-raised); }
.record-select:focus { border-color: var(--accent); outline: none; } .record-select:focus { border-color: var(--border-strong); outline: none; color: var(--text-secondary); }
.record-select:disabled { opacity: 0.4; cursor: default; } .record-select:disabled { opacity: 0.35; cursor: default; }
.record-select option { background: var(--bg-surface); color: var(--text-secondary); } .record-select option { background: var(--bg-surface); color: var(--text-secondary); }
.record-select-score { max-width: 100px; } .record-select-score { max-width: 90px; }
.record-icon-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-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); flex-shrink: 0; }
.record-icon-btn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); background: var(--bg-overlay); } .record-icon-btn { 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; }
.record-icon-btn.icon-active { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); } .record-icon-btn:hover:not(:disabled) { color: var(--text-muted); background: var(--bg-overlay); }
.record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); border-color: var(--color-error); background: var(--color-error-bg); } .record-icon-btn.icon-active { color: var(--accent-fg); }
.record-icon-btn.icon-danger:hover:not(:disabled) { color: var(--color-error); background: var(--color-error-bg); }
.record-icon-btn:disabled { opacity: 0.3; cursor: default; } .record-icon-btn:disabled { opacity: 0.3; cursor: default; }
.record-progress { display: flex; flex-direction: column; gap: 4px; } .record-progress { display: flex; flex-direction: column; gap: 4px; }
.record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 3px 4px; margin: -3px -4px; transition: background var(--t-fast); } .record-progress.clickable { cursor: pointer; border-radius: var(--radius-sm); padding: 3px 4px; margin: -3px -4px; transition: background var(--t-fast); }
.record-progress.clickable:hover { background: var(--bg-overlay); } .record-progress.clickable:hover { background: var(--bg-overlay); }
.record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .record-progress-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); display: flex; align-items: center; gap: var(--sp-1); }
.edit-hint { opacity: 0; font-size: 10px; transition: opacity var(--t-fast); } .edit-hint { opacity: 0; font-size: 10px; transition: opacity var(--t-fast); color: var(--text-faint); }
.record-progress.clickable:hover .edit-hint { opacity: 1; } .record-progress.clickable:hover .edit-hint { opacity: 1; }
.record-progress-track { height: 2px; background: var(--border-base); border-radius: var(--radius-full); overflow: hidden; } .record-progress-track { height: 2px; background: var(--border-strong); border-radius: var(--radius-full); overflow: hidden; }
.record-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; } .record-progress-fill { height: 100%; background: var(--accent); border-radius: var(--radius-full); transition: width 0.3s ease; }
/* Chapter editor */ /* Chapter editor */
.chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--accent-dim); background: var(--bg-overlay); } .chapter-editor { display: flex; flex-direction: column; gap: var(--sp-2); padding: var(--sp-3); border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: var(--bg-surface); }
.chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); } .chapter-editor-top { display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); }
.chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); } .chapter-editor-label { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); } .chapter-input-wrap { display: flex; align-items: center; gap: var(--sp-2); }
.chapter-input { width: 68px; background: var(--bg-raised); border: 1px solid var(--accent-dim); border-radius: var(--radius-sm); padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; } .chapter-input { width: 64px; background: var(--bg-raised); border: 1px solid var(--border-strong); border-radius: var(--radius-sm); padding: 3px 6px; font-family: var(--font-ui); font-size: var(--text-sm); color: var(--text-primary); outline: none; text-align: center; }
.chapter-input:focus { border-color: var(--accent); } .chapter-input:focus { border-color: var(--accent); }
.chapter-total { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); } .chapter-total { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
.chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 4px; } .chapter-slider { width: 100%; accent-color: var(--accent); cursor: pointer; height: 3px; }
.chapter-editor-actions { display: flex; gap: var(--sp-2); justify-content: flex-end; } .chapter-editor-actions { display: flex; gap: var(--sp-2); justify-content: flex-end; }
.chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 3px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); } .chapter-save-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--accent-dim); background: var(--accent-muted); color: var(--accent-fg); cursor: pointer; transition: filter var(--t-base); }
.chapter-save-btn:hover { filter: brightness(1.15); } .chapter-save-btn:hover { filter: brightness(1.15); }
.chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-xs); 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; transition: color var(--t-base), border-color var(--t-base); } .chapter-cancel-btn { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 4px 8px; border-radius: var(--radius-sm); border: none; background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base); }
.chapter-cancel-btn:hover { color: var(--text-muted); border-color: var(--border-strong); } .chapter-cancel-btn:hover { color: var(--text-muted); }
/* Search */ /* Search */
.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; } .search-bar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; background: var(--bg-surface); }
:global(.search-icon) { color: var(--text-faint); flex-shrink: 0; } :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 { 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-input::placeholder { color: var(--text-faint); }
@@ -618,17 +640,17 @@
/* Results */ /* Results */
.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 { 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:hover:not(:disabled) { background: var(--bg-raised); }
.result-row:disabled { opacity: 0.5; cursor: default; } .result-row:disabled { opacity: 0.4; cursor: default; }
.result-bound { background: var(--accent-muted) !important; } .result-bound { background: color-mix(in srgb, var(--accent) 8%, transparent) !important; }
.result-cover { width: 46px; height: 66px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); flex-shrink: 0; } .result-cover { width: 44px; height: 62px; object-fit: cover; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); flex-shrink: 0; }
.result-cover-empty { background: var(--bg-raised); } .result-cover-empty { background: var(--bg-raised); }
.hidden { display: none; } .hidden { display: none; }
.result-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); padding-top: 2px; } .result-info { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); padding-top: 2px; }
.result-title { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); text-align: left; } .result-title { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); text-align: left; }
.result-meta { display: flex; flex-wrap: wrap; gap: var(--sp-1); } .result-meta { display: flex; flex-wrap: wrap; gap: var(--sp-1); }
.result-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 1px 6px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); } .result-tag { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); padding: 1px 5px; border-radius: var(--radius-sm); border: 1px solid var(--border-dim); background: var(--bg-raised); color: var(--text-faint); }
.result-summary { font-size: var(--text-xs); color: var(--text-faint); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-align: left; } .result-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-xs); letter-spacing: var(--tracking-wide); padding: 4px 12px; border-radius: var(--radius-sm); border: 1px solid var(--border-strong); 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-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-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; } .result-action-on { color: var(--accent-fg) !important; border-color: var(--accent-dim) !important; background: var(--accent-muted) !important; }
+99 -187
View File
@@ -28,41 +28,36 @@ export type LibraryStatusFilter =
| "HIATUS" | "HIATUS"
| "UNKNOWN"; | "UNKNOWN";
/** Checkbox-style content filters — multiple can be active at once per tab. */
export type LibraryContentFilter = export type LibraryContentFilter =
| "unread" | "unread"
| "started" | "started"
| "downloaded" | "downloaded"
| "bookmarked"; | "bookmarked";
export type BuiltinTheme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
export type Theme = BuiltinTheme | string; // custom themes have string IDs like "custom:abc123" export type BuiltinTheme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
export type Theme = BuiltinTheme | string;
export interface ThemeTokens { export interface ThemeTokens {
/* Backgrounds */
"bg-void": string; "bg-void": string;
"bg-base": string; "bg-base": string;
"bg-surface": string; "bg-surface": string;
"bg-raised": string; "bg-raised": string;
"bg-overlay": string; "bg-overlay": string;
"bg-subtle": string; "bg-subtle": string;
/* Borders */
"border-dim": string; "border-dim": string;
"border-base": string; "border-base": string;
"border-strong": string; "border-strong": string;
"border-focus": string; "border-focus": string;
/* Text */
"text-primary": string; "text-primary": string;
"text-secondary": string; "text-secondary": string;
"text-muted": string; "text-muted": string;
"text-faint": string; "text-faint": string;
"text-disabled": string; "text-disabled": string;
/* Accent */
"accent": string; "accent": string;
"accent-dim": string; "accent-dim": string;
"accent-muted": string; "accent-muted": string;
"accent-fg": string; "accent-fg": string;
"accent-bright": string; "accent-bright": string;
/* Semantic */
"color-error": string; "color-error": string;
"color-error-bg": string; "color-error-bg": string;
"color-success": string; "color-success": string;
@@ -71,7 +66,7 @@ export interface ThemeTokens {
} }
export interface CustomTheme { export interface CustomTheme {
id: string; // "custom:abc123" id: string;
name: string; name: string;
tokens: ThemeTokens; tokens: ThemeTokens;
} }
@@ -104,7 +99,6 @@ export const DEFAULT_THEME_TOKENS: ThemeTokens = {
"color-info-bg": "#121a1f", "color-info-bg": "#121a1f",
}; };
export interface HistoryEntry { export interface HistoryEntry {
mangaId: number; mangaId: number;
mangaTitle: string; mangaTitle: string;
@@ -122,21 +116,13 @@ export interface BookmarkEntry {
chapterName: string; chapterName: string;
pageNumber: number; pageNumber: number;
savedAt: number; savedAt: number;
/** Optional user label, e.g. "before the fight scene" */
label?: string; label?: string;
} }
/**
* ReadLogEntry append-only record of every chapter-completion event.
* Unlike HistoryEntry (which dedupes per chapter for the "continue" UI),
* this log never overwrites existing entries. It is the source of truth
* for all reading stats.
*/
export interface ReadLogEntry { export interface ReadLogEntry {
mangaId: number; mangaId: number;
chapterId: number; chapterId: number;
readAt: number; readAt: number;
/** Minutes spent on this chapter (estimated from page count or default). */
minutes: number; minutes: number;
} }
@@ -151,7 +137,7 @@ export interface ReadingStats {
lastStreakDate: string; lastStreakDate: string;
} }
const AVG_MIN_PER_CHAPTER = 5; // fallback when no page count is available const AVG_MIN_PER_CHAPTER = 5;
export const DEFAULT_READING_STATS: ReadingStats = { export const DEFAULT_READING_STATS: ReadingStats = {
totalChaptersRead: 0, totalChaptersRead: 0,
@@ -178,16 +164,32 @@ export interface ActiveDownload {
progress: number; progress: number;
} }
export interface MangaPrefs {
autoDownload: boolean;
downloadAhead: number;
deleteOnRead: boolean;
deleteDelayHours: number;
maxKeepChapters: number;
pauseUpdates: boolean;
refreshInterval: "global" | "daily" | "weekly" | "manual";
preferredScanlator: string;
}
export const DEFAULT_MANGA_PREFS: MangaPrefs = {
autoDownload: false,
downloadAhead: 0,
deleteOnRead: false,
deleteDelayHours: 0,
maxKeepChapters: 0,
pauseUpdates: false,
refreshInterval: "global",
preferredScanlator: "",
};
export interface Settings { export interface Settings {
pageStyle: PageStyle; pageStyle: PageStyle;
readingDirection: ReadingDirection; readingDirection: ReadingDirection;
fitMode: FitMode; fitMode: FitMode;
/**
* Reader zoom level unitless float multiplier relative to the viewer
* container width. 1.0 = image fills the viewer, 1.5 = 150%, 0.8 = 80%.
* Replaces the old `maxPageWidth` pixel value.
*/
readerZoom: number; readerZoom: number;
pageGap: boolean; pageGap: boolean;
optimizeContrast: boolean; optimizeContrast: boolean;
@@ -202,11 +204,6 @@ export interface Settings {
chapterSortDir: ChapterSortDir; chapterSortDir: ChapterSortDir;
chapterSortMode: ChapterSortMode; chapterSortMode: ChapterSortMode;
chapterPageSize: number; chapterPageSize: number;
/**
* UI zoom level unitless float multiplier applied on top of the
* platform scale factor from the OS/monitor. 1.0 = no user adjustment.
* Replaces the old `uiScale` percentage integer.
*/
uiZoom: number; uiZoom: number;
compactSidebar: boolean; compactSidebar: boolean;
gpuAcceleration: boolean; gpuAcceleration: boolean;
@@ -226,6 +223,7 @@ export interface Settings {
renderLimit: number; renderLimit: number;
heroSlots: (number | null)[]; heroSlots: (number | null)[];
mangaLinks: Record<number, number[]>; mangaLinks: Record<number, number[]>;
mangaPrefs: Record<number, Partial<MangaPrefs>>;
serverAuthUser: string; serverAuthUser: string;
serverAuthPass: string; serverAuthPass: string;
serverAuthEnabled: boolean; serverAuthEnabled: boolean;
@@ -245,36 +243,20 @@ export interface Settings {
appLockPin: string; appLockPin: string;
customThemes: CustomTheme[]; customThemes: CustomTheme[];
hiddenCategoryIds: number[]; hiddenCategoryIds: number[];
/** Category ID that opens by default when the Library tab is first visited. null = no default (shows Saved). */
defaultLibraryCategoryId: number | null; defaultLibraryCategoryId: number | null;
/** nsfwFilteredTags: string[];
* Content filtering managed via the Content tab in Settings. nsfwAllowedSourceIds: string[];
* nsfwFilteredTags: substrings matched against genre tags (case-insensitive). nsfwBlockedSourceIds: string[];
* nsfwAllowedSourceIds: sources explicitly permitted even though isNsfw = true. libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
* nsfwBlockedSourceIds: sources always blocked regardless of tag content. libraryTabStatus: Record<string, LibraryStatusFilter>;
*/ libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
nsfwFilteredTags: string[];
nsfwAllowedSourceIds: string[];
nsfwBlockedSourceIds: string[];
/** Per-tab sort/filter state — keyed by libraryFilter value (e.g. "library", "downloaded", "42"). */
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
libraryTabStatus: Record<string, LibraryStatusFilter>;
/** Per-tab active content filters — keys are LibraryContentFilter, value is true when active. */
libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
// Legacy fields kept for migration reads only — never written after v3.
/** @deprecated use readerZoom */
maxPageWidth?: number; maxPageWidth?: number;
/** @deprecated use uiZoom */
uiScale?: number; uiScale?: number;
/** User-added extra directories to include when scanning storage usage. */
extraScanDirs: string[]; extraScanDirs: string[];
/** Cached downloads path from Suwayomi, kept in sync on storage tab load. */
serverDownloadsPath: string; serverDownloadsPath: string;
/** Cached local source path from Suwayomi, kept in sync on storage tab load. */
serverLocalSourcePath: string; serverLocalSourcePath: string;
} }
export const DEFAULT_SETTINGS: Settings = { export const DEFAULT_SETTINGS: Settings = {
pageStyle: "longstrip", pageStyle: "longstrip",
readingDirection: "ltr", readingDirection: "ltr",
@@ -312,6 +294,7 @@ export const DEFAULT_SETTINGS: Settings = {
renderLimit: 48, renderLimit: 48,
heroSlots: [null, null, null, null], heroSlots: [null, null, null, null],
mangaLinks: {}, mangaLinks: {},
mangaPrefs: {},
serverAuthUser: "", serverAuthUser: "",
serverAuthPass: "", serverAuthPass: "",
serverAuthEnabled: false, serverAuthEnabled: false,
@@ -343,12 +326,8 @@ export const DEFAULT_SETTINGS: Settings = {
serverLocalSourcePath: "", serverLocalSourcePath: "",
}; };
// ── Persistence ───────────────────────────────────────────────────────────────
const STORE_VERSION = 3; const STORE_VERSION = 3;
// Fields reset to their DEFAULT_SETTINGS value on each version bump.
// Add a key here whenever its default changes meaning between releases.
const RESET_ON_UPGRADE: (keyof Settings)[] = [ const RESET_ON_UPGRADE: (keyof Settings)[] = [
"serverBinary", "serverBinary",
"readerZoom", "readerZoom",
@@ -396,10 +375,11 @@ function mergeSettings(saved: any): Settings {
return { return {
...DEFAULT_SETTINGS, ...DEFAULT_SETTINGS,
...saved?.settings, ...saved?.settings,
keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds }, keybinds: { ...DEFAULT_KEYBINDS, ...saved?.settings?.keybinds },
heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null], heroSlots: saved?.settings?.heroSlots ?? [null, null, null, null],
mangaLinks: saved?.settings?.mangaLinks ?? {}, mangaLinks: saved?.settings?.mangaLinks ?? {},
customThemes: saved?.settings?.customThemes ?? [], mangaPrefs: saved?.settings?.mangaPrefs ?? {},
customThemes: saved?.settings?.customThemes ?? [],
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [], hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
libraryTabSort: saved?.settings?.libraryTabSort ?? {}, libraryTabSort: saved?.settings?.libraryTabSort ?? {},
libraryTabStatus: saved?.settings?.libraryTabStatus ?? {}, libraryTabStatus: saved?.settings?.libraryTabStatus ?? {},
@@ -421,32 +401,15 @@ function todayStr(): string {
const genId = () => Math.random().toString(36).slice(2, 10); const genId = () => Math.random().toString(36).slice(2, 10);
// ── Store ─────────────────────────────────────────────────────────────────────
class Store { class Store {
navPage: NavPage = $state(saved?.navPage ?? "home"); navPage: NavPage = $state(saved?.navPage ?? "home");
libraryFilter: LibraryFilter = $state("library"); libraryFilter: LibraryFilter = $state("library");
history: HistoryEntry[] = $state(saved?.history ?? []); history: HistoryEntry[] = $state(saved?.history ?? []);
/** readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
* readLog append-only, never deduped. Every chapter completion/progress bookmarks: BookmarkEntry[] = $state(saved?.bookmarks ?? []);
* event lands here. This is the authoritative source for all reading stats.
* Capped at 5 000 entries; oldest are trimmed first.
*/
readLog: ReadLogEntry[] = $state(saved?.readLog ?? []);
/**
* bookmarks user-placed markers at a specific page in a chapter.
* Capped at 200 entries; oldest are trimmed first when the cap is hit.
*/
bookmarks: BookmarkEntry[] = $state(saved?.bookmarks ?? []);
readingStats: ReadingStats = $state(mergeStats(saved)); readingStats: ReadingStats = $state(mergeStats(saved));
settings: Settings = $state(mergeSettings(saved)); settings: Settings = $state(mergeSettings(saved));
/**
* Bumped each time the reader closes. Home.svelte watches this to know
* when to re-fetch library data and refresh the hero section.
*/
readerSessionId: number = $state(0); readerSessionId: number = $state(0);
genreFilter: string = $state(""); genreFilter: string = $state("");
searchPrefill: string = $state(""); searchPrefill: string = $state("");
activeManga: Manga | null = $state(null); activeManga: Manga | null = $state(null);
@@ -460,25 +423,15 @@ class Store {
toasts: Toast[] = $state([]); toasts: Toast[] = $state([]);
activeChapter: Chapter | null = $state(null); activeChapter: Chapter | null = $state(null);
activeChapterList: Chapter[] = $state([]); activeChapterList: Chapter[] = $state([]);
// UI-only: synced from Tauri window events in App.svelte. Not persisted.
isFullscreen: boolean = $state(false); isFullscreen: boolean = $state(false);
categories: Category[] = $state([]);
// ── Shared category list ──────────────────────────────────────────────────
// Single source of truth for the category list, shared between Library and
// Settings. Library owns fetching; Settings reads and mutates in-place.
// No pub/sub or guard flags needed — both components share this $state ref.
categories: Category[] = $state([]);
// ── Discover session cache ────────────────────────────────────────────────
// Survives navigation within a session but is never persisted to localStorage.
// Key format: "<sourceId>|<type>|<genre>" or "local|<genre>"
discoverCache: Map<string, Manga[]> = $state(new Map()); discoverCache: Map<string, Manga[]> = $state(new Map());
discoverLibraryIds: Set<number> = $state(new Set()); discoverLibraryIds: Set<number> = $state(new Set());
discoverSrcOffset: number = $state(0); discoverSrcOffset: number = $state(0);
constructor() { constructor() {
$effect.root(() => { $effect.root(() => {
$effect(() => { persist({ storeVersion: STORE_VERSION }); }); $effect(() => { persist({ storeVersion: STORE_VERSION }); });
$effect(() => { persist({ navPage: this.navPage }); }); $effect(() => { persist({ navPage: this.navPage }); });
$effect(() => { persist({ libraryFilter: this.libraryFilter }); }); $effect(() => { persist({ libraryFilter: this.libraryFilter }); });
$effect(() => { persist({ history: this.history }); }); $effect(() => { persist({ history: this.history }); });
@@ -490,9 +443,6 @@ class Store {
} }
openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) { openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) {
// Always set activeManga when provided so the Reader has full manga
// context for Discord RPC (setReading) and any other manga-aware logic.
// Callers that already set store.activeManga directly may omit this arg.
if (manga) this.activeManga = manga; if (manga) this.activeManga = manga;
this.activeChapter = chapter; this.activeChapter = chapter;
this.activeChapterList = chapterList; this.activeChapterList = chapterList;
@@ -501,36 +451,20 @@ class Store {
} }
closeReader() { closeReader() {
// Null activeChapter FIRST so the history $effect in Reader can't fire
// one last time with stale chapter + pageNumber=1, overwriting the real
// last-read position with page 1.
this.activeChapter = null; this.activeChapter = null;
this.activeChapterList = []; this.activeChapterList = [];
this.pageUrls = []; this.pageUrls = [];
this.pageNumber = 1; this.pageNumber = 1;
this.readerSessionId += 1; // signals Home to refresh this.readerSessionId += 1;
} }
/**
* Record a reading event.
*
* @param entry - The history entry for the "continue reading" UI.
* @param completed - True when the chapter was fully read (triggers stat
* accrual). False for mid-chapter progress updates.
* @param minutes - Actual minutes to credit; defaults to AVG_MIN_PER_CHAPTER.
*/
addHistory(entry: HistoryEntry, completed = false, minutes = AVG_MIN_PER_CHAPTER) { addHistory(entry: HistoryEntry, completed = false, minutes = AVG_MIN_PER_CHAPTER) {
// ── 1. Update the deduped "continue reading" history ──────────────────
// Always keep the latest position for each chapter at the top.
if (this.history[0]?.chapterId === entry.chapterId) { if (this.history[0]?.chapterId === entry.chapterId) {
this.history[0] = { ...this.history[0], readAt: entry.readAt }; this.history[0] = { ...this.history[0], readAt: entry.readAt };
} else { } else {
this.history = [entry, ...this.history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300); this.history = [entry, ...this.history.filter(x => x.chapterId !== entry.chapterId)].slice(0, 300);
} }
// ── 2. Append to the read log (only on completion) ────────────────────
// This is append-only — every completed chapter read lands here,
// including re-reads. We cap at 5 000 to keep storage bounded.
if (completed) { if (completed) {
const logEntry: ReadLogEntry = { const logEntry: ReadLogEntry = {
mangaId: entry.mangaId, mangaId: entry.mangaId,
@@ -541,12 +475,7 @@ class Store {
this.readLog = [...this.readLog, logEntry].slice(-5000); this.readLog = [...this.readLog, logEntry].slice(-5000);
} }
// ── 3. Recompute stats from the read log ────────────────────────────── const log = completed ? [...this.readLog] : this.readLog;
// Use the log as ground truth so stats are always accurate even after
// history is cleared or entries are back-filled.
const log = completed
? [...this.readLog] // already updated above
: this.readLog;
const uniqueChapters = new Set(log.map(e => e.chapterId)); const uniqueChapters = new Set(log.map(e => e.chapterId));
const uniqueManga = new Set(log.map(e => e.mangaId)); const uniqueManga = new Set(log.map(e => e.mangaId));
@@ -575,15 +504,10 @@ class Store {
}; };
} }
/**
* Add or update a bookmark for the given chapter/page. Only one bookmark
* per chapter is kept adding a second one replaces the first.
*/
addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) {
const bookmark: BookmarkEntry = { ...entry, savedAt: Date.now(), label }; const bookmark: BookmarkEntry = { ...entry, savedAt: Date.now(), label };
this.bookmarks = [ this.bookmarks = [
bookmark, bookmark,
// Keep bookmarks from other manga only — one bookmark per manga at a time
...this.bookmarks.filter(b => b.mangaId !== entry.mangaId), ...this.bookmarks.filter(b => b.mangaId !== entry.mangaId),
].slice(0, 200); ].slice(0, 200);
} }
@@ -600,11 +524,11 @@ class Store {
return this.bookmarks.find(b => b.chapterId === chapterId); return this.bookmarks.find(b => b.chapterId === chapterId);
} }
clearHistory() { this.history = []; this.readLog = []; } clearHistory() { this.history = []; this.readLog = []; }
clearHistoryForManga(mangaId: number) { clearHistoryForManga(mangaId: number) {
this.history = this.history.filter(x => x.mangaId !== mangaId); this.history = this.history.filter(x => x.mangaId !== mangaId);
this.readLog = this.readLog.filter(x => x.mangaId !== mangaId); this.readLog = this.readLog.filter(x => x.mangaId !== mangaId);
// Recompute stats after removal
const uniqueChapters = new Set(this.readLog.map(e => e.chapterId)); const uniqueChapters = new Set(this.readLog.map(e => e.chapterId));
const uniqueManga = new Set(this.readLog.map(e => e.mangaId)); const uniqueManga = new Set(this.readLog.map(e => e.mangaId));
const totalMinutes = this.readLog.reduce((sum, e) => sum + e.minutes, 0); const totalMinutes = this.readLog.reduce((sum, e) => sum + e.minutes, 0);
@@ -623,7 +547,6 @@ class Store {
this.settings = { ...this.settings, heroSlots: [null, null, null, null], mangaLinks: {} }; this.settings = { ...this.settings, heroSlots: [null, null, null, null], mangaLinks: {} };
} }
linkManga(idA: number, idB: number) { linkManga(idA: number, idB: number) {
if (idA === idB) return; if (idA === idB) return;
const links = { ...this.settings.mangaLinks }; const links = { ...this.settings.mangaLinks };
@@ -641,7 +564,7 @@ class Store {
this.settings = { ...this.settings, mangaLinks: links }; this.settings = { ...this.settings, mangaLinks: links };
} }
getLinkedMangaIds(mangaId: number): number[] { return this.settings.mangaLinks[mangaId] ?? []; } getLinkedMangaIds(mangaId: number): number[] { return this.settings.mangaLinks[mangaId] ?? []; }
setHeroSlot(index: 1 | 2 | 3, mangaId: number | null) { setHeroSlot(index: 1 | 2 | 3, mangaId: number | null) {
const slots = [...(this.settings.heroSlots ?? [null, null, null, null])]; const slots = [...(this.settings.heroSlots ?? [null, null, null, null])];
@@ -653,23 +576,22 @@ class Store {
this.toasts = [...this.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5); this.toasts = [...this.toasts, { ...toast, id: Math.random().toString(36).slice(2) }].slice(-5);
} }
dismissToast(id: string) { this.toasts = this.toasts.filter(x => x.id !== id); } dismissToast(id: string) { this.toasts = this.toasts.filter(x => x.id !== id); }
setCategories(cats: Category[]) { this.categories = cats; } setCategories(cats: Category[]) { this.categories = cats; }
setActiveDownloads(next: ActiveDownload[]) { this.activeDownloads = next; } setActiveDownloads(next: ActiveDownload[]) { this.activeDownloads = next; }
setNavPage(next: NavPage) { this.navPage = next; } setNavPage(next: NavPage) { this.navPage = next; }
setLibraryFilter(next: LibraryFilter) { this.libraryFilter = next; } setLibraryFilter(next: LibraryFilter) { this.libraryFilter = next; }
setGenreFilter(next: string) { this.genreFilter = next; } setGenreFilter(next: string) { this.genreFilter = next; }
setSearchPrefill(next: string) { this.searchPrefill = next; } setSearchPrefill(next: string) { this.searchPrefill = next; }
setActiveManga(next: Manga | null) { this.activeManga = next; } setActiveManga(next: Manga | null) { this.activeManga = next; }
setPreviewManga(next: Manga | null) { this.previewManga = next; } setPreviewManga(next: Manga | null) { this.previewManga = next; }
setActiveSource(next: Source | null) { this.activeSource = next; } setActiveSource(next: Source | null) { this.activeSource = next; }
setPageUrls(next: string[]) { this.pageUrls = next; } setPageUrls(next: string[]) { this.pageUrls = next; }
setPageNumber(next: number) { this.pageNumber = next; } setPageNumber(next: number) { this.pageNumber = next; }
setLibraryTagFilter(next: string[]) { this.libraryTagFilter = next; } setLibraryTagFilter(next: string[]) { this.libraryTagFilter = next; }
setSettingsOpen(next: boolean) { this.settingsOpen = next; } setSettingsOpen(next: boolean) { this.settingsOpen = next; }
updateSettings(patch: Partial<Settings>) { this.settings = { ...this.settings, ...patch }; } updateSettings(patch: Partial<Settings>) { this.settings = { ...this.settings, ...patch }; }
resetKeybinds() { this.settings = { ...this.settings, keybinds: DEFAULT_KEYBINDS }; } resetKeybinds() { this.settings = { ...this.settings, keybinds: DEFAULT_KEYBINDS }; }
saveCustomTheme(theme: CustomTheme) { saveCustomTheme(theme: CustomTheme) {
const existing = this.settings.customThemes.findIndex(t => t.id === theme.id); const existing = this.settings.customThemes.findIndex(t => t.id === theme.id);
@@ -685,13 +607,6 @@ class Store {
this.settings = { ...this.settings, customThemes: next, theme: wasActive ? "dark" : this.settings.theme }; this.settings = { ...this.settings, customThemes: next, theme: wasActive ? "dark" : this.settings.theme };
} }
/**
* Auto-assign or remove the "Completed" category for a manga based on
* whether all chapters are read. Pass the `gql` executor to avoid a
* circular import between state.svelte.ts and client.ts.
*
* Call after any batch mark-read/unread operation.
*/
async checkAndMarkCompleted( async checkAndMarkCompleted(
mangaId: number, mangaId: number,
chaps: Chapter[], chaps: Chapter[],
@@ -706,7 +621,6 @@ class Store {
if (!completed) return; if (!completed) return;
if (allRead) { if (allRead) {
await gqlFn(UPDATE_MANGA_CATEGORIES, { mangaId, addTo: [completed.id], removeFrom: [] }).catch(console.error); await gqlFn(UPDATE_MANGA_CATEGORIES, { mangaId, addTo: [completed.id], removeFrom: [] }).catch(console.error);
// Ensure the manga is in the library so it shows up in the Saved tab
if (UPDATE_MANGA) { if (UPDATE_MANGA) {
await gqlFn(UPDATE_MANGA, { id: mangaId, inLibrary: true }).catch(console.error); await gqlFn(UPDATE_MANGA, { id: mangaId, inLibrary: true }).catch(console.error);
} }
@@ -730,43 +644,41 @@ class Store {
export const store = new Store(); export const store = new Store();
// ── Function re-exports — zero call-site changes for actions ──────────────────
export function openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) { store.openReader(chapter, chapterList, manga); } export function openReader(chapter: Chapter, chapterList: Chapter[], manga?: Manga | null) { store.openReader(chapter, chapterList, manga); }
export function closeReader() { store.closeReader(); } export function closeReader() { store.closeReader(); }
export function addHistory(entry: HistoryEntry, completed?: boolean, minutes?: number) { store.addHistory(entry, completed, minutes); } export function addHistory(entry: HistoryEntry, completed?: boolean, minutes?: number) { store.addHistory(entry, completed, minutes); }
export function clearHistory() { store.clearHistory(); } export function clearHistory() { store.clearHistory(); }
export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); } export function clearHistoryForManga(mangaId: number) { store.clearHistoryForManga(mangaId); }
export function wipeAllData() { store.wipeAllData(); } export function wipeAllData() { store.wipeAllData(); }
export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); } export function linkManga(idA: number, idB: number) { store.linkManga(idA, idB); }
export function unlinkManga(idA: number, idB: number) { store.unlinkManga(idA, idB); } export function unlinkManga(idA: number, idB: number) { store.unlinkManga(idA, idB); }
export function getLinkedMangaIds(mangaId: number) { return store.getLinkedMangaIds(mangaId); } export function getLinkedMangaIds(mangaId: number) { return store.getLinkedMangaIds(mangaId); }
export function setHeroSlot(i: 1|2|3, mangaId: number | null) { store.setHeroSlot(i, mangaId); } export function setHeroSlot(i: 1|2|3, mangaId: number | null) { store.setHeroSlot(i, mangaId); }
export function addToast(toast: Omit<Toast, "id">) { store.addToast(toast); } export function addToast(toast: Omit<Toast, "id">) { store.addToast(toast); }
export function dismissToast(id: string) { store.dismissToast(id); } export function dismissToast(id: string) { store.dismissToast(id); }
export function setCategories(cats: Category[]) { store.setCategories(cats); } export function setCategories(cats: Category[]) { store.setCategories(cats); }
export function setActiveDownloads(next: ActiveDownload[]) { store.setActiveDownloads(next); } export function setActiveDownloads(next: ActiveDownload[]) { store.setActiveDownloads(next); }
export function setNavPage(next: NavPage) { store.setNavPage(next); } export function setNavPage(next: NavPage) { store.setNavPage(next); }
export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); } export function setLibraryFilter(next: LibraryFilter) { store.setLibraryFilter(next); }
export function setGenreFilter(next: string) { store.setGenreFilter(next); } export function setGenreFilter(next: string) { store.setGenreFilter(next); }
export function setSearchPrefill(next: string) { store.setSearchPrefill(next); } export function setSearchPrefill(next: string) { store.setSearchPrefill(next); }
export function setActiveManga(next: Manga | null) { store.setActiveManga(next); } export function setActiveManga(next: Manga | null) { store.setActiveManga(next); }
export function setPreviewManga(next: Manga | null) { store.setPreviewManga(next); } export function setPreviewManga(next: Manga | null) { store.setPreviewManga(next); }
export function setActiveSource(next: Source | null) { store.setActiveSource(next); } export function setActiveSource(next: Source | null) { store.setActiveSource(next); }
export function setPageUrls(next: string[]) { store.setPageUrls(next); } export function setPageUrls(next: string[]) { store.setPageUrls(next); }
export function setPageNumber(next: number) { store.setPageNumber(next); } export function setPageNumber(next: number) { store.setPageNumber(next); }
export function setLibraryTagFilter(next: string[]) { store.setLibraryTagFilter(next); } export function setLibraryTagFilter(next: string[]) { store.setLibraryTagFilter(next); }
export function setSettingsOpen(next: boolean) { store.setSettingsOpen(next); } export function setSettingsOpen(next: boolean) { store.setSettingsOpen(next); }
export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); } export function updateSettings(patch: Partial<Settings>) { store.updateSettings(patch); }
export function resetKeybinds() { store.resetKeybinds(); } export function resetKeybinds() { store.resetKeybinds(); }
export function clearDiscoverCache() { store.clearDiscoverCache(); } export function clearDiscoverCache() { store.clearDiscoverCache(); }
export function addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { store.addBookmark(entry, label); } export function addBookmark(entry: Omit<BookmarkEntry, "savedAt">, label?: string) { store.addBookmark(entry, label); }
export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); } export function removeBookmark(chapterId: number) { store.removeBookmark(chapterId); }
export function clearBookmarks() { store.clearBookmarks(); } export function clearBookmarks() { store.clearBookmarks(); }
export function getBookmark(chapterId: number) { return store.getBookmark(chapterId); } export function getBookmark(chapterId: number) { return store.getBookmark(chapterId); }
export function toggleHiddenCategory(id: number) { store.toggleHiddenCategory(id); } export function toggleHiddenCategory(id: number) { store.toggleHiddenCategory(id); }
export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); } export function saveCustomTheme(theme: CustomTheme) { store.saveCustomTheme(theme); }
export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); } export function deleteCustomTheme(id: string) { store.deleteCustomTheme(id); }
export async function checkAndMarkCompleted( export async function checkAndMarkCompleted(
mangaId: number, mangaId: number,
chaps: Chapter[], chaps: Chapter[],