Files
Moku/src/components/explore/GenreDrillPage.tsx
T

384 lines
16 KiB
TypeScript

import { useEffect, useState, useMemo, useRef, memo, useCallback } from "react";
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client";
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA, UPDATE_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
import { useStore } from "../../store";
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
import type { Manga, Source } from "../../lib/types";
import s from "./GenreDrillPage.module.css";
// ── Constants ─────────────────────────────────────────────────────────────────
const PAGE_SIZE = 50;
const INITIAL_PAGES = 3;
const MAX_SOURCES = 12;
const CONCURRENCY = 4;
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* genreFilter in the store is either a single tag ("Action") or a `+`-joined
* multi-tag string ("Action+Romance"). Parse it into an array.
*
* Callers set multi-tag filters via:
* setGenreFilter("Action+Romance")
*
* The Explore feed's "See all" button continues to pass single strings and
* requires no change.
*/
function parseTags(genreFilter: string): string[] {
return genreFilter.split("+").map((t) => t.trim()).filter(Boolean);
}
/** "Action", "Action & Romance", "Action, Romance & Isekai" */
function tagsLabel(tags: string[]): string {
if (tags.length === 1) return tags[0];
return tags.slice(0, -1).join(", ") + " & " + tags[tags.length - 1];
}
/**
* Client-side AND filter.
* Sources only accept a single query string, so we send the first tag and
* drop results that don't also have the remaining tags in their genre list.
*/
function matchesAllTags(m: Manga, tags: string[]): boolean {
const genres = (m.genre ?? []).map((g) => g.toLowerCase());
return tags.every((t) => genres.includes(t.toLowerCase()));
}
async function runConcurrent<T>(
items: T[],
fn: (item: T) => Promise<void>,
signal: AbortSignal,
): Promise<void> {
let i = 0;
async function worker() {
while (i < items.length) {
if (signal.aborted) return;
const item = items[i++];
await fn(item).catch(() => {});
}
}
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
}
// ── CoverImg ──────────────────────────────────────────────────────────────────
const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string; alt: string; className?: string }) {
const [loaded, setLoaded] = useState(false);
return (
<img
src={src} alt={alt} className={className}
loading="lazy" decoding="async"
onLoad={() => setLoaded(true)}
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
/>
);
});
// ── GenreDrillPage ────────────────────────────────────────────────────────────
export default function GenreDrillPage() {
const genreFilter = useStore((st) => st.genreFilter);
const setGenreFilter = useStore((st) => st.setGenreFilter);
const setPreviewManga = useStore((st) => st.setPreviewManga);
const settings = useStore((st) => st.settings);
const folders = useStore((st) => st.settings.folders);
const addFolder = useStore((st) => st.addFolder);
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
// Parse the filter string into individual tags
const tags = useMemo(() => parseTags(genreFilter), [genreFilter]);
// First tag is sent as the source query string (sources accept only one term)
const primaryTag = tags[0] ?? "";
const [libraryManga, setLibraryManga] = useState<Manga[]>([]);
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
const [loadingInitial, setLoadingInitial] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
// Per-source next-page tracker; -1 means exhausted
const nextPageRef = useRef<Map<string, number>>(new Map());
const sourcesRef = useRef<Source[]>([]);
const abortRef = useRef<AbortController | null>(null);
// ── Initial load ─────────────────────────────────────────────────────────
useEffect(() => {
if (tags.length === 0) return;
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoadingInitial(true);
setSourceManga([]);
setLibraryManga([]);
setVisibleCount(PAGE_SIZE);
nextPageRef.current = new Map();
const preferredLang = settings.preferredExtensionLang || "en";
// ── Library (local DB, instant) ───────────────────────────────────────
cache.get(CACHE_KEYS.LIBRARY, () =>
Promise.all([
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
]).then(([all, lib]) => {
const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
return all.mangas.nodes.map((m) => libMap.get(m.id) ?? m);
})
)
.then((manga) => { if (!ctrl.signal.aborted) setLibraryManga(manga); })
.catch((e) => { if (e?.name !== "AbortError") console.error(e); });
// ── Sources: stream results as each source responds ───────────────────
// Source list is stable within a session — cache indefinitely.
cache.get(CACHE_KEYS.SOURCES, () =>
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => dedupeSources(d.sources.nodes.filter((src) => src.id !== "0"), preferredLang)),
Infinity,
).then(async (allSources) => {
const sources = allSources.slice(0, MAX_SOURCES);
sourcesRef.current = sources;
for (const src of sources) nextPageRef.current.set(src.id, -1);
await runConcurrent(sources, async (src) => {
if (ctrl.signal.aborted) return;
// PageSet tracks which pages we've already fetched for this (source, tags) bucket.
// On navigation-away → back the pages are still in the TTL store, so fetchPage
// returns the cached promise immediately without hitting the network.
const ps = getPageSet(src.id, "SEARCH", tags);
const pageItems: Manga[] = [];
for (let page = 1; page <= INITIAL_PAGES; page++) {
if (ctrl.signal.aborted) return;
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
const result = await cache
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
pageKey,
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: primaryTag },
ctrl.signal,
).then((d) => d.fetchSourceManga),
)
.catch((e: any) => {
if (e?.name !== "AbortError") console.error(e);
return null;
});
if (!result || ctrl.signal.aborted) break;
ps.add(page);
// For multi-tag searches: client-side AND filter for tags beyond the first.
// Sources only support a single query string, so we send primaryTag and
// drop results that don't contain the remaining tags in their genre array.
const matching = tags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, tags))
: result.mangas;
pageItems.push(...matching);
if (!result.hasNextPage) {
nextPageRef.current.set(src.id, -1);
break;
} else if (page === INITIAL_PAGES) {
nextPageRef.current.set(src.id, INITIAL_PAGES + 1);
}
}
if (!ctrl.signal.aborted && pageItems.length > 0) {
setSourceManga((prev) => dedupeMangaById([...prev, ...pageItems]));
setLoadingInitial(false);
}
}, ctrl.signal);
if (!ctrl.signal.aborted) setLoadingInitial(false);
}).catch((e) => {
if (e?.name !== "AbortError") console.error(e);
if (!ctrl.signal.aborted) setLoadingInitial(false);
});
return () => { ctrl.abort(); };
// genreFilter (not tags) as the dep — tags is derived from it and would
// cause an extra render on every parse; genreFilter is the stable identity.
}, [genreFilter]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Derived merged list ───────────────────────────────────────────────────
const filtered = useMemo(() => {
// For multi-tag: library results must match ALL tags
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags));
const libIds = new Set(libMatches.map((m) => m.id));
const srcOnly = sourceManga.filter((m) => !libIds.has(m.id));
return dedupeMangaById([...libMatches, ...srcOnly]);
}, [libraryManga, sourceManga, tags]);
// ── Load more ─────────────────────────────────────────────────────────────
const hasMoreVisible = visibleCount < filtered.length;
const hasMoreNetwork = sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
const hasMore = hasMoreVisible || hasMoreNetwork;
const loadMore = useCallback(async () => {
if (loadingMore) return;
// Fast path: buffered results already in memory
if (hasMoreVisible) {
setVisibleCount((v) => v + PAGE_SIZE);
return;
}
// Slow path: fetch next pages from sources
const sources = sourcesRef.current.filter((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
if (!sources.length) return;
setLoadingMore(true);
abortRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
try {
await runConcurrent(sources, async (src) => {
const page = nextPageRef.current.get(src.id)!;
if (ctrl.signal.aborted) return;
const ps = getPageSet(src.id, "SEARCH", tags);
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
const result = await cache
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
pageKey,
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: primaryTag },
ctrl.signal,
).then((d) => d.fetchSourceManga),
)
.catch((e: any) => {
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
return null;
});
if (!result || ctrl.signal.aborted) return;
ps.add(page);
nextPageRef.current.set(src.id, result.hasNextPage ? page + 1 : -1);
const matching = tags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, tags))
: result.mangas;
if (matching.length > 0)
setSourceManga((prev) => dedupeMangaById([...prev, ...matching]));
}, ctrl.signal);
} finally {
if (!ctrl.signal.aborted) {
setVisibleCount((v) => v + PAGE_SIZE);
setLoadingMore(false);
}
}
}, [loadingMore, hasMoreVisible, primaryTag, tags]);
// ── Context menu ──────────────────────────────────────────────────────────
function openCtx(e: React.MouseEvent, m: Manga) {
e.preventDefault(); e.stopPropagation();
setCtx({ x: e.clientX, y: e.clientY, manga: m });
}
function buildCtxItems(m: Manga): ContextMenuEntry[] {
return [
{
label: m.inLibrary ? "In Library" : "Add to library",
icon: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
disabled: m.inLibrary,
onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
.then(() => {
setSourceManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x));
cache.clear(CACHE_KEYS.LIBRARY);
})
.catch(console.error),
},
...(folders.length > 0 ? [
{ separator: true } as ContextMenuEntry,
...folders.map((f): ContextMenuEntry => ({
label: f.mangaIds.includes(m.id) ? `${f.name}` : f.name,
icon: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
onClick: () => assignMangaToFolder(f.id, m.id),
})),
] : []),
{ separator: true },
{
label: "New folder & add",
icon: <FolderSimplePlus size={13} weight="light" />,
onClick: () => {
const name = prompt("Folder name:");
if (name?.trim()) { const id = addFolder(name.trim()); assignMangaToFolder(id, m.id); }
},
},
];
}
const visibleItems = filtered.slice(0, visibleCount);
const label = tagsLabel(tags);
return (
<div className={s.root}>
<div className={s.header}>
<button className={s.back} onClick={() => setGenreFilter("")}>
<ArrowLeft size={13} weight="light" />
<span>Back</span>
</button>
<span className={s.title}>{label}</span>
{loadingInitial && filtered.length === 0 ? null : (
<span className={s.resultCount}>
{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}
</span>
)}
{!loadingInitial && hasMoreNetwork && (
<span className={s.loadingHint}>More loading</span>
)}
</div>
{loadingInitial && filtered.length === 0 ? (
<div className={s.grid}>
{Array.from({ length: 50 }).map((_, i) => (
<div key={i} className={s.cardSkeleton}>
<div className={["skeleton", s.coverSkeleton].join(" ")} />
<div className={["skeleton", s.titleSkeleton].join(" ")} />
</div>
))}
</div>
) : filtered.length === 0 ? (
<div className={s.empty}>No manga found for "{label}".</div>
) : (
<div className={s.grid}>
{visibleItems.map((m) => (
<button key={m.id} className={s.card} onClick={() => setPreviewManga(m)} onContextMenu={(e) => openCtx(e, m)}>
<div className={s.coverWrap}>
<CoverImg src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
{m.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
</div>
<p className={s.cardTitle}>{m.title}</p>
</button>
))}
{hasMore && (
<div className={s.showMoreCell}>
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
{loadingMore
? <><CircleNotch size={13} weight="light" className="anim-spin" style={{ display: "inline-block" }} /> Loading</>
: "Show more"}
</button>
</div>
)}
</div>
)}
{ctx && (
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
)}
</div>
);
}