Chore: Attempted De-Dupe Patch #1 & Alternative Thumbnails

This commit is contained in:
Youwes09
2026-03-19 21:39:51 -05:00
parent deb8a5ee02
commit b772b94c6c
8 changed files with 349 additions and 68 deletions
+34 -3
View File
@@ -4,12 +4,13 @@
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries"; import { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache"; import { cache, CACHE_KEYS } from "../../lib/cache";
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } from "../../lib/util"; import { dedupeSources, dedupeMangaByTitle, dedupeMangaById, groupDuplicates, normalizeTitle } from "../../lib/util";
import { settings, previewManga, activeSource, addFolder, assignMangaToFolder } from "../../store"; import { settings, previewManga, activeSource, addFolder, assignMangaToFolder } from "../../store";
import type { Manga, Source } from "../../lib/types"; import type { Manga, Source } from "../../lib/types";
import ContextMenu from "../shared/ContextMenu.svelte"; import ContextMenu from "../shared/ContextMenu.svelte";
import type { MenuEntry } from "../shared/ContextMenu.svelte"; import type { MenuEntry } from "../shared/ContextMenu.svelte";
import SourceBrowse from "../sources/SourceBrowse.svelte"; import SourceBrowse from "../sources/SourceBrowse.svelte";
import MangaPreview from "../shared/MangaPreview.svelte";
// ── Config ──────────────────────────────────────────────────────────────────── // ── Config ────────────────────────────────────────────────────────────────────
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"]; const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
@@ -56,6 +57,32 @@
// Context menu // Context menu
let ctx: { x: number; y: number; manga: Manga } | null = null; let ctx: { x: number; y: number; manga: Manga } | null = null;
// Raw pool of ALL items before dedup — used to find alternates for the preview switcher.
// Keyed by normalised title → array of Manga with that title from different sources.
let rawPool = new Map<string, Manga[]>();
function addToPool(items: Manga[]) {
for (const m of items) {
const k = normalizeTitle(m.title);
if (!rawPool.has(k)) rawPool.set(k, []);
const group = rawPool.get(k)!;
if (!group.some(x => x.id === m.id)) group.push(m);
}
}
// Get alternates (other source variants) for a given manga, excluding itself
function getAlternates(m: Manga): Manga[] {
const k = normalizeTitle(m.title);
return (rawPool.get(k) ?? []).filter(x => x.id !== m.id);
}
// Open preview with alternates pre-computed
let previewAlternates: Manga[] = [];
function openPreview(m: Manga) {
previewAlternates = getAlternates(m);
previewManga.set(m);
}
// ── Derived ─────────────────────────────────────────────────────────────────── // ── Derived ───────────────────────────────────────────────────────────────────
$: visibleGrid = genreResults.get(currentGenre) ?? []; $: visibleGrid = genreResults.get(currentGenre) ?? [];
$: isLoading = genreLoading || (currentGenre === "All" && loadingLib); $: isLoading = genreLoading || (currentGenre === "All" && loadingLib);
@@ -86,11 +113,12 @@
batchTimer = setInterval(() => { batchTimer = setInterval(() => {
if (batchAccum.size === 0) return; if (batchAccum.size === 0) return;
for (const [genre, incoming] of batchAccum) { for (const [genre, incoming] of batchAccum) {
addToPool(incoming);
const current = genreResults.get(genre) ?? []; const current = genreResults.get(genre) ?? [];
genreResults.set(genre, dedup([...current, ...incoming]).slice(0, GRID_LIMIT)); genreResults.set(genre, dedup([...current, ...incoming]).slice(0, GRID_LIMIT));
} }
batchAccum.clear(); batchAccum.clear();
genreResults = new Map(genreResults); // single Svelte reactivity trigger genreResults = new Map(genreResults);
}, BATCH_INTERVAL); }, BATCH_INTERVAL);
} }
@@ -242,6 +270,7 @@
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes) gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
).then(m => { ).then(m => {
allManga = dedupeMangaById(m); allManga = dedupeMangaById(m);
addToPool(allManga);
genreResults.set("All", dedup(allManga).slice(0, GRID_LIMIT)); genreResults.set("All", dedup(allManga).slice(0, GRID_LIMIT));
genreResults = new Map(genreResults); genreResults = new Map(genreResults);
}).catch(e => { console.error(e); loadError = true; }) }).catch(e => { console.error(e); loadError = true; })
@@ -313,7 +342,7 @@
{#each visibleGrid as m (m.id)} {#each visibleGrid as m (m.id)}
<button <button
class="manga-card" class="manga-card"
on:click={() => previewManga.set(m)} on:click={() => openPreview(m)}
on:contextmenu={(e) => openCtx(e, m)} on:contextmenu={(e) => openCtx(e, m)}
> >
<div class="cover-wrap"> <div class="cover-wrap">
@@ -343,6 +372,8 @@
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} /> <ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
{/if} {/if}
<MangaPreview alternates={previewAlternates} />
<style> <style>
.root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; } .root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; }
+4 -1
View File
@@ -128,6 +128,9 @@
$: heroEntry = activeSlot?.kind === "continue" ? activeSlot.entry : null; $: heroEntry = activeSlot?.kind === "continue" ? activeSlot.entry : null;
// Svelte action — focuses element on mount, avoiding the a11y autofocus warning
function focusEl(node: HTMLElement) { node.focus(); }
function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; } function cycleNext() { activeIdx = (activeIdx + 1) % TOTAL_SLOTS; }
function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; } function cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; }
function goToSlot(i: number) { activeIdx = i; } function goToSlot(i: number) { activeIdx = i; }
@@ -437,7 +440,7 @@
</div> </div>
<div class="picker-search-wrap"> <div class="picker-search-wrap">
<MagnifyingGlass size={13} weight="light" class="picker-search-icon" /> <MagnifyingGlass size={13} weight="light" class="picker-search-icon" />
<input class="picker-search" placeholder="Search library…" bind:value={pickerSearch} autofocus /> <input class="picker-search" placeholder="Search library…" bind:value={pickerSearch} use:focusEl />
</div> </div>
<div class="picker-list"> <div class="picker-list">
{#if loadingLibrary} {#if loadingLibrary}
+6 -7
View File
@@ -3,7 +3,7 @@
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash } from "phosphor-svelte"; import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash } from "phosphor-svelte";
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_LIBRARY, GET_ALL_MANGA, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries"; import { GET_LIBRARY, GET_ALL_MANGA, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache"; import { cache, CACHE_KEYS, CACHE_GROUPS, DEFAULT_TTL_MS } from "../../lib/cache";
import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util"; import { dedupeMangaById, dedupeMangaByTitle } from "../../lib/util";
import { settings, activeManga, libraryFilter, genreFilter, activeChapter } from "../../store"; import { settings, activeManga, libraryFilter, genreFilter, activeChapter } from "../../store";
import { addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders } from "../../store"; import { addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders } from "../../store";
@@ -39,7 +39,6 @@
} }
function loadData() { function loadData() {
// Saved tab — library only (inLibrary: true)
fetchLibrary() fetchLibrary()
.then((nodes) => { .then((nodes) => {
allManga = dedupeMangaByTitle(dedupeMangaById(nodes)); allManga = dedupeMangaByTitle(dedupeMangaById(nodes));
@@ -48,10 +47,10 @@
.catch((e) => error = e.message) .catch((e) => error = e.message)
.finally(() => loading = false); .finally(() => loading = false);
// Folder tabs — all manga regardless of inLibrary. cache.get(CACHE_KEYS.ALL_MANGA, () =>
// Cached separately so it doesn't bust the library cache. gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA).then((d) => d.mangas.nodes),
cache.get("all_manga_unfiltered", () => DEFAULT_TTL_MS,
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA).then((d) => d.mangas.nodes) CACHE_GROUPS.LIBRARY,
).then((nodes) => { ).then((nodes) => {
allMangaUnfiltered = dedupeMangaByTitle(dedupeMangaById(nodes)); allMangaUnfiltered = dedupeMangaByTitle(dedupeMangaById(nodes));
}).catch(console.error); }).catch(console.error);
@@ -138,7 +137,7 @@
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error); await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
allManga = allManga.filter((m) => m.id !== manga.id); allManga = allManga.filter((m) => m.id !== manga.id);
cache.clear(CACHE_KEYS.LIBRARY); cache.clear(CACHE_KEYS.LIBRARY);
cache.clear("all_manga_unfiltered"); cache.clearGroup(CACHE_GROUPS.LIBRARY);
} }
async function deleteAllDownloads(manga: Manga) { async function deleteAllDownloads(manga: Manga) {
+33 -5
View File
@@ -3,9 +3,10 @@
import { gql, thumbUrl } from "../../lib/client"; import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries"; import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache"; import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
import { dedupeSources, dedupeMangaById, dedupeMangaByTitle } from "../../lib/util"; import { dedupeSources, dedupeMangaById, dedupeMangaByTitle, normalizeTitle } from "../../lib/util";
import { settings, searchPrefill, previewManga } from "../../store"; import { settings, searchPrefill, previewManga } from "../../store";
import type { Manga, Source } from "../../lib/types"; import type { Manga, Source } from "../../lib/types";
import MangaPreview from "../shared/MangaPreview.svelte";
@@ -167,6 +168,7 @@
ctrl.signal, ctrl.signal,
); );
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
addToPool(d.fetchSourceManga.mangas);
kw_results = kw_results.map((r) => kw_results = kw_results.map((r) =>
r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r, r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r,
); );
@@ -263,6 +265,7 @@
ctrl.signal, ctrl.signal,
).then((d) => { ).then((d) => {
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
addToPool(d.mangas.nodes);
tag_localResults = d.mangas.nodes; tag_localResults = d.mangas.nodes;
tag_totalCount = d.mangas.totalCount; tag_totalCount = d.mangas.totalCount;
tag_localHasNext = d.mangas.pageInfo.hasNextPage; tag_localHasNext = d.mangas.pageInfo.hasNextPage;
@@ -320,6 +323,7 @@
: result.mangas; : result.mangas;
if (matching.length > 0) { if (matching.length > 0) {
addToPool(matching);
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching])); tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]));
tag_loadingSourceSearch = false; tag_loadingSourceSearch = false;
} }
@@ -392,8 +396,10 @@
? result.mangas.filter((m) => matchesAllTags(m, tag_activeTags)) ? result.mangas.filter((m) => matchesAllTags(m, tag_activeTags))
: result.mangas; : result.mangas;
if (matching.length > 0) if (matching.length > 0) {
addToPool(matching);
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching])); tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]));
}
}, ctrl.signal); }, ctrl.signal);
} finally { } finally {
if (!ctrl.signal.aborted) tag_loadingMoreSource = false; if (!ctrl.signal.aborted) tag_loadingMoreSource = false;
@@ -482,6 +488,26 @@
// ── Raw pool + alternates for MangaPreview thumbnail switcher ────────────────
// All manga seen across all sources — keyed by normalised title.
const rawPool = new Map<string, Manga[]>();
function addToPool(items: Manga[]) {
for (const m of items) {
const k = normalizeTitle(m.title);
if (!rawPool.has(k)) rawPool.set(k, []);
const g = rawPool.get(k)!;
if (!g.some(x => x.id === m.id)) g.push(m);
}
}
let previewAlternates: Manga[] = [];
function openPreview(m: Manga) {
const k = normalizeTitle(m.title);
previewAlternates = (rawPool.get(k) ?? []).filter(x => x.id !== m.id);
previewManga.set(m);
}
onDestroy(() => { onDestroy(() => {
kw_abortCtrl?.abort(); kw_abortCtrl?.abort();
tag_abortLocal?.abort(); tag_abortLocal?.abort();
@@ -684,7 +710,7 @@
{:else if mangas.length > 0} {:else if mangas.length > 0}
<div class="sourceRow"> <div class="sourceRow">
{#each mangas.slice(0, ($settings.renderLimit ?? 48)) as m (m.id)} {#each mangas.slice(0, ($settings.renderLimit ?? 48)) as m (m.id)}
<button class="card" on:click={() => previewManga.set(m)}> <button class="card" on:click={() => openPreview(m)}>
<div class="coverWrap"> <div class="coverWrap">
<img <img
src={thumbUrl(m.thumbnailUrl)} src={thumbUrl(m.thumbnailUrl)}
@@ -834,7 +860,7 @@
{:else if tag_mergedResults.length > 0} {:else if tag_mergedResults.length > 0}
<div class="tagGrid"> <div class="tagGrid">
{#each tag_mergedResults as m (m.id)} {#each tag_mergedResults as m (m.id)}
<button class="card" on:click={() => previewManga.set(m)}> <button class="card" on:click={() => openPreview(m)}>
<div class="coverWrap"> <div class="coverWrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" /> <img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if} {#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
@@ -1013,7 +1039,7 @@
{:else if src_browseResults.length > 0} {:else if src_browseResults.length > 0}
<div class="tagGrid"> <div class="tagGrid">
{#each src_browseResults as m (m.id)} {#each src_browseResults as m (m.id)}
<button class="card" on:click={() => previewManga.set(m)}> <button class="card" on:click={() => openPreview(m)}>
<div class="coverWrap"> <div class="coverWrap">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" /> <img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if} {#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
@@ -1057,6 +1083,8 @@
{/if} {/if}
</div> </div>
<MangaPreview alternates={previewAlternates} />
<style> <style>
/* ── Root ──────────────────────────────────────────────────────────────── */ /* ── Root ──────────────────────────────────────────────────────────────── */
-2
View File
@@ -863,7 +863,6 @@
.perf-stat { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); flex-shrink: 0; } .perf-stat { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-secondary); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
/* Storage limit */ /* Storage limit */
.storage-limit-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.storage-limit-input { .storage-limit-input {
width: 64px; text-align: center; width: 64px; text-align: center;
background: var(--bg-raised); border: 1px solid var(--border-dim); background: var(--bg-raised); border: 1px solid var(--border-dim);
@@ -876,5 +875,4 @@
.storage-limit-input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; } .storage-limit-input::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.storage-limit-input:focus { border-color: var(--border-strong); } .storage-limit-input:focus { border-color: var(--border-strong); }
.storage-limit-unit { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; } .storage-limit-unit { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); flex-shrink: 0; }
.storage-limit-note { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--accent-fg); letter-spacing: var(--tracking-wide); padding: 0 var(--sp-3) var(--sp-2); }
</style> </style>
+81 -2
View File
@@ -5,6 +5,7 @@
import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries"; import { GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache"; import { cache, CACHE_KEYS } from "../../lib/cache";
import { settings, previewManga, activeManga, navPage, genreFilter, openReader, addToast, addFolder, assignMangaToFolder, removeMangaFromFolder, checkAndMarkCompleted } from "../../store"; import { settings, previewManga, activeManga, navPage, genreFilter, openReader, addToast, addFolder, assignMangaToFolder, removeMangaFromFolder, checkAndMarkCompleted } from "../../store";
import { groupDuplicates } from "../../lib/util";
import type { Manga, Chapter } from "../../lib/types"; import type { Manga, Chapter } from "../../lib/types";
let manga: Manga | null = null; let manga: Manga | null = null;
@@ -20,6 +21,13 @@
let fetchError: string|null = null; let fetchError: string|null = null;
let folderRef: HTMLDivElement; let folderRef: HTMLDivElement;
// Alternate versions of this manga from other sources (same title/desc, diff source)
// Populated by the parent via the `alternates` prop if available.
export let alternates: Manga[] = [];
let selectedThumb: string | null = null; // null = use manga's own thumbnail
$: effectiveThumb = selectedThumb ?? ($previewManga?.thumbnailUrl ?? "");
let detailAbort: AbortController | null = null; let detailAbort: AbortController | null = null;
let chapterAbort: AbortController | null = null; let chapterAbort: AbortController | null = null;
@@ -28,6 +36,7 @@
previewManga.set(null); previewManga.set(null);
manga = null; chapters = []; descExpanded = false; manga = null; chapters = []; descExpanded = false;
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null; folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
selectedThumb = null;
} }
function formatDate(d: Date) { return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); } function formatDate(d: Date) { return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); }
@@ -157,17 +166,52 @@
</script> </script>
{#if $previewManga} {#if $previewManga}
<div class="backdrop" on:click={(e) => { if (e.target === e.currentTarget) close(); }}> <div class="backdrop" role="presentation" on:click={(e) => { if (e.target === e.currentTarget) close(); }} on:keydown={(e) => { if (e.key === "Escape") close(); }}>
<div class="modal" role="dialog" aria-label="Manga preview"> <div class="modal" role="dialog" aria-label="Manga preview">
<!-- Cover column --> <!-- Cover column -->
<div class="cover-col"> <div class="cover-col">
<div class="cover-wrap"> <div class="cover-wrap">
<img src={thumbUrl($previewManga.thumbnailUrl)} alt={displayManga?.title} class="cover" /> <img src={thumbUrl(effectiveThumb)} alt={displayManga?.title} class="cover" />
{#if loadingDetail} {#if loadingDetail}
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div> <div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
{/if} {/if}
</div> </div>
<!-- Alternate source thumbnails — shown when this manga exists in multiple sources -->
{#if alternates.length > 0}
<div class="thumb-switcher">
<span class="thumb-switcher-label">Sources</span>
<div class="thumb-switcher-row">
<!-- Primary -->
<button
class="thumb-option"
class:thumb-option-active={selectedThumb === null}
on:click={() => selectedThumb = null}
title={displayManga?.source?.displayName ?? "Primary"}
>
<img src={thumbUrl($previewManga?.thumbnailUrl ?? "")} alt="Primary" class="thumb-option-img" loading="lazy" />
{#if displayManga?.source?.displayName}
<span class="thumb-option-label">{displayManga.source.displayName}</span>
{/if}
</button>
<!-- Alternates -->
{#each alternates as alt (alt.id)}
<button
class="thumb-option"
class:thumb-option-active={selectedThumb === alt.thumbnailUrl}
on:click={() => selectedThumb = alt.thumbnailUrl}
title={alt.source?.displayName ?? alt.title}
>
<img src={thumbUrl(alt.thumbnailUrl)} alt={alt.title} class="thumb-option-img" loading="lazy" />
{#if alt.source?.displayName}
<span class="thumb-option-label">{alt.source.displayName}</span>
{/if}
</button>
{/each}
</div>
</div>
{/if}
<div class="cover-actions"> <div class="cover-actions">
<button class="action-btn" class:active={inLibrary} on:click={toggleLibrary} disabled={togglingLib || loadingDetail}> <button class="action-btn" class:active={inLibrary} on:click={toggleLibrary} disabled={togglingLib || loadingDetail}>
<BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} /> <BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} />
@@ -401,5 +445,40 @@
.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); }
.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; }
/* ── Thumbnail switcher ──────────────────────────────────────────────────── */
.thumb-switcher {
display: flex; flex-direction: column; gap: var(--sp-2);
border-top: 1px solid var(--border-dim); padding-top: var(--sp-3);
}
.thumb-switcher-label {
font-family: var(--font-ui); font-size: var(--text-2xs);
color: var(--text-faint); letter-spacing: var(--tracking-wider);
text-transform: uppercase;
}
.thumb-switcher-row {
display: flex; flex-wrap: wrap; gap: var(--sp-2);
}
.thumb-option {
display: flex; flex-direction: column; align-items: center; gap: 4px;
background: none; border: 1px solid var(--border-dim);
border-radius: var(--radius-sm); padding: 4px;
cursor: pointer; transition: border-color var(--t-base), background var(--t-base);
flex: 1; min-width: 52px; max-width: 64px;
}
.thumb-option:hover { border-color: var(--border-strong); background: var(--bg-overlay); }
.thumb-option-active { border-color: var(--accent) !important; background: var(--accent-muted) !important; }
.thumb-option-img {
width: 100%; aspect-ratio: 2/3; object-fit: cover;
border-radius: 2px; display: block;
}
.thumb-option-label {
font-family: var(--font-ui); font-size: 9px;
color: var(--text-faint); letter-spacing: var(--tracking-wide);
text-align: center; line-height: 1.2;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
width: 100%;
}
.thumb-option-active .thumb-option-label { color: var(--accent-fg); }
@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } } @keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } }
</style> </style>
+104 -41
View File
@@ -1,58 +1,107 @@
/** /**
* Session-level request cache. * Session-level request cache v3.
* *
* Key design decisions (v1, preserved): * Key design decisions (preserved from v1/v2):
* - Stores the Promise itself concurrent callers await the same fetch (no thundering herd). * - Stores the Promise itself concurrent callers await the same fetch (no thundering herd).
* - On real errors the entry is evicted so the next call retries. * - On real errors the entry is evicted so the next call retries.
* - AbortErrors do NOT evict the request was cancelled by the user, not failed. * - AbortErrors do NOT evict cancellation failure.
* This is critical: if we evicted on abort, rapid open/close would drain the browser's * - Subscribers are notified when a key is explicitly cleared or updated.
* connection pool (Chromium allows only 6 concurrent connections to the same origin).
* - Subscribers are notified when a key is explicitly cleared (for reactive invalidation).
* *
* v2 additions: * v3 additions:
* - TTL-aware get(): stale entries are re-fetched automatically (default 5 min). * - cache.set(): direct write without a fetcher for optimistic updates and
* Pass Infinity to pin an entry for the session (source list, extension list). * post-mutation cache patching. Notifies subscribers immediately.
* - getPageSet(): lightweight page-number tracker for multi-page browse sessions. * - Invalidation groups: tag a cache key with one or more group strings.
* Mirrors Suwayomi's CACHE_PAGES_KEY pattern so GenreDrillPage / Search TagTab * cache.clearGroup("library") clears ALL keys tagged with "library" in one call.
* can resume a session without re-fetching pages already in memory. * This replaces the pattern of manually calling cache.clear() on every related key.
* - Stable multi-tag cache keys: tag arrays are sorted before joining so * - Subscriber notifications on set() reactive components re-render when the
* ["Action","Romance"] and ["Romance","Action"] share the same bucket. * cache is updated, not just when it's cleared.
* - cache.update(): atomically patch a cached value (read transform write).
*/ */
interface Entry<T> { interface Entry<T> {
promise: Promise<T>; promise: Promise<T>;
fetchedAt: number; // ms since epoch fetchedAt: number;
} }
const store = new Map<string, Entry<unknown>>(); const store = new Map<string, Entry<unknown>>();
const subs = new Map<string, Set<() => void>>(); const subs = new Map<string, Set<() => void>>();
const groups = new Map<string, Set<string>>(); // groupTag → Set<cacheKey>
/** Default revalidation window: 5 min (matches Suwayomi's browse-page TTL). */
export const DEFAULT_TTL_MS = 5 * 60 * 1_000; export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
function notify(key: string) {
subs.get(key)?.forEach((cb) => cb());
}
export const cache = { export const cache = {
/** /**
* Return a cached promise. * Return a cached promise. Re-fetches once older than `ttl` ms.
* Re-fetches automatically once the entry is older than `ttl` ms. * Pass `Infinity` to pin for the session.
* Pass `Infinity` to cache for the entire session (e.g. source/extension lists).
*/ */
get<T>(key: string, fetcher: () => Promise<T>, ttl: number = DEFAULT_TTL_MS): Promise<T> { get<T>(
key: string,
fetcher: () => Promise<T>,
ttl: number = DEFAULT_TTL_MS,
group?: string | string[],
): Promise<T> {
const existing = store.get(key) as Entry<T> | undefined; const existing = store.get(key) as Entry<T> | undefined;
if (existing && Date.now() - existing.fetchedAt < ttl) return existing.promise; if (existing && Date.now() - existing.fetchedAt < ttl) return existing.promise;
const promise = fetcher().catch((err) => { const promise = fetcher().catch((err) => {
// Only evict on real failures, not user cancellations
if (err?.name !== "AbortError") store.delete(key); if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err); return Promise.reject(err);
}) as Promise<T>; }) as Promise<T>;
store.set(key, { promise, fetchedAt: Date.now() }); store.set(key, { promise, fetchedAt: Date.now() });
// Register in invalidation groups
if (group) {
const tags = Array.isArray(group) ? group : [group];
for (const tag of tags) {
if (!groups.has(tag)) groups.set(tag, new Set());
groups.get(tag)!.add(key);
}
}
// Notify subscribers once the fetch resolves (reactive update on new data)
promise.then(() => notify(key)).catch(() => {});
return promise; return promise;
}, },
/**
* Directly write a value into the cache for optimistic updates and
* post-mutation patching. Notifies subscribers immediately.
*/
set<T>(key: string, value: T, group?: string | string[]) {
const promise = Promise.resolve(value);
store.set(key, { promise, fetchedAt: Date.now() });
if (group) {
const tags = Array.isArray(group) ? group : [group];
for (const tag of tags) {
if (!groups.has(tag)) groups.set(tag, new Set());
groups.get(tag)!.add(key);
}
}
notify(key);
},
/**
* Atomically patch a cached value.
* If the key doesn't exist, does nothing.
*/
update<T>(key: string, fn: (prev: T) => T) {
const existing = store.get(key) as Entry<T> | undefined;
if (!existing) return;
const next = existing.promise.then(fn);
store.set(key, { promise: next, fetchedAt: Date.now() });
next.then(() => notify(key)).catch(() => {});
},
has(key: string): boolean { return store.has(key); }, has(key: string): boolean { return store.has(key); },
/** How old (ms) a cached entry is, or undefined if absent. */
ageOf(key: string): number | undefined { ageOf(key: string): number | undefined {
const e = store.get(key); const e = store.get(key);
return e ? Date.now() - e.fetchedAt : undefined; return e ? Date.now() - e.fetchedAt : undefined;
@@ -60,15 +109,30 @@ export const cache = {
clear(key: string) { clear(key: string) {
store.delete(key); store.delete(key);
subs.get(key)?.forEach((cb) => cb()); notify(key);
},
/**
* Clear all keys belonging to an invalidation group.
* e.g. cache.clearGroup("library") clears "library", "all_manga_unfiltered", etc.
*/
clearGroup(tag: string) {
const keys = groups.get(tag);
if (!keys) return;
for (const key of keys) {
store.delete(key);
notify(key);
}
groups.delete(tag);
}, },
clearAll() { clearAll() {
const allKeys = [...store.keys()];
store.clear(); store.clear();
subs.forEach((set) => set.forEach((cb) => cb())); groups.clear();
allKeys.forEach(notify);
}, },
/** Subscribe to cache invalidation for a key. Returns unsubscribe fn. */
subscribe(key: string, cb: () => void): () => void { subscribe(key: string, cb: () => void): () => void {
if (!subs.has(key)) subs.set(key, new Set()); if (!subs.has(key)) subs.set(key, new Set());
subs.get(key)!.add(cb); subs.get(key)!.add(cb);
@@ -78,24 +142,24 @@ export const cache = {
// ── Cache key constants ─────────────────────────────────────────────────────── // ── Cache key constants ───────────────────────────────────────────────────────
/**
* Invalidation group tags.
* cache.clearGroup(CACHE_GROUPS.LIBRARY) clears all library-related keys at once.
*/
export const CACHE_GROUPS = {
LIBRARY: "g:library", // library + all_manga_unfiltered
SOURCES: "g:sources", // sources list + per-source page caches
} as const;
export const CACHE_KEYS = { export const CACHE_KEYS = {
LIBRARY: "library", LIBRARY: "library",
SOURCES: "sources", ALL_MANGA: "all_manga_unfiltered",
POPULAR: "popular", SOURCES: "sources",
POPULAR: "popular",
GENRE: (genre: string) => `genre:${genre}`, GENRE: (genre: string) => `genre:${genre}`,
MANGA: (id: number) => `manga:${id}`, MANGA: (id: number) => `manga:${id}`,
CHAPTERS: (id: number) => `chapters:${id}`, CHAPTERS: (id: number) => `chapters:${id}`,
/**
* Stable key for a browse session's page-number set.
* Tag arrays are sorted so order never creates duplicate buckets
* ["Action","Romance"] and ["Romance","Action"] share one key.
*
* Examples:
* CACHE_KEYS.sourceMangaPages("src123", "POPULAR")
* CACHE_KEYS.sourceMangaPages("src123", "SEARCH", "naruto")
* CACHE_KEYS.sourceMangaPages("src123", "SEARCH", ["Action","Romance"])
*/
sourceMangaPages( sourceMangaPages(
sourceId: string, sourceId: string,
type: "POPULAR" | "LATEST" | "SEARCH", type: "POPULAR" | "LATEST" | "SEARCH",
@@ -105,7 +169,6 @@ export const CACHE_KEYS = {
return `pages:${sourceId}:${type}:${q}`; return `pages:${sourceId}:${type}:${q}`;
}, },
/** Per-page result key. Always pair with sourceMangaPages(). */
sourceMangaPage( sourceMangaPage(
sourceId: string, sourceId: string,
type: "POPULAR" | "LATEST" | "SEARCH", type: "POPULAR" | "LATEST" | "SEARCH",
+87 -7
View File
@@ -30,16 +30,60 @@ export function dedupeSources(sources: Source[], preferredLang: string): Source[
// ── Manga deduplication ─────────────────────────────────────────────────────── // ── Manga deduplication ───────────────────────────────────────────────────────
/** /**
* Deduplicates manga by title (case-insensitive), keeping the first occurrence. * Normalizes a title for fuzzy matching:
* Use this when merging results across sources eliminates the same series * - Lowercases and trims
* appearing multiple times in grids from different source variants. * - Strips common subtitle suffixes: "(Official)", "(Web Comic)", etc.
* - Removes all non-alphanumeric characters (punctuation, dashes, colons)
* - Strips leading articles: "the ", "a ", "an "
* - Collapses whitespace
*
* "The Solo Leveling: Official Comic" "solo leveling official comic"
* "Solo Leveling (Web Comic)" "solo leveling web comic"
*/ */
export function dedupeMangaByTitle<T extends { id: number; title: string }>(items: T[]): T[] { export function normalizeTitle(title: string): string {
const seen = new Set<string>(); return title
.toLowerCase()
.replace(/\(official\)|\(web comic\)|\(webtoon\)|\(manhwa\)|\(manhua\)/gi, "")
.replace(/[^a-z0-9\s]/g, " ")
.replace(/^(the|a|an)\s+/, "")
.replace(/\s+/g, " ")
.trim();
}
/**
* Builds a short fingerprint from a description first 120 chars, normalized.
* Used as a secondary dedup signal when titles differ but the series is the same.
* Returns null if the description is too short to be a reliable signal (< 40 chars).
*/
function descFingerprint(desc: string | null | undefined): string | null {
if (!desc) return null;
const norm = desc.toLowerCase().replace(/[^a-z0-9\s]/g, " ").replace(/\s+/g, " ").trim();
if (norm.length < 40) return null;
return norm.slice(0, 120);
}
/**
* Deduplicates manga by normalized title OR description fingerprint, keeping the
* first occurrence. Runs in a single O(n) pass no nested loops.
*
* Use this when merging results across sources. Same series from different source
* variants (e.g. MangaDex EN + Asura Scans) will be collapsed.
*
* The kept entry is the first one seen, so prefer passing library manga first so
* the richer/preferred entry survives.
*/
export function dedupeMangaByTitle<T extends { id: number; title: string; description?: string | null }>(items: T[]): T[] {
const seenTitles = new Set<string>();
const seenDescs = new Set<string>();
const out: T[] = []; const out: T[] = [];
for (const m of items) { for (const m of items) {
const key = m.title.toLowerCase().trim(); const tk = normalizeTitle(m.title);
if (!seen.has(key)) { seen.add(key); out.push(m); } const dk = descFingerprint(m.description);
if (seenTitles.has(tk)) continue;
if (dk && seenDescs.has(dk)) continue;
seenTitles.add(tk);
if (dk) seenDescs.add(dk);
out.push(m);
} }
return out; return out;
} }
@@ -57,3 +101,39 @@ export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
} }
return out; return out;
} }
/**
* Groups items that share a normalized title or description fingerprint.
* Returns an array of groups single-member groups are non-duplicates,
* multi-member groups are the same series from different sources.
*
* Used by MangaPreview to show alternate thumbnails for merged entries.
*/
export function groupDuplicates<T extends { id: number; title: string; description?: string | null }>(items: T[]): T[][] {
const titleMap = new Map<string, T[]>();
const descMap = new Map<string, T[]>();
for (const m of items) {
const tk = normalizeTitle(m.title);
const dk = descFingerprint(m.description);
const existingGroup = titleMap.get(tk) ?? (dk ? descMap.get(dk) : undefined);
if (existingGroup) {
existingGroup.push(m);
if (!titleMap.has(tk)) titleMap.set(tk, existingGroup);
if (dk && !descMap.has(dk)) descMap.set(dk, existingGroup);
} else {
const group = [m];
titleMap.set(tk, group);
if (dk) descMap.set(dk, group);
}
}
// Return unique groups only
const seen = new Set<T[]>();
const out: T[][] = [];
for (const g of titleMap.values()) {
if (!seen.has(g)) { seen.add(g); out.push(g); }
}
return out;
}