From 523fb4053841c178f111f1461919e23b3db85dd5 Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Mon, 23 Feb 2026 22:40:00 -0600 Subject: [PATCH] [V1] Major Revisions to Search & New Preview + Genre Filter (WIP Commit) --- src/App.tsx | 2 + .../{sources => explore}/Explore.module.css | 0 src/components/explore/Explore.tsx | 472 ++++++++++++ .../explore/GenreDrillPage.module.css | 136 ++++ src/components/explore/GenreDrillPage.tsx | 182 +++++ .../explore/MangaPreview.module.css | 385 ++++++++++ src/components/explore/MangaPreview.tsx | 555 ++++++++++++++ src/components/layout/Layout.tsx | 4 +- src/components/layout/Sidebar.tsx | 3 + src/components/pages/Library.tsx | 94 ++- src/components/pages/Search.module.css | 292 ++++++-- src/components/pages/Search.tsx | 706 +++++++++++++++--- src/components/pages/SeriesDetail.module.css | 29 +- src/components/pages/SeriesDetail.tsx | 310 +++++--- src/components/sources/Explore.tsx | 678 ----------------- src/lib/cache.ts | 106 +++ src/lib/client.ts | 67 +- src/lib/sourceUtils.ts | 46 ++ src/store/index.ts | 12 + 19 files changed, 3096 insertions(+), 983 deletions(-) rename src/components/{sources => explore}/Explore.module.css (100%) create mode 100644 src/components/explore/Explore.tsx create mode 100644 src/components/explore/GenreDrillPage.module.css create mode 100644 src/components/explore/GenreDrillPage.tsx create mode 100644 src/components/explore/MangaPreview.module.css create mode 100644 src/components/explore/MangaPreview.tsx delete mode 100644 src/components/sources/Explore.tsx create mode 100644 src/lib/cache.ts create mode 100644 src/lib/sourceUtils.ts diff --git a/src/App.tsx b/src/App.tsx index 7a27331..87d770f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { useStore } from "./store"; import Layout from "./components/layout/Layout"; import Reader from "./components/pages/Reader"; import Settings from "./components/settings/Settings"; +import MangaPreview from "./components/explore/MangaPreview"; import TitleBar from "./components/layout/TitleBar"; import Toaster from "./components/layout/Toaster"; import type { DownloadStatus, DownloadQueueItem } from "./lib/types"; @@ -103,6 +104,7 @@ export default function App() { {activeChapter ? : } {settingsOpen && } + ); diff --git a/src/components/sources/Explore.module.css b/src/components/explore/Explore.module.css similarity index 100% rename from src/components/sources/Explore.module.css rename to src/components/explore/Explore.module.css diff --git a/src/components/explore/Explore.tsx b/src/components/explore/Explore.tsx new file mode 100644 index 0000000..7496342 --- /dev/null +++ b/src/components/explore/Explore.tsx @@ -0,0 +1,472 @@ +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; + +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 ( + + ); +}); + +// ── 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 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 (runs once) ──────────────────────────────────── + useEffect(() => { + const preferredLang = settings.preferredExtensionLang || "en"; + + // 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(console.error) + .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); 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(console.error); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // ── 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.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.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.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.map((m) => ( + openManga(m)} onContextMenu={(e) => openCtx(e, m)} /> + ))} + {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) && ( +
+ Nothing to explore yet + Add manga to your library or install sources to get started. +
+ )} + + {ctx && ( + setCtx(null)} /> + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/explore/GenreDrillPage.module.css b/src/components/explore/GenreDrillPage.module.css new file mode 100644 index 0000000..c2f3ccd --- /dev/null +++ b/src/components/explore/GenreDrillPage.module.css @@ -0,0 +1,136 @@ +.root { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; + animation: fadeIn 0.14s ease both; +} + +.header { + display: flex; + align-items: center; + gap: var(--sp-3); + padding: var(--sp-4) var(--sp-6); + border-bottom: 1px solid var(--border-dim); + flex-shrink: 0; +} + +.back { + display: flex; + align-items: center; + gap: var(--sp-2); + color: var(--text-muted); + font-size: var(--text-xs); + font-family: var(--font-ui); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + background: none; + border: none; + cursor: pointer; + padding: 0; + transition: color var(--t-base); + flex-shrink: 0; +} +.back:hover { color: var(--text-secondary); } + +.title { + font-size: var(--text-base); + font-weight: var(--weight-medium); + color: var(--text-secondary); + letter-spacing: var(--tracking-tight); +} + +.loadingHint { + margin-left: auto; + font-family: var(--font-ui); + font-size: var(--text-2xs); + color: var(--text-faint); + letter-spacing: var(--tracking-wide); +} + +/* Grid fills entire remaining height, no show-more needed */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(clamp(100px, 13vw, 140px), 1fr)); + gap: var(--sp-4); + padding: var(--sp-5) var(--sp-6) var(--sp-6); + overflow-y: auto; + flex: 1; + align-content: start; + /* Smooth GPU-accelerated scrolling */ + will-change: scroll-position; + -webkit-overflow-scrolling: touch; + contain: layout style; +} + +.card { + background: none; + border: none; + padding: 0; + cursor: pointer; + text-align: left; +} +.card:hover .cover { filter: brightness(1.06); } +.card:hover .cardTitle { color: var(--text-primary); } + +.coverWrap { + position: relative; + aspect-ratio: 2 / 3; + overflow: hidden; + border-radius: var(--radius-md); + /* Solid bg shown while image fades in — matches skeleton color */ + background: var(--bg-raised); + border: 1px solid var(--border-dim); + transform: translateZ(0); +} + +.cover { + width: 100%; + height: 100%; + object-fit: cover; + transition: filter var(--t-base); + will-change: filter; +} + +.inLibraryBadge { + position: absolute; + bottom: var(--sp-1); + left: var(--sp-1); + font-family: var(--font-ui); + font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); + text-transform: uppercase; + background: var(--accent-muted); + color: var(--accent-fg); + border: 1px solid var(--accent-dim); + padding: 2px 5px; + border-radius: var(--radius-sm); +} + +.cardTitle { + margin-top: var(--sp-2); + font-size: var(--text-sm); + color: var(--text-secondary); + line-height: var(--leading-snug); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; + transition: color var(--t-base); +} + +/* Skeletons */ +.cardSkeleton { padding: 0; } +.coverSkeleton { aspect-ratio: 2 / 3; border-radius: var(--radius-md); } +.titleSkeleton { height: 11px; margin-top: var(--sp-2); width: 75%; } + +.empty { + display: flex; + align-items: center; + justify-content: center; + flex: 1; + color: var(--text-faint); + font-family: var(--font-ui); + font-size: var(--text-xs); + letter-spacing: var(--tracking-wide); +} \ No newline at end of file diff --git a/src/components/explore/GenreDrillPage.tsx b/src/components/explore/GenreDrillPage.tsx new file mode 100644 index 0000000..6bff9a1 --- /dev/null +++ b/src/components/explore/GenreDrillPage.tsx @@ -0,0 +1,182 @@ +import { useEffect, useState, useMemo, useRef, memo } from "react"; +import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder } 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, getTopSources } from "../../lib/cache"; +import { dedupeSources, dedupeMangaByTitle, 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"; + +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" }} + /> + ); +}); + +export default function GenreDrillPage() { + const genre = 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); + + const [libraryManga, setLibraryManga] = useState([]); + const [sourceManga, setSourceManga] = useState([]); + const [loadingLibrary, setLoadingLibrary] = useState(true); + const [loadingSources, setLoadingSources] = useState(true); + const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null); + const abortRef = useRef(null); + + useEffect(() => { + if (!genre) return; + + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + + setLoadingLibrary(true); + setLoadingSources(true); + setSourceManga([]); + + // ── Library ──────────────────────────────────────────────────────────── + 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(setLibraryManga) + .catch((e) => { if (e?.name !== "AbortError") console.error(e); }) + .finally(() => setLoadingLibrary(false)); + + // ── Sources ──────────────────────────────────────────────────────────── + const preferredLang = settings.preferredExtensionLang || "en"; + cache.get(CACHE_KEYS.SOURCES, () => + gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) + .then((d) => dedupeSources(d.sources.nodes, preferredLang)) + ).then((allSources) => { + const topSources = getTopSources(allSources); + return 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); + }) + ); + }) + .then((manga) => { if (!ctrl.signal.aborted) setSourceManga(manga); }) + .catch((e) => { if (e?.name !== "AbortError") console.error(e); }) + .finally(() => { if (!ctrl.signal.aborted) setLoadingSources(false); }); + + return () => { ctrl.abort(); }; + }, [genre]); + + const filtered = useMemo(() => { + const libMatches = libraryManga.filter((m) => (m.genre ?? []).includes(genre)); + const srcMatches = sourceManga.filter((m) => !m.genre?.length || m.genre.includes(genre)); + return dedupeMangaById([...libMatches, ...srcMatches]); + }, [libraryManga, sourceManga, genre]); + + 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 showSkeleton = loadingLibrary && filtered.length === 0; + + return ( +
+
+ + {genre} + {loadingSources && !loadingLibrary && filtered.length > 0 && ( + Loading more… + )} +
+ + {showSkeleton ? ( +
+ {Array.from({ length: 24 }).map((_, i) => ( +
+
+
+
+ ))} +
+ ) : filtered.length === 0 && !loadingSources ? ( +
No manga found for "{genre}".
+ ) : ( +
+ {filtered.map((m) => ( + + ))} +
+ )} + + {ctx && ( + setCtx(null)} /> + )} +
+ ); +} \ No newline at end of file diff --git a/src/components/explore/MangaPreview.module.css b/src/components/explore/MangaPreview.module.css new file mode 100644 index 0000000..7305c29 --- /dev/null +++ b/src/components/explore/MangaPreview.module.css @@ -0,0 +1,385 @@ +/* ── Animations ──────────────────────────────────────────────────────────── */ +@keyframes fadeIn { from { opacity: 0 } to { opacity: 1 } } +@keyframes scaleIn { from { opacity: 0; transform: scale(0.97) } to { opacity: 1; transform: scale(1) } } +@keyframes pulse { 0%,100% { opacity: 0.4 } 50% { opacity: 0.8 } } + +/* ── Backdrop ────────────────────────────────────────────────────────────── */ +.backdrop { + position: fixed; inset: 0; + background: rgba(0,0,0,0.72); + z-index: var(--z-settings); + display: flex; align-items: center; justify-content: center; + animation: fadeIn 0.12s ease both; + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); +} + +/* ── Modal shell ─────────────────────────────────────────────────────────── */ +.modal { + width: min(800px, calc(100vw - 48px)); + height: min(560px, calc(100vh - 80px)); + display: flex; + background: var(--bg-surface); + border: 1px solid var(--border-base); + border-radius: var(--radius-xl); + overflow: hidden; + animation: scaleIn 0.16s ease both; + box-shadow: 0 0 0 1px var(--border-dim), 0 24px 64px rgba(0,0,0,0.6), 0 8px 24px rgba(0,0,0,0.4); +} + +/* ── Cover column ────────────────────────────────────────────────────────── */ +.coverCol { + width: 190px; flex-shrink: 0; + background: var(--bg-raised); + border-right: 1px solid var(--border-dim); + display: flex; flex-direction: column; + padding: var(--sp-5) var(--sp-4) var(--sp-4); + gap: var(--sp-3); + overflow-y: auto; overflow-x: hidden; + scrollbar-width: none; +} +.coverCol::-webkit-scrollbar { display: none; } + +.coverWrap { + position: relative; + width: 100%; +} + +.cover { + width: 100%; aspect-ratio: 2 / 3; object-fit: cover; + border-radius: var(--radius-md); + border: 1px solid var(--border-dim); + display: block; +} + +.coverSpinner { + position: absolute; inset: 0; + display: flex; align-items: center; justify-content: center; + background: rgba(0,0,0,0.35); + border-radius: var(--radius-md); + color: var(--text-faint); +} + +.coverActions { + display: flex; flex-direction: column; gap: var(--sp-2); +} + +/* ── Cover action buttons ────────────────────────────────────────────────── */ +.actionBtn { + display: flex; align-items: center; justify-content: center; gap: var(--sp-2); + width: 100%; padding: 7px var(--sp-3); + border-radius: var(--radius-md); + font-family: var(--font-ui); font-size: var(--text-xs); + letter-spacing: var(--tracking-wide); + border: 1px solid var(--border-strong); + background: none; color: var(--text-muted); + cursor: pointer; + transition: color var(--t-base), border-color var(--t-base), background var(--t-base); + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + text-align: center; +} +.actionBtn:hover:not(:disabled) { color: var(--accent-fg); border-color: var(--accent); background: var(--accent-muted); } +.actionBtn:disabled { opacity: 0.4; cursor: default; } + +.actionBtnActive { + background: var(--accent-muted); + border-color: var(--accent-dim); + color: var(--accent-fg); +} +.actionBtnActive:hover:not(:disabled) { filter: brightness(1.1); } + +.actionBtnFolder { color: var(--text-secondary); border-color: var(--border-strong); } + +.actionBtnLabel { + flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; min-width: 0; +} + +/* ── Folder picker ───────────────────────────────────────────────────────── */ +.folderWrap { position: relative; width: 100%; } + +.folderMenu { + position: absolute; + bottom: calc(100% + 4px); left: 0; right: 0; + background: var(--bg-raised); + border: 1px solid var(--border-base); + border-radius: var(--radius-md); + padding: var(--sp-1); + display: flex; flex-direction: column; gap: 1px; + box-shadow: 0 8px 24px rgba(0,0,0,0.5); + z-index: 10; + animation: scaleIn 0.1s ease both; + transform-origin: bottom center; +} + +.folderEmpty { + font-family: var(--font-ui); font-size: var(--text-xs); + color: var(--text-faint); padding: var(--sp-2) var(--sp-3); +} + +.folderItem { + display: flex; align-items: center; gap: var(--sp-2); + padding: 6px var(--sp-3); border-radius: var(--radius-sm); + font-family: var(--font-ui); font-size: var(--text-xs); + color: var(--text-muted); + background: none; border: none; cursor: pointer; text-align: left; + transition: background var(--t-fast), color var(--t-fast); +} +.folderItem:hover { background: var(--bg-overlay); color: var(--text-primary); } +.folderItemOn { color: var(--accent-fg); } + +.folderDivider { height: 1px; background: var(--border-dim); margin: var(--sp-1) 0; } + +.folderCreateRow { + display: flex; gap: var(--sp-1); padding: var(--sp-1); +} +.folderInput { + flex: 1; background: var(--bg-overlay); + border: 1px solid var(--border-strong); + border-radius: var(--radius-sm); padding: 4px 8px; + color: var(--text-secondary); font-family: var(--font-ui); font-size: var(--text-xs); + outline: none; min-width: 0; +} +.folderInput:focus { border-color: var(--border-focus); } + +.folderOkBtn { + font-family: var(--font-ui); font-size: var(--text-xs); + padding: 4px 8px; border-radius: var(--radius-sm); + border: 1px solid var(--border-strong); + background: none; color: var(--text-muted); cursor: pointer; flex-shrink: 0; + transition: color var(--t-base); +} +.folderOkBtn:disabled { opacity: 0.4; cursor: default; } +.folderOkBtn:not(:disabled):hover { color: var(--accent-fg); border-color: var(--accent); } + +.folderNewBtn { + padding: 6px var(--sp-3); border-radius: var(--radius-sm); + font-family: var(--font-ui); font-size: var(--text-xs); + color: var(--text-faint); background: none; border: none; + cursor: pointer; text-align: left; width: 100%; + transition: color var(--t-fast); +} +.folderNewBtn:hover { color: var(--accent-fg); } + +/* ── Content column ──────────────────────────────────────────────────────── */ +.content { + flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0; +} + +/* ── Header ──────────────────────────────────────────────────────────────── */ +.contentHeader { + display: flex; align-items: flex-start; justify-content: space-between; + gap: var(--sp-4); padding: var(--sp-5) var(--sp-6) var(--sp-4); + border-bottom: 1px solid var(--border-dim); flex-shrink: 0; +} + +.titleBlock { + flex: 1; min-width: 0; display: flex; flex-direction: column; gap: var(--sp-1); +} + +.title { + font-size: var(--text-lg); font-weight: var(--weight-medium); + color: var(--text-primary); letter-spacing: var(--tracking-tight); + line-height: var(--leading-tight); +} + +.byline { + font-size: var(--text-sm); color: var(--text-muted); line-height: var(--leading-snug); +} + +.skByline { + height: 14px; width: 55%; + background: var(--bg-overlay); border-radius: var(--radius-sm); + animation: pulse 1.4s ease infinite; +} + +.closeBtn { + display: flex; align-items: center; justify-content: center; + width: 28px; height: 28px; border-radius: var(--radius-sm); + color: var(--text-faint); border: none; background: none; + cursor: pointer; flex-shrink: 0; + transition: color var(--t-base), background var(--t-base); +} +.closeBtn:hover { color: var(--text-muted); background: var(--bg-raised); } + +/* ── Scrollable body ─────────────────────────────────────────────────────── */ +.contentBody { + flex: 1; overflow-y: auto; + padding: var(--sp-5) var(--sp-6); + display: flex; flex-direction: column; gap: var(--sp-4); + scrollbar-width: thin; +} + +/* ── Error banner ────────────────────────────────────────────────────────── */ +.errorBanner { + font-family: var(--font-ui); font-size: var(--text-xs); + color: var(--color-warn, #f59e0b); + background: color-mix(in srgb, var(--color-warn, #f59e0b) 10%, transparent); + border: 1px solid color-mix(in srgb, var(--color-warn, #f59e0b) 25%, transparent); + border-radius: var(--radius-sm); padding: 6px var(--sp-3); +} + +/* ── Skeleton rows ───────────────────────────────────────────────────────── */ +.skRow { + display: flex; gap: var(--sp-2); align-items: center; +} +.skBadge { + height: 20px; width: 54px; + background: var(--bg-overlay); border-radius: var(--radius-sm); + animation: pulse 1.4s ease infinite; +} + +.skDesc { + display: flex; flex-direction: column; gap: 8px; padding: var(--sp-2) 0; +} +.skLine { + height: 13px; background: var(--bg-overlay); + border-radius: var(--radius-sm); + animation: pulse 1.4s ease infinite; +} + +/* ── Badges ──────────────────────────────────────────────────────────────── */ +.badges { display: flex; flex-wrap: wrap; gap: var(--sp-2); } + +.badge { + font-family: var(--font-ui); font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); text-transform: uppercase; + padding: 3px 8px; border-radius: var(--radius-sm); + border: 1px solid var(--border-dim); + background: var(--bg-raised); color: var(--text-faint); +} +.badgeGreen { + background: color-mix(in srgb, #22c55e 12%, transparent); + border-color: color-mix(in srgb, #22c55e 30%, transparent); + color: #22c55e; +} +.badgeDim { /* default */ } +.badgeAccent { + background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); +} +.badgeUnread { + background: color-mix(in srgb, #f59e0b 12%, transparent); + border-color: color-mix(in srgb, #f59e0b 30%, transparent); + color: #f59e0b; +} +.badgeNsfw { + background: color-mix(in srgb, #ef4444 12%, transparent); + border-color: color-mix(in srgb, #ef4444 30%, transparent); + color: #ef4444; +} + +/* ── Chapter box — clearly separated from description ────────────────────── */ +.chapterBox { + display: flex; flex-direction: column; gap: var(--sp-3); + padding: var(--sp-4); + background: var(--bg-raised); + border: 1px solid var(--border-dim); + border-radius: var(--radius-md); +} + +.chapterLoading { + display: flex; align-items: center; gap: var(--sp-2); +} +.chapterLoadingLabel { + font-family: var(--font-ui); font-size: var(--text-xs); + color: var(--text-faint); letter-spacing: var(--tracking-wide); +} + +.chapterMeta { + display: flex; align-items: center; justify-content: space-between; gap: var(--sp-3); +} + +.chapterLabel { + font-family: var(--font-ui); font-size: var(--text-xs); + color: var(--text-muted); letter-spacing: var(--tracking-wide); +} + +.dlAllBtn { + display: flex; align-items: center; gap: var(--sp-1); + font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); + padding: 3px 10px; border-radius: var(--radius-sm); + border: 1px solid var(--border-dim); background: none; color: var(--text-faint); + cursor: pointer; flex-shrink: 0; + transition: color var(--t-base), border-color var(--t-base); +} +.dlAllBtn:hover:not(:disabled) { color: var(--text-muted); border-color: var(--border-strong); } +.dlAllBtn:disabled { opacity: 0.5; cursor: default; } + +.progressTrack { + height: 3px; background: var(--bg-overlay); + border-radius: var(--radius-full); overflow: hidden; +} +.progressFill { + height: 100%; background: var(--accent); + border-radius: var(--radius-full); + transition: width 0.3s ease; +} + +.readBtn { + display: flex; align-items: center; gap: var(--sp-2); + padding: 8px var(--sp-4); + border-radius: var(--radius-md); + font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); + background: var(--accent-muted); border: 1px solid var(--accent-dim); color: var(--accent-fg); + cursor: pointer; align-self: flex-start; + transition: filter var(--t-base); +} +.readBtn:hover { filter: brightness(1.1); } + +/* ── Description block ───────────────────────────────────────────────────── */ +.descBlock { + display: flex; flex-direction: column; gap: var(--sp-2); + border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); +} + +.desc { + font-size: var(--text-sm); color: var(--text-muted); + line-height: var(--leading-base); + display: -webkit-box; -webkit-line-clamp: 5; -webkit-box-orient: vertical; overflow: hidden; +} +.descOpen { + display: block; -webkit-line-clamp: unset; overflow: visible; +} + +.descToggle { + display: flex; align-items: center; gap: var(--sp-1); + font-family: var(--font-ui); font-size: var(--text-xs); + color: var(--text-faint); background: none; border: none; + cursor: pointer; padding: 0; align-self: flex-start; + transition: color var(--t-base); +} +.descToggle:hover { color: var(--accent-fg); } + +/* ── Genre tags ──────────────────────────────────────────────────────────── */ +.genres { display: flex; flex-wrap: wrap; gap: var(--sp-1); } + +.genreTag { + font-family: var(--font-ui); font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); padding: 3px 8px; + border-radius: var(--radius-sm); border: 1px solid var(--border-dim); + background: var(--bg-raised); color: var(--text-faint); +} + +/* ── Metadata table ──────────────────────────────────────────────────────── */ +.metaTable { + display: flex; flex-direction: column; gap: 1px; + border-top: 1px solid var(--border-dim); padding-top: var(--sp-3); +} + +.metaRow { + display: flex; align-items: baseline; gap: var(--sp-3); padding: 5px 0; +} +.metaKey { + font-family: var(--font-ui); font-size: var(--text-xs); + color: var(--text-faint); letter-spacing: var(--tracking-wide); + text-transform: uppercase; min-width: 56px; flex-shrink: 0; +} +.metaVal { + font-size: var(--text-sm); color: var(--text-secondary); + line-height: var(--leading-snug); +} +.metaLink { + display: inline-flex; align-items: center; gap: 4px; + font-size: var(--text-sm); color: var(--accent-fg); + text-decoration: none; transition: opacity var(--t-base); +} +.metaLink:hover { opacity: 0.75; } \ No newline at end of file diff --git a/src/components/explore/MangaPreview.tsx b/src/components/explore/MangaPreview.tsx new file mode 100644 index 0000000..e6292e3 --- /dev/null +++ b/src/components/explore/MangaPreview.tsx @@ -0,0 +1,555 @@ +import { useEffect, useRef, useState, useCallback } from "react"; +import { + X, BookmarkSimple, ArrowSquareOut, Play, + CircleNotch, Books, CaretDown, FolderSimplePlus, Folder, +} from "@phosphor-icons/react"; +import { gql, thumbUrl } from "../../lib/client"; +import { + GET_MANGA, GET_CHAPTERS, FETCH_MANGA, FETCH_CHAPTERS, UPDATE_MANGA, ENQUEUE_CHAPTERS_DOWNLOAD, +} from "../../lib/queries"; +import { cache, CACHE_KEYS } from "../../lib/cache"; +import { useStore } from "../../store"; +import type { Manga, Chapter } from "../../lib/types"; +import s from "./MangaPreview.module.css"; + +export default function MangaPreview() { + const previewManga = useStore((st) => st.previewManga); + const setPreviewManga = useStore((st) => st.setPreviewManga); + const setActiveManga = useStore((st) => st.setActiveManga); + const setNavPage = useStore((st) => st.setNavPage); + const openReader = useStore((st) => st.openReader); + const addToast = useStore((st) => st.addToast); + const folders = useStore((st) => st.settings.folders); + const addFolder = useStore((st) => st.addFolder); + const assignMangaToFolder = useStore((st) => st.assignMangaToFolder); + const removeMangaFromFolder = useStore((st) => st.removeMangaFromFolder); + + const [manga, setManga] = useState(null); + const [chapters, setChapters] = useState([]); + const [loadingDetail, setLoadingDetail] = useState(false); + const [loadingChapters, setLoadingChapters] = useState(false); + const [togglingLib, setTogglingLib] = useState(false); + const [descExpanded, setDescExpanded] = useState(false); + const [folderOpen, setFolderOpen] = useState(false); + const [newFolderName, setNewFolderName] = useState(""); + const [creatingFolder, setCreatingFolder] = useState(false); + const [queueingAll, setQueueingAll] = useState(false); + const [fetchError, setFetchError] = useState(null); + + const backdropRef = useRef(null); + const detailAbort = useRef(null); + const chapterAbort = useRef(null); + const folderRef = useRef(null); + + const close = useCallback(() => { + detailAbort.current?.abort(); + chapterAbort.current?.abort(); + setPreviewManga(null); + setManga(null); + setChapters([]); + setDescExpanded(false); + setFolderOpen(false); + setCreatingFolder(false); + setNewFolderName(""); + setFetchError(null); + }, [setPreviewManga]); + + // ── Fetch detail + chapters on open ────────────────────────────────────── + useEffect(() => { + if (!previewManga) return; + + // Abort any in-flight requests from previous manga + detailAbort.current?.abort(); + chapterAbort.current?.abort(); + + const dCtrl = new AbortController(); + const cCtrl = new AbortController(); + detailAbort.current = dCtrl; + chapterAbort.current = cCtrl; + + setManga(null); + setChapters([]); + setDescExpanded(false); + setFetchError(null); + setLoadingDetail(true); + setLoadingChapters(true); + + const id = previewManga.id; + + // ── Detail fetch strategy ───────────────────────────────────────────── + // For source/explore manga we must call FETCH_MANGA (mutation that + // hits the source and syncs to the local DB). GET_MANGA only works for + // manga already in the local DB with full metadata. + // + // Fast path: if we already cached a full record, use it directly. + // Slow path: always try FETCH_MANGA first — it never fails for valid IDs + // and returns the richest data. Fall back to GET_MANGA if it errors. + // + (async (): Promise => { + const cacheKey = CACHE_KEYS.MANGA(id); + + // Already have a cached rich record — no network needed + if (cache.has(cacheKey)) { + return cache.get(cacheKey, () => + Promise.resolve(previewManga as Manga) + ) as Promise; + } + + // Try FETCH_MANGA first — works for all manga regardless of whether + // they are in the local DB yet (it fetches from source and syncs). + try { + const d = await gql<{ fetchManga: { manga: Manga } }>( + FETCH_MANGA, { id }, dCtrl.signal + ); + return d.fetchManga.manga; + } catch (e: any) { + if (e?.name === "AbortError") throw e; + // FETCH_MANGA failed (e.g. source offline) — fall back to local DB + const local = await gql<{ manga: Manga }>( + GET_MANGA, { id }, dCtrl.signal + ).then((d) => d.manga); + if (local) return local; + throw new Error("Could not load manga details"); + } + })() + .then((fullManga) => { + if (dCtrl.signal.aborted) return; + // Cache the rich record so re-opening is instant + if (!cache.has(CACHE_KEYS.MANGA(id))) { + cache.get(CACHE_KEYS.MANGA(id), () => Promise.resolve(fullManga)); + } + setManga(fullManga); + setLoadingDetail(false); + }) + .catch((e) => { + if (e?.name === "AbortError") return; + console.error("MangaPreview detail fetch:", e); + // Show whatever sparse data we have from previewManga + setManga(previewManga as Manga); + setFetchError("Could not load full details — showing cached data"); + setLoadingDetail(false); + }); + + // ── Chapter fetch — local DB first, fall back to source fetch ──────── + gql<{ chapters: { nodes: Chapter[] } }>( + GET_CHAPTERS, { mangaId: id }, cCtrl.signal + ) + .then(async (d) => { + if (cCtrl.signal.aborted) return; + let nodes = [...d.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); + // If no local chapters yet (explore/source manga), fetch from source + if (nodes.length === 0) { + try { + const fetched = await gql<{ fetchChapters: { chapters: Chapter[] } }>( + FETCH_CHAPTERS, { mangaId: id }, cCtrl.signal + ); + if (!cCtrl.signal.aborted) + nodes = [...fetched.fetchChapters.chapters].sort((a, b) => a.sourceOrder - b.sourceOrder); + } catch (e: any) { + if (e?.name === "AbortError") return; + // Leave nodes empty — not a fatal error + } + } + if (!cCtrl.signal.aborted) setChapters(nodes); + }) + .catch((e) => { if (e?.name !== "AbortError") console.error(e); }) + .finally(() => { if (!cCtrl.signal.aborted) setLoadingChapters(false); }); + + return () => { dCtrl.abort(); cCtrl.abort(); }; + }, [previewManga?.id]); // eslint-disable-line react-hooks/exhaustive-deps + + // ── Keyboard close ──────────────────────────────────────────────────────── + useEffect(() => { + if (!previewManga) return; + const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") close(); }; + window.addEventListener("keydown", onKey); + return () => window.removeEventListener("keydown", onKey); + }, [previewManga, close]); + + // ── Folder outside click ────────────────────────────────────────────────── + useEffect(() => { + if (!folderOpen) return; + const handler = (e: MouseEvent) => { + if (folderRef.current && !folderRef.current.contains(e.target as Node)) { + setFolderOpen(false); setCreatingFolder(false); setNewFolderName(""); + } + }; + document.addEventListener("mousedown", handler); + return () => document.removeEventListener("mousedown", handler); + }, [folderOpen]); + + if (!previewManga) return null; + + // Always show title/cover from previewManga immediately; upgrade to fetched manga when ready + const displayManga = manga ?? previewManga; + const totalCount = chapters.length; + const readCount = chapters.filter((c) => c.isRead).length; + const unreadCount = totalCount - readCount; + const downloadedCount = chapters.filter((c) => c.isDownloaded).length; + const bookmarkCount = chapters.filter((c) => c.isBookmarked).length; + const inLibrary = manga?.inLibrary ?? previewManga.inLibrary ?? false; + + // Scanlators — deduplicated, non-empty + const scanlators = [...new Set( + chapters.map((c) => c.scanlator).filter((sc): sc is string => !!sc?.trim()) + )]; + + // Publication date range from chapter upload dates + const uploadDates = chapters + .map((c) => c.uploadDate ? new Date(c.uploadDate).getTime() : null) + .filter((d): d is number => d !== null && !isNaN(d)); + const firstUpload = uploadDates.length ? new Date(Math.min(...uploadDates)) : null; + const lastUpload = uploadDates.length ? new Date(Math.max(...uploadDates)) : null; + + function formatDate(d: Date) { + return d.toLocaleDateString(undefined, { year: "numeric", month: "short", day: "numeric" }); + } + + const statusLabel = displayManga.status + ? displayManga.status.charAt(0) + displayManga.status.slice(1).toLowerCase() + : null; + + const continueChapter = (() => { + if (!chapters.length) return null; + const asc = [...chapters]; + const inProgress = asc.find((c) => !c.isRead && (c.lastPageRead ?? 0) > 0); + if (inProgress) return { ch: inProgress, label: `Continue · Ch.${inProgress.chapterNumber}` }; + const firstUnread = asc.find((c) => !c.isRead); + if (firstUnread) return { ch: firstUnread, label: `Start · Ch.${firstUnread.chapterNumber}` }; + return { ch: asc[0], label: "Read again" }; + })(); + + async function toggleLibrary() { + if (!manga) return; + setTogglingLib(true); + const next = !manga.inLibrary; + await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error); + const updated = { ...manga, inLibrary: next }; + setManga(updated); + // Update cache so subsequent opens reflect new state + cache.clear(CACHE_KEYS.MANGA(manga.id)); + cache.get(CACHE_KEYS.MANGA(manga.id), () => Promise.resolve(updated)); + cache.clear(CACHE_KEYS.LIBRARY); + setTogglingLib(false); + addToast({ kind: "success", title: next ? "Added to library" : "Removed from library" }); + } + + async function downloadAll() { + const ids = chapters.filter((c) => !c.isDownloaded && !c.isRead).map((c) => c.id); + if (!ids.length) return; + setQueueingAll(true); + await gql(ENQUEUE_CHAPTERS_DOWNLOAD, { chapterIds: ids }).catch(console.error); + addToast({ kind: "download", title: "Downloading", body: `${ids.length} chapters queued` }); + setQueueingAll(false); + } + + function openSeriesDetail() { + setActiveManga(displayManga); + setNavPage("library"); + close(); + } + + function handleFolderCreate() { + const name = newFolderName.trim(); + if (!name || !previewManga) return; + const newId = addFolder(name); + assignMangaToFolder(newId, previewManga.id); + setNewFolderName(""); + setCreatingFolder(false); + } + + const assignedFolders = folders.filter((f) => f.mangaIds.includes(previewManga.id)); + + return ( +
{ if (e.target === backdropRef.current) close(); }} + > +
+ + {/* ── Cover column ── */} +
+
+ {displayManga.title} + {loadingDetail && ( +
+ +
+ )} +
+ +
+ + + + + {/* Folder picker */} +
+ + + {folderOpen && ( +
+ {folders.length === 0 && !creatingFolder && ( +

No folders yet

+ )} + {folders.map((f) => { + const isIn = f.mangaIds.includes(previewManga.id); + return ( + + ); + })} +
+ {creatingFolder ? ( +
+ setNewFolderName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleFolderCreate(); + if (e.key === "Escape") { setCreatingFolder(false); setNewFolderName(""); } + }} + /> + +
+ ) : ( + + )} +
+ )} +
+
+
+ + {/* ── Content column ── */} +
+ + {/* Header — title visible immediately from previewManga */} +
+
+

{displayManga.title}

+ {loadingDetail + ?
+ : (displayManga.author || displayManga.artist) + ?

+ {[displayManga.author, displayManga.artist] + .filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")} +

+ : null} +
+ +
+ + {/* Scrollable body */} +
+ + {/* Error banner */} + {fetchError && ( +
{fetchError}
+ )} + + {/* ── Badges ── */} + {loadingDetail ? ( +
+
+
+
+ ) : ( +
+ {statusLabel && ( + {statusLabel} + )} + {displayManga.source && ( + + {displayManga.source.displayName}{(displayManga.source as any).isNsfw ? " · 18+" : ""} + + )} + {inLibrary && In Library} + {!loadingChapters && unreadCount > 0 && ( + {unreadCount} unread + )} + {!loadingChapters && bookmarkCount > 0 && ( + {bookmarkCount} bookmarked + )} +
+ )} + + {/* ── Chapter section — visually separated box ── */} +
+ {loadingChapters ? ( +
+ + Loading chapters… +
+ ) : totalCount > 0 ? ( + <> +
+ + {totalCount} {totalCount === 1 ? "chapter" : "chapters"} + {readCount > 0 && ` · ${readCount} read`} + {unreadCount > 0 && readCount > 0 && ` · ${unreadCount} left`} + {downloadedCount > 0 && ` · ${downloadedCount} dl`} + + {unreadCount > 0 && ( + + )} +
+ {readCount > 0 && ( +
+
+
+ )} + {continueChapter && ( + + )} + + ) : !loadingDetail ? ( + + No chapters in local library + + ) : null} +
+ + {/* ── Description — clearly separated from chapter block ── */} + {loadingDetail ? ( +
+
+
+
+
+ ) : displayManga.description ? ( +
+

+ {displayManga.description} +

+ {displayManga.description.length > 220 && ( + + )} +
+ ) : null} + + {/* ── Genre tags ── */} + {!loadingDetail && displayManga.genre && displayManga.genre.length > 0 && ( +
+ {displayManga.genre.map((g) => {g})} +
+ )} + + {/* ── Metadata table ── */} + {!loadingDetail && ( +
+ {displayManga.author && ( +
+ Author + {displayManga.author} +
+ )} + {displayManga.artist && displayManga.artist !== displayManga.author && ( +
+ Artist + {displayManga.artist} +
+ )} + {statusLabel && ( +
+ Status + {statusLabel} +
+ )} + {displayManga.source && ( +
+ Source + {displayManga.source.displayName} +
+ )} + {!loadingChapters && scanlators.length > 0 && ( +
+ {scanlators.length === 1 ? "Scanlator" : "Scanlators"} + {scanlators.join(", ")} +
+ )} + {!loadingChapters && firstUpload && lastUpload && ( +
+ Published + + {firstUpload.getTime() === lastUpload.getTime() + ? formatDate(firstUpload) + : `${formatDate(firstUpload)} – ${formatDate(lastUpload)}`} + +
+ )} + {!loadingChapters && downloadedCount > 0 && ( +
+ Downloaded + {downloadedCount} / {totalCount} chapters +
+ )} + {!loadingChapters && bookmarkCount > 0 && ( +
+ Bookmarks + {bookmarkCount} chapter{bookmarkCount !== 1 ? "s" : ""} +
+ )} + {displayManga.realUrl && ( +
+ Link + + Open + +
+ )} +
+ )} + +
+
+ +
+
+ ); +} \ No newline at end of file diff --git a/src/components/layout/Layout.tsx b/src/components/layout/Layout.tsx index 6e8c296..e018d09 100644 --- a/src/components/layout/Layout.tsx +++ b/src/components/layout/Layout.tsx @@ -4,7 +4,7 @@ import Library from "../pages/Library"; import SeriesDetail from "../pages/SeriesDetail"; import History from "../pages/History"; import Search from "../pages/Search"; -import Explore from "../sources/Explore"; +import Explore from "../explore/Explore"; import DownloadQueue from "../downloads/DownloadQueue"; import ExtensionList from "../extensions/ExtensionList"; import s from "./Layout.module.css"; @@ -14,7 +14,7 @@ export default function Layout() { const activeManga = useStore((s) => s.activeManga); function renderContent() { - if (navPage === "library" && activeManga) return ; + if (activeManga) return ; switch (navPage) { case "library": return ; case "search": return ; diff --git a/src/components/layout/Sidebar.tsx b/src/components/layout/Sidebar.tsx index d70351b..6da1256 100644 --- a/src/components/layout/Sidebar.tsx +++ b/src/components/layout/Sidebar.tsx @@ -20,10 +20,13 @@ export default function Sidebar() { const setActiveSource = useStore((state) => state.setActiveSource); const setActiveManga = useStore((state) => state.setActiveManga); const setLibraryFilter = useStore((state) => state.setLibraryFilter); + const setGenreFilter = useStore((state) => state.setGenreFilter); const openSettings = useStore((state) => state.openSettings); function navigate(id: NavPage) { setNavPage(id); + setActiveManga(null); + setGenreFilter(""); if (id !== "explore") setActiveSource(null); } diff --git a/src/components/pages/Library.tsx b/src/components/pages/Library.tsx index efbcd31..0b3c15c 100644 --- a/src/components/pages/Library.tsx +++ b/src/components/pages/Library.tsx @@ -3,21 +3,30 @@ import { MagnifyingGlass, Books, DownloadSimple, X, Folder, FolderSimplePlus, Tr import { useVirtualizer } from "@tanstack/react-virtual"; import { gql, thumbUrl } from "../../lib/client"; import { GET_LIBRARY, UPDATE_MANGA, GET_CHAPTERS, DELETE_DOWNLOADED_CHAPTERS, DEQUEUE_DOWNLOAD } from "../../lib/queries"; +import { cache, CACHE_KEYS } from "../../lib/cache"; import { useStore } from "../../store"; import type { Manga, Chapter } from "../../lib/types"; import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu"; import s from "./Library.module.css"; -// 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 +const ROW_HEIGHT = 260; + +function FadeImg({ src, alt, className, objectFit }: { src: string; alt: string; className?: string; objectFit?: string }) { + const [loaded, setLoaded] = useState(false); + return ( + {alt} setLoaded(true)} + /> + ); +} const MangaCard = memo(function MangaCard({ - manga, - onClick, - onContextMenu, - cropCovers, + manga, onClick, onContextMenu, cropCovers, }: { manga: Manga; onClick: () => void; @@ -27,13 +36,11 @@ const MangaCard = memo(function MangaCard({ return ( ); @@ -352,13 +367,7 @@ export default function Library() { : "No manga found."}
) : ( - /* Virtual scroll container */ -
+
{virtualizer.getVirtualItems().map((virtualRow) => { const rowManga = rows[virtualRow.index]; return ( @@ -382,7 +391,6 @@ export default function Library() { cropCovers={settings.libraryCropCovers} /> ))} - {/* Ghost cards on last row to fill grid */} {virtualRow.index === rows.length - 1 && Array.from({ length: cols - rowManga.length }).map((_, i) => (
@@ -394,20 +402,10 @@ export default function Library() { )} {ctx && ( - setCtx(null)} - /> + setCtx(null)} /> )} {emptyCtx && ( - setEmptyCtx(null)} - /> + setEmptyCtx(null)} /> )}
); diff --git a/src/components/pages/Search.module.css b/src/components/pages/Search.module.css index 9afbe99..8b71c6a 100644 --- a/src/components/pages/Search.module.css +++ b/src/components/pages/Search.module.css @@ -1,30 +1,79 @@ +/* ── Root ────────────────────────────────────────────────────────────────── */ .root { display: flex; flex-direction: column; height: 100%; overflow: hidden; animation: fadeIn 0.14s ease both; } +/* ── Header ──────────────────────────────────────────────────────────────── */ .header { display: flex; align-items: center; gap: var(--sp-4); - padding: var(--sp-4) var(--sp-6); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; + padding: var(--sp-3) var(--sp-6) var(--sp-3) var(--sp-6); + border-bottom: 1px solid var(--border-dim); flex-shrink: 0; } + .heading { font-family: var(--font-ui); font-size: var(--text-xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; flex-shrink: 0; } + +/* ── Tab bar ─────────────────────────────────────────────────────────────── */ +.tabs { + display: flex; gap: 2px; + background: var(--bg-raised); border: 1px solid var(--border-dim); + border-radius: var(--radius-md); padding: 2px; +} +.tab { + display: flex; align-items: center; gap: 5px; + font-family: var(--font-ui); font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); text-transform: uppercase; + padding: 4px 10px; border-radius: var(--radius-sm); border: none; + background: none; color: var(--text-faint); cursor: pointer; + transition: background var(--t-base), color var(--t-base); white-space: nowrap; +} +.tab:hover { color: var(--text-muted); } +.tabActive { + background: var(--accent-muted); color: var(--accent-fg); + border: 1px solid var(--accent-dim); +} +.tabActive:hover { color: var(--accent-fg); } + +/* ── Keyword tab bar area ────────────────────────────────────────────────── */ +.keywordBar { + flex-shrink: 0; display: flex; flex-direction: column; + border-bottom: 1px solid var(--border-dim); +} + +/* ── Shared search bar ───────────────────────────────────────────────────── */ .searchBar { - flex: 1; display: flex; align-items: center; gap: var(--sp-2); + display: flex; align-items: center; gap: var(--sp-2); background: var(--bg-raised); border: 1px solid var(--border-dim); border-radius: var(--radius-lg); padding: 0 var(--sp-3) 0 var(--sp-2); transition: border-color var(--t-base); + margin: var(--sp-3) var(--sp-6); } .searchBar:focus-within { border-color: var(--border-strong); } + .searchIcon { color: var(--text-faint); flex-shrink: 0; } + .searchInput { flex: 1; background: none; border: none; outline: none; color: var(--text-primary); font-size: var(--text-sm); padding: 8px 0; } .searchInput::placeholder { color: var(--text-faint); } + +.advancedBtn { + display: flex; align-items: center; justify-content: center; + width: 26px; height: 26px; border-radius: var(--radius-sm); + background: none; border: 1px solid transparent; + color: var(--text-faint); cursor: pointer; flex-shrink: 0; + transition: color var(--t-base), border-color var(--t-base), background var(--t-base); +} +.advancedBtn:hover { color: var(--text-muted); border-color: var(--border-dim); } +.advancedBtnActive { + background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); +} + .searchBtn { font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); padding: 5px 12px; border-radius: var(--radius-md); flex-shrink: 0; @@ -35,74 +84,108 @@ .searchBtn:hover:not(:disabled) { filter: brightness(1.1); } .searchBtn:disabled { opacity: 0.4; cursor: default; } -.langBar { - display: flex; - align-items: center; - flex-wrap: wrap; - gap: var(--sp-1); - padding: var(--sp-2) var(--sp-6); - border-bottom: 1px solid var(--border-dim); - flex-shrink: 0; +.clearSearchBtn { + display: flex; align-items: center; justify-content: center; + width: 20px; height: 20px; border-radius: 50%; + font-size: 15px; line-height: 1; + color: var(--text-faint); background: var(--bg-overlay); border: none; + cursor: pointer; flex-shrink: 0; transition: color var(--t-base); +} +.clearSearchBtn:hover { color: var(--text-muted); } + +/* ── Advanced filter panel ───────────────────────────────────────────────── */ +.advancedPanel { + display: flex; flex-direction: column; gap: var(--sp-3); + padding: 0 var(--sp-6) var(--sp-4); + animation: fadeIn 0.1s ease both; } -.langBtn { - font-family: var(--font-ui); - font-size: var(--text-2xs); - letter-spacing: var(--tracking-wider); - padding: 3px 8px; - border-radius: var(--radius-sm); - border: 1px solid var(--border-dim); - background: none; - color: var(--text-faint); - cursor: pointer; +.advancedHeader { display: flex; align-items: center; justify-content: space-between; } +.advancedTitle { + font-family: var(--font-ui); font-size: var(--text-2xs); + color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; +} +.advancedActions { display: flex; gap: var(--sp-3); } +.advancedLink { + font-family: var(--font-ui); font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); color: var(--accent-fg); + background: none; border: none; cursor: pointer; padding: 0; + transition: opacity var(--t-base); +} +.advancedLink:hover { opacity: 0.7; } + +.langGrid { display: flex; flex-wrap: wrap; gap: var(--sp-1); } +.langFilterRow { display: flex; flex-wrap: wrap; gap: var(--sp-1); padding: var(--sp-3) var(--sp-3) 0; } + +.langChip { + font-family: var(--font-ui); font-size: var(--text-2xs); + letter-spacing: var(--tracking-wider); padding: 3px 8px; + border-radius: var(--radius-sm); border: 1px solid var(--border-dim); + background: none; color: var(--text-faint); cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); + white-space: nowrap; } -.langBtn:hover { color: var(--text-muted); border-color: var(--border-strong); } -.langBtnActive { - background: var(--accent-muted); - border-color: var(--accent-dim); - color: var(--accent-fg); -} -.langBtnActive:hover { background: var(--accent-muted); color: var(--accent-fg); } - -.sourceCount { - font-family: var(--font-ui); - font-size: var(--text-2xs); - color: var(--text-faint); - letter-spacing: var(--tracking-wide); - margin-left: auto; +.langChip:hover { color: var(--text-muted); border-color: var(--border-strong); } +.langChipActive { + background: var(--accent-muted); border-color: var(--accent-dim); color: var(--accent-fg); } -.results { flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); display: flex; flex-direction: column; gap: var(--sp-6); } +.advancedDivider { height: 1px; background: var(--border-dim); margin: 0 calc(-1 * var(--sp-6)); } +.advancedCheck { + display: flex; align-items: center; gap: var(--sp-2); + font-family: var(--font-ui); font-size: var(--text-xs); + color: var(--text-muted); cursor: pointer; user-select: none; +} +.checkbox { accent-color: var(--accent-fg); width: 13px; height: 13px; cursor: pointer; } +.advancedFooter { + font-family: var(--font-ui); font-size: var(--text-2xs); + color: var(--text-faint); letter-spacing: var(--tracking-wide); +} +.advancedFooter strong { color: var(--text-muted); font-weight: var(--weight-medium); } + +/* ── Keyword results list ────────────────────────────────────────────────── */ +.results { + flex: 1; overflow-y: auto; padding: var(--sp-5) var(--sp-6); + display: flex; flex-direction: column; gap: var(--sp-6); + scrollbar-width: thin; +} .sourceSection { display: flex; flex-direction: column; gap: var(--sp-3); } -.sourceHeader { - display: flex; align-items: center; gap: var(--sp-2); -} +.sourceHeader { display: flex; align-items: center; gap: var(--sp-2); } .sourceIcon { width: 18px; height: 18px; border-radius: 4px; object-fit: cover; flex-shrink: 0; } .sourceName { font-size: var(--text-sm); font-weight: var(--weight-medium); color: var(--text-secondary); } -.resultCount { +.sourceLang { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); - letter-spacing: var(--tracking-wide); margin-left: auto; + letter-spacing: var(--tracking-wider); padding: 1px 5px; + border: 1px solid var(--border-dim); border-radius: var(--radius-sm); +} +.resultCount { + font-family: var(--font-ui); font-size: var(--text-2xs); + color: var(--text-faint); letter-spacing: var(--tracking-wide); margin-left: auto; } .sourceError { font-size: var(--text-xs); color: var(--color-error); padding: 0 var(--sp-1); } .sourceRow { display: flex; gap: var(--sp-3); overflow-x: auto; - padding-bottom: var(--sp-2); - scrollbar-width: thin; + padding-bottom: var(--sp-2); scrollbar-width: thin; } + +/* ── Shared manga card ───────────────────────────────────────────────────── */ .card { - flex-shrink: 0; width: 110px; background: none; border: none; padding: 0; + flex-shrink: 0; width: 110px; + background: none; border: none; padding: 0; cursor: pointer; text-align: left; } .card:hover .cover { filter: brightness(1.06); } + .coverWrap { - position: relative; aspect-ratio: 2/3; border-radius: var(--radius-md); - overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); + position: relative; aspect-ratio: 2/3; + border-radius: var(--radius-md); overflow: hidden; + background: var(--bg-raised); border: 1px solid var(--border-dim); } .cover { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); } + .inLibBadge { position: absolute; bottom: var(--sp-1); left: var(--sp-1); font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); @@ -115,14 +198,127 @@ line-height: var(--leading-snug); } +/* ── Skeleton cards ──────────────────────────────────────────────────────── */ .skCard { flex-shrink: 0; width: 110px; } -.skCover { aspect-ratio: 2/3; border-radius: var(--radius-md); } -.skTitle { height: 11px; margin-top: var(--sp-1); width: 75%; } +.skCover { aspect-ratio: 2/3; border-radius: var(--radius-md); width: 100%; } +.skTitle { height: 11px; margin-top: var(--sp-1); width: 75%; border-radius: 3px; } +/* ── Empty state ─────────────────────────────────────────────────────────── */ .empty { flex: 1; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: var(--sp-2); + padding: var(--sp-6); } .emptyIcon { color: var(--text-faint); } .emptyText { font-size: var(--text-base); color: var(--text-muted); } -.emptyHint { font-size: var(--text-sm); color: var(--text-faint); } \ No newline at end of file +.emptyHint { font-size: var(--text-sm); color: var(--text-faint); text-align: center; max-width: 280px; } + +.advancedLinkStandalone { + display: flex; align-items: center; gap: var(--sp-1); + font-family: var(--font-ui); font-size: var(--text-xs); + color: var(--text-faint); background: none; border: none; + cursor: pointer; padding: 0; margin-top: var(--sp-1); + transition: color var(--t-base); +} +.advancedLinkStandalone:hover { color: var(--accent-fg); } + +/* ── Split layout (tag + source tabs) ───────────────────────────────────── */ +.splitRoot { flex: 1; display: flex; overflow: hidden; } + +.splitSidebar { + width: 192px; flex-shrink: 0; + border-right: 1px solid var(--border-dim); + display: flex; flex-direction: column; overflow: hidden; + background: var(--bg-raised); +} + +.splitSearchWrap { + display: flex; align-items: center; gap: var(--sp-2); + padding: var(--sp-3); + border-bottom: 1px solid var(--border-dim); flex-shrink: 0; +} +.splitSearchIcon { color: var(--text-faint); flex-shrink: 0; } +.splitSearchInput { + flex: 1; background: none; border: none; outline: none; + color: var(--text-primary); font-size: var(--text-xs); + font-family: var(--font-ui); letter-spacing: var(--tracking-wide); +} +.splitSearchInput::placeholder { color: var(--text-faint); } + +.splitList { + flex: 1; overflow-y: auto; padding: var(--sp-2); + display: flex; flex-direction: column; gap: 1px; scrollbar-width: thin; +} + +.splitItem { + display: flex; align-items: center; width: 100%; + padding: 7px var(--sp-2); border-radius: var(--radius-md); + border: none; background: none; + color: var(--text-muted); font-size: var(--text-sm); + cursor: pointer; text-align: left; + transition: background var(--t-fast), color var(--t-fast); +} +.splitItem:hover { background: var(--bg-overlay); color: var(--text-secondary); } +.splitItemActive { background: var(--accent-muted); color: var(--accent-fg); } +.splitItemActive:hover { background: var(--accent-muted); color: var(--accent-fg); } + +.splitItemSource { gap: var(--sp-2); } +.splitItemLabel { flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.splitSourceIcon { width: 16px; height: 16px; border-radius: 3px; object-fit: cover; flex-shrink: 0; } + +.splitEmpty { + font-family: var(--font-ui); font-size: var(--text-xs); + color: var(--text-faint); padding: var(--sp-3) var(--sp-2); +} +.splitLoading { + flex: 1; display: flex; align-items: center; justify-content: center; +} + +/* ── Split right content ─────────────────────────────────────────────────── */ +.splitContent { flex: 1; display: flex; flex-direction: column; overflow: hidden; } + +.splitContentHeader { + display: flex; align-items: center; gap: var(--sp-3); + padding: var(--sp-3) var(--sp-5); + border-bottom: 1px solid var(--border-dim); flex-shrink: 0; + flex-wrap: wrap; + min-height: 52px; +} +.splitContentTitle { + font-size: var(--text-base); font-weight: var(--weight-medium); + color: var(--text-secondary); letter-spacing: var(--tracking-tight); +} +.splitResultCount { + font-family: var(--font-ui); font-size: var(--text-2xs); + color: var(--text-faint); letter-spacing: var(--tracking-wide); + margin-left: auto; +} +.splitSourceTitle { + display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0; +} +.sourceBrowseBar { + display: flex; align-items: center; gap: var(--sp-2); flex: 1; min-width: 0; +} + +/* ── Grid (tag + source results) ─────────────────────────────────────────── */ +.tagGrid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 9vw, 120px), 1fr)); + gap: var(--sp-3); + padding: var(--sp-4) var(--sp-5); + overflow-y: auto; flex: 1; + align-content: start; + scrollbar-width: thin; +} +/* In the grid, cards stretch to fill the column */ +.tagGrid .card { width: auto; } +.tagGrid .skCard { width: auto; } +.tagGrid .skCover { width: 100%; } + +/* ── NSFW badge ──────────────────────────────────────────────────────────── */ +.nsfwBadge { + font-family: var(--font-ui); font-size: var(--text-2xs); + letter-spacing: var(--tracking-wide); padding: 1px 5px; + border-radius: var(--radius-sm); border: 1px solid var(--border-dim); + color: var(--text-faint); flex-shrink: 0; +} \ No newline at end of file diff --git a/src/components/pages/Search.tsx b/src/components/pages/Search.tsx index d2165a2..ce0e458 100644 --- a/src/components/pages/Search.tsx +++ b/src/components/pages/Search.tsx @@ -1,11 +1,19 @@ -import { useState, useRef, useCallback, useEffect } from "react"; -import { MagnifyingGlass, CircleNotch } from "@phosphor-icons/react"; +import { useState, useRef, useCallback, useEffect, memo, useMemo } from "react"; +import { + MagnifyingGlass, CircleNotch, SlidersHorizontal, Hash, List, +} from "@phosphor-icons/react"; import { gql, thumbUrl } from "../../lib/client"; import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries"; +import { cache, CACHE_KEYS, getTopSources } from "../../lib/cache"; +import { dedupeSources, dedupeMangaByTitle } from "../../lib/sourceUtils"; import { useStore } from "../../store"; import type { Manga, Source } from "../../lib/types"; import s from "./Search.module.css"; +// ── Types ───────────────────────────────────────────────────────────────────── + +type SearchTab = "keyword" | "tag" | "source"; + interface SourceResult { source: Source; mangas: Manga[]; @@ -13,15 +21,30 @@ interface SourceResult { error: string | null; } -const CONCURRENCY = 3; +// ── Constants ───────────────────────────────────────────────────────────────── + +const CONCURRENCY = 4; +const RESULTS_PER_SOURCE = 8; + +const COMMON_GENRES = [ + "Action","Adventure","Comedy","Drama","Fantasy","Romance", + "Sci-Fi","Slice of Life","Horror","Mystery","Thriller","Sports", + "Supernatural","Mecha","Historical","Psychological","School Life", + "Shounen","Seinen","Josei","Shoujo","Isekai","Martial Arts", + "Magic","Music","Cooking","Medical","Military","Harem","Ecchi", +]; + +// ── Concurrent fetch helper ─────────────────────────────────────────────────── async function runConcurrent( items: T[], - fn: (item: T) => Promise + 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(() => {}); } @@ -29,84 +52,280 @@ async function runConcurrent( await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker)); } +// ── Shared card ─────────────────────────────────────────────────────────────── + +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" }} + /> + ); +}); + +function MangaCard({ manga, onClick }: { manga: Manga; onClick: () => void }) { + return ( + + ); +} + +function GridSkeleton({ count = 18 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+
+
+
+ ))} +
+ ); +} + +function RowSkeleton({ count = 4 }: { count?: number }) { + return ( +
+ {Array.from({ length: count }).map((_, i) => ( +
+
+
+
+ ))} +
+ ); +} + +// ── Root ────────────────────────────────────────────────────────────────────── + export default function Search() { - const [query, setQuery] = useState(""); - const [submitted, setSubmitted] = useState(""); - const [results, setResults] = useState([]); - const [allSources, setAllSources] = useState([]); + const [tab, setTab] = useState("keyword"); + + const preferredLang = useStore((st) => st.settings.preferredExtensionLang); + const searchPrefill = useStore((st) => st.searchPrefill ?? ""); + const setSearchPrefill = useStore((st) => st.setSearchPrefill); + const setPreviewManga = useStore((st) => st.setPreviewManga); + + const [allSources, setAllSources] = useState([]); const [loadingSources, setLoadingSources] = useState(false); - const [activeLang, setActiveLang] = useState("preferred"); - const inputRef = useRef(null); - const setActiveManga = useStore((st) => st.setActiveManga); - const setNavPage = useStore((st) => st.setNavPage); - const preferredLang = useStore((st) => st.settings.preferredExtensionLang); + const pendingPrefill = useRef(""); + // Consume searchPrefill → route to keyword tab + useEffect(() => { + if (!searchPrefill) return; + pendingPrefill.current = searchPrefill; + setTab("keyword"); + setSearchPrefill(""); + }, [searchPrefill, setSearchPrefill]); + + // Load sources once, shared across all tabs useEffect(() => { setLoadingSources(true); - gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) - .then((d) => setAllSources(d.sources.nodes.filter((s) => s.id !== "0"))) + cache.get(CACHE_KEYS.SOURCES, () => + gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) + .then((d) => d.sources.nodes.filter((src) => src.id !== "0")) + ) + .then(setAllSources) .catch(console.error) .finally(() => setLoadingSources(false)); + // eslint-disable-next-line react-hooks/exhaustive-deps }, []); - const langs = ["preferred", ...Array.from(new Set(allSources.map((s) => s.lang))).sort(), "all"]; - - const visibleSources = allSources.filter((src) => { - if (activeLang === "all") return true; - if (activeLang === "preferred") return src.lang === preferredLang; - return src.lang === activeLang; - }); - - const runSearch = useCallback(async () => { - const q = query.trim(); - if (!q || !visibleSources.length) return; - setSubmitted(q); - - setResults(visibleSources.map((src) => ({ source: src, mangas: [], loading: true, error: null }))); - - await runConcurrent(visibleSources, async (src) => { - try { - const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, { - source: src.id, type: "SEARCH", page: 1, query: q, - }); - setResults((prev) => prev.map((r) => - r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r - )); - } catch (e: any) { - setResults((prev) => prev.map((r) => - r.source.id === src.id ? { ...r, loading: false, error: e.message } : r - )); - } - }); - }, [query, visibleSources]); - - function openManga(m: Manga) { - setActiveManga(m); - setNavPage("library"); - } - - const hasResults = results.some((r) => r.mangas.length > 0); - const allDone = results.every((r) => !r.loading); + const availableLangs = useMemo(() => + Array.from(new Set(allSources.map((s) => s.lang))).sort(), [allSources]); + const hasMultipleLangs = availableLangs.length > 1; return (

Search

+
+ + + +
+
+ + {tab === "keyword" && ( + + )} + {tab === "tag" && ( + + )} + {tab === "source" && ( + + )} +
+ ); +} + +// ── Keyword tab ─────────────────────────────────────────────────────────────── + +function KeywordTab({ + allSources, loadingSources, availableLangs, hasMultipleLangs, + preferredLang, pendingPrefill, onMangaClick, +}: { + allSources: Source[]; + loadingSources: boolean; + availableLangs: string[]; + hasMultipleLangs: boolean; + preferredLang: string; + pendingPrefill: React.MutableRefObject; + onMangaClick: (m: Manga) => void; +}) { + const [query, setQuery] = useState(""); + const [submitted, setSubmitted] = useState(""); + const [results, setResults] = useState([]); + const [showAdvanced, setShowAdvanced] = useState(false); + const [selectedLangs, setSelectedLangs] = useState>(new Set()); + const [includeNsfw, setIncludeNsfw] = useState(false); + + const abortRef = useRef(null); + const inputRef = useRef(null); + const allSourcesRef = useRef([]); + const selectedLangsRef = useRef>(new Set()); + + useEffect(() => { allSourcesRef.current = allSources; }, [allSources]); + useEffect(() => { selectedLangsRef.current = selectedLangs; }, [selectedLangs]); + + // Set default lang selection once sources load + useEffect(() => { + if (!allSources.length) return; + const available = new Set(allSources.map((s) => s.lang)); + setSelectedLangs(available.has(preferredLang) + ? new Set([preferredLang]) + : new Set(availableLangs.slice(0, 1)) + ); + }, [allSources]); // eslint-disable-line react-hooks/exhaustive-deps + + // Consume prefill once sources are ready + useEffect(() => { + if (loadingSources || !pendingPrefill.current || submitted) return; + if (!allSourcesRef.current.length) return; + const q = pendingPrefill.current; + pendingPrefill.current = ""; + setQuery(q); + doSearch(q); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [loadingSources]); + + useEffect(() => () => { abortRef.current?.abort(); }, []); + + const getVisibleSources = useCallback((): Source[] => { + let filtered = allSourcesRef.current; + if (selectedLangsRef.current.size > 0) + filtered = filtered.filter((s) => selectedLangsRef.current.has(s.lang)); + if (!includeNsfw) + filtered = filtered.filter((s) => !s.isNsfw); + return filtered; + }, [includeNsfw]); + + const doSearch = useCallback(async (q: string) => { + const trimmed = q.trim(); + if (!trimmed) return; + const visible = getVisibleSources(); + if (!visible.length) return; + + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + + setSubmitted(trimmed); + setResults(visible.map((src) => ({ source: src, mangas: [], loading: true, error: null }))); + + await runConcurrent(visible, async (src) => { + if (ctrl.signal.aborted) return; + try { + const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>( + FETCH_SOURCE_MANGA, + { source: src.id, type: "SEARCH", page: 1, query: trimmed }, + ctrl.signal, + ); + if (ctrl.signal.aborted) return; + setResults((prev) => prev.map((r) => + r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r + )); + } catch (e: any) { + if (ctrl.signal.aborted || e?.name === "AbortError") return; + setResults((prev) => prev.map((r) => + r.source.id === src.id ? { ...r, loading: false, error: e.message } : r + )); + } + }, ctrl.signal); + }, [getVisibleSources]); + + function toggleLang(lang: string) { + setSelectedLangs((prev) => { + const next = new Set(prev); + if (next.has(lang)) { if (next.size === 1) return prev; next.delete(lang); } + else next.add(lang); + return next; + }); + } + + const visibleCount = getVisibleSources().length; + const hasResults = results.some((r) => r.mangas.length > 0); + const allDone = results.every((r) => !r.loading); + + return ( + <> +
setQuery(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && runSearch()} - autoFocus + onKeyDown={(e) => e.key === "Enter" && doSearch(query)} /> + {hasMultipleLangs && ( + + )}
-
-
- {langs.map((l) => ( - - ))} - {visibleSources.length > 0 && ( - {visibleSources.length} sources + {hasMultipleLangs && showAdvanced && ( +
+
+ Languages +
+ + +
+
+
+ {availableLangs.map((lang) => ( + + ))} +
+
+ +
+ Searching {visibleCount} source{visibleCount !== 1 ? "s" : ""} +
+
)}
@@ -136,8 +371,15 @@ export default function Search() {

Search across sources

- Searching {visibleSources.length} {activeLang === "preferred" ? `${preferredLang.toUpperCase()}` : activeLang === "all" ? "" : activeLang.toUpperCase()} source{visibleSources.length !== 1 ? "s" : ""}. + {hasMultipleLangs + ? `${visibleCount} source${visibleCount !== 1 ? "s" : ""} · ${selectedLangs.size} language${selectedLangs.size !== 1 ? "s" : ""}` + : `${visibleCount} source${visibleCount !== 1 ? "s" : ""}`}

+ {hasMultipleLangs && !showAdvanced && ( + + )}
)} @@ -148,59 +390,321 @@ export default function Search() {
)} - {results .filter((r) => r.mangas.length > 0 || r.loading || r.error) .map(({ source, mangas, loading, error }) => (
- {source.displayName} { (e.target as HTMLImageElement).style.display = "none"; }} - /> + {source.displayName} { (e.target as HTMLImageElement).style.display = "none"; }} /> {source.displayName} - {loading && } - {!loading && mangas.length > 0 && ( - {mangas.length} results - )} + {hasMultipleLangs && {source.lang.toUpperCase()}} + {loading && } + {!loading && mangas.length > 0 && {mangas.length} results}
- {error ? (

{error}

) : loading ? ( -
- {Array.from({ length: 4 }).map((_, i) => ( -
-
-
-
- ))} -
+ ) : mangas.length > 0 ? (
- {mangas.slice(0, 8).map((m) => ( - + {mangas.slice(0, RESULTS_PER_SOURCE).map((m) => ( + onMangaClick(m)} /> ))}
) : null}
))} - - {allDone && !hasResults && submitted && ( + {allDone && !hasResults && (

No results for "{submitted}"

)}
)} + + ); +} + +// ── Tag tab ─────────────────────────────────────────────────────────────────── + +function TagTab({ + preferredLang, onMangaClick, +}: { + allSources: Source[]; + loadingSources: boolean; + preferredLang: string; + onMangaClick: (m: Manga) => void; +}) { + const [activeTag, setActiveTag] = useState(null); + const [tagResults, setTagResults] = useState([]); + const [loadingTag, setLoadingTag] = useState(false); + const [tagFilter, setTagFilter] = useState(""); + const abortRef = useRef(null); + + useEffect(() => () => { abortRef.current?.abort(); }, []); + + async function drillTag(tag: string) { + if (tag === activeTag && !loadingTag) return; + setActiveTag(tag); + setTagResults([]); + setLoadingTag(true); + + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + + try { + const sources = await cache.get(CACHE_KEYS.SOURCES, () => + gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) + .then((d) => d.sources.nodes.filter((s) => s.id !== "0")) + ); + const deduped = dedupeSources(sources, preferredLang); + const top = getTopSources(deduped); + + const results = await cache.get(CACHE_KEYS.GENRE(tag), () => + Promise.allSettled( + top.map((src) => + gql<{ fetchSourceManga: { mangas: Manga[] } }>( + FETCH_SOURCE_MANGA, + { source: src.id, type: "SEARCH", page: 1, query: tag }, + ctrl.signal, + ).then((d) => d.fetchSourceManga.mangas) + ) + ).then((settled) => { + const merged: Manga[] = []; + for (const r of settled) + if (r.status === "fulfilled") merged.push(...r.value); + return dedupeMangaByTitle(merged); + }) + ); + + if (!ctrl.signal.aborted) setTagResults(results); + } catch (e: any) { + if (e?.name !== "AbortError") console.error(e); + } finally { + if (!ctrl.signal.aborted) setLoadingTag(false); + } + } + + const filteredGenres = useMemo(() => { + const q = tagFilter.trim().toLowerCase(); + return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES; + }, [tagFilter]); + + return ( +
+
+
+ + setTagFilter(e.target.value)} + /> +
+
+ {filteredGenres.map((tag) => ( + + ))} + {filteredGenres.length === 0 &&

No matching tags

} +
+
+ +
+ {!activeTag ? ( +
+ +

Browse by tag

+

Select a genre tag to see matching manga across your sources.

+
+ ) : ( + <> +
+ {activeTag} + {loadingTag + ? + : {tagResults.length} results} +
+ {loadingTag ? ( + + ) : tagResults.length > 0 ? ( +
+ {tagResults.map((m) => ( + onMangaClick(m)} /> + ))} +
+ ) : ( +
+

No results for "{activeTag}"

+
+ )} + + )} +
+
+ ); +} + +// ── Source tab ──────────────────────────────────────────────────────────────── + +function SourceTab({ + allSources, loadingSources, availableLangs, hasMultipleLangs, onMangaClick, +}: { + allSources: Source[]; + loadingSources: boolean; + availableLangs: string[]; + hasMultipleLangs: boolean; + onMangaClick: (m: Manga) => void; +}) { + const [selectedLang, setSelectedLang] = useState("all"); + const [activeSource, setActiveSource] = useState(null); + const [browseResults, setBrowseResults] = useState([]); + const [loadingBrowse, setLoadingBrowse] = useState(false); + const [browseQuery, setBrowseQuery] = useState(""); + const [submitted, setSubmitted] = useState(""); + const abortRef = useRef(null); + + useEffect(() => () => { abortRef.current?.abort(); }, []); + + const visibleSources = useMemo(() => + selectedLang === "all" ? allSources : allSources.filter((s) => s.lang === selectedLang), + [allSources, selectedLang] + ); + + async function fetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string) { + abortRef.current?.abort(); + const ctrl = new AbortController(); + abortRef.current = ctrl; + setLoadingBrowse(true); + setBrowseResults([]); + + try { + const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>( + FETCH_SOURCE_MANGA, + { source: src.id, type, page: 1, query: q ?? null }, + ctrl.signal, + ); + if (!ctrl.signal.aborted) setBrowseResults(d.fetchSourceManga.mangas); + } catch (e: any) { + if (e?.name !== "AbortError") console.error(e); + } finally { + if (!ctrl.signal.aborted) setLoadingBrowse(false); + } + } + + function selectSource(src: Source) { + setActiveSource(src); + setBrowseQuery(""); + setSubmitted(""); + fetchBrowse(src, "POPULAR"); + } + + function handleSearch() { + if (!activeSource || !browseQuery.trim()) return; + setSubmitted(browseQuery.trim()); + fetchBrowse(activeSource, "SEARCH", browseQuery.trim()); + } + + function clearSearch() { + setBrowseQuery(""); + setSubmitted(""); + if (activeSource) fetchBrowse(activeSource, "POPULAR"); + } + + return ( +
+
+ {hasMultipleLangs && ( +
+ {["all", ...availableLangs].map((lang) => ( + + ))} +
+ )} + {loadingSources ? ( +
+ +
+ ) : ( +
+ {visibleSources.map((src) => ( + + ))} + {visibleSources.length === 0 &&

No sources for this language

} +
+ )} +
+ +
+ {!activeSource ? ( +
+ +

Browse a source

+

Select a source to see its popular titles, or search within it.

+
+ ) : ( + <> +
+
+ { (e.target as HTMLImageElement).style.display = "none"; }} /> + {activeSource.displayName} + {loadingBrowse && } + {!loadingBrowse && browseResults.length > 0 && {browseResults.length} results} +
+
+
+ + setBrowseQuery(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleSearch()} + /> + {submitted && ( + + )} +
+ +
+
+ + {loadingBrowse ? : browseResults.length > 0 ? ( +
+ {browseResults.map((m) => onMangaClick(m)} />)} +
+ ) : ( +
+

{submitted ? `No results for "${submitted}"` : "No results"}

+
+ )} + + )} +
); } \ No newline at end of file diff --git a/src/components/pages/SeriesDetail.module.css b/src/components/pages/SeriesDetail.module.css index 6cd9627..4ebc1dd 100644 --- a/src/components/pages/SeriesDetail.module.css +++ b/src/components/pages/SeriesDetail.module.css @@ -724,6 +724,18 @@ pointer-events: none; } +/* In-progress progress fill bar (width set inline) */ +.gridCellProgress { + position: absolute; + bottom: 0; + left: 0; + height: 2px; + background: var(--accent); + border-radius: 0 0 var(--radius-sm) var(--radius-sm); + pointer-events: none; + z-index: 2; +} + /* In-progress — accent highlight on bottom edge */ .gridCellInProgress { border-color: var(--accent-dim); @@ -933,18 +945,23 @@ align-items: center; gap: var(--sp-2); width: 100%; - margin-top: var(--sp-2); - padding: 7px var(--sp-3); + padding: 6px var(--sp-2); border-radius: var(--radius-md); + font-family: var(--font-ui); font-size: var(--text-xs); - color: var(--color-error); + letter-spacing: var(--tracking-wide); + color: var(--text-faint); background: none; - border: 1px solid var(--color-error); + border: 1px solid var(--border-dim); cursor: pointer; text-align: left; - transition: background var(--t-base); + transition: color var(--t-base), border-color var(--t-base), background var(--t-base); +} +.deleteAllBtn:hover:not(:disabled) { + color: var(--color-error); + border-color: color-mix(in srgb, var(--color-error) 40%, transparent); + background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent)); } -.deleteAllBtn:hover:not(:disabled) { background: var(--color-error-bg); } .deleteAllBtn:disabled { opacity: 0.4; cursor: default; } /* ── Danger item in dl dropdown ─────────────────────────────────────── */ diff --git a/src/components/pages/SeriesDetail.tsx b/src/components/pages/SeriesDetail.tsx index 65b0f1d..2dcb7f5 100644 --- a/src/components/pages/SeriesDetail.tsx +++ b/src/components/pages/SeriesDetail.tsx @@ -11,6 +11,7 @@ import { UPDATE_MANGA, MARK_CHAPTER_READ, MARK_CHAPTERS_READ, DELETE_DOWNLOADED_CHAPTERS, ENQUEUE_CHAPTERS_DOWNLOAD, } from "../../lib/queries"; +import { cache, CACHE_KEYS, recordSourceAccess } from "../../lib/cache"; import { useStore } from "../../store"; import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu"; import MigrateModal from "./MigrateModal"; @@ -35,6 +36,18 @@ interface CtxState { const CHAPTERS_PER_PAGE = 25; +// How long before we consider a manga detail / chapter list stale and silently re-fetch. +// This prevents hammering the server when rapidly opening/closing while still keeping +// data fresh enough for normal use. +const MANGA_CACHE_TTL_MS = 5 * 60 * 1000; // 5 min — detail rarely changes mid-session +const CHAPTER_CACHE_TTL_MS = 2 * 60 * 1000; // 2 min — chapters update more often + +// ── TTL-aware memory stores (cleared on page refresh, not persisted) ────────── +// These supplement the session `cache` with timestamp tracking so we know when +// to silently re-validate in the background. +const mangaDetailStore = new Map(); +const chapterStore = new Map(); + // ── Download dropdown ───────────────────────────────────────────────────────── interface DownloadDropdownProps { @@ -93,7 +106,6 @@ function DownloadDropdown({ return (
- {continueChapter && continueIdx >= 0 && ( <>

From Ch.{continueChapter.chapter.chapterNumber}

@@ -289,10 +301,9 @@ export default function SeriesDetail() { const settings = useStore((state) => state.settings); const updateSettings = useStore((state) => state.updateSettings); const addToast = useStore((state) => state.addToast); - const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter); - const setLibraryFilter = useStore((state) => state.setLibraryFilter); + const setGenreFilter = useStore((state) => state.setGenreFilter); - const [manga, setManga] = useState(activeManga); + const [manga, setManga] = useState(null); const [chapters, setChapters] = useState([]); const [loadingManga, setLoadingManga] = useState(false); const [loadingChapters, setLoadingChapters] = useState(true); @@ -311,47 +322,140 @@ export default function SeriesDetail() { const [descExpanded, setDescExpanded] = useState(false); const [genresExpanded, setGenresExpanded] = useState(false); + // Track the abort controllers for in-flight requests so we can cancel on unmount/change + // Manga detail and chapters each get their own controller so they don't clobber each other + const mangaAbortRef = useRef(null); + const chapterAbortRef = useRef(null); + // Track the manga ID we're currently loading to discard stale results + const loadingForRef = useRef(null); + const sortDir = settings.chapterSortDir; - // Load extended manga details + // ── Manga detail: serve from TTL cache, silently re-validate if stale ────── useEffect(() => { if (!activeManga) return; + + const mangaId = activeManga.id; + + // Cancel any in-flight manga detail request from a previous manga + mangaAbortRef.current?.abort(); + const ctrl = new AbortController(); + mangaAbortRef.current = ctrl; + loadingForRef.current = mangaId; + + const cached = mangaDetailStore.get(mangaId); + const now = Date.now(); + + if (cached) { + // Serve from memory immediately — no loading state, no flash + setManga(cached.data); + setLoadingManga(false); + + // If cache is fresh enough, skip the network entirely + if (now - cached.fetchedAt < MANGA_CACHE_TTL_MS) return; + + // Stale: re-validate silently in the background (no spinner) + gql<{ manga: Manga }>(GET_MANGA, { id: mangaId }, ctrl.signal) + .then((data) => { + if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return; + mangaDetailStore.set(mangaId, { data: data.manga, fetchedAt: Date.now() }); + setManga(data.manga); + if (data.manga.source?.id) recordSourceAccess(data.manga.source.id); + }) + .catch((e) => { if (e?.name !== "AbortError") console.error(e); }); + + return; + } + + // Nothing cached — show skeleton and fetch setLoadingManga(true); - gql<{ manga: Manga }>(GET_MANGA, { id: activeManga.id }) - .then((data) => setManga(data.manga)) - .catch(console.error) - .finally(() => setLoadingManga(false)); + gql<{ manga: Manga }>(GET_MANGA, { id: mangaId }, ctrl.signal) + .then((data) => { + if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return; + mangaDetailStore.set(mangaId, { data: data.manga, fetchedAt: Date.now() }); + setManga(data.manga); + if (data.manga.source?.id) recordSourceAccess(data.manga.source.id); + }) + .catch((e) => { if (e?.name !== "AbortError") console.error(e); }) + .finally(() => { + if (!ctrl.signal.aborted && loadingForRef.current === mangaId) setLoadingManga(false); + }); + + return () => { ctrl.abort(); mangaAbortRef.current = null; }; }, [activeManga?.id]); - const loadChapters = useCallback((mangaId: number) => { - return gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }) - .then((data) => { - const sorted = [...data.chapters.nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); - setChapters(sorted); - return sorted; - }); + // ── Chapter loading: cache-first, background refresh only when stale ──────── + const applyChapters = useCallback((nodes: Chapter[]) => { + const sorted = [...nodes].sort((a, b) => a.sourceOrder - b.sourceOrder); + setChapters(sorted); + return sorted; }, []); - // Load chapters: show cache immediately, then silently refresh from source useEffect(() => { if (!activeManga) return; - setLoadingChapters(true); - setChapters([]); + + const mangaId = activeManga.id; setChapterPage(1); - loadChapters(activeManga.id) - .then((cached) => - gql(FETCH_CHAPTERS, { mangaId: activeManga.id }) - .then(() => loadChapters(activeManga.id)) + // Cancel any previous in-flight chapter requests + chapterAbortRef.current?.abort(); + const ctrl = new AbortController(); + chapterAbortRef.current = ctrl; + loadingForRef.current = mangaId; + + const cached = chapterStore.get(mangaId); + const now = Date.now(); + + if (cached) { + // Show cached data instantly + applyChapters(cached.data); + setLoadingChapters(false); + + // Fresh enough — don't touch the network at all + if (now - cached.fetchedAt < CHAPTER_CACHE_TTL_MS) return; + + // Stale — silently re-validate: fetch from source then re-read local DB + // We don't clear the chapter list while this happens (no flicker) + gql(FETCH_CHAPTERS, { mangaId }, ctrl.signal) + .then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }, ctrl.signal)) + .then((data) => { + if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return; + chapterStore.set(mangaId, { data: data.chapters.nodes, fetchedAt: Date.now() }); + applyChapters(data.chapters.nodes); + }) + .catch((e) => { if (e?.name !== "AbortError") console.error(e); }); + + return; + } + + // Nothing cached — show skeleton, load local DB first (fast), then source + setChapters([]); + setLoadingChapters(true); + + gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }, ctrl.signal) + .then((data) => { + if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return; + // Show local DB result immediately so the user isn't staring at a spinner + applyChapters(data.chapters.nodes); + setLoadingChapters(false); + + // Now silently fetch from the source to pick up any new chapters + return gql(FETCH_CHAPTERS, { mangaId }, ctrl.signal) + .then(() => gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }, ctrl.signal)) .then((fresh) => { - // Suppress no-op: if count unchanged the state is already correct - void (fresh.length === cached.length); - }) - .catch(console.error) - ) - .catch(console.error) - .finally(() => setLoadingChapters(false)); - }, [activeManga?.id]); + if (ctrl.signal.aborted || loadingForRef.current !== mangaId) return; + chapterStore.set(mangaId, { data: fresh.chapters.nodes, fetchedAt: Date.now() }); + applyChapters(fresh.chapters.nodes); + }); + }) + .catch((e) => { + if (ctrl.signal.aborted || e?.name === "AbortError") return; + console.error(e); + setLoadingChapters(false); + }); + + return () => { ctrl.abort(); chapterAbortRef.current = null; }; + }, [activeManga?.id, applyChapters]); // ── Derived state ────────────────────────────────────────────────────────── @@ -388,17 +492,32 @@ export default function SeriesDetail() { setTogglingLibrary(true); const next = !manga.inLibrary; await gql(UPDATE_MANGA, { id: manga.id, inLibrary: next }).catch(console.error); - setManga((prev) => prev ? { ...prev, inLibrary: next } : prev); + const updated = { ...manga, inLibrary: next }; + setManga(updated); + // Update the detail cache so re-open reflects the new state + if (mangaDetailStore.has(manga.id)) { + const entry = mangaDetailStore.get(manga.id)!; + mangaDetailStore.set(manga.id, { ...entry, data: updated }); + } + cache.clear(CACHE_KEYS.LIBRARY); setTogglingLibrary(false); } + const reloadChapters = useCallback((mangaId: number, signal?: AbortSignal) => { + return gql<{ chapters: { nodes: Chapter[] } }>(GET_CHAPTERS, { mangaId }, signal) + .then((data) => { + chapterStore.set(mangaId, { data: data.chapters.nodes, fetchedAt: Date.now() }); + applyChapters(data.chapters.nodes); + }); + }, [applyChapters]); + async function enqueue(chapter: Chapter, e: React.MouseEvent) { e.stopPropagation(); setEnqueueing((prev) => new Set(prev).add(chapter.id)); await gql(ENQUEUE_DOWNLOAD, { chapterId: chapter.id }).catch(console.error); addToast({ kind: "download", title: "Download queued", body: chapter.name }); setEnqueueing((prev) => { const n = new Set(prev); n.delete(chapter.id); return n; }); - if (activeManga) loadChapters(activeManga.id); + if (activeManga) reloadChapters(activeManga.id); } async function enqueueMultiple(chapterIds: number[]) { @@ -409,18 +528,27 @@ export default function SeriesDetail() { title: "Download queued", body: `${chapterIds.length} chapter${chapterIds.length !== 1 ? "s" : ""} added to queue`, }); - if (activeManga) loadChapters(activeManga.id); + if (activeManga) reloadChapters(activeManga.id); } async function markRead(chapterId: number, isRead: boolean) { await gql(MARK_CHAPTER_READ, { id: chapterId, isRead }).catch(console.error); - setChapters((prev) => prev.map((c) => c.id === chapterId ? { ...c, isRead } : c)); + setChapters((prev) => { + const updated = prev.map((c) => c.id === chapterId ? { ...c, isRead } : c); + if (activeManga) chapterStore.set(activeManga.id, { data: updated, fetchedAt: Date.now() }); + return updated; + }); } async function markBulk(ids: number[], isRead: boolean) { if (!ids.length) return; await gql(MARK_CHAPTERS_READ, { ids, isRead }).catch(console.error); - setChapters((prev) => prev.map((c) => ids.includes(c.id) ? { ...c, isRead } : c)); + setChapters((prev) => { + const idSet = new Set(ids); + const updated = prev.map((c) => idSet.has(c.id) ? { ...c, isRead } : c); + if (activeManga) chapterStore.set(activeManga.id, { data: updated, fetchedAt: Date.now() }); + return updated; + }); } const markAllAboveRead = (i: number) => @@ -434,7 +562,11 @@ export default function SeriesDetail() { async function deleteDownloaded(chapterId: number) { await gql(DELETE_DOWNLOADED_CHAPTERS, { ids: [chapterId] }).catch(console.error); - setChapters((prev) => prev.map((c) => c.id === chapterId ? { ...c, isDownloaded: false } : c)); + setChapters((prev) => { + const updated = prev.map((c) => c.id === chapterId ? { ...c, isDownloaded: false } : c); + if (activeManga) chapterStore.set(activeManga.id, { data: updated, fetchedAt: Date.now() }); + return updated; + }); } async function deleteAllDownloads() { @@ -442,21 +574,26 @@ export default function SeriesDetail() { if (!ids.length) return; setDeletingAll(true); await gql(DELETE_DOWNLOADED_CHAPTERS, { ids }).catch(console.error); - setChapters((prev) => prev.map((c) => ({ ...c, isDownloaded: false }))); + setChapters((prev) => { + const updated = prev.map((c) => ({ ...c, isDownloaded: false })); + if (activeManga) chapterStore.set(activeManga.id, { data: updated, fetchedAt: Date.now() }); + return updated; + }); setDeletingAll(false); } async function refreshChapters() { if (!activeManga || refreshing) return; setRefreshing(true); + // Force-invalidate the chapter cache for this manga so we get a fresh fetch + chapterStore.delete(activeManga.id); await gql(FETCH_CHAPTERS, { mangaId: activeManga.id }) - .then(() => loadChapters(activeManga.id)) + .then(() => reloadChapters(activeManga.id)) .then(() => addToast({ kind: "success", title: "Chapters refreshed" })) .catch((e) => addToast({ kind: "error", title: "Refresh failed", body: e?.message ?? String(e) })) .finally(() => setRefreshing(false)); } - // ── FIX: restored missing function declaration ───────────────────────────── function openContextMenu(e: React.MouseEvent, chapter: Chapter, indexInSorted: number) { e.preventDefault(); setCtx({ x: e.clientX, y: e.clientY, chapter, indexInSorted }); @@ -555,7 +692,7 @@ export default function SeriesDetail() {
@@ -596,11 +733,7 @@ export default function SeriesDetail() { key={g} className={[s.genre, s.genreClickable].join(" ")} title={`Filter library by "${g}"`} - onClick={() => { - setLibraryTagFilter([g]); - setLibraryFilter("library"); - setActiveManga(null); - }} + onClick={() => setGenreFilter(g)} > {g} @@ -682,36 +815,20 @@ export default function SeriesDetail() { {readCount > 0 && ` · ${readCount} read`}

- {/* Quick mark-all */} - {totalCount > 0 && ( -
- - -
- )} - - {/* Details (collapsible) */} + {/* Source info — collapsible details */} {!loadingManga && manga?.source && (
{detailsOpen && (
@@ -719,14 +836,32 @@ export default function SeriesDetail() { Source {manga.source.displayName}
-
- Language - {manga.source.name.match(/\(([^)]+)\)$/)?.[1] ?? "—"} -
-
- Source ID - {manga.source.id} -
+ {manga.status && ( +
+ Status + + {manga.status.charAt(0) + manga.status.slice(1).toLowerCase()} + +
+ )} + {manga.author && ( +
+ Author + {manga.author} +
+ )} + {manga.artist && manga.artist !== manga.author && ( +
+ Artist + {manga.artist} +
+ )} + {totalCount > 0 && ( +
+ Progress + {readCount} / {totalCount} read +
+ )} )}
)}
)} + + {manga && !manga.source && ( + + )}
{/* ── Chapter list ── */} @@ -761,8 +903,7 @@ export default function SeriesDetail() { > {sortDir === "desc" ? - : - } + : } {sortDir === "desc" ? "Newest first" : "Oldest first"} @@ -916,7 +1057,6 @@ export default function SeriesDetail() { pageChapters.map((ch) => { const idxInSorted = sortedChapters.indexOf(ch); return ( - // div instead of button so the nested download/delete buttons are valid HTML
)} - {/* Read indicator — always shown when read */} {ch.isRead && ( )} - {/* Download / status indicator — independent of read state */} {ch.isDownloaded ? ( - ); -}); - -// ── 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)} - /> - )} -
- ); -} \ No newline at end of file diff --git a/src/lib/cache.ts b/src/lib/cache.ts new file mode 100644 index 0000000..8534af2 --- /dev/null +++ b/src/lib/cache.ts @@ -0,0 +1,106 @@ +/** + * Session-level request cache. + * + * Key design decisions: + * - Stores the Promise itself — concurrent callers await the same fetch (no thundering herd). + * - On real errors the entry is evicted so the next call retries. + * - AbortErrors do NOT evict — the request was cancelled by the user, not failed. + * This is critical: if we evicted on abort, rapid open/close would drain the browser's + * connection pool (Chromium allows only 6 concurrent connections to the same origin). + * - Subscribers are notified when a key is explicitly cleared (for reactive invalidation). + */ +const store = new Map>(); +const subs = new Map void>>(); + +export const cache = { + get(key: string, fetcher: () => Promise): Promise { + if (!store.has(key)) { + store.set(key, fetcher().catch((err) => { + // Only evict on real failures, not user cancellations + if (err?.name !== "AbortError") store.delete(key); + return Promise.reject(err); + })); + } + return store.get(key) as Promise; + }, + has(key: string): boolean { return store.has(key); }, + clear(key: string) { + store.delete(key); + subs.get(key)?.forEach((cb) => cb()); + }, + clearAll() { + store.clear(); + subs.forEach((set) => set.forEach((cb) => cb())); + }, + /** Subscribe to cache invalidation for a key. Returns unsubscribe fn. */ + subscribe(key: string, cb: () => void): () => void { + if (!subs.has(key)) subs.set(key, new Set()); + subs.get(key)!.add(cb); + return () => subs.get(key)?.delete(cb); + }, +}; + +// ── Cache key constants — single source of truth, prevents mismatches ───────── +export const CACHE_KEYS = { + LIBRARY: "library", + SOURCES: "sources", + POPULAR: "popular", + GENRE: (genre: string) => `genre:${genre}`, + MANGA: (id: number) => `manga:${id}`, + CHAPTERS: (id: number) => `chapters:${id}`, +} as const; + +// ── In-flight request deduplication (for non-cached calls) ──────────────────── +// +// Some requests (chapter lists, manga detail) are NOT stored in the long-lived +// cache but still get fired multiple times when a user rapidly opens/closes a +// manga. This map deduplicates them so only one network round-trip is active at +// a time per key — regardless of how many components request it simultaneously. +// +const inflight = new Map>(); + +export function deduped(key: string, fetcher: () => Promise): Promise { + if (inflight.has(key)) return inflight.get(key) as Promise; + const p = fetcher().finally(() => inflight.delete(key)); + inflight.set(key, p); + return p; +} + +// ── Source frecency helpers ──────────────────────────────────────────────────── + +const FRECENCY_KEY = "moku-source-frecency"; +const MAX_FRECENCY_SOURCES = 4; + +type FrecencyMap = Record; + +function loadFrecency(): FrecencyMap { + try { + const raw = localStorage.getItem(FRECENCY_KEY); + return raw ? JSON.parse(raw) : {}; + } catch { return {}; } +} + +function saveFrecency(map: FrecencyMap) { + try { localStorage.setItem(FRECENCY_KEY, JSON.stringify(map)); } catch {} +} + +export function recordSourceAccess(sourceId: string) { + if (!sourceId || sourceId === "0") return; + const map = loadFrecency(); + map[sourceId] = (map[sourceId] ?? 0) + 1; + saveFrecency(map); +} + +export function getTopSources(sources: T[]): T[] { + const map = loadFrecency(); + const withScore = sources.map((s) => ({ s, score: map[s.id] ?? 0 })); + const hasFrecency = withScore.some((x) => x.score > 0); + + if (hasFrecency) { + return withScore + .sort((a, b) => b.score - a.score) + .slice(0, MAX_FRECENCY_SOURCES) + .map((x) => x.s); + } + return sources.slice(0, MAX_FRECENCY_SOURCES); +} \ No newline at end of file diff --git a/src/lib/client.ts b/src/lib/client.ts index 97f1b88..57a854b 100644 --- a/src/lib/client.ts +++ b/src/lib/client.ts @@ -1,7 +1,6 @@ const DEFAULT_URL = "http://127.0.0.1:4567"; function getServerUrl(): string { - // Read from persisted Zustand store if available, fall back to default try { const raw = localStorage.getItem("moku-store"); if (raw) { @@ -26,15 +25,55 @@ interface GQLResponse { errors?: { message: string }[]; } -// Retry with exponential backoff — Suwayomi may not be ready on first load -async function fetchWithRetry(url: string, init: RequestInit, retries = 8, delayMs = 500): Promise { +/** Sleep that resolves early if the signal is aborted — never blocks a cancelled request. */ +function abortableSleep(ms: number, signal?: AbortSignal): Promise { + return new Promise((resolve, reject) => { + if (signal?.aborted) { reject(new DOMException("Aborted", "AbortError")); return; } + const timer = setTimeout(resolve, ms); + signal?.addEventListener("abort", () => { + clearTimeout(timer); + reject(new DOMException("Aborted", "AbortError")); + }, { once: true }); + }); +} + +/** + * Retry wrapper with these guarantees: + * 1. AbortErrors always propagate immediately — no retry, no delay. + * 2. Retry delays are abort-aware — closing a manga mid-delay doesn't hang. + * 3. If the signal is already aborted before we even start, we bail instantly. + */ +async function fetchWithRetry( + url: string, + init: RequestInit, + signal?: AbortSignal, + retries = 3, + delayMs = 300, +): Promise { + // Bail immediately if already aborted before we start + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + for (let i = 0; i < retries; i++) { + // Check abort at the top of every iteration + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + try { - const res = await fetch(url, init); + const res = await fetch(url, { ...init, signal }); + + // Check abort again — fetch can return a response even after abort in some runtimes + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + return res; - } catch (e) { + } catch (e: any) { + // Never retry aborted requests + const isAbort = e?.name === "AbortError" || signal?.aborted; + if (isAbort) throw new DOMException("Aborted", "AbortError"); + + // Last retry — give up if (i === retries - 1) throw e; - await new Promise((r) => setTimeout(r, delayMs * Math.pow(1.5, i))); + + // Abort-aware delay between retries + await abortableSleep(delayMs * Math.pow(1.5, i), signal); } } throw new Error("unreachable"); @@ -42,23 +81,23 @@ async function fetchWithRetry(url: string, init: RequestInit, retries = 8, delay export async function gql( query: string, - variables?: Record + variables?: Record, + signal?: AbortSignal, ): Promise { const res = await fetchWithRetry(gqlUrl(), { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ query, variables }), - }); + }, signal); - if (!res.ok) { - throw new Error(`Suwayomi HTTP ${res.status}`); - } + // Check abort before reading the body — avoids hanging on res.json() after cancel + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + if (!res.ok) throw new Error(`Suwayomi HTTP ${res.status}`); const json: GQLResponse = await res.json(); - if (json.errors?.length) { - throw new Error(json.errors[0].message); - } + if (signal?.aborted) throw new DOMException("Aborted", "AbortError"); + if (json.errors?.length) throw new Error(json.errors[0].message); return json.data; } \ No newline at end of file diff --git a/src/lib/sourceUtils.ts b/src/lib/sourceUtils.ts new file mode 100644 index 0000000..b839f21 --- /dev/null +++ b/src/lib/sourceUtils.ts @@ -0,0 +1,46 @@ +import type { Source } from "./types"; + +/** + * Deduplicates sources by name, preferring the given language. + * This prevents fetching MangaDex EN + MangaDex ES + MangaDex FR separately. + */ +export function dedupeSources(sources: Source[], preferredLang: string): Source[] { + const byName = new Map(); + for (const src of sources) { + if (src.id === "0") continue; + 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 picked; +} + +/** + * Deduplicates manga by title (case-insensitive), keeping the first occurrence. + * This eliminates the same series appearing from multiple sources in grids. + */ +export function dedupeMangaByTitle(items: T[]): T[] { + const seen = new Set(); + const out: T[] = []; + for (const m of items) { + const key = m.title.toLowerCase().trim(); + if (!seen.has(key)) { seen.add(key); out.push(m); } + } + return out; +} + +/** + * Deduplicates manga by id only (lossless — use when sources are already deduped). + */ +export function dedupeMangaById(items: T[]): T[] { + const seen = new Set(); + const out: T[] = []; + for (const m of items) { + if (!seen.has(m.id)) { seen.add(m.id); out.push(m); } + } + return out; +} \ No newline at end of file diff --git a/src/store/index.ts b/src/store/index.ts index a510de5..21c3bee 100644 --- a/src/store/index.ts +++ b/src/store/index.ts @@ -103,8 +103,14 @@ export const DEFAULT_SETTINGS: Settings = { interface Store { navPage: NavPage; setNavPage: (page: NavPage) => void; + genreFilter: string; + setGenreFilter: (genre: string) => void; + searchPrefill: string; + setSearchPrefill: (q: string) => void; activeManga: Manga | null; setActiveManga: (manga: Manga | null) => void; + previewManga: Manga | null; + setPreviewManga: (manga: Manga | null) => void; activeChapter: Chapter | null; activeChapterList: Chapter[]; openReader: (chapter: Chapter, chapterList: Chapter[]) => void; @@ -152,8 +158,14 @@ export const useStore = create()( (set, get) => ({ navPage: "library", setNavPage: (navPage) => set({ navPage }), + genreFilter: "", + setGenreFilter: (genreFilter) => set({ genreFilter }), + searchPrefill: "", + setSearchPrefill: (searchPrefill) => set({ searchPrefill }), activeManga: null, setActiveManga: (activeManga) => set({ activeManga }), + previewManga: null, + setPreviewManga: (previewManga) => set({ previewManga }), activeChapter: null, activeChapterList: [], openReader: (chapter, chapterList) =>