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( items: T[], fn: (item: T) => Promise, signal: AbortSignal, ): Promise { 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 ( {alt} 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([]); const [sourceManga, setSourceManga] = useState([]); 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>(new Map()); const sourcesRef = useRef([]); const abortRef = useRef(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: , 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: , onClick: () => assignMangaToFolder(f.id, m.id), })), ] : []), { separator: true }, { label: "New folder & add", icon: , 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 (
{label} {loadingInitial && filtered.length === 0 ? null : ( {visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length} )} {!loadingInitial && hasMoreNetwork && ( More loading… )}
{loadingInitial && filtered.length === 0 ? (
{Array.from({ length: 50 }).map((_, i) => (
))}
) : filtered.length === 0 ? (
No manga found for "{label}".
) : (
{visibleItems.map((m) => ( ))} {hasMore && (
)}
)} {ctx && ( setCtx(null)} /> )}
); }