diff --git a/flake.nix b/flake.nix index 7e6b410..b06b694 100644 --- a/flake.nix +++ b/flake.nix @@ -89,7 +89,7 @@ version = "0.1.0"; src = frontendSrc; fetcherVersion = 1; - hash = "sha256-2Hdzsjwbb+CKiRn/nGHwLeysKvpvEhd5C213YgWmOSU="; + hash = "sha256-bpGYsB534RPNNAcYR9BA61vvFpSG6Xu2hY923PakCyY="; }; buildPhase = "pnpm build"; diff --git a/package.json b/package.json index 067dafe..f974eee 100644 --- a/package.json +++ b/package.json @@ -17,6 +17,7 @@ "@radix-ui/react-progress": "^1.1.8", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-virtual": "^3.13.18", "@tauri-apps/api": "^2.0.0", "@tauri-apps/plugin-shell": "~2", "clsx": "^2.1.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2a7ca53..afed34a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@18.3.7(@types/react@18.3.28))(@types/react@18.3.28)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@tanstack/react-virtual': + specifier: ^3.13.18 + version: 3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1) '@tauri-apps/api': specifier: ^2.0.0 version: 2.10.1 @@ -837,6 +840,15 @@ packages: cpu: [x64] os: [win32] + '@tanstack/react-virtual@3.13.18': + resolution: {integrity: sha512-dZkhyfahpvlaV0rIKnvQiVoWPyURppl6w4m9IwMDpuIjcJ1sD9YGWrt0wISvgU7ewACXx2Ct46WPgI6qAD4v6A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/virtual-core@3.13.18': + resolution: {integrity: sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==} + '@tauri-apps/api@2.10.1': resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} @@ -2107,6 +2119,14 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.58.0': optional: true + '@tanstack/react-virtual@3.13.18(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@tanstack/virtual-core': 3.13.18 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@tanstack/virtual-core@3.13.18': {} + '@tauri-apps/api@2.10.1': {} '@tauri-apps/cli-darwin-arm64@2.10.0': diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 142e2d7..6e8c296 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -4,8 +4,7 @@ import Library from "../pages/Library"; import SeriesDetail from "../pages/SeriesDetail"; import History from "../pages/History"; import Search from "../pages/Search"; -import SourceList from "../sources/SourceList"; -import SourceBrowse from "../sources/SourceBrowse"; +import Explore from "../sources/Explore"; import DownloadQueue from "../downloads/DownloadQueue"; import ExtensionList from "../extensions/ExtensionList"; import s from "./Layout.module.css"; @@ -13,16 +12,15 @@ import s from "./Layout.module.css"; export default function Layout() { const navPage = useStore((s) => s.navPage); const activeManga = useStore((s) => s.activeManga); - const activeSource = useStore((s) => s.activeSource); function renderContent() { if (navPage === "library" && activeManga) return ; - if (navPage === "sources" && activeSource) return ; switch (navPage) { case "library": return ; case "search": return ; case "history": return ; - case "sources": return ; + case "sources": return ; + case "explore": return ; case "downloads": return ; case "extensions": return ; default: return ; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index 22322bf..d70351b 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -9,7 +9,7 @@ const TABS: { id: NavPage; icon: React.ReactNode; label: string }[] = [ { id: "library", icon: , label: "Library" }, { id: "search", icon: , label: "Search" }, { id: "history", icon: , label: "History" }, - { id: "sources", icon: , label: "Sources" }, + { id: "explore", icon: , label: "Explore" }, { id: "downloads", icon: , label: "Downloads" }, { id: "extensions", icon: , label: "Extensions" }, ]; @@ -24,7 +24,7 @@ export default function Sidebar() { function navigate(id: NavPage) { setNavPage(id); - if (id !== "sources") setActiveSource(null); + if (id !== "explore") setActiveSource(null); } function goHome() { diff --git a/src/components/pages/Library.module.css b/src/components/pages/Library.module.css index b7de032..ebe9ab8 100644 --- a/src/components/pages/Library.module.css +++ b/src/components/pages/Library.module.css @@ -107,22 +107,32 @@ .search::placeholder { color: var(--text-faint); } .search:focus { border-color: var(--border-strong); } -/* Grid */ -.grid { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); +/* Virtual row — flexbox instead of CSS grid so virtualizer controls height */ +.virtualRow { + display: flex; gap: var(--sp-4); - /* Contain stacking contexts for GPU layers */ - contain: layout style; + padding: 0 var(--sp-6); + align-items: start; } +/* Individual card fills its flex slot */ .card { + flex: 1 1 130px; + min-width: 0; + max-width: 200px; background: none; border: none; padding: 0; cursor: pointer; text-align: left; - /* Promote to own GPU layer on hover only */ +} + +.ghostCard { + flex: 1 1 130px; + min-width: 0; + max-width: 200px; + pointer-events: none; + visibility: hidden; } .card:hover .cover { filter: brightness(1.06); } @@ -177,38 +187,12 @@ transition: color var(--t-base); } -/* Show more */ -.showMore { - display: flex; - justify-content: center; - padding: var(--sp-6) 0 var(--sp-4); -} - -.showMoreBtn { - display: flex; - align-items: center; - gap: var(--sp-3); - font-family: var(--font-ui); - font-size: var(--text-xs); - letter-spacing: var(--tracking-wide); - color: var(--text-muted); - border: 1px solid var(--border-base); - border-radius: var(--radius-md); - padding: 7px 20px; - background: var(--bg-raised); - cursor: pointer; - transition: color var(--t-base), border-color var(--t-base), background var(--t-base); -} - -.showMoreBtn:hover { - color: var(--text-primary); - border-color: var(--border-strong); - background: var(--bg-overlay); -} - -.showMoreCount { - color: var(--text-faint); - font-size: var(--text-2xs); +/* Skeleton grid still uses CSS grid since it's fixed 12 items */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: var(--sp-4); + padding: var(--sp-4) var(--sp-6) 0; } /* Skeleton */ @@ -225,6 +209,14 @@ width: 80%; } +/* Ghost cards fill trailing grid space without taking interaction */ +.ghostCard { + padding: 0; + pointer-events: none; + visibility: hidden; + aspect-ratio: 2 / 3; +} + .center { display: flex; flex-direction: column; diff --git a/src/components/pages/Library.tsx b/src/components/pages/Library.tsx index da38a2a..49c0347 100644 --- a/src/components/pages/Library.tsx +++ b/src/components/pages/Library.tsx @@ -1,16 +1,18 @@ -import { useEffect, useState, useMemo, useCallback, memo } from "react"; +import { useEffect, useState, useMemo, useCallback, memo, useRef } from "react"; import { MagnifyingGlass, Books, DownloadSimple, X, Folder, Trash } from "@phosphor-icons/react"; +import { useVirtualizer } from "@tanstack/react-virtual"; import { gql, thumbUrl } from "../../lib/client"; -import { GET_LIBRARY, GET_ALL_MANGA, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS } from "../../lib/queries"; +import { GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS } from "../../lib/queries"; import { useStore } from "../../store"; import type { Manga, Chapter } 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; +// Keep in sync with CSS grid: minmax(130px, 1fr) + var(--sp-4)=16px gap +const CARD_MIN_W = 130; +const CARD_GAP = 16; +const ROW_HEIGHT = 260; // ~195px cover + ~40px title + 16px gap + buffer -// Memoized card to prevent re-renders when siblings change const MangaCard = memo(function MangaCard({ manga, onClick, @@ -43,78 +45,89 @@ const MangaCard = memo(function MangaCard({ }); 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 [allManga, setAllManga] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [search, setSearch] = useState(""); + const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null); + const scrollRef = useRef(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 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); - const folders = useStore((state) => state.settings.folders); + const folders = useStore((state) => state.settings.folders); 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)); - }) + gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY) + .then((lib) => setAllManga(lib.mangas.nodes)) .catch((e) => setError(e.message)) .finally(() => setLoading(false)); }, []); - useEffect(() => { setVisibleCount(INITIAL_PAGE_SIZE); }, [libraryFilter, search]); + // Reset scroll when filter/search changes + useEffect(() => { + scrollRef.current?.scrollTo({ top: 0 }); + }, [libraryFilter, search]); - // Reset filter if the active folder tab gets hidden useEffect(() => { const activeFolder = folders.find((f) => f.id === libraryFilter); - if (activeFolder && !activeFolder.showTab) { - setLibraryFilter("library"); - } + if (activeFolder && !activeFolder.showTab) setLibraryFilter("library"); }, [folders]); const isBuiltinFilter = libraryFilter === "all" || libraryFilter === "library" || libraryFilter === "downloaded"; const filtered = useMemo(() => { let items = allManga; - if (libraryFilter === "library") { items = items.filter((m) => m.inLibrary); } else if (libraryFilter === "downloaded") { items = items.filter((m) => (m.downloadCount ?? 0) > 0); } else if (!isBuiltinFilter) { - // folder filter const folder = folders.find((f) => f.id === libraryFilter); - if (folder) { - items = items.filter((m) => folder.mangaIds.includes(m.id)); - } + if (folder) items = items.filter((m) => folder.mangaIds.includes(m.id)); } - - // tag filter only applies to library/all/folder views - if (libraryTagFilter.length > 0) { - items = items.filter((m) => - libraryTagFilter.every((tag) => (m.genre ?? []).includes(tag)) - ); - } - + if (libraryTagFilter.length > 0) + items = items.filter((m) => libraryTagFilter.every((tag) => (m.genre ?? []).includes(tag))); if (search.trim()) { const q = search.toLowerCase(); items = items.filter((m) => m.title.toLowerCase().includes(q)); } - return items; }, [allManga, libraryFilter, search, libraryTagFilter, folders, isBuiltinFilter]); - const visible = filtered.slice(0, visibleCount); - const hasMore = visibleCount < filtered.length; + // ── Virtualizer setup ────────────────────────────────────────────────────── + // We need to know columns to chunk filtered into rows. + // Use a ResizeObserver on the scroll container to get real width. + const [containerWidth, setContainerWidth] = useState(800); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const ro = new ResizeObserver(([entry]) => { + setContainerWidth(entry.contentRect.width); + }); + ro.observe(el); + return () => ro.disconnect(); + }, []); + + const cols = Math.max(1, Math.floor((containerWidth + CARD_GAP) / (CARD_MIN_W + CARD_GAP))); + + const rows = useMemo(() => { + const result: Manga[][] = []; + for (let i = 0; i < filtered.length; i += cols) + result.push(filtered.slice(i, i + cols)); + return result; + }, [filtered, cols]); + + const virtualizer = useVirtualizer({ + count: rows.length, + getScrollElement: () => scrollRef.current, + estimateSize: () => ROW_HEIGHT, + overscan: 3, + }); const handleCardClick = useCallback( (m: Manga) => () => setActiveManga(m), @@ -129,34 +142,23 @@ export default function Library() { async function deleteAllDownloads(manga: Manga) { try { const data = await gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId: manga.id }); - const downloadedIds = data.chapters.nodes - .filter((c) => c.isDownloaded) - .map((c) => c.id); - if (!downloadedIds.length) return; - await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: downloadedIds }); - setAllManga((prev) => - prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m) - ); - } catch (e) { - console.error(e); - } + const ids = data.chapters.nodes.filter((c) => c.isDownloaded).map((c) => c.id); + if (!ids.length) return; + await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }); + setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m)); + } catch (e) { console.error(e); } } function openCtx(e: React.MouseEvent, m: Manga) { e.preventDefault(); - const menuW = 200; - const menuH = 160; - const x = Math.min(e.clientX, window.innerWidth - menuW - 8); - const y = Math.min(e.clientY, window.innerHeight - menuH - 8); + const x = Math.min(e.clientX, window.innerWidth - 208); + const y = Math.min(e.clientY, window.innerHeight - 168); setCtx({ x, y, manga: m }); } function buildCtxItems(m: Manga): ContextMenuEntry[] { return [ - { - label: "Open", - onClick: () => setActiveManga(m), - }, + { label: "Open", onClick: () => setActiveManga(m) }, { separator: true }, { label: m.inLibrary ? "Remove from library" : "Add to library", @@ -189,9 +191,7 @@ export default function Library() { library: allManga.filter((m) => m.inLibrary).length, downloaded: allManga.filter((m) => (m.downloadCount ?? 0) > 0).length, }; - folders.forEach((f) => { - result[f.id] = allManga.filter((m) => f.mangaIds.includes(m.id)).length; - }); + folders.forEach((f) => { result[f.id] = allManga.filter((m) => f.mangaIds.includes(m.id)).length; }); return result; }, [allManga, folders]); @@ -203,12 +203,11 @@ export default function Library() { ); return ( -
+

Library

- {/* Built-in tabs */} {(["library", "downloaded", "all"] as const).map((f) => ( ))} - {/* Folder tabs — only shown if the folder has showTab enabled */} {folders.filter((f) => f.showTab).map((folder) => (
- {/* Tag filter panel */} {allTags.length > 0 && (
{libraryTagFilter.length > 0 && ( )} {allTags.map((tag) => { @@ -264,13 +258,9 @@ export default function Library() { return ( ); @@ -298,31 +288,47 @@ export default function Library() { : "No manga found."}
) : ( - <> -
- {visible.map((m) => ( - openCtx(e, m)} - cropCovers={settings.libraryCropCovers} - /> - ))} -
- {hasMore && ( -
- -
- )} - + {rowManga.map((m) => ( + openCtx(e, m)} + cropCovers={settings.libraryCropCovers} + /> + ))} + {/* Ghost cards on last row to fill grid */} + {virtualRow.index === rows.length - 1 && + Array.from({ length: cols - rowManga.length }).map((_, i) => ( +
+ ))} +
+ ); + })} +
)} + {ctx && ( ; +} + +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, + subtitle, + progress, +}: { + manga: Manga; + onClick: () => 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 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}.
+ )} +
+
+ ); +} + +// ── 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); + + // 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)); + }, []); + + // Once library loaded AND sources ready, search each frecency genre across sources + 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))); + } + 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)} + subtitle={chapterName} + progress={progress} + /> + ))} + {Array.from({ length: GHOST_COUNT }).map((_, i) => ( + + ))} +
+
+ )} + + {/* Recommended */} + {(recommended.length > 0 || loadingLib) && ( +
} + loading={loadingLib} + > +
+ {recommended.map((m) => ( + openManga(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)} /> + ))} + {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)} /> + ))} + {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. + +
+ )} +
+ ); +} \ No newline at end of file diff --git a/src/lib/queries.ts b/src/lib/queries.ts index 8ad9732..5ff1d3b 100644 --- a/src/lib/queries.ts +++ b/src/lib/queries.ts @@ -10,6 +10,7 @@ export const GET_LIBRARY = ` inLibrary downloadCount unreadCount + genre chapters { totalCount } diff --git a/src/store/index.ts b/src/store/index.ts index f69526a..9047dff 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -6,7 +6,7 @@ import { DEFAULT_KEYBINDS, type Keybinds } from "../lib/keybinds"; export type PageStyle = "single" | "double" | "longstrip"; export type FitMode = "width" | "height" | "screen" | "original"; export type LibraryFilter = "all" | "library" | "downloaded" | string; // string = folder id -export type NavPage = "library" | "sources" | "downloads" | "extensions" | "history" | "search"; +export type NavPage = "library" | "sources" | "explore" | "downloads" | "extensions" | "history" | "search"; export type ReadingDirection = "ltr" | "rtl"; export type ChapterSortDir = "desc" | "asc";