mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Feat: Automation Panel (WIP) & SeriesDetail Additions
This commit is contained in:
@@ -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.
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -204,7 +230,8 @@
|
|||||||
$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;
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -24,7 +27,7 @@
|
|||||||
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);
|
||||||
@@ -44,98 +47,55 @@
|
|||||||
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);
|
||||||
|
|
||||||
// Series link state
|
|
||||||
let linkPickerOpen: boolean = $state(false);
|
let linkPickerOpen: boolean = $state(false);
|
||||||
let linkSearch: string = $state("");
|
let linkSearch: string = $state("");
|
||||||
let allMangaForLink: Manga[] = $state([]);
|
let allMangaForLink: Manga[] = $state([]);
|
||||||
let loadingLinkList: boolean = $state(false);
|
let loadingLinkList: boolean = $state(false);
|
||||||
|
|
||||||
// Tracking modal
|
|
||||||
let trackingOpen: boolean = $state(false);
|
|
||||||
|
|
||||||
// Multi-select
|
|
||||||
let selectedIds: Set<number> = $state(new Set());
|
let selectedIds: Set<number> = $state(new Set());
|
||||||
const hasSelection = $derived(selectedIds.size > 0);
|
let sortMenuOpen: boolean = $state(false);
|
||||||
|
let dlDropRef: HTMLDivElement | undefined = $state();
|
||||||
function toggleSelect(id: number, e: MouseEvent | KeyboardEvent) {
|
let folderPickerRef: HTMLDivElement | undefined = $state();
|
||||||
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(); }
|
|
||||||
|
|
||||||
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 mangaAbort: AbortController | null = null;
|
||||||
let chapterAbort: AbortController | null = null;
|
let chapterAbort: AbortController | null = null;
|
||||||
let loadingFor: number | null = null;
|
let loadingFor: number | null = null;
|
||||||
|
let _prevChapterIds: Set<number> = new Set();
|
||||||
|
|
||||||
function formatDate(ts: string | null | undefined): string {
|
function focusOnMount(node: HTMLElement) { node.focus(); }
|
||||||
if (!ts) return "";
|
|
||||||
const n = Number(ts);
|
const mangaPrefs = $derived.by((): Partial<MangaPrefs> => {
|
||||||
const d = new Date(n > 1e10 ? n : n * 1000);
|
if (!store.activeManga) return {};
|
||||||
return d.toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric" });
|
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];
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyChapters(nodes: Chapter[]) {
|
const hasSelection = $derived(selectedIds.size > 0);
|
||||||
chapters = nodes;
|
|
||||||
if (store.activeManga && nodes.length > 0) checkAndMarkCompleted(store.activeManga.id, nodes);
|
|
||||||
}
|
|
||||||
|
|
||||||
const sortDir = $derived(store.settings.chapterSortDir);
|
const sortDir = $derived(store.settings.chapterSortDir);
|
||||||
const sortMode = $derived(store.settings.chapterSortMode ?? "source");
|
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).
|
|
||||||
* Always passed to openReader so the Reader's idx-based prev/next
|
|
||||||
* navigation is direction-independent of the user's display sort.
|
|
||||||
*/
|
|
||||||
const chaptersAsc = $derived(
|
|
||||||
[...chapters].sort((a, b) => a.sourceOrder - b.sourceOrder)
|
|
||||||
);
|
|
||||||
const totalPages = $derived(Math.ceil(sortedChapters.length / CHAPTERS_PER_PAGE));
|
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 pageChapters = $derived(sortedChapters.slice((chapterPage - 1) * CHAPTERS_PER_PAGE, chapterPage * CHAPTERS_PER_PAGE));
|
||||||
const readCount = $derived(chapters.filter(c => c.isRead).length);
|
const readCount = $derived(chapters.filter(c => c.isRead).length);
|
||||||
const totalCount = $derived(chapters.length);
|
const totalCount = $derived(chapters.length);
|
||||||
const progressPct = $derived(totalCount > 0 ? (readCount / totalCount) * 100 : 0);
|
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,27 +609,16 @@
|
|||||||
{/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}>
|
||||||
@@ -605,18 +641,33 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</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"}>
|
<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}
|
{#if viewMode === "list"}<SquaresFour size={14} weight="light" />{:else}<List size={14} weight="light" />{/if}
|
||||||
</button>
|
</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>
|
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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>
|
||||||
<div class="meta-row">
|
<div class="meta-col">
|
||||||
<span class="meta-key">Published</span>
|
<div class="meta-row"><span class="meta-key">Author</span><span class="meta-val">{displayManga?.author ?? "N/A"}</span></div>
|
||||||
<span class="meta-val">{firstUpload.getTime() === lastUpload.getTime() ? formatDate(firstUpload) : `${formatDate(firstUpload)} – ${formatDate(lastUpload)}`}</span>
|
<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>
|
||||||
{/if}
|
|
||||||
{#if !loadingChapters && downloadedCount > 0}<div class="meta-row"><span class="meta-key">Downloaded</span><span class="meta-val">{downloadedCount} / {totalCount} chapters</span></div>{/if}
|
|
||||||
{#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>
|
</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; }
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|
||||||
|
|||||||
+31
-119
@@ -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 BuiltinTheme = "dark" | "high-contrast" | "light" | "light-contrast" | "midnight" | "warm";
|
||||||
export type Theme = BuiltinTheme | string; // custom themes have string IDs like "custom:abc123"
|
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;
|
||||||
/**
|
|
||||||
* Content filtering — managed via the Content tab in Settings.
|
|
||||||
* nsfwFilteredTags: substrings matched against genre tags (case-insensitive).
|
|
||||||
* nsfwAllowedSourceIds: sources explicitly permitted even though isNsfw = true.
|
|
||||||
* nsfwBlockedSourceIds: sources always blocked regardless of tag content.
|
|
||||||
*/
|
|
||||||
nsfwFilteredTags: string[];
|
nsfwFilteredTags: string[];
|
||||||
nsfwAllowedSourceIds: string[];
|
nsfwAllowedSourceIds: string[];
|
||||||
nsfwBlockedSourceIds: string[];
|
nsfwBlockedSourceIds: string[];
|
||||||
/** Per-tab sort/filter state — keyed by libraryFilter value (e.g. "library", "downloaded", "42"). */
|
|
||||||
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
|
libraryTabSort: Record<string, { mode: LibrarySortMode; dir: LibrarySortDir }>;
|
||||||
libraryTabStatus: Record<string, LibraryStatusFilter>;
|
libraryTabStatus: Record<string, LibraryStatusFilter>;
|
||||||
/** Per-tab active content filters — keys are LibraryContentFilter, value is true when active. */
|
|
||||||
libraryTabFilters: Record<string, Partial<Record<LibraryContentFilter, boolean>>>;
|
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",
|
||||||
@@ -399,6 +378,7 @@ function mergeSettings(saved: any): 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 ?? {},
|
||||||
|
mangaPrefs: saved?.settings?.mangaPrefs ?? {},
|
||||||
customThemes: saved?.settings?.customThemes ?? [],
|
customThemes: saved?.settings?.customThemes ?? [],
|
||||||
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
|
hiddenCategoryIds: saved?.settings?.hiddenCategoryIds ?? [],
|
||||||
libraryTabSort: saved?.settings?.libraryTabSort ?? {},
|
libraryTabSort: saved?.settings?.libraryTabSort ?? {},
|
||||||
@@ -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 — append-only, never deduped. Every chapter completion/progress
|
|
||||||
* 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 ?? []);
|
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 ?? []);
|
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,18 +423,8 @@ 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);
|
||||||
|
|
||||||
// ── 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([]);
|
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);
|
||||||
@@ -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);
|
||||||
}
|
}
|
||||||
@@ -601,10 +525,10 @@ class Store {
|
|||||||
}
|
}
|
||||||
|
|
||||||
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 };
|
||||||
@@ -670,7 +593,6 @@ class Store {
|
|||||||
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);
|
||||||
const next = existing >= 0
|
const next = existing >= 0
|
||||||
@@ -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,8 +644,6 @@ 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); }
|
||||||
|
|||||||
Reference in New Issue
Block a user