Library
- {/* Built-in tabs */}
{(["library", "downloaded", "all"] as const).map((f) => (
))}
- {/* Folder tabs — only shown if the folder has showTab enabled */}
{folders.filter((f) => f.showTab).map((folder) => (
- {/* Tag filter panel */}
{allTags.length > 0 && (
{libraryTagFilter.length > 0 && (
)}
{allTags.map((tag) => {
@@ -264,13 +258,9 @@ export default function Library() {
return (
);
@@ -298,31 +288,47 @@ export default function Library() {
: "No manga found."}
) : (
- <>
-
- {visible.map((m) => (
- openCtx(e, m)}
- cropCovers={settings.libraryCropCovers}
- />
- ))}
-
- {hasMore && (
-
-
)}
+
{ctx && (
;
+}
+
+const GHOST_COUNT = 3;
+
+// ── Skeleton row ──────────────────────────────────────────────────────────────
+
+function SkeletonRow({ count = 8 }: { count?: number }) {
+ return (
+
+ {Array.from({ length: count }).map((_, i) => (
+
+ ))}
+
+ );
+}
+
+// ── Mini card ─────────────────────────────────────────────────────────────────
+
+const MiniCard = memo(function MiniCard({
+ manga,
+ onClick,
+ subtitle,
+ progress,
+}: {
+ manga: Manga;
+ onClick: () => void;
+ subtitle?: string;
+ progress?: number;
+}) {
+ return (
+
+ );
+});
+
+// ── Genre drill-down ──────────────────────────────────────────────────────────
+
+function GenreDrill({
+ genre,
+ manga,
+ sourceManga,
+ onBack,
+ onOpen,
+}: {
+ genre: string;
+ manga: Manga[];
+ sourceManga: Manga[];
+ onBack: () => void;
+ onOpen: (m: Manga) => void;
+}) {
+ const filtered = useMemo(() => {
+ const combined = new Map
();
+ [...manga, ...sourceManga]
+ .filter((m) => (m.genre ?? []).includes(genre))
+ .forEach((m) => combined.set(m.id, m));
+ return Array.from(combined.values());
+ }, [manga, sourceManga, genre]);
+
+ return (
+
+
+
+
{genre}
+
+
+ {filtered.map((m) => (
+
+ ))}
+ {filtered.length === 0 && (
+
No manga found for {genre}.
+ )}
+
+
+ );
+}
+
+// ── Section ───────────────────────────────────────────────────────────────────
+
+function Section({
+ title,
+ icon,
+ onSeeAll,
+ loading,
+ children,
+}: {
+ title: string;
+ icon?: React.ReactNode;
+ onSeeAll?: () => void;
+ loading?: boolean;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
+
+ {icon}
+ {title}
+
+
+ {onSeeAll && (
+
+ )}
+
+ {loading ?
: children}
+
+ );
+}
+
+// ── Main ──────────────────────────────────────────────────────────────────────
+
+type ExploreMode = "explore" | "sources";
+type DrillState = { type: "genre"; genre: string } | null;
+
+export default function Explore() {
+ const [mode, setMode] = useState("explore");
+ const [drill, setDrill] = useState(null);
+ const activeSource = useStore((s) => s.activeSource);
+
+ if (activeSource) return ;
+
+ if (drill?.type === "genre" && mode === "explore") {
+ return setDrill(null)} />;
+ }
+
+ return (
+
+
+
+
Explore
+
+
+
+
+
+
+
+ {mode === "explore" ?
:
}
+
+ );
+}
+
+// ── Drill wrapper ─────────────────────────────────────────────────────────────
+
+function DrillWrapper({ drill, onBack }: { drill: DrillState; onBack: () => void }) {
+ const [allManga, setAllManga] = useState([]);
+ const [sourceManga, setSourceManga] = useState([]);
+ const setActiveManga = useStore((s) => s.setActiveManga);
+ const setNavPage = useStore((s) => s.setNavPage);
+ const settings = useStore((s) => s.settings);
+
+ useEffect(() => {
+ Promise.all([
+ gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
+ gql<{ mangas: { nodes: Manga[] } }>(GET_LIBRARY),
+ ]).then(([all, lib]) => {
+ const libMap = new Map(lib.mangas.nodes.map((m) => [m.id, m]));
+ setAllManga(all.mangas.nodes.map((m) => libMap.get(m.id) ?? m));
+ }).catch(console.error);
+
+ const preferredLang = settings.preferredExtensionLang || "en";
+ gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
+ .then((d) => {
+ const all = d.sources.nodes.filter((src) => src.id !== "0");
+ const byName = new Map();
+ for (const src of all) {
+ if (!byName.has(src.name)) byName.set(src.name, []);
+ byName.get(src.name)!.push(src);
+ }
+ const picked: Source[] = [];
+ for (const group of byName.values()) {
+ const preferred = group.find((s) => s.lang === preferredLang);
+ picked.push(preferred ?? group.sort((a, b) => a.lang.localeCompare(b.lang))[0]);
+ }
+ return Promise.allSettled(
+ picked.map((src) =>
+ gql<{ fetchSourceManga: { mangas: Manga[] } }>(FETCH_SOURCE_MANGA, {
+ source: src.id, type: "POPULAR", page: 1, query: null,
+ }).then((d) => d.fetchSourceManga.mangas)
+ )
+ );
+ })
+ .then((results) => {
+ const seen = new Set();
+ const merged: Manga[] = [];
+ for (const r of results) {
+ if (r.status === "fulfilled")
+ for (const m of r.value)
+ if (!seen.has(m.id)) { seen.add(m.id); merged.push(m); }
+ }
+ setSourceManga(merged);
+ })
+ .catch(console.error);
+ }, []);
+
+ if (!drill) return null;
+
+ return (
+ { setActiveManga(m); setNavPage("library"); }}
+ />
+ );
+}
+
+// ── Explore feed ──────────────────────────────────────────────────────────────
+
+function ExploreFeed({ onDrill }: { onDrill: (d: DrillState) => void }) {
+ const [allManga, setAllManga] = useState([]);
+ const [loadingLib, setLoadingLib] = useState(true);
+ // Popular row: deduped results from POPULAR fetch across all sources
+ const [popularManga, setPopularManga] = useState([]);
+ const [loadingPopular, setLoadingPopular] = useState(true);
+ // Genre search results: genre → merged Manga[] from SEARCH per source
+ const [genreResults, setGenreResults] = useState