import { useEffect, useState, useMemo, memo } from "react"; import { ArrowLeft, ArrowRight, Compass, List, BookOpen, Star, Fire, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react"; import { gql, thumbUrl } from "../../lib/client"; import { UPDATE_MANGA } from "../../lib/queries"; 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 "./SourceList"; import SourceBrowse from "./SourceBrowse"; import s from "./Explore.module.css"; // ── Frecency ────────────────────────────────────────────────────────────────── function frecencyScore(readAt: number, count: number): number { const hoursSince = (Date.now() - readAt) / 3_600_000; return count / Math.log(hoursSince + 2); } // ── Ghost card ──────────────────────────────────────────────────────────────── function GhostCard() { return
; } const GHOST_COUNT = 3; // ── Skeleton row ────────────────────────────────────────────────────────────── function SkeletonRow({ count = 8 }: { count?: number }) { return (
{Array.from({ length: count }).map((_, i) => (
))}
); } // ── 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 ( ); }); // ── Genre drill-down ────────────────────────────────────────────────────────── function GenreDrill({ genre, manga, sourceManga, onBack, onOpen, }: { genre: string; manga: Manga[]; sourceManga: Manga[]; onBack: () => void; onOpen: (m: Manga) => void; }) { const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null); const folders = useStore((st) => st.settings.folders); const addFolder = useStore((st) => st.addFolder); const assignMangaToFolder = useStore((st) => st.assignMangaToFolder); 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 }).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 filtered = useMemo(() => { const combined = new Map(); [...manga, ...sourceManga] .filter((m) => (m.genre ?? []).includes(genre)) .forEach((m) => combined.set(m.id, m)); return Array.from(combined.values()); }, [manga, sourceManga, genre]); return (
{genre}
{filtered.map((m) => ( ))} {filtered.length === 0 && (
No manga found for {genre}.
)}
{ctx && ( setCtx(null)} /> )}
); } // ── 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 ────────────────────────────────────────────────────────────────────── type ExploreMode = "explore" | "sources"; type DrillState = { type: "genre"; genre: string } | null; export default function Explore() { const [mode, setMode] = useState("explore"); const [drill, setDrill] = useState(null); const activeSource = useStore((s) => s.activeSource); if (activeSource) return ; if (drill?.type === "genre" && mode === "explore") { return setDrill(null)} />; } return (

Explore

{mode === "explore" ? : }
); } // ── Drill wrapper ───────────────────────────────────────────────────────────── function DrillWrapper({ drill, onBack }: { drill: DrillState; onBack: () => void }) { const [allManga, setAllManga] = useState([]); const [sourceManga, setSourceManga] = useState([]); const setActiveManga = useStore((s) => s.setActiveManga); const setNavPage = useStore((s) => s.setNavPage); const settings = useStore((s) => s.settings); useEffect(() => { 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])); setAllManga(all.mangas.nodes.map((m) => libMap.get(m.id) ?? m)); }).catch(console.error); const preferredLang = settings.preferredExtensionLang || "en"; gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) .then((d) => { const all = d.sources.nodes.filter((src) => src.id !== "0"); const byName = new Map(); for (const src of all) { if (!byName.has(src.name)) byName.set(src.name, []); byName.get(src.name)!.push(src); } const picked: Source[] = []; for (const group of byName.values()) { const preferred = group.find((s) => s.lang === preferredLang); picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]); } return Promise.allSettled( picked.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 seen = new Set(); const merged: Manga[] = []; for (const r of results) { if (r.status === "fulfilled") for (const m of r.value) if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); } } setSourceManga(merged); }) .catch(console.error); }, []); if (!drill) return null; return ( { setActiveManga(m); setNavPage("library"); }} /> ); } // ── Explore feed ────────────────────────────────────────────────────────────── function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) { const [allManga, setAllManga] = useState([]); const [loadingLib, setLoadingLib] = useState(true); // Popular row: deduped results from POPULAR fetch across all sources const [popularManga, setPopularManga] = useState([]); const [loadingPopular, setLoadingPopular] = useState(true); // Genre search results: genre → merged Manga[] from SEARCH per source const [genreResults, setGenreResults] = useState>(new Map()); const [loadingGenres, setLoadingGenres] = useState(false); const [sources, setSources] = useState([]); const history = useStore((s) => s.history); const settings = useStore((s) => s.settings); const setActiveManga = useStore((s) => s.setActiveManga); const setNavPage = useStore((s) => s.setNavPage); 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); 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(() => setActiveManga({ ...m, inLibrary: true })) .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); } }, }, ]; } // Load library useEffect(() => { 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])); setAllManga(all.mangas.nodes.map((m) => libMap.get(m.id) ?? m)); }) .catch(console.error) .finally(() => setLoadingLib(false)); }, []); // Load sources → fetch POPULAR from all (for popular row), // then once we know frecency genres, fire SEARCH per genre per source useEffect(() => { const preferredLang = settings.preferredExtensionLang || "en"; gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) .then((d) => { const all = d.sources.nodes.filter((src) => src.id !== "0"); // Dedupe by name, pick preferred lang const byName = new Map(); for (const src of all) { if (!byName.has(src.name)) byName.set(src.name, []); byName.get(src.name)!.push(src); } const picked: Source[] = []; for (const group of byName.values()) { const preferred = group.find((s) => s.lang === preferredLang); picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]); } setSources(picked); if (picked.length === 0) { setLoadingPopular(false); return; } // Fetch POPULAR from all sources for the popular row return Promise.allSettled( picked.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 seen = new Set(); const merged: Manga[] = []; for (const r of results) if (r.status === "fulfilled") for (const m of r.value) if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); } setPopularManga(merged.slice(0, 30)); // Return picked sources for genre search phase return picked; }); }) .catch(console.error) .finally(() => setLoadingPopular(false)); }, []); const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama", "Sci-fi", "Horror"]; 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 still empty (new user, no library), fall back to foundational genres if (genreWeights.size === 0) { return FOUNDATIONAL_GENRES.slice(0, 5); } return Array.from(genreWeights.entries()) .sort((a, b) => b[1] - a[1]) .slice(0, 3) // top 3 genres only .map(([g]) => g); }, [allManga, history]); // Fire genre searches once we have both genres and sources useEffect(() => { if (frecencyGenres.length === 0 || sources.length === 0) return; setLoadingGenres(true); // For each genre, search all sources concurrently, then merge results // Cap to top 3 sources to limit requests (3 genres × 3 sources = 9 searches max) const searchSources = sources.slice(0, 3); Promise.allSettled( frecencyGenres.map((genre) => Promise.allSettled( searchSources.map((src) => gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page: 1, query: genre, }).then((d) => d.fetchSourceManga.mangas) ) ).then((results) => { const seen = new Set(); const merged: Manga[] = []; for (const r of results) if (r.status === "fulfilled") for (const m of r.value) if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); } return { genre, mangas: merged.slice(0, 24) }; }) ) ).then((results) => { const map = new Map(); for (const r of results) if (r.status === "fulfilled") map.set(r.value.genre, r.value.mangas); setGenreResults(map); }) .catch(console.error) .finally(() => setLoadingGenres(false)); }, [frecencyGenres.join(","), sources.map((s) => s.id).join(",")]); function openManga(m: Manga) { setActiveManga(m); setNavPage("library"); } // ── 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 (frecency) ────────────────────────────────────────────── 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 = loadingLib || loadingGenres; return (
{/* Continue Reading */} {(continueReading.length > 0 || loadingLib) && (
} loading={loadingLib} >
{continueReading.map(({ manga, chapterName, progress }) => ( openManga(manga)} onContextMenu={(e) => openCtx(e, manga)} subtitle={chapterName} progress={progress} /> ))} {Array.from({ length: GHOST_COUNT }).map((_, i) => ( ))}
)} {/* Recommended */} {(recommended.length > 0 || loadingLib) && (
} loading={loadingLib} >
{recommended.map((m) => ( openManga(m)} onContextMenu={(e) => openCtx(e, m)} /> ))} {Array.from({ length: GHOST_COUNT }).map((_, i) => ( ))}
)} {/* Popular across deduplicated sources */} {(popularManga.length > 0 || loadingPopular) && (
1 ? `Popular across ${sources.length} sources` : "Popular" } icon={} loading={loadingPopular} > {sources.length === 0 ? (
No sources installed. Add extensions first.
) : (
{popularManga.map((m) => ( openManga(m)} onContextMenu={(e) => openCtx(e, m)} /> ))} {Array.from({ length: GHOST_COUNT }).map((_, i) => ( ))}
)}
)} {/* Genre rows — searched from sources by genre name */} {frecencyGenres.map((genre) => { const items = genreResults.get(genre) ?? []; const isLoading = genresLoading && items.length === 0; if (!isLoading && items.length === 0) return null; return (
onDrill({ type: "genre", genre })} loading={isLoading} >
{items.map((m) => ( openManga(m)} onContextMenu={(e) => openCtx(e, m)} /> ))} {Array.from({ length: GHOST_COUNT }).map((_, i) => ( ))}
); })} {/* Empty state */} {!loadingLib && !loadingPopular && !loadingGenres && continueReading.length === 0 && recommended.length === 0 && popularManga.length === 0 && frecencyGenres.every((g) => !genreResults.get(g)?.length) && (
Nothing to explore yet Add manga to your library or install sources to get started.
)} {ctx && ( setCtx(null)} /> )}
); }