import { useEffect, useState, useMemo, useCallback, memo } from "react"; import { MagnifyingGlass, Books, DownloadSimple, X } from "@phosphor-icons/react"; import { gql, thumbUrl } from "../../lib/client"; import { GET_LIBRARY, GET_ALL_MANGA, UPDATE_MANGA } from "../../lib/queries"; import { useStore } from "../../store"; import type { LibraryFilter } from "../../store"; import type { Manga } from "../../lib/types"; import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu"; import s from "./Library.module.css"; const INITIAL_PAGE_SIZE = 48; const PAGE_INCREMENT = 48; // Memoized card to prevent re-renders when siblings change const MangaCard = memo(function MangaCard({ manga, onClick, onContextMenu, cropCovers, }: { manga: Manga; onClick: () => void; onContextMenu: (e: React.MouseEvent) => void; cropCovers: boolean; }) { return ( {!!manga.downloadCount && ( {manga.downloadCount} )} {manga.title} ); }); export default function Library() { const [allManga, setAllManga] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [search, setSearch] = useState(""); const [visibleCount, setVisibleCount] = useState(INITIAL_PAGE_SIZE); const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null); const setActiveManga = useStore((state) => state.setActiveManga); const libraryFilter = useStore((state) => state.libraryFilter); const setLibraryFilter = useStore((state) => state.setLibraryFilter); const settings = useStore((state) => state.settings); const libraryTagFilter = useStore((state) => state.libraryTagFilter); const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter); useEffect(() => { // Fetch all manga (for downloaded filter on non-library entries) and // library manga (for unreadCount/chapter progress). Merge: library wins. 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((e) => setError(e.message)) .finally(() => setLoading(false)); }, []); // Reset visible count when filter/search changes useEffect(() => { setVisibleCount(INITIAL_PAGE_SIZE); }, [libraryFilter, search]); const filtered = useMemo(() => { let items = allManga; // Apply filter tab if (libraryFilter === "library") { items = items.filter((m) => m.inLibrary); } else if (libraryFilter === "downloaded") { items = items.filter((m) => (m.downloadCount ?? 0) > 0); } // Apply search if (search.trim()) { const q = search.toLowerCase(); items = items.filter((m) => m.title.toLowerCase().includes(q)); } return items; }, [allManga, libraryFilter, search]); const visible = filtered.slice(0, visibleCount); const hasMore = visibleCount < filtered.length; const handleCardClick = useCallback( (m: Manga) => () => setActiveManga(m), [setActiveManga] ); async function removeFromLibrary(manga: Manga) { await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error); setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, inLibrary: false } : m)); } function openCtx(e: React.MouseEvent, m: Manga) { e.preventDefault(); const menuW = 200; const menuH = 96; const x = Math.min(e.clientX, window.innerWidth - menuW - 8); const y = Math.min(e.clientY, window.innerHeight - menuH - 8); setCtx({ x, y, manga: m }); } function buildCtxItems(m: Manga): ContextMenuEntry[] { return [ { label: "Open", onClick: () => setActiveManga(m), }, { separator: true }, { label: m.inLibrary ? "Remove from library" : "Add to library", danger: m.inLibrary, onClick: () => m.inLibrary ? removeFromLibrary(m) : gql(UPDATE_MANGA, { id: m.id, inLibrary: true }) .then(() => setAllManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x))) .catch(console.error), }, ]; } // All genres present in current library const allTags = useMemo(() => { const tagSet = new Set(); allManga.filter((m) => m.inLibrary).forEach((m) => (m.genre ?? []).forEach((g) => tagSet.add(g))); return Array.from(tagSet).sort(); }, [allManga]); const counts = useMemo(() => ({ all: allManga.length, library: allManga.filter((m) => m.inLibrary).length, downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length, }), [allManga]); if (error) return ( Could not reach Suwayomi {error} ); return ( Library {(["library", "downloaded", "all"] as LibraryFilter[]).map((f) => ( setLibraryFilter(f)} > {f === "library" ? ( <> Saved> ) : f === "downloaded" ? ( <> Downloaded> ) : ( <>All> )} {counts[f]} ))} setSearch(e.target.value)} /> {/* Tag filter panel */} {allTags.length > 0 && ( {libraryTagFilter.length > 0 && ( setLibraryTagFilter([])}> Clear )} {allTags.map((tag) => { const active = libraryTagFilter.includes(tag); return ( setLibraryTagFilter( active ? libraryTagFilter.filter((t) => t !== tag) : [...libraryTagFilter, tag] ) }> {tag} ); })} )} {loading ? ( {Array.from({ length: 12 }).map((_, i) => ( ))} ) : filtered.length === 0 ? ( {libraryFilter === "library" ? "No manga saved to library. Browse sources to add some." : libraryFilter === "downloaded" ? "No downloaded manga." : "No manga found."} ) : ( <> {visible.map((m) => ( openCtx(e, m)} cropCovers={settings.libraryCropCovers} /> ))} {hasMore && ( setVisibleCount((c) => c + PAGE_INCREMENT)} > Show more {filtered.length - visibleCount} remaining )} > )} {ctx && ( setCtx(null)} /> )} ); }
{manga.title}
Could not reach Suwayomi
{error}