mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
Feat: Implemented Series-Link
This commit is contained in:
@@ -4,13 +4,12 @@
|
|||||||
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, groupDuplicates, normalizeTitle } from "../../lib/util";
|
import { dedupeSources, dedupeMangaByTitle, dedupeMangaById } 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"];
|
||||||
@@ -57,39 +56,13 @@
|
|||||||
// 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);
|
||||||
|
|
||||||
// ── Dedup helper — always apply id first then title ───────────────────────────
|
// ── Dedup helper — always apply id first then title ───────────────────────────
|
||||||
function dedup(items: Manga[]): Manga[] {
|
function dedup(items: Manga[]): Manga[] {
|
||||||
return dedupeMangaByTitle(dedupeMangaById(items));
|
return dedupeMangaByTitle(dedupeMangaById(items), $settings.mangaLinks);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Concurrent fan-out — conservative concurrency keeps connections free ──────
|
// ── Concurrent fan-out — conservative concurrency keeps connections free ──────
|
||||||
@@ -113,7 +86,6 @@
|
|||||||
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));
|
||||||
}
|
}
|
||||||
@@ -266,11 +238,10 @@
|
|||||||
const lang = $settings.preferredExtensionLang || "en";
|
const lang = $settings.preferredExtensionLang || "en";
|
||||||
|
|
||||||
// Local library — populates "All" tab
|
// Local library — populates "All" tab
|
||||||
cache.get(CACHE_KEYS.LIBRARY, () =>
|
cache.get(CACHE_KEYS.DISCOVER, () =>
|
||||||
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; })
|
||||||
@@ -342,7 +313,7 @@
|
|||||||
{#each visibleGrid as m (m.id)}
|
{#each visibleGrid as m (m.id)}
|
||||||
<button
|
<button
|
||||||
class="manga-card"
|
class="manga-card"
|
||||||
on:click={() => openPreview(m)}
|
on:click={() => previewManga.set(m)}
|
||||||
on:contextmenu={(e) => openCtx(e, m)}
|
on:contextmenu={(e) => openCtx(e, m)}
|
||||||
>
|
>
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
@@ -372,8 +343,6 @@
|
|||||||
<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; }
|
||||||
|
|
||||||
|
|||||||
@@ -33,15 +33,18 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function fetchLibrary() {
|
function fetchLibrary() {
|
||||||
return cache.get(CACHE_KEYS.LIBRARY, () =>
|
return cache.get(
|
||||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then((d) => d.mangas.nodes)
|
CACHE_KEYS.LIBRARY,
|
||||||
|
() => gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then((d) => d.mangas.nodes),
|
||||||
|
DEFAULT_TTL_MS,
|
||||||
|
CACHE_GROUPS.LIBRARY,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadData() {
|
function loadData() {
|
||||||
fetchLibrary()
|
fetchLibrary()
|
||||||
.then((nodes) => {
|
.then((nodes) => {
|
||||||
allManga = dedupeMangaByTitle(dedupeMangaById(nodes));
|
allManga = dedupeMangaByTitle(dedupeMangaById(nodes), $settings.mangaLinks);
|
||||||
error = null;
|
error = null;
|
||||||
})
|
})
|
||||||
.catch((e) => error = e.message)
|
.catch((e) => error = e.message)
|
||||||
@@ -52,7 +55,7 @@
|
|||||||
DEFAULT_TTL_MS,
|
DEFAULT_TTL_MS,
|
||||||
CACHE_GROUPS.LIBRARY,
|
CACHE_GROUPS.LIBRARY,
|
||||||
).then((nodes) => {
|
).then((nodes) => {
|
||||||
allMangaUnfiltered = dedupeMangaByTitle(dedupeMangaById(nodes));
|
allMangaUnfiltered = dedupeMangaByTitle(dedupeMangaById(nodes), $settings.mangaLinks);
|
||||||
}).catch(console.error);
|
}).catch(console.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,8 +139,7 @@
|
|||||||
async function removeFromLibrary(manga: Manga) {
|
async function removeFromLibrary(manga: Manga) {
|
||||||
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.clearGroup(CACHE_GROUPS.LIBRARY); // clears "library" + "all_manga_unfiltered" + notifies subscribers
|
||||||
cache.clearGroup(CACHE_GROUPS.LIBRARY);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function deleteAllDownloads(manga: Manga) {
|
async function deleteAllDownloads(manga: Manga) {
|
||||||
|
|||||||
@@ -3,10 +3,9 @@
|
|||||||
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, normalizeTitle } from "../../lib/util";
|
import { dedupeSources, dedupeMangaById, dedupeMangaByTitle } 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";
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -168,7 +167,6 @@
|
|||||||
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,
|
||||||
);
|
);
|
||||||
@@ -224,7 +222,7 @@
|
|||||||
tag_searchSources
|
tag_searchSources
|
||||||
? [...tag_localResults, ...tag_sourceResults.filter((m) => !tag_localIds.has(m.id))]
|
? [...tag_localResults, ...tag_sourceResults.filter((m) => !tag_localIds.has(m.id))]
|
||||||
: tag_localResults
|
: tag_localResults
|
||||||
));
|
), $settings.mangaLinks);
|
||||||
$: tag_totalVisible = tag_mergedResults.length;
|
$: tag_totalVisible = tag_mergedResults.length;
|
||||||
$: tag_sourceHasMore = tag_searchSources && [...tag_srcNextPage.values()].some((p) => p > 0);
|
$: tag_sourceHasMore = tag_searchSources && [...tag_srcNextPage.values()].some((p) => p > 0);
|
||||||
|
|
||||||
@@ -236,9 +234,16 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Auto-enable source search if local results are sparse (< 20 after initial load)
|
// Auto-enable source search if local results are sparse (< 20 after initial load)
|
||||||
$: if (!tag_loadingLocal && tag_activeTags.length > 0 && tag_localResults.length < 20 && !tag_searchSources && !loadingSources) {
|
// Use a flag so this only fires once per tag set, not on every reactive update
|
||||||
tag_searchSources = true;
|
let tag_autoSearchFired = false;
|
||||||
|
$: if (!tag_loadingLocal && tag_activeTags.length > 0 && !tag_autoSearchFired && !tag_searchSources && !loadingSources) {
|
||||||
|
if (tag_localResults.length < 20) {
|
||||||
|
tag_autoSearchFired = true;
|
||||||
|
tag_searchSources = true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
// Reset the flag when tags change
|
||||||
|
$: { tag_activeTags; tag_autoSearchFired = false; }
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
const _search = tag_searchSources;
|
const _search = tag_searchSources;
|
||||||
@@ -265,7 +270,6 @@
|
|||||||
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;
|
||||||
@@ -282,7 +286,8 @@
|
|||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
tag_abortSource = ctrl;
|
tag_abortSource = ctrl;
|
||||||
|
|
||||||
tag_sourceResults = [];
|
// Don't blank existing results — keep them visible while new ones load.
|
||||||
|
// Only reset if the tags actually changed (tracked by the calling reactive block).
|
||||||
tag_srcNextPage = new Map();
|
tag_srcNextPage = new Map();
|
||||||
tag_loadingSourceSearch = true;
|
tag_loadingSourceSearch = true;
|
||||||
|
|
||||||
@@ -323,8 +328,7 @@
|
|||||||
: result.mangas;
|
: result.mangas;
|
||||||
|
|
||||||
if (matching.length > 0) {
|
if (matching.length > 0) {
|
||||||
addToPool(matching);
|
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), $settings.mangaLinks);
|
||||||
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]));
|
|
||||||
tag_loadingSourceSearch = false;
|
tag_loadingSourceSearch = false;
|
||||||
}
|
}
|
||||||
}, ctrl.signal).finally(() => {
|
}, ctrl.signal).finally(() => {
|
||||||
@@ -397,8 +401,7 @@
|
|||||||
: result.mangas;
|
: result.mangas;
|
||||||
|
|
||||||
if (matching.length > 0) {
|
if (matching.length > 0) {
|
||||||
addToPool(matching);
|
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]), $settings.mangaLinks);
|
||||||
tag_sourceResults = dedupeMangaByTitle(dedupeMangaById([...tag_sourceResults, ...matching]));
|
|
||||||
}
|
}
|
||||||
}, ctrl.signal);
|
}, ctrl.signal);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -488,26 +491,6 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// ── 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();
|
||||||
@@ -710,7 +693,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={() => openPreview(m)}>
|
<button class="card" on:click={() => previewManga.set(m)}>
|
||||||
<div class="coverWrap">
|
<div class="coverWrap">
|
||||||
<img
|
<img
|
||||||
src={thumbUrl(m.thumbnailUrl)}
|
src={thumbUrl(m.thumbnailUrl)}
|
||||||
@@ -860,7 +843,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={() => openPreview(m)}>
|
<button class="card" on:click={() => previewManga.set(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}
|
||||||
@@ -1039,7 +1022,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={() => openPreview(m)}>
|
<button class="card" on:click={() => previewManga.set(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}
|
||||||
@@ -1083,8 +1066,6 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MangaPreview alternates={previewAlternates} />
|
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
/* ── Root ──────────────────────────────────────────────────────────────── */
|
/* ── Root ──────────────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from "svelte";
|
import { onMount, onDestroy } from "svelte";
|
||||||
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder } from "phosphor-svelte";
|
import { X, BookmarkSimple, ArrowSquareOut, Play, CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, LinkSimpleHorizontalBreak } from "phosphor-svelte";
|
||||||
import { gql, thumbUrl } from "../../lib/client";
|
import { gql, thumbUrl } from "../../lib/client";
|
||||||
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, linkManga, unlinkManga, getLinkedMangaIds } from "../../store";
|
||||||
import { groupDuplicates } from "../../lib/util";
|
|
||||||
import type { Manga, Chapter } from "../../lib/types";
|
import type { Manga, Chapter } from "../../lib/types";
|
||||||
|
import { GET_ALL_MANGA } from "../../lib/queries";
|
||||||
|
|
||||||
let manga: Manga | null = null;
|
let manga: Manga | null = null;
|
||||||
let chapters: Chapter[] = [];
|
let chapters: Chapter[] = [];
|
||||||
@@ -21,12 +21,45 @@
|
|||||||
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)
|
// ── Link picker ──────────────────────────────────────────────────────────────
|
||||||
// Populated by the parent via the `alternates` prop if available.
|
let linkPickerOpen = false;
|
||||||
export let alternates: Manga[] = [];
|
let linkSearch = "";
|
||||||
let selectedThumb: string | null = null; // null = use manga's own thumbnail
|
let allMangaForLink: Manga[] = [];
|
||||||
|
let loadingLinkList = false;
|
||||||
|
|
||||||
$: effectiveThumb = selectedThumb ?? ($previewManga?.thumbnailUrl ?? "");
|
$: linkedIds = $previewManga ? ($settings.mangaLinks?.[$previewManga.id] ?? []) : [];
|
||||||
|
|
||||||
|
$: linkPickerResults = (() => {
|
||||||
|
const others = allMangaForLink.filter(m => m.id !== $previewManga?.id);
|
||||||
|
const q = linkSearch.trim().toLowerCase();
|
||||||
|
const filtered = q ? others.filter(m => m.title.toLowerCase().includes(q)) : others;
|
||||||
|
// Linked entries bubble to the top
|
||||||
|
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() {
|
||||||
|
linkPickerOpen = true;
|
||||||
|
linkSearch = "";
|
||||||
|
if (allMangaForLink.length) return;
|
||||||
|
loadingLinkList = true;
|
||||||
|
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA)
|
||||||
|
.then(d => { allMangaForLink = d.mangas.nodes; })
|
||||||
|
.catch(console.error)
|
||||||
|
.finally(() => { loadingLinkList = false; });
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLinkPicker() { linkPickerOpen = false; linkSearch = ""; }
|
||||||
|
|
||||||
|
function handleLink(other: Manga) {
|
||||||
|
if (!$previewManga) return;
|
||||||
|
if (linkedIds.includes(other.id)) {
|
||||||
|
unlinkManga($previewManga.id, other.id);
|
||||||
|
} else {
|
||||||
|
linkManga($previewManga.id, other.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let detailAbort: AbortController | null = null;
|
let detailAbort: AbortController | null = null;
|
||||||
let chapterAbort: AbortController | null = null;
|
let chapterAbort: AbortController | null = null;
|
||||||
@@ -36,7 +69,6 @@
|
|||||||
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" }); }
|
||||||
@@ -70,7 +102,10 @@
|
|||||||
detailAbort?.abort(); chapterAbort?.abort();
|
detailAbort?.abort(); chapterAbort?.abort();
|
||||||
const dCtrl = new AbortController(), cCtrl = new AbortController();
|
const dCtrl = new AbortController(), cCtrl = new AbortController();
|
||||||
detailAbort = dCtrl; chapterAbort = cCtrl;
|
detailAbort = dCtrl; chapterAbort = cCtrl;
|
||||||
manga = null; chapters = []; descExpanded = false; fetchError = null;
|
// Pre-populate from the shallow grid entry immediately — description/genres
|
||||||
|
// will appear as soon as the full fetch resolves, but title/cover show now.
|
||||||
|
manga = $previewManga as Manga;
|
||||||
|
chapters = []; descExpanded = false; fetchError = null;
|
||||||
loadingDetail = true; loadingChapters = true;
|
loadingDetail = true; loadingChapters = true;
|
||||||
|
|
||||||
(async (): Promise<Manga> => {
|
(async (): Promise<Manga> => {
|
||||||
@@ -172,57 +207,29 @@
|
|||||||
<!-- Cover column -->
|
<!-- Cover column -->
|
||||||
<div class="cover-col">
|
<div class="cover-col">
|
||||||
<div class="cover-wrap">
|
<div class="cover-wrap">
|
||||||
<img src={thumbUrl(effectiveThumb)} alt={displayManga?.title} class="cover" />
|
<img src={thumbUrl($previewManga.thumbnailUrl)} 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">
|
||||||
|
|
||||||
|
<!-- Library -->
|
||||||
<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"} />
|
<span class="action-icon"><BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} /></span>
|
||||||
{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}
|
<span class="action-label">{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Series Detail -->
|
||||||
<button class="action-btn" on:click={openSeriesDetail}>
|
<button class="action-btn" on:click={openSeriesDetail}>
|
||||||
<Books size={13} weight="light" /> Series Detail
|
<span class="action-icon"><Books size={13} weight="light" /></span>
|
||||||
|
<span class="action-label">Series Detail</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- Folders -->
|
||||||
<div class="folder-wrap" bind:this={folderRef}>
|
<div class="folder-wrap" bind:this={folderRef}>
|
||||||
<button class="action-btn" class:folder-active={assignedFolders.length > 0} on:click={() => folderOpen = !folderOpen}>
|
<button class="action-btn" class:active={assignedFolders.length > 0} on:click={() => folderOpen = !folderOpen}>
|
||||||
<FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} />
|
<span class="action-icon"><FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} /></span>
|
||||||
<span class="action-label">{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}</span>
|
<span class="action-label">{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}</span>
|
||||||
</button>
|
</button>
|
||||||
{#if folderOpen}
|
{#if folderOpen}
|
||||||
@@ -249,6 +256,13 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Series Link -->
|
||||||
|
<button class="action-btn" class:active={linkedIds.length > 0} on:click={openLinkPicker}>
|
||||||
|
<span class="action-icon"><LinkSimpleHorizontalBreak size={13} weight={linkedIds.length > 0 ? "fill" : "light"} /></span>
|
||||||
|
<span class="action-label">{linkedIds.length > 0 ? `Series Link (${linkedIds.length})` : "Series Link"}</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -364,6 +378,47 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Link picker modal ───────────────────────────────────────────────────── -->
|
||||||
|
{#if linkPickerOpen}
|
||||||
|
<div class="link-backdrop" role="presentation"
|
||||||
|
on:click|self={closeLinkPicker}
|
||||||
|
on:keydown={(e) => e.key === "Escape" && closeLinkPicker()}>
|
||||||
|
<div class="link-modal">
|
||||||
|
<div class="link-header">
|
||||||
|
<span class="link-title">Link as same series</span>
|
||||||
|
<button class="close-btn" on:click={closeLinkPicker}><X size={14} weight="light" /></button>
|
||||||
|
</div>
|
||||||
|
<p class="link-hint">
|
||||||
|
Mark two manga as the same series so duplicates are merged in search and discover.
|
||||||
|
Click a linked entry again to unlink.
|
||||||
|
</p>
|
||||||
|
<div class="link-search-wrap">
|
||||||
|
<input class="link-search" placeholder="Search your library…" bind:value={linkSearch} use:focus />
|
||||||
|
</div>
|
||||||
|
<div class="link-list">
|
||||||
|
{#if loadingLinkList}
|
||||||
|
<p class="link-empty">Loading…</p>
|
||||||
|
{:else if linkPickerResults.length === 0}
|
||||||
|
<p class="link-empty">No results</p>
|
||||||
|
{:else}
|
||||||
|
{#each linkPickerResults as m (m.id)}
|
||||||
|
{@const isLinked = linkedIds.includes(m.id)}
|
||||||
|
<button class="link-row" class:link-row-linked={isLinked} on:click={() => handleLink(m)}>
|
||||||
|
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} class="link-thumb" loading="lazy" decoding="async" />
|
||||||
|
<div class="link-info">
|
||||||
|
<span class="link-manga-title">{m.title}</span>
|
||||||
|
{#if m.source?.displayName}<span class="link-source">{m.source.displayName}</span>{/if}
|
||||||
|
</div>
|
||||||
|
<span class="link-status">{isLinked ? "✓ Linked" : "Link"}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<script context="module">
|
<script context="module">
|
||||||
@@ -373,17 +428,24 @@
|
|||||||
<style>
|
<style>
|
||||||
.backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.72); z-index: var(--z-settings); display: flex; align-items: center; justify-content: center; animation: fadeIn 0.12s ease both; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
|
.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; animation: fadeIn 0.12s ease both; backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); }
|
||||||
.modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); }
|
.modal { width: min(800px, calc(100vw - 48px)); height: min(560px, calc(100vh - 80px)); display: flex; background: var(--bg-surface); border: 1px solid var(--border-base); border-radius: var(--radius-xl); overflow: hidden; animation: scaleIn 0.16s ease both; box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6); }
|
||||||
.cover-col { width: 190px; flex-shrink: 0; background: var(--bg-raised); border-right: 1px solid var(--border-dim); display: flex; flex-direction: column; padding: var(--sp-5) var(--sp-4) var(--sp-4); gap: var(--sp-3); overflow-y: auto; overflow-x: hidden; scrollbar-width: none; }
|
.cover-col { width: 190px; flex-shrink: 0; background: var(--bg-raised); border-right: 1px solid var(--border-dim); display: flex; flex-direction: column; padding: var(--sp-5) var(--sp-4) var(--sp-4); gap: var(--sp-3); overflow: hidden; }
|
||||||
.cover-col::-webkit-scrollbar { display: none; }
|
|
||||||
.cover-wrap { position: relative; width: 100%; }
|
.cover-wrap { position: relative; width: 100%; }
|
||||||
.cover { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--border-dim); display: block; }
|
.cover { width: 100%; aspect-ratio: 2/3; object-fit: cover; border-radius: var(--radius-md); border: 1px solid var(--border-dim); display: block; }
|
||||||
.cover-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: var(--radius-md); color: var(--text-faint); }
|
.cover-spinner { position: absolute; inset: 0; display: flex; align-items: center; justify-content: center; background: rgba(0,0,0,0.35); border-radius: var(--radius-md); color: var(--text-faint); }
|
||||||
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
|
.cover-actions { display: flex; flex-direction: column; gap: var(--sp-2); }
|
||||||
.action-btn { display: flex; align-items: center; justify-content: center; gap: var(--sp-2); width: 100%; padding: 7px var(--sp-3); border-radius: var(--radius-md); font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); border: 1px solid var(--border-strong); background: none; color: var(--text-muted); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; text-align: center; }
|
.action-btn {
|
||||||
|
display: flex; align-items: center; gap: var(--sp-2); width: 100%;
|
||||||
|
padding: 7px var(--sp-3); border-radius: var(--radius-md);
|
||||||
|
font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide);
|
||||||
|
border: 1px solid var(--border-strong); background: none; color: var(--text-muted);
|
||||||
|
cursor: pointer; text-align: left;
|
||||||
|
transition: color var(--t-base), border-color var(--t-base), background var(--t-base);
|
||||||
|
}
|
||||||
.action-btn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
|
.action-btn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); }
|
||||||
.action-btn:disabled { opacity: 0.4; cursor: default; }
|
.action-btn:disabled { opacity: 0.4; cursor: default; }
|
||||||
.action-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
.action-btn.active { background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); }
|
||||||
.action-btn.folder-active { color: var(--text-secondary); border-color: var(--border-strong); }
|
/* Fixed-width icon slot — keeps all labels optically aligned */
|
||||||
|
.action-icon { display: flex; align-items: center; justify-content: center; width: 16px; flex-shrink: 0; }
|
||||||
.action-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
.action-label { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; }
|
||||||
.folder-wrap { position: relative; width: 100%; }
|
.folder-wrap { position: relative; width: 100%; }
|
||||||
.folder-menu { position: absolute; bottom: calc(100% + 4px); left: 0; right: 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); display: flex; flex-direction: column; gap: 1px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 10; animation: scaleIn 0.1s ease both; transform-origin: bottom center; }
|
.folder-menu { position: absolute; bottom: calc(100% + 4px); left: 0; right: 0; background: var(--bg-raised); border: 1px solid var(--border-base); border-radius: var(--radius-md); padding: var(--sp-1); display: flex; flex-direction: column; gap: 1px; box-shadow: 0 8px 24px rgba(0,0,0,0.5); z-index: 10; animation: scaleIn 0.1s ease both; transform-origin: bottom center; }
|
||||||
@@ -408,7 +470,8 @@
|
|||||||
.sk-byline { height: 14px; width: 55%; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
|
.sk-byline { height: 14px; width: 55%; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
|
||||||
.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; flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
.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; flex-shrink: 0; transition: color var(--t-base), background var(--t-base); }
|
||||||
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
.close-btn:hover { color: var(--text-muted); background: var(--bg-raised); }
|
||||||
.content-body { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); scrollbar-width: thin; }
|
.content-body { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-4); scrollbar-width: none; }
|
||||||
|
.content-body::-webkit-scrollbar { display: none; }
|
||||||
.error-banner { font-family: var(--font-ui); font-size: var(--text-xs); color: #f59e0b; background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.25); border-radius: var(--radius-sm); padding: 6px var(--sp-3); }
|
.error-banner { font-family: var(--font-ui); font-size: var(--text-xs); color: #f59e0b; background: rgba(245,158,11,0.1); border: 1px solid rgba(245,158,11,0.25); border-radius: var(--radius-sm); padding: 6px var(--sp-3); }
|
||||||
.sk-row { display: flex; gap: var(--sp-2); align-items: center; }
|
.sk-row { display: flex; gap: var(--sp-2); align-items: center; }
|
||||||
.sk-badge { height: 20px; width: 54px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
|
.sk-badge { height: 20px; width: 54px; background: var(--bg-overlay); border-radius: var(--radius-sm); animation: pulse 1.4s ease infinite; }
|
||||||
@@ -445,40 +508,29 @@
|
|||||||
.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 ──────────────────────────────────────────────────── */
|
/* ── Link picker ─────────────────────────────────────────────────────────── */
|
||||||
.thumb-switcher {
|
.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; }
|
||||||
display: flex; flex-direction: column; gap: var(--sp-2);
|
.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; }
|
||||||
border-top: 1px solid var(--border-dim); padding-top: var(--sp-3);
|
.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-title { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); }
|
||||||
.thumb-switcher-label {
|
.link-hint { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); line-height: var(--leading-snug); padding: var(--sp-3) var(--sp-5) 0; flex-shrink: 0; }
|
||||||
font-family: var(--font-ui); font-size: var(--text-2xs);
|
.link-search-wrap { padding: var(--sp-3) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; }
|
||||||
color: var(--text-faint); letter-spacing: var(--tracking-wider);
|
.link-search { width: 100%; background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-md); padding: 6px 10px; color: var(--text-primary); font-size: var(--text-sm); outline: none; transition: border-color var(--t-base); }
|
||||||
text-transform: uppercase;
|
.link-search:focus { border-color: var(--border-strong); }
|
||||||
}
|
.link-list { flex: 1; overflow-y: auto; padding: var(--sp-2); scrollbar-width: none; }
|
||||||
.thumb-switcher-row {
|
.link-list::-webkit-scrollbar { display: none; }
|
||||||
display: flex; flex-wrap: wrap; gap: var(--sp-2);
|
.link-empty { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); padding: var(--sp-4) var(--sp-3); text-align: center; letter-spacing: var(--tracking-wide); }
|
||||||
}
|
.link-row { display: flex; align-items: center; gap: var(--sp-3); width: 100%; padding: 8px var(--sp-3); border-radius: var(--radius-md); border: none; background: none; text-align: left; cursor: pointer; transition: background var(--t-fast); }
|
||||||
.thumb-option {
|
.link-row:hover { background: var(--bg-raised); }
|
||||||
display: flex; flex-direction: column; align-items: center; gap: 4px;
|
.link-row-linked { background: var(--accent-muted) !important; }
|
||||||
background: none; border: 1px solid var(--border-dim);
|
.link-thumb { width: 34px; height: 48px; border-radius: var(--radius-sm); object-fit: cover; flex-shrink: 0; border: 1px solid var(--border-dim); }
|
||||||
border-radius: var(--radius-sm); padding: 4px;
|
.link-info { flex: 1; display: flex; flex-direction: column; gap: 2px; overflow: hidden; min-width: 0; }
|
||||||
cursor: pointer; transition: border-color var(--t-base), background var(--t-base);
|
.link-manga-title { font-size: var(--text-sm); color: var(--text-secondary); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
flex: 1; min-width: 52px; max-width: 64px;
|
.link-source { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wide); }
|
||||||
}
|
.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); }
|
||||||
.thumb-option:hover { border-color: var(--border-strong); background: var(--bg-overlay); }
|
.link-row-linked .link-status { color: var(--accent-fg); border-color: var(--accent-dim); background: var(--accent-muted); }
|
||||||
.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 } }
|
||||||
|
@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } }
|
||||||
|
@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ export const CACHE_GROUPS = {
|
|||||||
export const CACHE_KEYS = {
|
export const CACHE_KEYS = {
|
||||||
LIBRARY: "library",
|
LIBRARY: "library",
|
||||||
ALL_MANGA: "all_manga_unfiltered",
|
ALL_MANGA: "all_manga_unfiltered",
|
||||||
|
DISCOVER: "discover_all_manga", // Discover's unfiltered fetch — separate from library
|
||||||
SOURCES: "sources",
|
SOURCES: "sources",
|
||||||
POPULAR: "popular",
|
POPULAR: "popular",
|
||||||
GENRE: (genre: string) => `genre:${genre}`,
|
GENRE: (genre: string) => `genre:${genre}`,
|
||||||
|
|||||||
+93
-76
@@ -7,11 +7,6 @@ export function cn(...inputs: ClassValue[]) {
|
|||||||
|
|
||||||
// ── Source deduplication ──────────────────────────────────────────────────────
|
// ── Source deduplication ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
|
||||||
* Deduplicates sources by name, preferring the given language.
|
|
||||||
* This prevents fetching MangaDex EN + MangaDex ES + MangaDex FR separately —
|
|
||||||
* only the preferred-lang variant (or alphabetically first fallback) is kept.
|
|
||||||
*/
|
|
||||||
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
export function dedupeSources(sources: Source[], preferredLang: string): Source[] {
|
||||||
const byName = new Map<string, Source[]>();
|
const byName = new Map<string, Source[]>();
|
||||||
for (const src of sources) {
|
for (const src of sources) {
|
||||||
@@ -30,15 +25,10 @@ export function dedupeSources(sources: Source[], preferredLang: string): Source[
|
|||||||
// ── Manga deduplication ───────────────────────────────────────────────────────
|
// ── Manga deduplication ───────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Normalizes a title for fuzzy matching:
|
* Normalizes a title for fuzzy matching.
|
||||||
* - Lowercases and trims
|
* Strips punctuation, articles, and common source-specific suffixes so that
|
||||||
* - Strips common subtitle suffixes: "(Official)", "(Web Comic)", etc.
|
* "The Greatest Estate Developer" and "Yeokdaegeum Yeongji Seolgyesa" won't
|
||||||
* - Removes all non-alphanumeric characters (punctuation, dashes, colons)
|
* match on title alone — but their identical descriptions will catch them.
|
||||||
* - 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 normalizeTitle(title: string): string {
|
export function normalizeTitle(title: string): string {
|
||||||
return title
|
return title
|
||||||
@@ -51,47 +41,110 @@ export function normalizeTitle(title: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds a short fingerprint from a description — first 120 chars, normalized.
|
* Normalizes a string for fingerprinting — strip all non-alpha, collapse spaces.
|
||||||
* 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 {
|
function norm(s: string): string {
|
||||||
if (!desc) return null;
|
return s.toLowerCase().replace(/[^a-z0-9]/g, " ").replace(/\s+/g, " ").trim();
|
||||||
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
|
* Description fingerprint — first 200 normalized chars.
|
||||||
* first occurrence. Runs in a single O(n) pass — no nested loops.
|
* Long enough to reliably identify the same series across sources even when
|
||||||
*
|
* translations differ in punctuation or minor wording.
|
||||||
* Use this when merging results across sources. Same series from different source
|
* Returns null if too short (< 60 chars) to be a reliable signal.
|
||||||
* 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[] {
|
function descFingerprint(desc: string | null | undefined): string | null {
|
||||||
const seenTitles = new Set<string>();
|
if (!desc) return null;
|
||||||
const seenDescs = new Set<string>();
|
const n = norm(desc);
|
||||||
|
if (n.length < 60) return null;
|
||||||
|
return n.slice(0, 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Author fingerprint — normalized concatenation of author + artist.
|
||||||
|
* Used as a tie-breaker / additional signal alongside description.
|
||||||
|
* Two manga with the same authors AND same description are almost certainly
|
||||||
|
* the same series. Returns null if no author info.
|
||||||
|
*/
|
||||||
|
function authorFingerprint(author?: string | null, artist?: string | null): string | null {
|
||||||
|
const parts = [author, artist].filter(Boolean).map(s => norm(s!));
|
||||||
|
if (!parts.length) return null;
|
||||||
|
return parts.sort().join("|");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Deduplicates manga by:
|
||||||
|
* 1. Normalized title
|
||||||
|
* 2. Description fingerprint (first 200 chars)
|
||||||
|
* 3. Author + description together
|
||||||
|
* 4. User-defined links (mangaLinks from store) — explicit "same series" overrides
|
||||||
|
*
|
||||||
|
* Pass `links` as `settings.mangaLinks` to honour user-registered pairs.
|
||||||
|
* When two entries match, the PREFERRED one is kept:
|
||||||
|
* - Library membership wins
|
||||||
|
* - Otherwise higher downloadCount wins
|
||||||
|
* - Otherwise first occurrence wins
|
||||||
|
*/
|
||||||
|
export function dedupeMangaByTitle<T extends {
|
||||||
|
id: number;
|
||||||
|
title: string;
|
||||||
|
description?: string | null;
|
||||||
|
author?: string | null;
|
||||||
|
artist?: string | null;
|
||||||
|
inLibrary?: boolean;
|
||||||
|
downloadCount?: number;
|
||||||
|
}>(items: T[], links: Record<number, number[]> = {}): T[] {
|
||||||
|
const byTitle = new Map<string, number>();
|
||||||
|
const byDesc = new Map<string, number>();
|
||||||
|
const byAuthorDesc = new Map<string, number>();
|
||||||
|
// id → index in out[]
|
||||||
|
const byId = new Map<number, number>();
|
||||||
const out: T[] = [];
|
const out: T[] = [];
|
||||||
|
|
||||||
for (const m of items) {
|
for (const m of items) {
|
||||||
const tk = normalizeTitle(m.title);
|
const tk = normalizeTitle(m.title);
|
||||||
const dk = descFingerprint(m.description);
|
const dk = descFingerprint(m.description);
|
||||||
if (seenTitles.has(tk)) continue;
|
const ak = (dk && m.author) ? `${authorFingerprint(m.author, m.artist)}||${dk}` : null;
|
||||||
if (dk && seenDescs.has(dk)) continue;
|
|
||||||
seenTitles.add(tk);
|
// Check user-defined links first (explicit override)
|
||||||
if (dk) seenDescs.add(dk);
|
const linkedIds = links[m.id] ?? [];
|
||||||
|
const linkedIdx = linkedIds.map(lid => byId.get(lid)).find(i => i !== undefined);
|
||||||
|
|
||||||
|
const existingIdx =
|
||||||
|
linkedIdx ??
|
||||||
|
byTitle.get(tk) ??
|
||||||
|
(dk ? byDesc.get(dk) : undefined) ??
|
||||||
|
(ak ? byAuthorDesc.get(ak) : undefined);
|
||||||
|
|
||||||
|
if (existingIdx !== undefined) {
|
||||||
|
const existing = out[existingIdx];
|
||||||
|
const mBetter =
|
||||||
|
(m.inLibrary && !existing.inLibrary) ||
|
||||||
|
(!existing.inLibrary && (m.downloadCount ?? 0) > (existing.downloadCount ?? 0));
|
||||||
|
|
||||||
|
if (mBetter) {
|
||||||
|
out[existingIdx] = m;
|
||||||
|
byTitle.set(tk, existingIdx);
|
||||||
|
byId.set(m.id, existingIdx);
|
||||||
|
if (dk) byDesc.set(dk, existingIdx);
|
||||||
|
if (ak) byAuthorDesc.set(ak, existingIdx);
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const idx = out.length;
|
||||||
out.push(m);
|
out.push(m);
|
||||||
|
byTitle.set(tk, idx);
|
||||||
|
byId.set(m.id, idx);
|
||||||
|
if (dk) byDesc.set(dk, idx);
|
||||||
|
if (ak) byAuthorDesc.set(ak, idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deduplicates manga by id only (lossless — use when sources are already deduped).
|
* Deduplicates manga by id only (lossless).
|
||||||
* Use this when merging library results with source results for the same query,
|
|
||||||
* where the same manga id may appear in both sets.
|
|
||||||
*/
|
*/
|
||||||
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
|
export function dedupeMangaById<T extends { id: number }>(items: T[]): T[] {
|
||||||
const seen = new Set<number>();
|
const seen = new Set<number>();
|
||||||
@@ -101,39 +154,3 @@ 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;
|
|
||||||
}
|
|
||||||
|
|||||||
+45
-5
@@ -61,12 +61,13 @@ export interface Settings {
|
|||||||
markReadOnNext: boolean; readerDebounceMs: number; theme: Theme;
|
markReadOnNext: boolean; readerDebounceMs: number; theme: Theme;
|
||||||
libraryBranches: boolean;
|
libraryBranches: boolean;
|
||||||
renderLimit: number;
|
renderLimit: number;
|
||||||
/**
|
|
||||||
* Hero slot pinning for the Home page.
|
|
||||||
* 4 slots total. Index 0 = always auto (continue reading, not pinnable).
|
|
||||||
* Indices 1-3: null = auto (fill from recent history), number = pinned mangaId.
|
|
||||||
*/
|
|
||||||
heroSlots: (number | null)[];
|
heroSlots: (number | null)[];
|
||||||
|
/**
|
||||||
|
* User-defined manga links — manually registered "same series" pairs.
|
||||||
|
* Key: mangaId, Value: array of mangaIds this entry is linked to.
|
||||||
|
* Links are bidirectional at lookup time; only stored in one direction.
|
||||||
|
*/
|
||||||
|
mangaLinks: Record<number, number[]>;
|
||||||
}
|
}
|
||||||
|
|
||||||
const COMPLETED_FOLDER_DEFAULT: Folder = {
|
const COMPLETED_FOLDER_DEFAULT: Folder = {
|
||||||
@@ -87,6 +88,7 @@ export const DEFAULT_SETTINGS: Settings = {
|
|||||||
markReadOnNext: true, readerDebounceMs: 120, theme: "dark",
|
markReadOnNext: true, readerDebounceMs: 120, theme: "dark",
|
||||||
libraryBranches: true, renderLimit: 48,
|
libraryBranches: true, renderLimit: 48,
|
||||||
heroSlots: [null, null, null, null],
|
heroSlots: [null, null, null, null],
|
||||||
|
mangaLinks: {},
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Persistence ───────────────────────────────────────────────────────────────
|
// ── Persistence ───────────────────────────────────────────────────────────────
|
||||||
@@ -115,6 +117,7 @@ function mergeSettings(saved: any): Settings {
|
|||||||
folders: [completedFolder, ...otherFolders],
|
folders: [completedFolder, ...otherFolders],
|
||||||
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 ?? {},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -248,6 +251,43 @@ export function checkAndMarkCompleted(mangaId: number, chapters: Chapter[]) {
|
|||||||
else unmarkMangaCompleted(mangaId);
|
else unmarkMangaCompleted(mangaId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Manga links ("same series" user overrides) ────────────────────────────────
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Link two manga as "same series". Bidirectional — looking up either id
|
||||||
|
* will return the other. Idempotent.
|
||||||
|
*/
|
||||||
|
export function linkManga(idA: number, idB: number) {
|
||||||
|
if (idA === idB) return;
|
||||||
|
settings.update(s => {
|
||||||
|
const links = { ...s.mangaLinks };
|
||||||
|
links[idA] = [...new Set([...(links[idA] ?? []), idB])];
|
||||||
|
links[idB] = [...new Set([...(links[idB] ?? []), idA])];
|
||||||
|
return { ...s, mangaLinks: links };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove a link between two manga.
|
||||||
|
*/
|
||||||
|
export function unlinkManga(idA: number, idB: number) {
|
||||||
|
settings.update(s => {
|
||||||
|
const links = { ...s.mangaLinks };
|
||||||
|
links[idA] = (links[idA] ?? []).filter(id => id !== idB);
|
||||||
|
links[idB] = (links[idB] ?? []).filter(id => id !== idA);
|
||||||
|
if (!links[idA].length) delete links[idA];
|
||||||
|
if (!links[idB].length) delete links[idB];
|
||||||
|
return { ...s, mangaLinks: links };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns all mangaIds linked to a given mangaId (direct links only, not transitive).
|
||||||
|
*/
|
||||||
|
export function getLinkedMangaIds(mangaId: number): number[] {
|
||||||
|
return get(settings).mangaLinks[mangaId] ?? [];
|
||||||
|
}
|
||||||
|
|
||||||
// ── Hero slots ────────────────────────────────────────────────────────────────
|
// ── Hero slots ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user