import { useEffect, useState, useMemo, useRef, memo } from "react"; import { ArrowRight, Compass, List, BookOpen, Star, Fire, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react"; import GenreDrillPage from "./GenreDrillPage"; import { gql, thumbUrl } from "../../lib/client"; import { UPDATE_MANGA } from "../../lib/queries"; import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache"; import { dedupeSources, dedupeMangaByTitle } from "../../lib/sourceUtils"; import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu"; import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries"; import { useStore } from "../../store"; import type { Manga, Source } from "../../lib/types"; import SourceList from "../sources/SourceList"; import SourceBrowse from "../sources/SourceBrowse"; import s from "./Explore.module.css"; // ── Frecency score ──────────────────────────────────────────────────────────── function frecencyScore(readAt: number, count: number): number { const hoursSince = (Date.now() - readAt) / 3_600_000; return count / Math.log(hoursSince + 2); } // ── Ghost / Skeleton ────────────────────────────────────────────────────────── function GhostCard() { return
; } const GHOST_COUNT = 3; const ROW_CAP = 25; // Hijack vertical wheel delta → horizontal scroll on .row divs function handleRowWheel(e: React.WheelEvent) { if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; const el = e.currentTarget; const canScrollLeft = el.scrollLeft > 0; const canScrollRight = el.scrollLeft < el.scrollWidth - el.clientWidth - 1; if (!canScrollLeft && !canScrollRight) return; e.stopPropagation(); el.scrollLeft += e.deltaY; } function SkeletonRow({ count = 8 }: { count?: number }) { return (
{Array.from({ length: count }).map((_, i) => (
))}
); } // ── Cover image with fade-in ────────────────────────────────────────────────── 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" }} /> ); }); // ── Mini card ───────────────────────────────────────────────────────────────── const MiniCard = memo(function MiniCard({ manga, onClick, onContextMenu, subtitle, progress, }: { manga: Manga; onClick: () => void; onContextMenu?: (e: React.MouseEvent) => void; subtitle?: string; progress?: number; }) { return ( ); }); // ── Explore More end-cap ────────────────────────────────────────────────────── const ExploreMoreCard = memo(function ExploreMoreCard({ genre, onClick, }: { genre: string; onClick: () => void }) { return ( ); }); // ── Section ─────────────────────────────────────────────────────────────────── function Section({ title, icon, onSeeAll, loading, children, }: { title: string; icon?: React.ReactNode; onSeeAll?: () => void; loading?: boolean; children: React.ReactNode; }) { return (
{icon}{title} {onSeeAll && ( )}
{loading ? : children}
); } // ── Main component ──────────────────────────────────────────────────────────── type ExploreMode = "explore" | "sources"; export default function Explore() { const [mode, setMode] = useState("explore"); const activeSource = useStore((s) => s.activeSource); const genreFilter = useStore((s) => s.genreFilter); if (activeSource) return ; if (genreFilter) return ; return (

Explore

{/* Keep ExploreFeed always mounted so data survives tab switches */}
{mode === "sources" && }
); } // ── Explore feed ────────────────────────────────────────────────────────────── const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama"]; function ExploreFeed() { const [allManga, setAllManga] = useState([]); const [loadingLib, setLoadingLib] = useState(true); const [popularManga, setPopularManga] = useState([]); const [loadingPopular, setLoadingPopular] = useState(true); const [genreResults, setGenreResults] = useState>(new Map()); const [loadingGenres, setLoadingGenres] = useState(false); const [sources, setSources] = useState([]); const [loadError, setLoadError] = useState(false); const [retryCount, setRetryCount] = useState(0); const abortRef = useRef(null); const fetchedGenresRef = useRef(""); const history = useStore((s) => s.history); const settings = useStore((s) => s.settings); const setPreviewManga = useStore((s) => s.setPreviewManga); const setGenreFilter = useStore((s) => s.setGenreFilter); const folders = useStore((s) => s.settings.folders); const addFolder = useStore((s) => s.addFolder); const assignMangaToFolder = useStore((s) => s.assignMangaToFolder); const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null); useEffect(() => { return () => { abortRef.current?.abort(); }; }, []); 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(() => { 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); } }, }, ]; } // ── Library + sources load (retries when suwayomi wasn't ready) ───────────── useEffect(() => { // If we already have data, no need to re-fetch (cache hit path) const alreadyLoaded = allManga.length > 0 && sources.length > 0; if (alreadyLoaded) return; setLoadingLib(true); setLoadingPopular(true); setLoadError(false); const preferredLang = settings.preferredExtensionLang || "en"; // Clear stale failed cache entries so we actually retry if (retryCount > 0) { cache.clear(CACHE_KEYS.LIBRARY); cache.clear(CACHE_KEYS.SOURCES); fetchedGenresRef.current = ""; } // Library — fire immediately, independent of sources 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(setAllManga) .catch((e) => { console.error(e); setLoadError(true); }) .finally(() => setLoadingLib(false)); // Sources — then kick off popular AND genres simultaneously cache.get(CACHE_KEYS.SOURCES, () => gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) .then((d) => dedupeSources(d.sources.nodes, preferredLang)) ).then((allSources) => { if (allSources.length === 0) { setLoadingPopular(false); setLoadError(true); return; } // Cap to 2 sources for the explore feed — halves the network calls const topSources = getTopSources(allSources).slice(0, 2); setSources(allSources); // ── Popular — don't block genres ────────────────────────────────── cache.get(CACHE_KEYS.POPULAR, () => Promise.allSettled( topSources.map((src) => gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { source: src.id, type: "POPULAR", page: 1, query: null, }).then((d) => d.fetchSourceManga.mangas) ) ).then((results) => { const merged: Manga[] = []; for (const r of results) if (r.status === "fulfilled") merged.push(...r.value); return dedupeMangaByTitle(merged).slice(0, 30); }) ).then(setPopularManga).catch(console.error).finally(() => setLoadingPopular(false)); // ── Genres — start immediately alongside popular using foundational // genres as a starting point; personalized genres replace these once // library loads. Results stream in as each genre resolves. const genresToFetch = FOUNDATIONAL_GENRES.slice(0, 3); const genreKey = genresToFetch.join(","); if (fetchedGenresRef.current === genreKey) return; fetchedGenresRef.current = genreKey; setLoadingGenres(true); abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; const streamingMap = new Map(); Promise.allSettled( genresToFetch.map((genre) => cache.get(CACHE_KEYS.GENRE(genre), () => Promise.allSettled( topSources.map((src) => gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page: 1, query: genre, }, ctrl.signal).then((d) => d.fetchSourceManga.mangas) ) ).then((results) => { const merged: Manga[] = []; for (const r of results) if (r.status === "fulfilled") merged.push(...r.value); return dedupeMangaByTitle(merged).slice(0, 24); }) ).then((mangas) => { if (ctrl.signal.aborted) return; // Stream: each genre paints immediately as it resolves streamingMap.set(genre, mangas); setGenreResults(new Map(streamingMap)); }) ) ) .catch((e) => { if (e?.name !== "AbortError") console.error(e); }) .finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); }); }) .catch((e) => { console.error(e); setLoadError(true); setLoadingPopular(false); }); // eslint-disable-next-line react-hooks/exhaustive-deps }, [retryCount]); // ── Frecency genres (derived from history + library) ────────────────────── const frecencyGenres = useMemo(() => { const mangaScores = new Map(); const mangaReadAt = new Map(); for (const entry of history) { mangaScores.set(entry.mangaId, (mangaScores.get(entry.mangaId) ?? 0) + 1); if (entry.readAt > (mangaReadAt.get(entry.mangaId) ?? 0)) mangaReadAt.set(entry.mangaId, entry.readAt); } const genreWeights = new Map(); const mangaMap = new Map(allManga.map((m) => [m.id, m])); for (const [mangaId, count] of mangaScores.entries()) { const score = frecencyScore(mangaReadAt.get(mangaId) ?? 0, count); for (const genre of mangaMap.get(mangaId)?.genre ?? []) genreWeights.set(genre, (genreWeights.get(genre) ?? 0) + score); } if (genreWeights.size === 0) allManga.filter((m) => m.inLibrary).forEach((m) => (m.genre ?? []).forEach((g) => genreWeights.set(g, (genreWeights.get(g) ?? 0) + 1))); if (genreWeights.size === 0) return FOUNDATIONAL_GENRES.slice(0, 3); return Array.from(genreWeights.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 3) .map(([g]) => g); }, [allManga, history]); // ── Re-fetch only when personalized genres differ from what's cached ─────── useEffect(() => { if (frecencyGenres.length === 0 || sources.length === 0) return; const genreKey = frecencyGenres.join(","); if (fetchedGenresRef.current === genreKey) return; // already fetched, cache hit fetchedGenresRef.current = genreKey; setLoadingGenres(true); abortRef.current?.abort(); const ctrl = new AbortController(); abortRef.current = ctrl; const topSources = getTopSources(sources).slice(0, 2); const streamingMap = new Map(); Promise.allSettled( frecencyGenres.map((genre) => cache.get(CACHE_KEYS.GENRE(genre), () => Promise.allSettled( topSources.map((src) => gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page: 1, query: genre, }, ctrl.signal).then((d) => d.fetchSourceManga.mangas) ) ).then((results) => { const merged: Manga[] = []; for (const r of results) if (r.status === "fulfilled") merged.push(...r.value); return dedupeMangaByTitle(merged).slice(0, 24); }) ).then((mangas) => { if (ctrl.signal.aborted) return; streamingMap.set(genre, mangas); setGenreResults(new Map(streamingMap)); }) ) ) .catch((e) => { if (e?.name !== "AbortError") console.error(e); }) .finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); }); }, [frecencyGenres, sources]); function openManga(m: Manga) { setPreviewManga(m); } // ── Continue reading ────────────────────────────────────────────────────── const continueReading = useMemo(() => { const mangaMap = new Map(allManga.map((m) => [m.id, m])); const seen = new Set(); const result: { manga: Manga; chapterName: string; progress: number }[] = []; for (const entry of history) { if (seen.has(entry.mangaId)) continue; seen.add(entry.mangaId); const manga = mangaMap.get(entry.mangaId); if (!manga) continue; result.push({ manga, chapterName: entry.chapterName, progress: entry.pageNumber > 0 ? Math.min(entry.pageNumber / 20, 1) : 0 }); if (result.length >= 12) break; } return result; }, [history, allManga]); // ── Recommended ─────────────────────────────────────────────────────────── const recommended = useMemo(() => { if (allManga.length === 0 || frecencyGenres.length === 0) return []; const continueIds = new Set(continueReading.map((r) => r.manga.id)); return allManga .filter((m) => m.inLibrary && !continueIds.has(m.id) && frecencyGenres.some((g) => (m.genre ?? []).includes(g))) .slice(0, 20); }, [allManga, frecencyGenres, continueReading]); const genresLoading = loadingGenres; return (
{(continueReading.length > 0 || loadingLib) && (
} loading={loadingLib}>
{continueReading.slice(0, ROW_CAP).map(({ manga, chapterName, progress }) => ( openManga(manga)} onContextMenu={(e) => openCtx(e, manga)} subtitle={chapterName} progress={progress} /> ))} {Array.from({ length: GHOST_COUNT }).map((_, i) => )}
)} {(recommended.length > 0 || loadingLib) && (
} loading={loadingLib}>
{recommended.slice(0, ROW_CAP).map((m) => ( openManga(m)} onContextMenu={(e) => openCtx(e, m)} /> ))} {Array.from({ length: GHOST_COUNT }).map((_, i) => )}
)} {(popularManga.length > 0 || loadingPopular) && (
1 ? `Popular across ${sources.length} sources` : "Popular"} icon={} loading={loadingPopular} > {sources.length === 0 ? (
No sources installed. Add extensions first.
) : (
{popularManga.slice(0, ROW_CAP).map((m) => ( openManga(m)} onContextMenu={(e) => openCtx(e, m)} /> ))} {Array.from({ length: GHOST_COUNT }).map((_, i) => )}
)}
)} {frecencyGenres.map((genre) => { const items = genreResults.get(genre) ?? []; const isLoading = genresLoading && items.length === 0; if (!isLoading && items.length === 0) return null; return (
setGenreFilter(genre)} loading={isLoading}>
{items.slice(0, ROW_CAP).map((m) => ( openManga(m)} onContextMenu={(e) => openCtx(e, m)} /> ))} {items.length >= ROW_CAP && ( setGenreFilter(genre)} /> )} {Array.from({ length: GHOST_COUNT }).map((_, i) => )}
); })} {!loadingLib && !loadingPopular && !loadingGenres && continueReading.length === 0 && recommended.length === 0 && popularManga.length === 0 && frecencyGenres.every((g) => !genreResults.get(g)?.length) && (
{loadError ? ( <> Could not reach Suwayomi Make sure the server is running, then try again. ) : ( <> Nothing to explore yet Add manga to your library or install sources to get started. )}
)} {ctx && ( setCtx(null)} /> )}
); }