mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 01:09:56 -05:00
535 lines
23 KiB
TypeScript
535 lines
23 KiB
TypeScript
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;
|
|
const ROW_CAP = 25;
|
|
|
|
// Hijack vertical wheel delta → horizontal scroll on .row divs
|
|
function handleRowWheel(e: React.WheelEvent<HTMLDivElement>) {
|
|
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
|
|
const el = e.currentTarget;
|
|
const canScrollLeft = el.scrollLeft > 0;
|
|
const canScrollRight = el.scrollLeft < el.scrollWidth - el.clientWidth - 1;
|
|
if (!canScrollLeft && !canScrollRight) return;
|
|
e.stopPropagation();
|
|
el.scrollLeft += e.deltaY;
|
|
}
|
|
|
|
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>
|
|
);
|
|
});
|
|
|
|
// ── Explore More end-cap ──────────────────────────────────────────────────────
|
|
|
|
const ExploreMoreCard = memo(function ExploreMoreCard({
|
|
genre, onClick,
|
|
}: { genre: string; onClick: () => void }) {
|
|
return (
|
|
<button className={s.exploreMoreCard} onClick={onClick} title={`See all ${genre} manga`}>
|
|
<div className={s.exploreMoreInner}>
|
|
<ArrowRight size={20} weight="light" className={s.exploreMoreIcon} />
|
|
<span className={s.exploreMoreLabel}>Explore more</span>
|
|
<span className={s.exploreMoreGenre}>{genre}</span>
|
|
</div>
|
|
</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 [loadError, setLoadError] = useState(false);
|
|
const [retryCount, setRetryCount] = useState(0);
|
|
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 (retries when suwayomi wasn't ready) ─────────────
|
|
useEffect(() => {
|
|
// If we already have data, no need to re-fetch (cache hit path)
|
|
const alreadyLoaded = allManga.length > 0 && sources.length > 0;
|
|
if (alreadyLoaded) return;
|
|
|
|
setLoadingLib(true);
|
|
setLoadingPopular(true);
|
|
setLoadError(false);
|
|
|
|
const preferredLang = settings.preferredExtensionLang || "en";
|
|
|
|
// Clear stale failed cache entries so we actually retry
|
|
if (retryCount > 0) {
|
|
cache.clear(CACHE_KEYS.LIBRARY);
|
|
cache.clear(CACHE_KEYS.SOURCES);
|
|
fetchedGenresRef.current = "";
|
|
}
|
|
|
|
// 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((e) => { console.error(e); setLoadError(true); })
|
|
.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); setLoadError(true); 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((e) => { console.error(e); setLoadError(true); setLoadingPopular(false); });
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [retryCount]);
|
|
|
|
// ── 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} onWheel={handleRowWheel}>
|
|
{continueReading.slice(0, ROW_CAP).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} onWheel={handleRowWheel}>
|
|
{recommended.slice(0, ROW_CAP).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} onWheel={handleRowWheel}>
|
|
{popularManga.slice(0, ROW_CAP).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} onWheel={handleRowWheel}>
|
|
{items.slice(0, ROW_CAP).map((m) => (
|
|
<MiniCard key={m.id} manga={m} onClick={() => openManga(m)} onContextMenu={(e) => openCtx(e, m)} />
|
|
))}
|
|
{items.length >= ROW_CAP && (
|
|
<ExploreMoreCard genre={genre} onClick={() => setGenreFilter(genre)} />
|
|
)}
|
|
{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}>
|
|
{loadError ? (
|
|
<>
|
|
<span>Could not reach Suwayomi</span>
|
|
<span className={s.emptyHint}>Make sure the server is running, then try again.</span>
|
|
<button
|
|
style={{ marginTop: "var(--sp-3)", padding: "6px 16px", borderRadius: "var(--radius-md)", border: "1px solid var(--border-dim)", background: "var(--bg-raised)", color: "var(--text-muted)", cursor: "pointer", fontFamily: "var(--font-ui)", fontSize: "var(--text-xs)", letterSpacing: "var(--tracking-wide)" }}
|
|
onClick={() => { setLoadingLib(true); setLoadingPopular(true); setRetryCount((c) => c + 1); }}
|
|
>
|
|
Retry
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<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>
|
|
);
|
|
} |