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 { GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
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 type { Manga, Source } from "../../lib/types";
import ContextMenu from "../shared/ContextMenu.svelte";
import type { MenuEntry } from "../shared/ContextMenu.svelte";
import SourceBrowse from "../sources/SourceBrowse.svelte";
import MangaPreview from "../shared/MangaPreview.svelte";
// ── Config ────────────────────────────────────────────────────────────────────
const GENRE_TABS = ["All", "Action", "Romance", "Fantasy", "Comedy", "Drama", "Horror", "Sci-Fi", "Adventure", "Thriller"];
@@ -56,6 +57,32 @@
// Context menu
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 ───────────────────────────────────────────────────────────────────
$: visibleGrid = genreResults.get(currentGenre) ?? [];
$: isLoading = genreLoading || (currentGenre === "All" && loadingLib);
@@ -86,11 +113,12 @@
batchTimer = setInterval(() => {
if (batchAccum.size === 0) return;
for (const [genre, incoming] of batchAccum) {
addToPool(incoming);
const current = genreResults.get(genre) ?? [];
genreResults.set(genre, dedup([...current, ...incoming]).slice(0, GRID_LIMIT));
}
batchAccum.clear();
genreResults = new Map(genreResults); // single Svelte reactivity trigger
genreResults = new Map(genreResults);
}, BATCH_INTERVAL);
}
@@ -242,6 +270,7 @@
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA).then(d => d.mangas.nodes)
).then(m => {
allManga = dedupeMangaById(m);
addToPool(allManga);
genreResults.set("All", dedup(allManga).slice(0, GRID_LIMIT));
genreResults = new Map(genreResults);
}).catch(e => { console.error(e); loadError = true; })
@@ -313,7 +342,7 @@
{#each visibleGrid as m (m.id)}
<button
class="manga-card"
on:click={() => previewManga.set(m)}
on:click={() => openPreview(m)}
on:contextmenu={(e) => openCtx(e, m)}
>
<div class="cover-wrap">
@@ -343,6 +372,8 @@
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => ctx = null} />
{/if}
<MangaPreview alternates={previewAlternates} />
<style>
.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;
// 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 cyclePrev() { activeIdx = (activeIdx - 1 + TOTAL_SLOTS) % TOTAL_SLOTS; }
function goToSlot(i: number) { activeIdx = i; }
@@ -437,7 +440,7 @@
</div>
<div class="picker-search-wrap">
<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 class="picker-list">
{#if loadingLibrary}
+6 -7
View File
@@ -3,7 +3,7 @@
import { MagnifyingGlass, Books, DownloadSimple, Folder, FolderSimplePlus, Trash } from "phosphor-svelte";
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 { 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 { settings, activeManga, libraryFilter, genreFilter, activeChapter } from "../../store";
import { addFolder, assignMangaToFolder, removeMangaFromFolder, getMangaFolders } from "../../store";
@@ -39,7 +39,6 @@
}
function loadData() {
// Saved tab — library only (inLibrary: true)
fetchLibrary()
.then((nodes) => {
allManga = dedupeMangaByTitle(dedupeMangaById(nodes));
@@ -48,10 +47,10 @@
.catch((e) => error = e.message)
.finally(() => loading = false);
// Folder tabs — all manga regardless of inLibrary.
// Cached separately so it doesn't bust the library cache.
cache.get("all_manga_unfiltered", () =>
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA).then((d) => d.mangas.nodes)
cache.get(CACHE_KEYS.ALL_MANGA, () =>
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA).then((d) => d.mangas.nodes),
DEFAULT_TTL_MS,
CACHE_GROUPS.LIBRARY,
).then((nodes) => {
allMangaUnfiltered = dedupeMangaByTitle(dedupeMangaById(nodes));
}).catch(console.error);
@@ -138,7 +137,7 @@
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
allManga = allManga.filter((m) => m.id !== manga.id);
cache.clear(CACHE_KEYS.LIBRARY);
cache.clear("all_manga_unfiltered");
cache.clearGroup(CACHE_GROUPS.LIBRARY);
}
async function deleteAllDownloads(manga: Manga) {
+33 -5
View File
@@ -3,9 +3,10 @@
import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
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 type { Manga, Source } from "../../lib/types";
import MangaPreview from "../shared/MangaPreview.svelte";
@@ -167,6 +168,7 @@
ctrl.signal,
);
if (ctrl.signal.aborted) return;
addToPool(d.fetchSourceManga.mangas);
kw_results = kw_results.map((r) =>
r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r,
);
@@ -263,6 +265,7 @@
ctrl.signal,
).then((d) => {
if (ctrl.signal.aborted) return;
addToPool(d.mangas.nodes);
tag_localResults = d.mangas.nodes;
tag_totalCount = d.mangas.totalCount;
tag_localHasNext = d.mangas.pageInfo.hasNextPage;
@@ -320,6 +323,7 @@
: result.mangas;
if (matching.length > 0) {
addToPool(matching);
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]));
tag_loadingSourceSearch = false;
}
@@ -392,8 +396,10 @@
? result.mangas.filter((m) => matchesAllTags(m, tag_activeTags))
: result.mangas;
if (matching.length > 0)
if (matching.length > 0) {
addToPool(matching);
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]));
}
}, ctrl.signal);
} finally {
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(() => {
kw_abortCtrl?.abort();
tag_abortLocal?.abort();
@@ -684,7 +710,7 @@
{:else if mangas.length > 0}
<div class="sourceRow">
{#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">
<img
src={thumbUrl(m.thumbnailUrl)}
@@ -834,7 +860,7 @@
{:else if tag_mergedResults.length > 0}
<div class="tagGrid">
{#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">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
@@ -1013,7 +1039,7 @@
{:else if src_browseResults.length > 0}
<div class="tagGrid">
{#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">
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="cover" loading="lazy" decoding="async" />
{#if m.inLibrary}<span class="inLibBadge">Saved</span>{/if}
@@ -1057,6 +1083,8 @@
{/if}
</div>
<MangaPreview alternates={previewAlternates} />
<style>
/* ── 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; }
/* Storage limit */
.storage-limit-row { display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; }
.storage-limit-input {
width: 64px; text-align: center;
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: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-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>
+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 { cache, CACHE_KEYS } from "../../lib/cache";
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";
let manga: Manga | null = null;
@@ -20,6 +21,13 @@
let fetchError: string|null = null;
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 chapterAbort: AbortController | null = null;
@@ -28,6 +36,7 @@
previewManga.set(null);
manga = null; chapters = []; descExpanded = false;
folderOpen = false; creatingFolder = false; newFolderName = ""; fetchError = null;
selectedThumb = null;
}
function formatDate(d: Date) { return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); }
@@ -157,17 +166,52 @@
</script>
{#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">
<!-- Cover column -->
<div class="cover-col">
<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}
<div class="cover-spinner"><CircleNotch size={18} weight="light" class="anim-spin" /></div>
{/if}
</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">
<button class="action-btn" class:active={inLibrary} on:click={toggleLibrary} disabled={togglingLib || loadingDetail}>
<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-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; }
/* ── 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 } }
</style>