mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
[V1] Major Revisions to Search & New Preview + Genre Filter (WIP Commit)
This commit is contained in:
@@ -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 ? <Reader /> : <Layout />}
|
||||
</div>
|
||||
{settingsOpen && <Settings />}
|
||||
<MangaPreview />
|
||||
<Toaster />
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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 <div className={s.ghostCard} aria-hidden />; }
|
||||
const GHOST_COUNT = 3;
|
||||
|
||||
function SkeletonRow({ count = 8 }: { count?: number }) {
|
||||
return (
|
||||
<div className={s.skeletonRow}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className={s.cardSkeleton}>
|
||||
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
||||
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<img
|
||||
src={src} alt={alt} className={className}
|
||||
loading="lazy" decoding="async"
|
||||
onLoad={() => 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 (
|
||||
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
||||
<div className={s.coverWrap}>
|
||||
<CoverImg src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} className={s.cover} />
|
||||
{manga.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
||||
{progress !== undefined && progress > 0 && (
|
||||
<div className={s.progressBar}>
|
||||
<div className={s.progressFill} style={{ width: `${progress * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className={s.title}>{manga.title}</p>
|
||||
{subtitle && <p className={s.subtitle}>{subtitle}</p>}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
// ── Section ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function Section({
|
||||
title, icon, onSeeAll, loading, children,
|
||||
}: {
|
||||
title: string; icon?: React.ReactNode; onSeeAll?: () => void;
|
||||
loading?: boolean; children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={s.section}>
|
||||
<div className={s.sectionHeader}>
|
||||
<span className={s.sectionTitle}>
|
||||
<span className={s.sectionTitleIcon}>{icon}{title}</span>
|
||||
</span>
|
||||
{onSeeAll && (
|
||||
<button className={s.seeAll} onClick={onSeeAll}>
|
||||
See all <ArrowRight size={11} weight="light" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{loading ? <SkeletonRow /> : children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main component ────────────────────────────────────────────────────────────
|
||||
|
||||
type ExploreMode = "explore" | "sources";
|
||||
|
||||
export default function Explore() {
|
||||
const [mode, setMode] = useState<ExploreMode>("explore");
|
||||
const activeSource = useStore((s) => s.activeSource);
|
||||
const genreFilter = useStore((s) => s.genreFilter);
|
||||
|
||||
if (activeSource) return <SourceBrowse />;
|
||||
if (genreFilter) return <GenreDrillPage />;
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<div className={s.headerLeft}>
|
||||
<h1 className={s.heading}>Explore</h1>
|
||||
<div className={s.tabs}>
|
||||
<button
|
||||
className={[s.tab, mode === "explore" ? s.tabActive : ""].join(" ").trim()}
|
||||
onClick={() => setMode("explore")}
|
||||
>
|
||||
<Compass size={11} weight="bold" /> Explore
|
||||
</button>
|
||||
<button
|
||||
className={[s.tab, mode === "sources" ? s.tabActive : ""].join(" ").trim()}
|
||||
onClick={() => setMode("sources")}
|
||||
>
|
||||
<List size={11} weight="bold" /> Sources
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Keep ExploreFeed always mounted so data survives tab switches */}
|
||||
<div style={{ display: mode === "explore" ? "contents" : "none" }}><ExploreFeed /></div>
|
||||
{mode === "sources" && <SourceList />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Explore feed ──────────────────────────────────────────────────────────────
|
||||
|
||||
const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama"];
|
||||
|
||||
function ExploreFeed() {
|
||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||
const [loadingLib, setLoadingLib] = useState(true);
|
||||
const [popularManga, setPopularManga] = useState<Manga[]>([]);
|
||||
const [loadingPopular, setLoadingPopular] = useState(true);
|
||||
const [genreResults, setGenreResults] = useState<Map<string, Manga[]>>(new Map());
|
||||
const [loadingGenres, setLoadingGenres] = useState(false);
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const fetchedGenresRef = useRef<string>("");
|
||||
|
||||
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: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||
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: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{
|
||||
label: "New folder & add",
|
||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||
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<string, Manga[]>();
|
||||
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<number, number>();
|
||||
const mangaReadAt = new Map<number, number>();
|
||||
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<string, number>();
|
||||
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<string, Manga[]>();
|
||||
|
||||
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<number>();
|
||||
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 (
|
||||
<div className={s.body}>
|
||||
|
||||
{(continueReading.length > 0 || loadingLib) && (
|
||||
<Section title="Continue Reading" icon={<BookOpen size={11} weight="bold" />} loading={loadingLib}>
|
||||
<div className={s.row}>
|
||||
{continueReading.map(({ manga, chapterName, progress }) => (
|
||||
<MiniCard key={manga.id} manga={manga} onClick={() => openManga(manga)}
|
||||
onContextMenu={(e) => openCtx(e, manga)} subtitle={chapterName} progress={progress} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-cr-${i}`} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(recommended.length > 0 || loadingLib) && (
|
||||
<Section title="Recommended for You" icon={<Star size={11} weight="bold" />} loading={loadingLib}>
|
||||
<div className={s.row}>
|
||||
{recommended.map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-rec-${i}`} />)}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{(popularManga.length > 0 || loadingPopular) && (
|
||||
<Section
|
||||
title={sources.length === 1 ? `Popular on ${sources[0].displayName}` : sources.length > 1 ? `Popular across ${sources.length} sources` : "Popular"}
|
||||
icon={<Fire size={11} weight="bold" />}
|
||||
loading={loadingPopular}
|
||||
>
|
||||
{sources.length === 0 ? (
|
||||
<div className={s.noSource}>No sources installed. Add extensions first.</div>
|
||||
) : (
|
||||
<div className={s.row}>
|
||||
{popularManga.map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-pop-${i}`} />)}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{frecencyGenres.map((genre) => {
|
||||
const items = genreResults.get(genre) ?? [];
|
||||
const isLoading = genresLoading && items.length === 0;
|
||||
if (!isLoading && items.length === 0) return null;
|
||||
return (
|
||||
<Section key={genre} title={genre} onSeeAll={() => setGenreFilter(genre)} loading={isLoading}>
|
||||
<div className={s.row}>
|
||||
{items.map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => <GhostCard key={`ghost-${genre}-${i}`} />)}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
|
||||
{!loadingLib && !loadingPopular && !loadingGenres &&
|
||||
continueReading.length === 0 && recommended.length === 0 &&
|
||||
popularManga.length === 0 && frecencyGenres.every((g) => !genreResults.get(g)?.length) && (
|
||||
<div className={s.empty}>
|
||||
<span>Nothing to explore yet</span>
|
||||
<span className={s.emptyHint}>Add manga to your library or install sources to get started.</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ctx && (
|
||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 (
|
||||
<img
|
||||
src={src} alt={alt} className={className}
|
||||
loading="lazy" decoding="async"
|
||||
onLoad={() => 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<Manga[]>([]);
|
||||
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
|
||||
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<AbortController | null>(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: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||
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: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{
|
||||
label: "New folder & add",
|
||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||
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 (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<button className={s.back} onClick={() => setGenreFilter("")}>
|
||||
<ArrowLeft size={13} weight="light" />
|
||||
<span>Back</span>
|
||||
</button>
|
||||
<span className={s.title}>{genre}</span>
|
||||
{loadingSources && !loadingLibrary && filtered.length > 0 && (
|
||||
<span className={s.loadingHint}>Loading more…</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showSkeleton ? (
|
||||
<div className={s.grid}>
|
||||
{Array.from({ length: 24 }).map((_, i) => (
|
||||
<div key={i} className={s.cardSkeleton}>
|
||||
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
||||
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filtered.length === 0 && !loadingSources ? (
|
||||
<div className={s.empty}>No manga found for "{genre}".</div>
|
||||
) : (
|
||||
<div className={s.grid}>
|
||||
{filtered.map((m) => (
|
||||
<button key={m.id} className={s.card} onClick={() => setPreviewManga(m)} onContextMenu={(e) => openCtx(e, m)}>
|
||||
<div className={s.coverWrap}>
|
||||
<CoverImg src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
|
||||
{m.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
||||
</div>
|
||||
<p className={s.cardTitle}>{m.title}</p>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ctx && (
|
||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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; }
|
||||
@@ -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<Manga | null>(null);
|
||||
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||
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<string | null>(null);
|
||||
|
||||
const backdropRef = useRef<HTMLDivElement>(null);
|
||||
const detailAbort = useRef<AbortController | null>(null);
|
||||
const chapterAbort = useRef<AbortController | null>(null);
|
||||
const folderRef = useRef<HTMLDivElement>(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<Manga> => {
|
||||
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<Manga>;
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
className={s.backdrop}
|
||||
ref={backdropRef}
|
||||
onClick={(e) => { if (e.target === backdropRef.current) close(); }}
|
||||
>
|
||||
<div className={s.modal} role="dialog" aria-label="Manga preview">
|
||||
|
||||
{/* ── Cover column ── */}
|
||||
<div className={s.coverCol}>
|
||||
<div className={s.coverWrap}>
|
||||
<img
|
||||
src={thumbUrl(previewManga.thumbnailUrl)}
|
||||
alt={displayManga.title}
|
||||
className={s.cover}
|
||||
/>
|
||||
{loadingDetail && (
|
||||
<div className={s.coverSpinner}>
|
||||
<CircleNotch size={18} weight="light" className="anim-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.coverActions}>
|
||||
<button
|
||||
className={[s.actionBtn, inLibrary ? s.actionBtnActive : ""].join(" ")}
|
||||
onClick={toggleLibrary}
|
||||
disabled={togglingLib || loadingDetail}
|
||||
>
|
||||
<BookmarkSimple size={13} weight={inLibrary ? "fill" : "light"} />
|
||||
{togglingLib ? "…" : inLibrary ? "In Library" : "Add to Library"}
|
||||
</button>
|
||||
|
||||
<button className={s.actionBtn} onClick={openSeriesDetail}>
|
||||
<Books size={13} weight="light" />
|
||||
Series Detail
|
||||
</button>
|
||||
|
||||
{/* Folder picker */}
|
||||
<div className={s.folderWrap} ref={folderRef}>
|
||||
<button
|
||||
className={[s.actionBtn, assignedFolders.length > 0 ? s.actionBtnFolder : ""].join(" ")}
|
||||
onClick={() => setFolderOpen((p) => !p)}
|
||||
>
|
||||
<FolderSimplePlus size={13} weight={assignedFolders.length > 0 ? "fill" : "light"} />
|
||||
<span className={s.actionBtnLabel}>
|
||||
{assignedFolders.length > 0 ? assignedFolders.map((f) => f.name).join(", ") : "Add to folder"}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{folderOpen && (
|
||||
<div className={s.folderMenu}>
|
||||
{folders.length === 0 && !creatingFolder && (
|
||||
<p className={s.folderEmpty}>No folders yet</p>
|
||||
)}
|
||||
{folders.map((f) => {
|
||||
const isIn = f.mangaIds.includes(previewManga.id);
|
||||
return (
|
||||
<button key={f.id}
|
||||
className={[s.folderItem, isIn ? s.folderItemOn : ""].join(" ")}
|
||||
onClick={() => isIn
|
||||
? removeMangaFromFolder(f.id, previewManga.id)
|
||||
: assignMangaToFolder(f.id, previewManga.id)}
|
||||
>
|
||||
<Folder size={12} weight={isIn ? "fill" : "light"} />
|
||||
{isIn ? "✓ " : ""}{f.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<div className={s.folderDivider} />
|
||||
{creatingFolder ? (
|
||||
<div className={s.folderCreateRow}>
|
||||
<input autoFocus className={s.folderInput} placeholder="Folder name…"
|
||||
value={newFolderName} onChange={(e) => setNewFolderName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter") handleFolderCreate();
|
||||
if (e.key === "Escape") { setCreatingFolder(false); setNewFolderName(""); }
|
||||
}}
|
||||
/>
|
||||
<button className={s.folderOkBtn} onClick={handleFolderCreate} disabled={!newFolderName.trim()}>Add</button>
|
||||
</div>
|
||||
) : (
|
||||
<button className={s.folderNewBtn} onClick={() => setCreatingFolder(true)}>+ New folder</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Content column ── */}
|
||||
<div className={s.content}>
|
||||
|
||||
{/* Header — title visible immediately from previewManga */}
|
||||
<div className={s.contentHeader}>
|
||||
<div className={s.titleBlock}>
|
||||
<h2 className={s.title}>{displayManga.title}</h2>
|
||||
{loadingDetail
|
||||
? <div className={s.skByline} />
|
||||
: (displayManga.author || displayManga.artist)
|
||||
? <p className={s.byline}>
|
||||
{[displayManga.author, displayManga.artist]
|
||||
.filter(Boolean).filter((v, i, a) => a.indexOf(v) === i).join(" · ")}
|
||||
</p>
|
||||
: null}
|
||||
</div>
|
||||
<button className={s.closeBtn} onClick={close}><X size={15} weight="light" /></button>
|
||||
</div>
|
||||
|
||||
{/* Scrollable body */}
|
||||
<div className={s.contentBody}>
|
||||
|
||||
{/* Error banner */}
|
||||
{fetchError && (
|
||||
<div className={s.errorBanner}>{fetchError}</div>
|
||||
)}
|
||||
|
||||
{/* ── Badges ── */}
|
||||
{loadingDetail ? (
|
||||
<div className={s.skRow}>
|
||||
<div className={s.skBadge} />
|
||||
<div className={s.skBadge} style={{ width: 72 }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={s.badges}>
|
||||
{statusLabel && (
|
||||
<span className={[s.badge,
|
||||
displayManga.status === "ONGOING" ? s.badgeGreen : s.badgeDim
|
||||
].join(" ")}>{statusLabel}</span>
|
||||
)}
|
||||
{displayManga.source && (
|
||||
<span className={[s.badge, (displayManga.source as any).isNsfw ? s.badgeNsfw : ""].join(" ").trim()}>
|
||||
{displayManga.source.displayName}{(displayManga.source as any).isNsfw ? " · 18+" : ""}
|
||||
</span>
|
||||
)}
|
||||
{inLibrary && <span className={[s.badge, s.badgeAccent].join(" ")}>In Library</span>}
|
||||
{!loadingChapters && unreadCount > 0 && (
|
||||
<span className={[s.badge, s.badgeUnread].join(" ")}>{unreadCount} unread</span>
|
||||
)}
|
||||
{!loadingChapters && bookmarkCount > 0 && (
|
||||
<span className={s.badge}>{bookmarkCount} bookmarked</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Chapter section — visually separated box ── */}
|
||||
<div className={s.chapterBox}>
|
||||
{loadingChapters ? (
|
||||
<div className={s.chapterLoading}>
|
||||
<CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
<span className={s.chapterLoadingLabel}>Loading chapters…</span>
|
||||
</div>
|
||||
) : totalCount > 0 ? (
|
||||
<>
|
||||
<div className={s.chapterMeta}>
|
||||
<span className={s.chapterLabel}>
|
||||
{totalCount} {totalCount === 1 ? "chapter" : "chapters"}
|
||||
{readCount > 0 && ` · ${readCount} read`}
|
||||
{unreadCount > 0 && readCount > 0 && ` · ${unreadCount} left`}
|
||||
{downloadedCount > 0 && ` · ${downloadedCount} dl`}
|
||||
</span>
|
||||
{unreadCount > 0 && (
|
||||
<button className={s.dlAllBtn} onClick={downloadAll} disabled={queueingAll}>
|
||||
{queueingAll && <CircleNotch size={11} weight="light" className="anim-spin" />}
|
||||
{queueingAll ? "Queuing…" : "Download unread"}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{readCount > 0 && (
|
||||
<div className={s.progressTrack}>
|
||||
<div className={s.progressFill} style={{ width: `${(readCount / totalCount) * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
{continueChapter && (
|
||||
<button className={s.readBtn}
|
||||
onClick={() => { openReader(continueChapter.ch, chapters); close(); }}
|
||||
>
|
||||
<Play size={12} weight="fill" />
|
||||
{continueChapter.label}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
) : !loadingDetail ? (
|
||||
<span className={s.chapterLabel} style={{ color: "var(--text-faint)" }}>
|
||||
No chapters in local library
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* ── Description — clearly separated from chapter block ── */}
|
||||
{loadingDetail ? (
|
||||
<div className={s.skDesc}>
|
||||
<div className={s.skLine} style={{ width: "100%" }} />
|
||||
<div className={s.skLine} style={{ width: "88%" }} />
|
||||
<div className={s.skLine} style={{ width: "70%" }} />
|
||||
</div>
|
||||
) : displayManga.description ? (
|
||||
<div className={s.descBlock}>
|
||||
<p className={[s.desc, descExpanded ? s.descOpen : ""].join(" ")}>
|
||||
{displayManga.description}
|
||||
</p>
|
||||
{displayManga.description.length > 220 && (
|
||||
<button className={s.descToggle} onClick={() => setDescExpanded((p) => !p)}>
|
||||
{descExpanded ? "Show less" : "Show more"}
|
||||
<CaretDown size={10} weight="light" style={{
|
||||
transform: descExpanded ? "rotate(180deg)" : "none",
|
||||
transition: "transform 0.15s ease",
|
||||
}} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* ── Genre tags ── */}
|
||||
{!loadingDetail && displayManga.genre && displayManga.genre.length > 0 && (
|
||||
<div className={s.genres}>
|
||||
{displayManga.genre.map((g) => <span key={g} className={s.genreTag}>{g}</span>)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ── Metadata table ── */}
|
||||
{!loadingDetail && (
|
||||
<div className={s.metaTable}>
|
||||
{displayManga.author && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Author</span>
|
||||
<span className={s.metaVal}>{displayManga.author}</span>
|
||||
</div>
|
||||
)}
|
||||
{displayManga.artist && displayManga.artist !== displayManga.author && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Artist</span>
|
||||
<span className={s.metaVal}>{displayManga.artist}</span>
|
||||
</div>
|
||||
)}
|
||||
{statusLabel && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Status</span>
|
||||
<span className={s.metaVal}>{statusLabel}</span>
|
||||
</div>
|
||||
)}
|
||||
{displayManga.source && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Source</span>
|
||||
<span className={s.metaVal}>{displayManga.source.displayName}</span>
|
||||
</div>
|
||||
)}
|
||||
{!loadingChapters && scanlators.length > 0 && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>{scanlators.length === 1 ? "Scanlator" : "Scanlators"}</span>
|
||||
<span className={s.metaVal}>{scanlators.join(", ")}</span>
|
||||
</div>
|
||||
)}
|
||||
{!loadingChapters && firstUpload && lastUpload && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Published</span>
|
||||
<span className={s.metaVal}>
|
||||
{firstUpload.getTime() === lastUpload.getTime()
|
||||
? formatDate(firstUpload)
|
||||
: `${formatDate(firstUpload)} – ${formatDate(lastUpload)}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{!loadingChapters && downloadedCount > 0 && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Downloaded</span>
|
||||
<span className={s.metaVal}>{downloadedCount} / {totalCount} chapters</span>
|
||||
</div>
|
||||
)}
|
||||
{!loadingChapters && bookmarkCount > 0 && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Bookmarks</span>
|
||||
<span className={s.metaVal}>{bookmarkCount} chapter{bookmarkCount !== 1 ? "s" : ""}</span>
|
||||
</div>
|
||||
)}
|
||||
{displayManga.realUrl && (
|
||||
<div className={s.metaRow}>
|
||||
<span className={s.metaKey}>Link</span>
|
||||
<a href={displayManga.realUrl} target="_blank" rel="noreferrer" className={s.metaLink}>
|
||||
Open <ArrowSquareOut size={11} weight="light" />
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <SeriesDetail />;
|
||||
if (activeManga) return <SeriesDetail />;
|
||||
switch (navPage) {
|
||||
case "library": return <Library />;
|
||||
case "search": return <Search />;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 (
|
||||
<img
|
||||
src={src} alt={alt} className={className}
|
||||
loading="lazy" decoding="async"
|
||||
style={{ objectFit: (objectFit ?? "cover") as any, opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||
onLoad={() => 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 (
|
||||
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
||||
<div className={s.coverWrap}>
|
||||
<img
|
||||
<FadeImg
|
||||
src={thumbUrl(manga.thumbnailUrl)}
|
||||
alt={manga.title}
|
||||
className={s.cover}
|
||||
style={{ objectFit: cropCovers ? "cover" : "contain" }}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
objectFit={cropCovers ? "cover" : "contain"}
|
||||
/>
|
||||
{!!manga.downloadCount && (
|
||||
<span className={s.downloadedBadge}>{manga.downloadCount}</span>
|
||||
@@ -44,6 +51,12 @@ const MangaCard = memo(function MangaCard({
|
||||
);
|
||||
});
|
||||
|
||||
function fetchLibrary() {
|
||||
return cache.get(CACHE_KEYS.LIBRARY, () =>
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY).then((lib) => lib.mangas.nodes)
|
||||
);
|
||||
}
|
||||
|
||||
export default function Library() {
|
||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -59,19 +72,27 @@ export default function Library() {
|
||||
const settings = useStore((state) => state.settings);
|
||||
const libraryTagFilter = useStore((state) => state.libraryTagFilter);
|
||||
const setLibraryTagFilter = useStore((state) => state.setLibraryTagFilter);
|
||||
const setGenreFilter = useStore((state) => state.setGenreFilter);
|
||||
const folders = useStore((state) => state.settings.folders);
|
||||
const addFolder = useStore((state) => state.addFolder);
|
||||
const assignMangaToFolder = useStore((state) => state.assignMangaToFolder);
|
||||
const removeMangaFromFolder = useStore((state) => state.removeMangaFromFolder);
|
||||
|
||||
useEffect(() => {
|
||||
gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY)
|
||||
.then((lib) => setAllManga(lib.mangas.nodes))
|
||||
const loadData = useCallback((showLoading = false) => {
|
||||
if (showLoading) setLoading(true);
|
||||
fetchLibrary()
|
||||
.then((nodes) => { setAllManga(nodes); setError(null); })
|
||||
.catch((e) => setError(e.message))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
// Reset scroll when filter/search changes
|
||||
useEffect(() => {
|
||||
loadData(true);
|
||||
// Re-fetch when library cache is invalidated (e.g. by Explore or GenreDrillPage)
|
||||
const unsub = cache.subscribe(CACHE_KEYS.LIBRARY, () => loadData(false));
|
||||
return unsub;
|
||||
}, [loadData]);
|
||||
|
||||
useEffect(() => {
|
||||
scrollRef.current?.scrollTo({ top: 0 });
|
||||
}, [libraryFilter, search]);
|
||||
@@ -138,7 +159,9 @@ export default function Library() {
|
||||
|
||||
async function removeFromLibrary(manga: Manga) {
|
||||
await gql(UPDATE_MANGA, { id: manga.id, inLibrary: false }).catch(console.error);
|
||||
// Optimistic update first, then invalidate cache
|
||||
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, inLibrary: false } : m));
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
}
|
||||
|
||||
async function deleteAllDownloads(manga: Manga) {
|
||||
@@ -147,14 +170,8 @@ export default function Library() {
|
||||
const downloadedChapters = data.chapters.nodes.filter((c) => c.isDownloaded);
|
||||
const ids = downloadedChapters.map((c) => c.id);
|
||||
if (!ids.length) return;
|
||||
|
||||
// Delete the downloaded files
|
||||
await gql(DELETE_DOWNLOADED_CHAPTERS, { ids });
|
||||
|
||||
// Also remove these chapters from the download queue (fix #12)
|
||||
// Fire-and-forget — queue removal is best-effort
|
||||
await Promise.allSettled(ids.map((id) => gql(DEQUEUE_DOWNLOAD, { chapterId: id })));
|
||||
|
||||
setAllManga((prev) => prev.map((m) => m.id === manga.id ? { ...m, downloadCount: 0 } : m));
|
||||
} catch (e) { console.error(e); }
|
||||
}
|
||||
@@ -172,9 +189,7 @@ export default function Library() {
|
||||
return {
|
||||
label: inFolder ? `✓ ${f.name}` : f.name,
|
||||
icon: <Folder size={13} weight={inFolder ? "fill" : "light"} />,
|
||||
onClick: () => inFolder
|
||||
? removeMangaFromFolder(f.id, m.id)
|
||||
: assignMangaToFolder(f.id, m.id),
|
||||
onClick: () => inFolder ? removeMangaFromFolder(f.id, m.id) : assignMangaToFolder(f.id, m.id),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -192,7 +207,10 @@ export default function Library() {
|
||||
onClick: () => m.inLibrary
|
||||
? removeFromLibrary(m)
|
||||
: gql(UPDATE_MANGA, { id: m.id, inLibrary: true })
|
||||
.then(() => setAllManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x)))
|
||||
.then(() => {
|
||||
setAllManga((prev) => prev.map((x) => x.id === m.id ? { ...x, inLibrary: true } : x));
|
||||
cache.clear(CACHE_KEYS.LIBRARY);
|
||||
})
|
||||
.catch(console.error),
|
||||
},
|
||||
{
|
||||
@@ -262,7 +280,6 @@ export default function Library() {
|
||||
className={s.root}
|
||||
ref={scrollRef}
|
||||
onContextMenu={(e) => {
|
||||
// Only fire on the bare background, not on cards
|
||||
if ((e.target as HTMLElement).closest("button")) return;
|
||||
e.preventDefault();
|
||||
setEmptyCtx({ x: e.clientX, y: e.clientY });
|
||||
@@ -322,9 +339,7 @@ export default function Library() {
|
||||
return (
|
||||
<button key={tag}
|
||||
className={[s.tagChip, active ? s.tagChipActive : ""].join(" ")}
|
||||
onClick={() => setLibraryTagFilter(
|
||||
active ? libraryTagFilter.filter((t) => t !== tag) : [...libraryTagFilter, tag]
|
||||
)}>
|
||||
onClick={() => setGenreFilter(tag)}>
|
||||
{tag}
|
||||
</button>
|
||||
);
|
||||
@@ -352,13 +367,7 @@ export default function Library() {
|
||||
: "No manga found."}
|
||||
</div>
|
||||
) : (
|
||||
/* Virtual scroll container */
|
||||
<div
|
||||
style={{
|
||||
height: virtualizer.getTotalSize(),
|
||||
position: "relative",
|
||||
}}
|
||||
>
|
||||
<div style={{ height: virtualizer.getTotalSize(), position: "relative" }}>
|
||||
{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) => (
|
||||
<div key={`ghost-${i}`} className={s.ghostCard} aria-hidden />
|
||||
@@ -394,20 +402,10 @@ export default function Library() {
|
||||
)}
|
||||
|
||||
{ctx && (
|
||||
<ContextMenu
|
||||
x={ctx.x}
|
||||
y={ctx.y}
|
||||
items={buildCtxItems(ctx.manga)}
|
||||
onClose={() => setCtx(null)}
|
||||
/>
|
||||
<ContextMenu x={ctx.x} y={ctx.y} items={buildCtxItems(ctx.manga)} onClose={() => setCtx(null)} />
|
||||
)}
|
||||
{emptyCtx && (
|
||||
<ContextMenu
|
||||
x={emptyCtx.x}
|
||||
y={emptyCtx.y}
|
||||
items={buildEmptyCtxItems()}
|
||||
onClose={() => setEmptyCtx(null)}
|
||||
/>
|
||||
<ContextMenu x={emptyCtx.x} y={emptyCtx.y} items={buildEmptyCtxItems()} onClose={() => setEmptyCtx(null)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -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); }
|
||||
.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;
|
||||
}
|
||||
+605
-101
@@ -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<T>(
|
||||
items: T[],
|
||||
fn: (item: T) => Promise<void>
|
||||
fn: (item: T) => Promise<void>,
|
||||
signal: AbortSignal,
|
||||
): Promise<void> {
|
||||
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<T>(
|
||||
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 (
|
||||
<img src={src} alt={alt} className={className}
|
||||
loading="lazy" decoding="async" onLoad={() => setLoaded(true)}
|
||||
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
function MangaCard({ manga, onClick }: { manga: Manga; onClick: () => void }) {
|
||||
return (
|
||||
<button className={s.card} onClick={onClick}>
|
||||
<div className={s.coverWrap}>
|
||||
<CoverImg src={thumbUrl(manga.thumbnailUrl)} alt={manga.title} className={s.cover} />
|
||||
{manga.inLibrary && <span className={s.inLibBadge}>Saved</span>}
|
||||
</div>
|
||||
<p className={s.cardTitle}>{manga.title}</p>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function GridSkeleton({ count = 18 }: { count?: number }) {
|
||||
return (
|
||||
<div className={s.tagGrid}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className={s.skCard} style={{ width: "auto" }}>
|
||||
<div className={["skeleton", s.skCover].join(" ")} style={{ aspectRatio: "2/3", width: "100%" }} />
|
||||
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RowSkeleton({ count = 4 }: { count?: number }) {
|
||||
return (
|
||||
<div className={s.sourceRow}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className={s.skCard}>
|
||||
<div className={["skeleton", s.skCover].join(" ")} />
|
||||
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Root ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function Search() {
|
||||
const [query, setQuery] = useState("");
|
||||
const [submitted, setSubmitted] = useState("");
|
||||
const [results, setResults] = useState<SourceResult[]>([]);
|
||||
const [allSources, setAllSources] = useState<Source[]>([]);
|
||||
const [tab, setTab] = useState<SearchTab>("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<Source[]>([]);
|
||||
const [loadingSources, setLoadingSources] = useState(false);
|
||||
const [activeLang, setActiveLang] = useState<string>("preferred");
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const setActiveManga = useStore((st) => st.setActiveManga);
|
||||
const setNavPage = useStore((st) => st.setNavPage);
|
||||
const preferredLang = useStore((st) => st.settings.preferredExtensionLang);
|
||||
const pendingPrefill = useRef<string>("");
|
||||
|
||||
// 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<string>(allSources.map((s) => s.lang))).sort(), [allSources]);
|
||||
const hasMultipleLangs = availableLangs.length > 1;
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<h1 className={s.heading}>Search</h1>
|
||||
<div className={s.tabs}>
|
||||
<button className={[s.tab, tab === "keyword" ? s.tabActive : ""].join(" ")} onClick={() => setTab("keyword")}>
|
||||
<MagnifyingGlass size={11} weight="bold" /> Keyword
|
||||
</button>
|
||||
<button className={[s.tab, tab === "tag" ? s.tabActive : ""].join(" ")} onClick={() => setTab("tag")}>
|
||||
<Hash size={11} weight="bold" /> Tags
|
||||
</button>
|
||||
<button className={[s.tab, tab === "source" ? s.tabActive : ""].join(" ")} onClick={() => setTab("source")}>
|
||||
<List size={11} weight="bold" /> Sources
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{tab === "keyword" && (
|
||||
<KeywordTab
|
||||
allSources={allSources}
|
||||
loadingSources={loadingSources}
|
||||
availableLangs={availableLangs}
|
||||
hasMultipleLangs={hasMultipleLangs}
|
||||
preferredLang={preferredLang}
|
||||
pendingPrefill={pendingPrefill}
|
||||
onMangaClick={setPreviewManga}
|
||||
/>
|
||||
)}
|
||||
{tab === "tag" && (
|
||||
<TagTab
|
||||
allSources={allSources}
|
||||
loadingSources={loadingSources}
|
||||
preferredLang={preferredLang}
|
||||
onMangaClick={setPreviewManga}
|
||||
/>
|
||||
)}
|
||||
{tab === "source" && (
|
||||
<SourceTab
|
||||
allSources={allSources}
|
||||
loadingSources={loadingSources}
|
||||
availableLangs={availableLangs}
|
||||
hasMultipleLangs={hasMultipleLangs}
|
||||
onMangaClick={setPreviewManga}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Keyword tab ───────────────────────────────────────────────────────────────
|
||||
|
||||
function KeywordTab({
|
||||
allSources, loadingSources, availableLangs, hasMultipleLangs,
|
||||
preferredLang, pendingPrefill, onMangaClick,
|
||||
}: {
|
||||
allSources: Source[];
|
||||
loadingSources: boolean;
|
||||
availableLangs: string[];
|
||||
hasMultipleLangs: boolean;
|
||||
preferredLang: string;
|
||||
pendingPrefill: React.MutableRefObject<string>;
|
||||
onMangaClick: (m: Manga) => void;
|
||||
}) {
|
||||
const [query, setQuery] = useState("");
|
||||
const [submitted, setSubmitted] = useState("");
|
||||
const [results, setResults] = useState<SourceResult[]>([]);
|
||||
const [showAdvanced, setShowAdvanced] = useState(false);
|
||||
const [selectedLangs, setSelectedLangs] = useState<Set<string>>(new Set());
|
||||
const [includeNsfw, setIncludeNsfw] = useState(false);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const allSourcesRef = useRef<Source[]>([]);
|
||||
const selectedLangsRef = useRef<Set<string>>(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 (
|
||||
<>
|
||||
<div className={s.keywordBar}>
|
||||
<div className={s.searchBar}>
|
||||
<MagnifyingGlass size={14} className={s.searchIcon} weight="light" />
|
||||
<input
|
||||
ref={inputRef}
|
||||
ref={inputRef} autoFocus
|
||||
className={s.searchInput}
|
||||
placeholder="Search across sources…"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && runSearch()}
|
||||
autoFocus
|
||||
onKeyDown={(e) => e.key === "Enter" && doSearch(query)}
|
||||
/>
|
||||
{hasMultipleLangs && (
|
||||
<button
|
||||
className={[s.advancedBtn, showAdvanced ? s.advancedBtnActive : ""].join(" ")}
|
||||
onClick={() => setShowAdvanced((v) => !v)}
|
||||
title="Language & filter options"
|
||||
>
|
||||
<SlidersHorizontal size={13} weight="light" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className={s.searchBtn}
|
||||
onClick={runSearch}
|
||||
onClick={() => doSearch(query)}
|
||||
disabled={!query.trim() || loadingSources}
|
||||
>
|
||||
{loadingSources
|
||||
@@ -114,20 +333,36 @@ export default function Search() {
|
||||
: "Search"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={s.langBar}>
|
||||
{langs.map((l) => (
|
||||
<button
|
||||
key={l}
|
||||
onClick={() => setActiveLang(l)}
|
||||
className={[s.langBtn, activeLang === l ? s.langBtnActive : ""].join(" ").trim()}
|
||||
>
|
||||
{l === "preferred" ? `${preferredLang.toUpperCase()} (default)` : l === "all" ? "All" : l.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
{visibleSources.length > 0 && (
|
||||
<span className={s.sourceCount}>{visibleSources.length} sources</span>
|
||||
{hasMultipleLangs && showAdvanced && (
|
||||
<div className={s.advancedPanel}>
|
||||
<div className={s.advancedHeader}>
|
||||
<span className={s.advancedTitle}>Languages</span>
|
||||
<div className={s.advancedActions}>
|
||||
<button className={s.advancedLink} onClick={() => setSelectedLangs(new Set(availableLangs))}>All</button>
|
||||
<button className={s.advancedLink} onClick={() => setSelectedLangs(new Set([preferredLang]))}>Reset</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className={s.langGrid}>
|
||||
{availableLangs.map((lang) => (
|
||||
<button key={lang}
|
||||
className={[s.langChip, selectedLangs.has(lang) ? s.langChipActive : ""].join(" ")}
|
||||
onClick={() => toggleLang(lang)}
|
||||
>
|
||||
{lang === preferredLang ? `${lang.toUpperCase()} ★` : lang.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className={s.advancedDivider} />
|
||||
<label className={s.advancedCheck}>
|
||||
<input type="checkbox" checked={includeNsfw}
|
||||
onChange={(e) => setIncludeNsfw(e.target.checked)} className={s.checkbox} />
|
||||
Include NSFW sources
|
||||
</label>
|
||||
<div className={s.advancedFooter}>
|
||||
Searching <strong>{visibleCount}</strong> source{visibleCount !== 1 ? "s" : ""}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -136,8 +371,15 @@ export default function Search() {
|
||||
<MagnifyingGlass size={36} weight="light" className={s.emptyIcon} />
|
||||
<p className={s.emptyText}>Search across sources</p>
|
||||
<p className={s.emptyHint}>
|
||||
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" : ""}`}
|
||||
</p>
|
||||
{hasMultipleLangs && !showAdvanced && (
|
||||
<button className={s.advancedLinkStandalone} onClick={() => setShowAdvanced(true)}>
|
||||
<SlidersHorizontal size={12} weight="light" /> Adjust language filters
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -148,59 +390,321 @@ export default function Search() {
|
||||
<CircleNotch size={20} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{results
|
||||
.filter((r) => r.mangas.length > 0 || r.loading || r.error)
|
||||
.map(({ source, mangas, loading, error }) => (
|
||||
<div key={source.id} className={s.sourceSection}>
|
||||
<div className={s.sourceHeader}>
|
||||
<img
|
||||
src={thumbUrl(source.iconUrl)}
|
||||
alt={source.displayName}
|
||||
className={s.sourceIcon}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
<img src={thumbUrl(source.iconUrl)} alt={source.displayName} className={s.sourceIcon}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span className={s.sourceName}>{source.displayName}</span>
|
||||
{loading && <CircleNotch size={12} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />}
|
||||
{!loading && mangas.length > 0 && (
|
||||
<span className={s.resultCount}>{mangas.length} results</span>
|
||||
)}
|
||||
{hasMultipleLangs && <span className={s.sourceLang}>{source.lang.toUpperCase()}</span>}
|
||||
{loading && <CircleNotch size={12} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", marginLeft: "auto" }} />}
|
||||
{!loading && mangas.length > 0 && <span className={s.resultCount}>{mangas.length} results</span>}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className={s.sourceError}>{error}</p>
|
||||
) : loading ? (
|
||||
<div className={s.sourceRow}>
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className={s.skCard}>
|
||||
<div className={["skeleton", s.skCover].join(" ")} />
|
||||
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<RowSkeleton />
|
||||
) : mangas.length > 0 ? (
|
||||
<div className={s.sourceRow}>
|
||||
{mangas.slice(0, 8).map((m) => (
|
||||
<button key={m.id} className={s.card} onClick={() => openManga(m)}>
|
||||
<div className={s.coverWrap}>
|
||||
<img src={thumbUrl(m.thumbnailUrl)} alt={m.title} className={s.cover} />
|
||||
{m.inLibrary && <span className={s.inLibBadge}>In Library</span>}
|
||||
</div>
|
||||
<p className={s.cardTitle}>{m.title}</p>
|
||||
</button>
|
||||
{mangas.slice(0, RESULTS_PER_SOURCE).map((m) => (
|
||||
<MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{allDone && !hasResults && submitted && (
|
||||
{allDone && !hasResults && (
|
||||
<div className={s.empty}>
|
||||
<p className={s.emptyText}>No results for "{submitted}"</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Tag tab ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function TagTab({
|
||||
preferredLang, onMangaClick,
|
||||
}: {
|
||||
allSources: Source[];
|
||||
loadingSources: boolean;
|
||||
preferredLang: string;
|
||||
onMangaClick: (m: Manga) => void;
|
||||
}) {
|
||||
const [activeTag, setActiveTag] = useState<string | null>(null);
|
||||
const [tagResults, setTagResults] = useState<Manga[]>([]);
|
||||
const [loadingTag, setLoadingTag] = useState(false);
|
||||
const [tagFilter, setTagFilter] = useState("");
|
||||
const abortRef = useRef<AbortController | null>(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 (
|
||||
<div className={s.splitRoot}>
|
||||
<div className={s.splitSidebar}>
|
||||
<div className={s.splitSearchWrap}>
|
||||
<MagnifyingGlass size={12} className={s.splitSearchIcon} weight="light" />
|
||||
<input
|
||||
className={s.splitSearchInput}
|
||||
placeholder="Filter tags…"
|
||||
value={tagFilter}
|
||||
onChange={(e) => setTagFilter(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
<div className={s.splitList}>
|
||||
{filteredGenres.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
className={[s.splitItem, activeTag === tag ? s.splitItemActive : ""].join(" ")}
|
||||
onClick={() => drillTag(tag)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
{filteredGenres.length === 0 && <p className={s.splitEmpty}>No matching tags</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={s.splitContent}>
|
||||
{!activeTag ? (
|
||||
<div className={s.empty}>
|
||||
<Hash size={32} weight="light" className={s.emptyIcon} />
|
||||
<p className={s.emptyText}>Browse by tag</p>
|
||||
<p className={s.emptyHint}>Select a genre tag to see matching manga across your sources.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={s.splitContentHeader}>
|
||||
<span className={s.splitContentTitle}>{activeTag}</span>
|
||||
{loadingTag
|
||||
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
: <span className={s.splitResultCount}>{tagResults.length} results</span>}
|
||||
</div>
|
||||
{loadingTag ? (
|
||||
<GridSkeleton />
|
||||
) : tagResults.length > 0 ? (
|
||||
<div className={s.tagGrid}>
|
||||
{tagResults.map((m) => (
|
||||
<MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className={s.empty}>
|
||||
<p className={s.emptyText}>No results for "{activeTag}"</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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<string>("all");
|
||||
const [activeSource, setActiveSource] = useState<Source | null>(null);
|
||||
const [browseResults, setBrowseResults] = useState<Manga[]>([]);
|
||||
const [loadingBrowse, setLoadingBrowse] = useState(false);
|
||||
const [browseQuery, setBrowseQuery] = useState("");
|
||||
const [submitted, setSubmitted] = useState("");
|
||||
const abortRef = useRef<AbortController | null>(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 (
|
||||
<div className={s.splitRoot}>
|
||||
<div className={s.splitSidebar}>
|
||||
{hasMultipleLangs && (
|
||||
<div className={s.langFilterRow}>
|
||||
{["all", ...availableLangs].map((lang) => (
|
||||
<button key={lang}
|
||||
className={[s.langChip, selectedLang === lang ? s.langChipActive : ""].join(" ")}
|
||||
onClick={() => setSelectedLang(lang)}
|
||||
>
|
||||
{lang === "all" ? "All" : lang.toUpperCase()}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{loadingSources ? (
|
||||
<div className={s.splitLoading}>
|
||||
<CircleNotch size={16} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||
</div>
|
||||
) : (
|
||||
<div className={s.splitList}>
|
||||
{visibleSources.map((src) => (
|
||||
<button key={src.id}
|
||||
className={[s.splitItem, s.splitItemSource, activeSource?.id === src.id ? s.splitItemActive : ""].join(" ")}
|
||||
onClick={() => selectSource(src)}
|
||||
>
|
||||
<img src={thumbUrl(src.iconUrl)} alt="" className={s.splitSourceIcon}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span className={s.splitItemLabel}>{src.displayName}</span>
|
||||
{src.isNsfw && <span className={s.nsfwBadge}>18+</span>}
|
||||
</button>
|
||||
))}
|
||||
{visibleSources.length === 0 && <p className={s.splitEmpty}>No sources for this language</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={s.splitContent}>
|
||||
{!activeSource ? (
|
||||
<div className={s.empty}>
|
||||
<List size={32} weight="light" className={s.emptyIcon} />
|
||||
<p className={s.emptyText}>Browse a source</p>
|
||||
<p className={s.emptyHint}>Select a source to see its popular titles, or search within it.</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className={s.splitContentHeader}>
|
||||
<div className={s.splitSourceTitle}>
|
||||
<img src={thumbUrl(activeSource.iconUrl)} alt="" className={s.splitSourceIcon}
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||
<span className={s.splitContentTitle}>{activeSource.displayName}</span>
|
||||
{loadingBrowse && <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />}
|
||||
{!loadingBrowse && browseResults.length > 0 && <span className={s.splitResultCount}>{browseResults.length} results</span>}
|
||||
</div>
|
||||
<div className={s.sourceBrowseBar}>
|
||||
<div className={s.searchBar} style={{ flex: 1 }}>
|
||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
||||
<input
|
||||
className={s.searchInput}
|
||||
placeholder={`Search ${activeSource.displayName}…`}
|
||||
value={browseQuery}
|
||||
onChange={(e) => setBrowseQuery(e.target.value)}
|
||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||
/>
|
||||
{submitted && (
|
||||
<button className={s.clearSearchBtn} onClick={clearSearch} title="Clear search">×</button>
|
||||
)}
|
||||
</div>
|
||||
<button className={s.searchBtn} onClick={handleSearch} disabled={!browseQuery.trim() || loadingBrowse}>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loadingBrowse ? <GridSkeleton /> : browseResults.length > 0 ? (
|
||||
<div className={s.tagGrid}>
|
||||
{browseResults.map((m) => <MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />)}
|
||||
</div>
|
||||
) : (
|
||||
<div className={s.empty}>
|
||||
<p className={s.emptyText}>{submitted ? `No results for "${submitted}"` : "No results"}</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 ─────────────────────────────────────── */
|
||||
|
||||
@@ -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<number, { data: Manga; fetchedAt: number }>();
|
||||
const chapterStore = new Map<number, { data: Chapter[]; fetchedAt: number }>();
|
||||
|
||||
// ── Download dropdown ─────────────────────────────────────────────────────────
|
||||
|
||||
interface DownloadDropdownProps {
|
||||
@@ -93,7 +106,6 @@ function DownloadDropdown({
|
||||
|
||||
return (
|
||||
<div className={s.dlDropdown} ref={ref}>
|
||||
|
||||
{continueChapter && continueIdx >= 0 && (
|
||||
<>
|
||||
<p className={s.dlSectionLabel}>From Ch.{continueChapter.chapter.chapterNumber}</p>
|
||||
@@ -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<Manga | null>(activeManga);
|
||||
const [manga, setManga] = useState<Manga | null>(null);
|
||||
const [chapters, setChapters] = useState<Chapter[]>([]);
|
||||
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<AbortController | null>(null);
|
||||
const chapterAbortRef = useRef<AbortController | null>(null);
|
||||
// Track the manga ID we're currently loading to discard stale results
|
||||
const loadingForRef = useRef<number | null>(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() {
|
||||
<div className={s.sidebar}>
|
||||
<button className={s.back} onClick={() => setActiveManga(null)}>
|
||||
<ArrowLeft size={13} weight="light" />
|
||||
<span>Library</span>
|
||||
<span>Back</span>
|
||||
</button>
|
||||
|
||||
<div className={s.coverWrap}>
|
||||
@@ -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}
|
||||
</button>
|
||||
@@ -682,36 +815,20 @@ export default function SeriesDetail() {
|
||||
{readCount > 0 && ` · ${readCount} read`}
|
||||
</p>
|
||||
|
||||
{/* Quick mark-all */}
|
||||
{totalCount > 0 && (
|
||||
<div className={s.markAllRow}>
|
||||
<button
|
||||
className={s.markAllBtn}
|
||||
onClick={() => markAllAboveRead(sortedChapters.length - 1)}
|
||||
disabled={readCount === totalCount}
|
||||
title="Mark all chapters as read"
|
||||
>
|
||||
<CheckCircle size={12} weight="light" />
|
||||
All read
|
||||
</button>
|
||||
<button
|
||||
className={s.markAllBtn}
|
||||
onClick={() => markAllAboveUnread(sortedChapters.length - 1)}
|
||||
disabled={readCount === 0}
|
||||
title="Mark all chapters as unread"
|
||||
>
|
||||
<Circle size={12} weight="light" />
|
||||
All unread
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Details (collapsible) */}
|
||||
{/* Source info — collapsible details */}
|
||||
{!loadingManga && manga?.source && (
|
||||
<div className={s.detailsSection}>
|
||||
<button className={s.detailsToggle} onClick={() => setDetailsOpen((p) => !p)}>
|
||||
<span>Details</span>
|
||||
<CaretDown size={11} weight="light" className={detailsOpen ? s.caretOpen : s.caretClosed} />
|
||||
<CaretDown
|
||||
size={11}
|
||||
weight="light"
|
||||
style={{
|
||||
transform: detailsOpen ? "rotate(180deg)" : "rotate(0deg)",
|
||||
transition: "transform 0.15s ease",
|
||||
flexShrink: 0,
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
{detailsOpen && (
|
||||
<div className={s.detailsBody}>
|
||||
@@ -719,14 +836,32 @@ export default function SeriesDetail() {
|
||||
<span className={s.detailKey}>Source</span>
|
||||
<span className={s.detailVal}>{manga.source.displayName}</span>
|
||||
</div>
|
||||
<div className={s.detailRow}>
|
||||
<span className={s.detailKey}>Language</span>
|
||||
<span className={s.detailVal}>{manga.source.name.match(/\(([^)]+)\)$/)?.[1] ?? "—"}</span>
|
||||
</div>
|
||||
<div className={s.detailRow}>
|
||||
<span className={s.detailKey}>Source ID</span>
|
||||
<span className={[s.detailVal, s.detailMono].join(" ")}>{manga.source.id}</span>
|
||||
</div>
|
||||
{manga.status && (
|
||||
<div className={s.detailRow}>
|
||||
<span className={s.detailKey}>Status</span>
|
||||
<span className={s.detailVal}>
|
||||
{manga.status.charAt(0) + manga.status.slice(1).toLowerCase()}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{manga.author && (
|
||||
<div className={s.detailRow}>
|
||||
<span className={s.detailKey}>Author</span>
|
||||
<span className={s.detailVal}>{manga.author}</span>
|
||||
</div>
|
||||
)}
|
||||
{manga.artist && manga.artist !== manga.author && (
|
||||
<div className={s.detailRow}>
|
||||
<span className={s.detailKey}>Artist</span>
|
||||
<span className={s.detailVal}>{manga.artist}</span>
|
||||
</div>
|
||||
)}
|
||||
{totalCount > 0 && (
|
||||
<div className={s.detailRow}>
|
||||
<span className={s.detailKey}>Progress</span>
|
||||
<span className={s.detailVal}>{readCount} / {totalCount} read</span>
|
||||
</div>
|
||||
)}
|
||||
<button className={s.migrateBtn} onClick={() => setMigrateOpen(true)}>
|
||||
<ArrowsClockwise size={12} weight="light" />
|
||||
Switch source
|
||||
@@ -738,13 +873,20 @@ export default function SeriesDetail() {
|
||||
disabled={deletingAll}
|
||||
>
|
||||
<Trash size={12} weight="light" />
|
||||
{deletingAll ? "Deleting…" : `Delete all downloads (${downloadedCount})`}
|
||||
{deletingAll ? "Deleting…" : `Delete downloads (${downloadedCount})`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{manga && !manga.source && (
|
||||
<button className={s.migrateBtn} onClick={() => setMigrateOpen(true)}>
|
||||
<ArrowsClockwise size={12} weight="light" />
|
||||
Switch source
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* ── Chapter list ── */}
|
||||
@@ -761,8 +903,7 @@ export default function SeriesDetail() {
|
||||
>
|
||||
{sortDir === "desc"
|
||||
? <SortDescending size={14} weight="light" />
|
||||
: <SortAscending size={14} weight="light" />
|
||||
}
|
||||
: <SortAscending size={14} weight="light" />}
|
||||
<span>{sortDir === "desc" ? "Newest first" : "Oldest first"}</span>
|
||||
</button>
|
||||
|
||||
@@ -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
|
||||
<div
|
||||
key={ch.id}
|
||||
role="button"
|
||||
@@ -941,11 +1081,9 @@ export default function SeriesDetail() {
|
||||
{ch.isBookmarked && (
|
||||
<BookmarkSimple size={12} weight="fill" className={s.bookmarkIcon} />
|
||||
)}
|
||||
{/* Read indicator — always shown when read */}
|
||||
{ch.isRead && (
|
||||
<CheckCircle size={14} weight="light" className={s.readIcon} />
|
||||
)}
|
||||
{/* Download / status indicator — independent of read state */}
|
||||
{ch.isDownloaded ? (
|
||||
<button
|
||||
className={s.dlBtn}
|
||||
|
||||
@@ -1,678 +0,0 @@
|
||||
import { useEffect, useState, useMemo, memo } from "react";
|
||||
import { ArrowLeft, ArrowRight, Compass, List, BookOpen, Star, Fire, BookmarkSimple, FolderSimplePlus, Folder } from "@phosphor-icons/react";
|
||||
import { gql, thumbUrl } from "../../lib/client";
|
||||
import { UPDATE_MANGA } from "../../lib/queries";
|
||||
import ContextMenu, { type ContextMenuEntry } from "../context/ContextMenu";
|
||||
import { GET_ALL_MANGA, GET_LIBRARY, GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
|
||||
import { useStore } from "../../store";
|
||||
import type { Manga, Source } from "../../lib/types";
|
||||
import SourceList from "./SourceList";
|
||||
import SourceBrowse from "./SourceBrowse";
|
||||
import s from "./Explore.module.css";
|
||||
|
||||
// ── Frecency ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function frecencyScore(readAt: number, count: number): number {
|
||||
const hoursSince = (Date.now() - readAt) / 3_600_000;
|
||||
return count / Math.log(hoursSince + 2);
|
||||
}
|
||||
|
||||
// ── Ghost card ────────────────────────────────────────────────────────────────
|
||||
|
||||
function GhostCard() {
|
||||
return <div className={s.ghostCard} aria-hidden />;
|
||||
}
|
||||
|
||||
const GHOST_COUNT = 3;
|
||||
|
||||
// ── Skeleton row ──────────────────────────────────────────────────────────────
|
||||
|
||||
function SkeletonRow({ count = 8 }: { count?: number }) {
|
||||
return (
|
||||
<div className={s.skeletonRow}>
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<div key={i} className={s.cardSkeleton}>
|
||||
<div className={["skeleton", s.coverSkeleton].join(" ")} />
|
||||
<div className={["skeleton", s.titleSkeleton].join(" ")} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── 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 (
|
||||
<button className={s.card} onClick={onClick} onContextMenu={onContextMenu}>
|
||||
<div className={s.coverWrap}>
|
||||
<img
|
||||
src={thumbUrl(manga.thumbnailUrl)}
|
||||
alt={manga.title}
|
||||
className={s.cover}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{manga.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
||||
{progress !== undefined && progress > 0 && (
|
||||
<div className={s.progressBar}>
|
||||
<div className={s.progressFill} style={{ width: `${progress * 100}%` }} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className={s.title}>{manga.title}</p>
|
||||
{subtitle && <p className={s.subtitle}>{subtitle}</p>}
|
||||
</button>
|
||||
);
|
||||
});
|
||||
|
||||
// ── 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: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||
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: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{
|
||||
label: "New folder & add",
|
||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||
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<number, Manga>();
|
||||
[...manga, ...sourceManga]
|
||||
.filter((m) => (m.genre ?? []).includes(genre))
|
||||
.forEach((m) => combined.set(m.id, m));
|
||||
return Array.from(combined.values());
|
||||
}, [manga, sourceManga, genre]);
|
||||
|
||||
return (
|
||||
<div className={s.drillRoot}>
|
||||
<div className={s.drillHeader}>
|
||||
<button className={s.back} onClick={onBack}>
|
||||
<ArrowLeft size={13} weight="light" />
|
||||
<span>Explore</span>
|
||||
</button>
|
||||
<span className={s.drillTitle}>{genre}</span>
|
||||
</div>
|
||||
<div className={s.drillGrid}>
|
||||
{filtered.map((m) => (
|
||||
<button key={m.id} className={s.drillCard} onClick={() => onOpen(m)} onContextMenu={(e) => openCtx(e, m)}>
|
||||
<div className={s.coverWrap}>
|
||||
<img
|
||||
src={thumbUrl(m.thumbnailUrl)}
|
||||
alt={m.title}
|
||||
className={s.cover}
|
||||
loading="lazy"
|
||||
decoding="async"
|
||||
/>
|
||||
{m.inLibrary && <span className={s.inLibraryBadge}>Saved</span>}
|
||||
</div>
|
||||
<p className={s.title}>{m.title}</p>
|
||||
</button>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<div className={s.empty}>No manga found for {genre}.</div>
|
||||
)}
|
||||
</div>
|
||||
{ctx && (
|
||||
<ContextMenu
|
||||
x={ctx.x}
|
||||
y={ctx.y}
|
||||
items={buildCtxItems(ctx.manga)}
|
||||
onClose={() => setCtx(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Section ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function Section({
|
||||
title,
|
||||
icon,
|
||||
onSeeAll,
|
||||
loading,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
icon?: React.ReactNode;
|
||||
onSeeAll?: () => void;
|
||||
loading?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={s.section}>
|
||||
<div className={s.sectionHeader}>
|
||||
<span className={s.sectionTitle}>
|
||||
<span className={s.sectionTitleIcon}>
|
||||
{icon}
|
||||
{title}
|
||||
</span>
|
||||
</span>
|
||||
{onSeeAll && (
|
||||
<button className={s.seeAll} onClick={onSeeAll}>
|
||||
See all <ArrowRight size={11} weight="light" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{loading ? <SkeletonRow /> : children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Main ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
type ExploreMode = "explore" | "sources";
|
||||
type DrillState = { type: "genre"; genre: string } | null;
|
||||
|
||||
export default function Explore() {
|
||||
const [mode, setMode] = useState<ExploreMode>("explore");
|
||||
const [drill, setDrill] = useState<DrillState>(null);
|
||||
const activeSource = useStore((s) => s.activeSource);
|
||||
|
||||
if (activeSource) return <SourceBrowse />;
|
||||
|
||||
if (drill?.type === "genre" && mode === "explore") {
|
||||
return <DrillWrapper drill={drill} onBack={() => setDrill(null)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={s.root}>
|
||||
<div className={s.header}>
|
||||
<div className={s.headerLeft}>
|
||||
<h1 className={s.heading}>Explore</h1>
|
||||
<div className={s.tabs}>
|
||||
<button
|
||||
className={[s.tab, mode === "explore" ? s.tabActive : ""].join(" ").trim()}
|
||||
onClick={() => setMode("explore")}
|
||||
>
|
||||
<Compass size={11} weight="bold" />
|
||||
Explore
|
||||
</button>
|
||||
<button
|
||||
className={[s.tab, mode === "sources" ? s.tabActive : ""].join(" ").trim()}
|
||||
onClick={() => setMode("sources")}
|
||||
>
|
||||
<List size={11} weight="bold" />
|
||||
Sources
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{mode === "explore" ? <ExploreFeed onDrill={setDrill} /> : <SourceList />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Drill wrapper ─────────────────────────────────────────────────────────────
|
||||
|
||||
function DrillWrapper({ drill, onBack }: { drill: DrillState; onBack: () => void }) {
|
||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
|
||||
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<string, Source[]>();
|
||||
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<number>();
|
||||
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 (
|
||||
<GenreDrill
|
||||
genre={drill.genre}
|
||||
manga={allManga}
|
||||
sourceManga={sourceManga}
|
||||
onBack={onBack}
|
||||
onOpen={(m) => { setActiveManga(m); setNavPage("library"); }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Explore feed ──────────────────────────────────────────────────────────────
|
||||
|
||||
function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) {
|
||||
const [allManga, setAllManga] = useState<Manga[]>([]);
|
||||
const [loadingLib, setLoadingLib] = useState(true);
|
||||
// Popular row: deduped results from POPULAR fetch across all sources
|
||||
const [popularManga, setPopularManga] = useState<Manga[]>([]);
|
||||
const [loadingPopular, setLoadingPopular] = useState(true);
|
||||
// Genre search results: genre → merged Manga[] from SEARCH per source
|
||||
const [genreResults, setGenreResults] = useState<Map<string, Manga[]>>(new Map());
|
||||
const [loadingGenres, setLoadingGenres] = useState(false);
|
||||
const [sources, setSources] = useState<Source[]>([]);
|
||||
|
||||
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: <BookmarkSimple size={13} weight={m.inLibrary ? "fill" : "light"} />,
|
||||
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: <Folder size={13} weight={f.mangaIds.includes(m.id) ? "fill" : "light"} />,
|
||||
onClick: () => assignMangaToFolder(f.id, m.id),
|
||||
})),
|
||||
] : []),
|
||||
{ separator: true },
|
||||
{
|
||||
label: "New folder & add",
|
||||
icon: <FolderSimplePlus size={13} weight="light" />,
|
||||
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<string, Source[]>();
|
||||
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<number>();
|
||||
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<number, number>();
|
||||
const mangaReadAt = new Map<number, number>();
|
||||
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<string, number>();
|
||||
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<number>();
|
||||
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<string, Manga[]>();
|
||||
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<number>();
|
||||
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 (
|
||||
<div className={s.body}>
|
||||
|
||||
{/* Continue Reading */}
|
||||
{(continueReading.length > 0 || loadingLib) && (
|
||||
<Section
|
||||
title="Continue Reading"
|
||||
icon={<BookOpen size={11} weight="bold" />}
|
||||
loading={loadingLib}
|
||||
>
|
||||
<div className={s.row}>
|
||||
{continueReading.map(({ manga, chapterName, progress }) => (
|
||||
<MiniCard
|
||||
key={manga.id}
|
||||
manga={manga}
|
||||
onClick={() => openManga(manga)}
|
||||
onContextMenu={(e) => openCtx(e, manga)}
|
||||
subtitle={chapterName}
|
||||
progress={progress}
|
||||
/>
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
||||
<GhostCard key={`ghost-cr-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Recommended */}
|
||||
{(recommended.length > 0 || loadingLib) && (
|
||||
<Section
|
||||
title="Recommended for You"
|
||||
icon={<Star size={11} weight="bold" />}
|
||||
loading={loadingLib}
|
||||
>
|
||||
<div className={s.row}>
|
||||
{recommended.map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
||||
<GhostCard key={`ghost-rec-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* Popular across deduplicated sources */}
|
||||
{(popularManga.length > 0 || loadingPopular) && (
|
||||
<Section
|
||||
title={
|
||||
sources.length === 1
|
||||
? `Popular on ${sources[0].displayName}`
|
||||
: sources.length > 1
|
||||
? `Popular across ${sources.length} sources`
|
||||
: "Popular"
|
||||
}
|
||||
icon={<Fire size={11} weight="bold" />}
|
||||
loading={loadingPopular}
|
||||
>
|
||||
{sources.length === 0 ? (
|
||||
<div className={s.noSource}>No sources installed. Add extensions first.</div>
|
||||
) : (
|
||||
<div className={s.row}>
|
||||
{popularManga.map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
||||
<GhostCard key={`ghost-pop-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
)}
|
||||
|
||||
{/* 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 (
|
||||
<Section
|
||||
key={genre}
|
||||
title={genre}
|
||||
onSeeAll={() => onDrill({ type: "genre", genre })}
|
||||
loading={isLoading}
|
||||
>
|
||||
<div className={s.row}>
|
||||
{items.map((m) => (
|
||||
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
||||
))}
|
||||
{Array.from({ length: GHOST_COUNT }).map((_, i) => (
|
||||
<GhostCard key={`ghost-${genre}-${i}`} />
|
||||
))}
|
||||
</div>
|
||||
</Section>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Empty state */}
|
||||
{!loadingLib && !loadingPopular && !loadingGenres &&
|
||||
continueReading.length === 0 && recommended.length === 0 &&
|
||||
popularManga.length === 0 && frecencyGenres.every((g) => !genreResults.get(g)?.length) && (
|
||||
<div className={s.empty}>
|
||||
<span>Nothing to explore yet</span>
|
||||
<span className={s.emptyHint}>
|
||||
Add manga to your library or install sources to get started.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{ctx && (
|
||||
<ContextMenu
|
||||
x={ctx.x}
|
||||
y={ctx.y}
|
||||
items={buildCtxItems(ctx.manga)}
|
||||
onClose={() => setCtx(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<string, Promise<unknown>>();
|
||||
const subs = new Map<string, Set<() => void>>();
|
||||
|
||||
export const cache = {
|
||||
get<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||
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<T>;
|
||||
},
|
||||
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<string, Promise<unknown>>();
|
||||
|
||||
export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
|
||||
if (inflight.has(key)) return inflight.get(key) as Promise<T>;
|
||||
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<string, number>;
|
||||
|
||||
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<T extends { id: string }>(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);
|
||||
}
|
||||
+53
-14
@@ -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<T> {
|
||||
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<Response> {
|
||||
/** Sleep that resolves early if the signal is aborted — never blocks a cancelled request. */
|
||||
function abortableSleep(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
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<Response> {
|
||||
// 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<T>(
|
||||
query: string,
|
||||
variables?: Record<string, unknown>
|
||||
variables?: Record<string, unknown>,
|
||||
signal?: AbortSignal,
|
||||
): Promise<T> {
|
||||
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<T> = 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;
|
||||
}
|
||||
@@ -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<string, Source[]>();
|
||||
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<T extends { id: number; title: string }>(items: T[]): T[] {
|
||||
const seen = new Set<string>();
|
||||
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<T extends { id: number }>(items: T[]): T[] {
|
||||
const seen = new Set<number>();
|
||||
const out: T[] = [];
|
||||
for (const m of items) {
|
||||
if (!seen.has(m.id)) { seen.add(m.id); out.push(m); }
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -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<Store>()(
|
||||
(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) =>
|
||||
|
||||
Reference in New Issue
Block a user