[V1] Query-Optimizations & Preparation for MacOS & Windows Compatibility

This commit is contained in:
Youwes09
2026-02-25 19:41:14 -06:00
parent 28e9e3bcf8
commit 9a0afed2b0
14 changed files with 1333 additions and 462 deletions
+8 -2
View File
@@ -36,6 +36,7 @@ export default function App() {
const prevQueueRef = useRef<DownloadQueueItem[]>([]);
const idleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const idleRef = useRef(false);
// expose devSplash trigger via window for settings
useEffect(() => {
@@ -43,10 +44,15 @@ export default function App() {
return () => { delete (window as any).__mokuShowSplash; };
}, []);
// Keep idleRef in sync so resetIdle can check it without a stale closure
useEffect(() => { idleRef.current = idle; }, [idle]);
useEffect(() => {
if (!appReady) return;
function resetIdle() {
setIdle(false);
// While the idle splash is visible, don't reset — let SplashScreen's own
// dismiss flow handle teardown so the exit animation plays fully.
if (idleRef.current) return;
if (idleTimerRef.current) clearTimeout(idleTimerRef.current);
const idleTimeoutMs = (settings.idleTimeoutMin ?? 5) * 60 * 1000;
if (idleTimeoutMs === 0) return;
@@ -178,7 +184,7 @@ export default function App() {
<SplashScreen
mode="idle"
showCards={settings.splashCards ?? true}
onDismiss={() => { setTimeout(() => setIdle(false), SPLASH_EXIT_MS + 20); }}
onDismiss={() => { setTimeout(() => { setIdle(false); }, SPLASH_EXIT_MS + 20); }}
/>
)}
{!activeChapter && <TitleBar/>}
+50 -78
View File
@@ -6,7 +6,7 @@ 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 { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
import { useStore } from "../../store";
import type { Manga, Source } from "../../lib/types";
import SourceList from "../sources/SourceList";
@@ -177,6 +177,35 @@ export default function Explore() {
const FOUNDATIONAL_GENRES = ["Action", "Romance", "Fantasy", "Adventure", "Comedy", "Drama"];
// Single query replacing GET_ALL_MANGA + GET_LIBRARY merge
const EXPLORE_ALL_MANGA = `
query ExploreAllManga {
mangas(orderBy: IN_LIBRARY_AT, orderByType: DESC) {
nodes {
id title thumbnailUrl inLibrary genre status
source { id displayName }
}
}
}
`;
// Fast genre row query against the local DB
const MANGAS_BY_GENRE_EXPLORE = `
query MangasByGenreExplore($genre: String!, $first: Int) {
mangas(
filter: { genre: { includesInsensitive: $genre } }
first: $first
orderBy: IN_LIBRARY_AT
orderByType: DESC
) {
nodes {
id title thumbnailUrl inLibrary genre status
source { id displayName }
}
}
}
`;
function ExploreFeed() {
const [allManga, setAllManga] = useState<Manga[]>([]);
const [loadingLib, setLoadingLib] = useState(true);
@@ -238,10 +267,11 @@ function ExploreFeed() {
];
}
// ── Library + sources load (retries when suwayomi wasn't ready) ─────────────
// ── Data load ─────────────────────────────────────────────────────────────
// Library + genre rows: single local DB query each — instant, no source calls.
// Popular: still needs fetchSourceManga since there's no local equivalent.
useEffect(() => {
// If we already have data, no need to re-fetch (cache hit path)
const alreadyLoaded = allManga.length > 0 && sources.length > 0;
const alreadyLoaded = allManga.length > 0;
if (alreadyLoaded) return;
setLoadingLib(true);
@@ -249,39 +279,29 @@ function ExploreFeed() {
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
// Single query for all manga — library flag included
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);
})
gql<{ mangas: { nodes: Manga[] } }>(EXPLORE_ALL_MANGA)
.then((d) => d.mangas.nodes)
).then(setAllManga)
.catch((e) => { console.error(e); setLoadError(true); })
.finally(() => setLoadingLib(false));
// Sources — then kick off popular AND genres simultaneously
// Sources — only needed for Popular section
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
if (allSources.length === 0) { setLoadingPopular(false); return; }
const topSources = getTopSources(allSources).slice(0, 2);
setSources(allSources);
// ── Popular — don't block genres ──────────────────────────────────
cache.get(CACHE_KEYS.POPULAR, () =>
Promise.allSettled(
topSources.map((src) =>
@@ -296,48 +316,7 @@ function ExploreFeed() {
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); });
}).catch((e) => { console.error(e); setLoadError(true); setLoadingPopular(false); });
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [retryCount]);
@@ -367,12 +346,13 @@ function ExploreFeed() {
.map(([g]) => g);
}, [allManga, history]);
// ── Re-fetch only when personalized genres differ from what's cached ───────
// ── Genre rows: query local DB directly ─────────────────────────────────
// One query per genre against the local mangas table — instant, no source I/O.
useEffect(() => {
if (frecencyGenres.length === 0 || sources.length === 0) return;
if (frecencyGenres.length === 0 || allManga.length === 0) return;
const genreKey = frecencyGenres.join(",");
if (fetchedGenresRef.current === genreKey) return; // already fetched, cache hit
if (fetchedGenresRef.current === genreKey) return;
fetchedGenresRef.current = genreKey;
setLoadingGenres(true);
@@ -380,24 +360,16 @@ function ExploreFeed() {
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);
})
gql<{ mangas: { nodes: Manga[] } }>(
MANGAS_BY_GENRE_EXPLORE,
{ genre, first: 25 },
ctrl.signal,
).then((d) => d.mangas.nodes)
).then((mangas) => {
if (ctrl.signal.aborted) return;
streamingMap.set(genre, mangas);
@@ -407,7 +379,7 @@ function ExploreFeed() {
)
.catch((e) => { if (e?.name !== "AbortError") console.error(e); })
.finally(() => { if (!ctrl.signal.aborted) setLoadingGenres(false); });
}, [frecencyGenres, sources]);
}, [frecencyGenres, allManga]);
function openManga(m: Manga) { setPreviewManga(m); }
+146 -68
View File
@@ -2,22 +2,54 @@ import { useEffect, useState, useMemo, useRef, memo, useCallback } from "react";
import { ArrowLeft, BookmarkSimple, FolderSimplePlus, Folder, CircleNotch } 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 } from "../../lib/cache";
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
import { dedupeSources, 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";
// ── Constants ─────────────────────────────────────────────────────────────────
const PAGE_SIZE = 50; // how many items to show at once
const INITIAL_PAGES = 3; // source API pages to fetch upfront per source
const MAX_SOURCES = 12; // max sources to query concurrently
const CONCURRENCY = 4; // parallel source fetches
// ── Constants ─────────────────────────────────────────────────────────────────
const PAGE_SIZE = 50;
const INITIAL_PAGES = 3;
const MAX_SOURCES = 12;
const CONCURRENCY = 4;
// ── Helpers ───────────────────────────────────────────────────────────────────
/**
* genreFilter in the store is either a single tag ("Action") or a `+`-joined
* multi-tag string ("Action+Romance"). Parse it into an array.
*
* Callers set multi-tag filters via:
* setGenreFilter("Action+Romance")
*
* The Explore feed's "See all" button continues to pass single strings and
* requires no change.
*/
function parseTags(genreFilter: string): string[] {
return genreFilter.split("+").map((t) => t.trim()).filter(Boolean);
}
/** "Action", "Action & Romance", "Action, Romance & Isekai" */
function tagsLabel(tags: string[]): string {
if (tags.length === 1) return tags[0];
return tags.slice(0, -1).join(", ") + " & " + tags[tags.length - 1];
}
/**
* Client-side AND filter.
* Sources only accept a single query string, so we send the first tag and
* drop results that don't also have the remaining tags in their genre list.
*/
function matchesAllTags(m: Manga, tags: string[]): boolean {
const genres = (m.genre ?? []).map((g) => g.toLowerCase());
return tags.every((t) => genres.includes(t.toLowerCase()));
}
async function runConcurrent<T>(
items: T[],
fn: (item: T) => Promise<void>,
fn: (item: T) => Promise<void>,
signal: AbortSignal,
): Promise<void> {
let i = 0;
@@ -46,7 +78,7 @@ const CoverImg = memo(function CoverImg({ src, alt, className }: { src: string;
// ── GenreDrillPage ────────────────────────────────────────────────────────────
export default function GenreDrillPage() {
const genre = useStore((st) => st.genreFilter);
const genreFilter = useStore((st) => st.genreFilter);
const setGenreFilter = useStore((st) => st.setGenreFilter);
const setPreviewManga = useStore((st) => st.setPreviewManga);
const settings = useStore((st) => st.settings);
@@ -54,6 +86,11 @@ export default function GenreDrillPage() {
const addFolder = useStore((st) => st.addFolder);
const assignMangaToFolder = useStore((st) => st.assignMangaToFolder);
// Parse the filter string into individual tags
const tags = useMemo(() => parseTags(genreFilter), [genreFilter]);
// First tag is sent as the source query string (sources accept only one term)
const primaryTag = tags[0] ?? "";
const [libraryManga, setLibraryManga] = useState<Manga[]>([]);
const [sourceManga, setSourceManga] = useState<Manga[]>([]);
const [loadingInitial, setLoadingInitial] = useState(true);
@@ -62,12 +99,13 @@ export default function GenreDrillPage() {
const [ctx, setCtx] = useState<{ x: number; y: number; manga: Manga } | null>(null);
// Per-source next-page tracker; -1 means exhausted
const nextPageRef = useRef<Map<string, number>>(new Map());
const sourcesRef = useRef<Source[]>([]);
const abortRef = useRef<AbortController | null>(null);
const nextPageRef = useRef<Map<string, number>>(new Map());
const sourcesRef = useRef<Source[]>([]);
const abortRef = useRef<AbortController | null>(null);
// ── Initial load ─────────────────────────────────────────────────────────
useEffect(() => {
if (!genre) return;
if (tags.length === 0) return;
abortRef.current?.abort();
const ctrl = new AbortController();
@@ -81,7 +119,7 @@ export default function GenreDrillPage() {
const preferredLang = settings.preferredExtensionLang || "en";
// ── Library (fire-and-forget, doesn't block skeleton removal) ─────────
// ── Library (local DB, instant) ───────────────────────────────────────
cache.get(CACHE_KEYS.LIBRARY, () =>
Promise.all([
gql<{ mangas: { nodes: Manga[] } }>(GET_ALL_MANGA),
@@ -94,46 +132,67 @@ export default function GenreDrillPage() {
.then((manga) => { if (!ctrl.signal.aborted) setLibraryManga(manga); })
.catch((e) => { if (e?.name !== "AbortError") console.error(e); });
// ── Sources: stream results in as each source responds ────────────────
// ── Sources: stream results as each source responds ───────────────────
// Source list is stable within a session — cache indefinitely.
cache.get(CACHE_KEYS.SOURCES, () =>
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => dedupeSources(d.sources.nodes.filter((s) => s.id !== "0"), preferredLang))
.then((d) => dedupeSources(d.sources.nodes.filter((src) => src.id !== "0"), preferredLang)),
Infinity,
).then(async (allSources) => {
const sources = allSources.slice(0, MAX_SOURCES);
sourcesRef.current = sources;
// Start all sources at -1 (unknown/exhausted); the fetch loop will set the correct next page
for (const src of sources) nextPageRef.current.set(src.id, -1);
await runConcurrent(sources, async (src) => {
if (ctrl.signal.aborted) return;
// PageSet tracks which pages we've already fetched for this (source, tags) bucket.
// On navigation-away → back the pages are still in the TTL store, so fetchPage
// returns the cached promise immediately without hitting the network.
const ps = getPageSet(src.id, "SEARCH", tags);
const pageItems: Manga[] = [];
for (let page = 1; page <= INITIAL_PAGES; page++) {
if (ctrl.signal.aborted) return;
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: genre },
ctrl.signal,
);
pageItems.push(...d.fetchSourceManga.mangas);
if (!d.fetchSourceManga.hasNextPage) {
nextPageRef.current.set(src.id, -1);
break;
} else if (page === INITIAL_PAGES) {
// Has more pages beyond what we fetched upfront — mark for "load more"
nextPageRef.current.set(src.id, INITIAL_PAGES + 1);
}
} catch (e: any) {
if (e?.name === "AbortError") return;
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
const result = await cache
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
pageKey,
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: primaryTag },
ctrl.signal,
).then((d) => d.fetchSourceManga),
)
.catch((e: any) => {
if (e?.name !== "AbortError") console.error(e);
return null;
});
if (!result || ctrl.signal.aborted) break;
ps.add(page);
// For multi-tag searches: client-side AND filter for tags beyond the first.
// Sources only support a single query string, so we send primaryTag and
// drop results that don't contain the remaining tags in their genre array.
const matching = tags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, tags))
: result.mangas;
pageItems.push(...matching);
if (!result.hasNextPage) {
nextPageRef.current.set(src.id, -1);
break;
} else if (page === INITIAL_PAGES) {
nextPageRef.current.set(src.id, INITIAL_PAGES + 1);
}
}
if (!ctrl.signal.aborted && pageItems.length > 0) {
// Dedupe by ID only — title dedup across sources is too aggressive and collapses
// legitimate different-source results that share a common title (e.g. "Action" genre)
setSourceManga((prev) => dedupeMangaById([...prev, ...pageItems]));
// Drop the skeleton as soon as we have anything
setLoadingInitial(false);
}
}, ctrl.signal);
@@ -145,34 +204,35 @@ export default function GenreDrillPage() {
});
return () => { ctrl.abort(); };
}, [genre]); // eslint-disable-line react-hooks/exhaustive-deps
// genreFilter (not tags) as the dep — tags is derived from it and would
// cause an extra render on every parse; genreFilter is the stable identity.
}, [genreFilter]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Derived merged list ───────────────────────────────────────────────────
// ── Derived merged list ───────────────────────────────────────────────────
const filtered = useMemo(() => {
const libMatches = libraryManga.filter((m) => (m.genre ?? []).includes(genre));
const libIds = new Set(libMatches.map((m) => m.id));
const srcAll = sourceManga.filter((m) => !libIds.has(m.id));
return dedupeMangaById([...libMatches, ...srcAll]);
}, [libraryManga, sourceManga, genre]);
// For multi-tag: library results must match ALL tags
const libMatches = libraryManga.filter((m) => matchesAllTags(m, tags));
const libIds = new Set(libMatches.map((m) => m.id));
const srcOnly = sourceManga.filter((m) => !libIds.has(m.id));
return dedupeMangaById([...libMatches, ...srcOnly]);
}, [libraryManga, sourceManga, tags]);
// ── Load more ─────────────────────────────────────────────────────────────
const hasMoreVisible = visibleCount < filtered.length;
const hasMoreNetwork = sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
const hasMore = hasMoreVisible || hasMoreNetwork;
// ── Load more ─────────────────────────────────────────────────────────────
const hasMoreVisible = visibleCount < filtered.length;
const hasMoreNetwork = sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
const hasMore = hasMoreVisible || hasMoreNetwork;
const loadMore = useCallback(async () => {
if (loadingMore) return;
// If there are buffered results, just reveal the next page
// Fast path: buffered results already in memory
if (hasMoreVisible) {
setVisibleCount((v) => v + PAGE_SIZE);
return;
}
// Fetch next pages from network
const sources = sourcesRef.current.filter(
(src) => (nextPageRef.current.get(src.id) ?? -1) > 0
);
// Slow path: fetch next pages from sources
const sources = sourcesRef.current.filter((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
if (!sources.length) return;
setLoadingMore(true);
@@ -184,18 +244,35 @@ export default function GenreDrillPage() {
await runConcurrent(sources, async (src) => {
const page = nextPageRef.current.get(src.id)!;
if (ctrl.signal.aborted) return;
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: genre },
ctrl.signal,
);
nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1);
if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0)
setSourceManga((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas]));
} catch (e: any) {
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
}
const ps = getPageSet(src.id, "SEARCH", tags);
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, tags);
const result = await cache
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
pageKey,
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: primaryTag },
ctrl.signal,
).then((d) => d.fetchSourceManga),
)
.catch((e: any) => {
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
return null;
});
if (!result || ctrl.signal.aborted) return;
ps.add(page);
nextPageRef.current.set(src.id, result.hasNextPage ? page + 1 : -1);
const matching = tags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, tags))
: result.mangas;
if (matching.length > 0)
setSourceManga((prev) => dedupeMangaById([...prev, ...matching]));
}, ctrl.signal);
} finally {
if (!ctrl.signal.aborted) {
@@ -203,7 +280,7 @@ export default function GenreDrillPage() {
setLoadingMore(false);
}
}
}, [loadingMore, hasMoreVisible, genre]);
}, [loadingMore, hasMoreVisible, primaryTag, tags]);
// ── Context menu ──────────────────────────────────────────────────────────
function openCtx(e: React.MouseEvent, m: Manga) {
@@ -245,6 +322,7 @@ export default function GenreDrillPage() {
}
const visibleItems = filtered.slice(0, visibleCount);
const label = tagsLabel(tags);
return (
<div className={s.root}>
@@ -253,7 +331,7 @@ export default function GenreDrillPage() {
<ArrowLeft size={13} weight="light" />
<span>Back</span>
</button>
<span className={s.title}>{genre}</span>
<span className={s.title}>{label}</span>
{loadingInitial && filtered.length === 0 ? null : (
<span className={s.resultCount}>
{visibleItems.length}{filtered.length > visibleCount ? "+" : ""} of {filtered.length}
@@ -274,7 +352,7 @@ export default function GenreDrillPage() {
))}
</div>
) : filtered.length === 0 ? (
<div className={s.empty}>No manga found for "{genre}".</div>
<div className={s.empty}>No manga found for "{label}".</div>
) : (
<div className={s.grid}>
{visibleItems.map((m) => (
@@ -290,8 +368,8 @@ export default function GenreDrillPage() {
<div className={s.showMoreCell}>
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
{loadingMore
? <><CircleNotch size={13} weight="light" className="anim-spin" style={{ display:"inline-block" }} /> Loading</>
: `Show more`}
? <><CircleNotch size={13} weight="light" className="anim-spin" style={{ display: "inline-block" }} /> Loading</>
: "Show more"}
</button>
</div>
)}
+8 -3
View File
@@ -428,10 +428,15 @@ export default function SplashScreen({
useEffect(() => {
if (mode !== "idle" || !onDismiss) return;
function handler() { triggerExit(onDismiss); }
window.addEventListener("keydown", handler, { once: true });
window.addEventListener("mousedown", handler, { once: true });
window.addEventListener("touchstart", handler, { once: true });
// Delay registering listeners by one frame so the event that triggered
// idle (mousemove/mousedown) doesn't immediately dismiss the splash.
const t = setTimeout(() => {
window.addEventListener("keydown", handler, { once: true });
window.addEventListener("mousedown", handler, { once: true });
window.addEventListener("touchstart", handler, { once: true });
}, 200);
return () => {
clearTimeout(t);
window.removeEventListener("keydown", handler);
window.removeEventListener("mousedown", handler);
window.removeEventListener("touchstart", handler);
+69
View File
@@ -339,4 +339,73 @@
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;
}
/* ── Multi-tag bar ───────────────────────────────────────────────────────────── */
.tagActiveBar {
display: flex; align-items: center; gap: var(--sp-3);
padding: var(--sp-2) var(--sp-5);
border-bottom: 1px solid var(--border-dim);
flex-shrink: 0; flex-wrap: wrap;
background: var(--bg-raised);
min-height: 40px;
}
.tagPillRow {
display: flex; flex-wrap: wrap; gap: var(--sp-1); flex: 1; min-width: 0;
}
.tagPill {
display: inline-flex; align-items: center; gap: 4px;
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide);
padding: 3px 6px 3px 8px; border-radius: var(--radius-sm);
background: var(--accent-muted); color: var(--accent-fg);
border: 1px solid var(--accent-dim);
white-space: nowrap;
}
.tagPillRemove {
display: flex; align-items: center; justify-content: center;
width: 14px; height: 14px; border-radius: 50%;
background: none; border: none; cursor: pointer;
color: var(--accent-fg); font-size: 13px; line-height: 1;
opacity: 0.7; padding: 0; flex-shrink: 0;
transition: opacity var(--t-fast);
}
.tagPillRemove:hover { opacity: 1; }
.tagBarRight {
display: flex; align-items: center; gap: var(--sp-2); flex-shrink: 0;
}
/* AND / OR toggle */
.tagModeToggle {
display: flex; align-items: center;
background: var(--bg-overlay); border: 1px solid var(--border-dim);
border-radius: var(--radius-sm); padding: 1px; gap: 1px;
}
.tagModeBtn {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wider); padding: 3px 8px;
border-radius: calc(var(--radius-sm) - 1px);
border: none; background: none; color: var(--text-faint);
cursor: pointer; transition: background var(--t-fast), color var(--t-fast);
}
.tagModeBtn:hover { color: var(--text-muted); }
.tagModeBtnActive {
background: var(--accent-muted); color: var(--accent-fg);
}
.tagClearAll {
font-family: var(--font-ui); font-size: var(--text-2xs);
letter-spacing: var(--tracking-wide); color: var(--text-faint);
background: none; border: none; cursor: pointer; padding: 3px 0;
transition: color var(--t-fast);
}
.tagClearAll:hover { color: var(--text-muted); }
/* Checkmark on active tag sidebar items */
.tagCheckMark {
font-size: 10px; margin-left: auto; padding-left: var(--sp-1);
color: var(--accent-fg); flex-shrink: 0;
}
+358 -147
View File
@@ -1,10 +1,10 @@
import { useState, useRef, useCallback, useEffect, memo, useMemo } from "react";
import {
MagnifyingGlass, CircleNotch, SlidersHorizontal, Hash, List,
MagnifyingGlass, CircleNotch, SlidersHorizontal, Hash, List, Globe,
} from "@phosphor-icons/react";
import { gql, thumbUrl } from "../../lib/client";
import { GET_SOURCES, FETCH_SOURCE_MANGA } from "../../lib/queries";
import { cache, CACHE_KEYS } from "../../lib/cache";
import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache";
import { dedupeSources, dedupeMangaById } from "../../lib/sourceUtils";
import { useStore } from "../../store";
import type { Manga, Source } from "../../lib/types";
@@ -13,18 +13,21 @@ import s from "./Search.module.css";
// ── Types ─────────────────────────────────────────────────────────────────────
type SearchTab = "keyword" | "tag" | "source";
type TagMode = "AND" | "OR";
interface SourceResult {
source: Source;
mangas: Manga[];
loading: boolean;
error: string | null;
error: string | null;
}
// ── Constants ─────────────────────────────────────────────────────────────────
const CONCURRENCY = 4;
const CONCURRENCY = 4;
const RESULTS_PER_SOURCE = 8;
const TAG_PAGE_SIZE = 48;
const MAX_TAG_SOURCES = 10; // sources queried when "Search sources" is toggled on
const COMMON_GENRES = [
"Action","Adventure","Comedy","Drama","Fantasy","Romance",
@@ -34,11 +37,11 @@ const COMMON_GENRES = [
"Magic","Music","Cooking","Medical","Military","Harem","Ecchi",
];
// ── Concurrent fetch helper ───────────────────────────────────────────────────
// ── Shared helpers ────────────────────────────────────────────────────────────
async function runConcurrent<T>(
items: T[],
fn: (item: T) => Promise<void>,
fn: (item: T) => Promise<void>,
signal: AbortSignal,
): Promise<void> {
let i = 0;
@@ -52,7 +55,13 @@ async function runConcurrent<T>(
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
}
// ── Shared card ───────────────────────────────────────────────────────────────
/** Keep only manga whose genre array includes every tag (case-insensitive). */
function matchesAllTags(m: Manga, tags: string[]): boolean {
const genres = (m.genre ?? []).map((g) => g.toLowerCase());
return tags.every((t) => genres.includes(t.toLowerCase()));
}
// ── Shared card components ────────────────────────────────────────────────────
const CoverImg = memo(function CoverImg({
src, alt, className,
@@ -114,7 +123,7 @@ export default function Search() {
const setSearchPrefill = useStore((st) => st.setSearchPrefill);
const setPreviewManga = useStore((st) => st.setPreviewManga);
const [allSources, setAllSources] = useState<Source[]>([]);
const [allSources, setAllSources] = useState<Source[]>([]);
const [loadingSources, setLoadingSources] = useState(false);
const pendingPrefill = useRef<string>("");
@@ -132,7 +141,8 @@ export default function Search() {
setLoadingSources(true);
cache.get(CACHE_KEYS.SOURCES, () =>
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => d.sources.nodes.filter((src) => src.id !== "0"))
.then((d) => d.sources.nodes.filter((src) => src.id !== "0")),
Infinity, // source list is stable within a session
)
.then(setAllSources)
.catch(console.error)
@@ -194,25 +204,26 @@ export default function Search() {
}
// ── Keyword tab ───────────────────────────────────────────────────────────────
// Unchanged from v1.
function KeywordTab({
allSources, loadingSources, availableLangs, hasMultipleLangs,
preferredLang, pendingPrefill, onMangaClick,
}: {
allSources: Source[];
loadingSources: boolean;
availableLangs: string[];
allSources: Source[];
loadingSources: boolean;
availableLangs: string[];
hasMultipleLangs: boolean;
preferredLang: string;
pendingPrefill: React.MutableRefObject<string>;
onMangaClick: (m: Manga) => void;
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 [includeNsfw, setIncludeNsfw] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
@@ -427,151 +438,273 @@ function KeywordTab({
}
// ── Tag tab ───────────────────────────────────────────────────────────────────
//
// Two data sources, selectable independently:
//
// 1. Local DB (always on) — instant MangaFilterInput query with AND/OR support.
// "Show more" uses GraphQL offset pagination.
//
// 2. Source search (opt-in via "Search sources" toggle) — fires FETCH_SOURCE_MANGA
// across the top sources, using getPageSet() + cache.get(sourceMangaPage) so
// results survive navigation and "Show more" fetches the next cached page before
// hitting the network.
// For multi-tag AND: sends the first tag as the source query string (sources only
// support one term) and client-filters the results by the remaining tags.
const TAG_PAGE_SIZE = 50; // items shown per "page"
const TAG_FETCH_PAGES = 3; // source pages to fetch per source on initial load
const TAG_MAX_SOURCES = 12; // max sources to query
const MANGAS_BY_GENRE = `
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) {
nodes {
id title thumbnailUrl inLibrary genre status
source { id displayName }
}
pageInfo { hasNextPage }
totalCount
}
}
`;
function buildGenreFilter(tags: string[], mode: TagMode): Record<string, unknown> {
if (tags.length === 0) return {};
if (mode === "AND") return { and: tags.map((t) => ({ genre: { includesInsensitive: t } })) };
return { or: tags.map((t) => ({ genre: { includesInsensitive: t } })) };
}
function TagTab({
preferredLang, onMangaClick,
allSources,
loadingSources,
preferredLang,
onMangaClick,
}: {
allSources: Source[];
allSources: Source[];
loadingSources: boolean;
preferredLang: string;
onMangaClick: (m: Manga) => void;
preferredLang: string;
onMangaClick: (m: Manga) => void;
}) {
const [activeTag, setActiveTag] = useState<string | null>(null);
const [tagResults, setTagResults] = useState<Manga[]>([]);
const [loadingTag, setLoadingTag] = useState(false);
const [loadingMore, setLoadingMore] = useState(false);
const [visibleCount, setVisibleCount] = useState(TAG_PAGE_SIZE);
const [tagFilter, setTagFilter] = useState("");
// Track next page to fetch per source for "load more from network"
const nextPageRef = useRef<Map<string, number>>(new Map());
const sourcesRef = useRef<Source[]>([]);
const abortRef = useRef<AbortController | null>(null);
const [activeTags, setActiveTags] = useState<string[]>([]);
const [tagMode, setTagMode] = useState<TagMode>("AND");
const [tagFilter, setTagFilter] = useState("");
useEffect(() => () => { abortRef.current?.abort(); }, []);
// ── Local DB state ────────────────────────────────────────────────────────
const [localResults, setLocalResults] = useState<Manga[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loadingLocal, setLoadingLocal] = useState(false);
const [loadingMoreLocal, setLoadingMoreLocal] = useState(false);
const [localOffset, setLocalOffset] = useState(0);
const [localHasNext, setLocalHasNext] = useState(false);
const abortLocalRef = useRef<AbortController | null>(null);
async function drillTag(tag: string) {
if (tag === activeTag && !loadingTag) return;
setActiveTag(tag);
setTagResults([]);
setLoadingTag(true);
setVisibleCount(TAG_PAGE_SIZE);
nextPageRef.current = new Map();
// ── Source search state ───────────────────────────────────────────────────
const [searchSources, setSearchSources] = useState(false);
const [sourceResults, setSourceResults] = useState<Manga[]>([]);
const [loadingSourceSearch, setLoadingSourceSearch] = useState(false);
const [loadingMoreSource, setLoadingMoreSource] = useState(false);
// Per-source next-page tracker; -1 = exhausted
const srcNextPageRef = useRef<Map<string, number>>(new Map());
const abortSourceRef = useRef<AbortController | null>(null);
abortRef.current?.abort();
useEffect(() => () => {
abortLocalRef.current?.abort();
abortSourceRef.current?.abort();
}, []);
// ── Local DB query ────────────────────────────────────────────────────────
useEffect(() => {
if (activeTags.length === 0) {
setLocalResults([]); setTotalCount(0); setLocalHasNext(false); setLocalOffset(0);
return;
}
abortLocalRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
abortLocalRef.current = ctrl;
setLocalResults([]); setTotalCount(0); setLocalOffset(0); setLocalHasNext(false);
setLoadingLocal(true);
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).slice(0, TAG_MAX_SOURCES);
sourcesRef.current = deduped;
gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean }; totalCount: number } }>(
MANGAS_BY_GENRE,
{ filter: buildGenreFilter(activeTags, tagMode), first: TAG_PAGE_SIZE, offset: 0 },
ctrl.signal,
).then((d) => {
if (ctrl.signal.aborted) return;
setLocalResults(d.mangas.nodes);
setTotalCount(d.mangas.totalCount);
setLocalHasNext(d.mangas.pageInfo.hasNextPage);
setLocalOffset(TAG_PAGE_SIZE);
}).catch((e: any) => {
if (e?.name !== "AbortError") console.error(e);
}).finally(() => {
if (!ctrl.signal.aborted) setLoadingLocal(false);
});
}, [activeTags, tagMode]); // eslint-disable-line react-hooks/exhaustive-deps
// Start all at -1; the fetch loop sets the real next page if hasNextPage is true
for (const src of deduped) {
nextPageRef.current.set(src.id, -1);
// ── Source search ─────────────────────────────────────────────────────────
// Fires when toggled on (or when tags change while already on).
// Uses getPageSet() + cache.get(sourceMangaPage) so the first page of each
// source is re-used from cache if the user navigates away and back.
useEffect(() => {
if (!searchSources || activeTags.length === 0 || loadingSources) return;
abortSourceRef.current?.abort();
const ctrl = new AbortController();
abortSourceRef.current = ctrl;
setSourceResults([]);
srcNextPageRef.current = new Map();
setLoadingSourceSearch(true);
const sources = dedupeSources(allSources, preferredLang).slice(0, MAX_TAG_SOURCES);
const primaryTag = activeTags[0]; // sources only support a single query string
for (const src of sources) srcNextPageRef.current.set(src.id, -1);
runConcurrent(sources, async (src) => {
if (ctrl.signal.aborted) return;
const ps = getPageSet(src.id, "SEARCH", activeTags);
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", 1, activeTags);
const result = await cache
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
pageKey,
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page: 1, query: primaryTag },
ctrl.signal,
).then((d) => d.fetchSourceManga),
)
.catch((e: any) => {
if (e?.name !== "AbortError") console.error(e);
return null;
});
if (!result || ctrl.signal.aborted) return;
ps.add(1);
srcNextPageRef.current.set(src.id, result.hasNextPage ? 2 : -1);
// Multi-tag AND: client-filter for tags beyond the first
const matching = activeTags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, activeTags))
: result.mangas;
if (matching.length > 0) {
setSourceResults((prev) => dedupeMangaById([...prev, ...matching]));
setLoadingSourceSearch(false); // reveal as results arrive
}
}, ctrl.signal).finally(() => {
if (!ctrl.signal.aborted) setLoadingSourceSearch(false);
});
// Stream results in: fetch each source's pages concurrently, update state as each settles
await runConcurrent(deduped, async (src) => {
if (ctrl.signal.aborted) return;
const pageResults: Manga[] = [];
// Fetch TAG_FETCH_PAGES pages in series per source
for (let page = 1; page <= TAG_FETCH_PAGES; page++) {
if (ctrl.signal.aborted) return;
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: tag },
ctrl.signal,
);
pageResults.push(...d.fetchSourceManga.mangas);
if (!d.fetchSourceManga.hasNextPage) {
nextPageRef.current.set(src.id, -1); // no more pages
break;
} else if (page === TAG_FETCH_PAGES) {
// Still has more pages beyond what we fetched upfront
nextPageRef.current.set(src.id, TAG_FETCH_PAGES + 1);
}
} catch (e: any) {
if (e?.name === "AbortError") return;
break; // source error — move on
}
}
if (!ctrl.signal.aborted && pageResults.length > 0) {
setTagResults((prev) => dedupeMangaById([...prev, ...pageResults]));
}
}, ctrl.signal);
return () => { ctrl.abort(); };
}, [searchSources, activeTags, allSources, loadingSources]); // eslint-disable-line react-hooks/exhaustive-deps
// ── Load more: local ──────────────────────────────────────────────────────
async function loadMoreLocal() {
if (loadingMoreLocal || !localHasNext) return;
setLoadingMoreLocal(true);
abortLocalRef.current?.abort();
const ctrl = new AbortController();
abortLocalRef.current = ctrl;
try {
const d = await gql<{ mangas: { nodes: Manga[]; pageInfo: { hasNextPage: boolean } } }>(
MANGAS_BY_GENRE,
{ filter: buildGenreFilter(activeTags, tagMode), first: TAG_PAGE_SIZE, offset: localOffset },
ctrl.signal,
);
if (ctrl.signal.aborted) return;
setLocalResults((prev) => [...prev, ...d.mangas.nodes]);
setLocalHasNext(d.mangas.pageInfo.hasNextPage);
setLocalOffset((o) => o + TAG_PAGE_SIZE);
} catch (e: any) {
if (e?.name !== "AbortError") console.error(e);
} finally {
if (!ctrl.signal.aborted) setLoadingTag(false);
if (!ctrl.signal.aborted) setLoadingMoreLocal(false);
}
}
async function loadMore() {
if (!activeTag || loadingMore) return;
// ── Load more: sources ────────────────────────────────────────────────────
const sourceHasMore = searchSources &&
[...srcNextPageRef.current.values()].some((p) => p > 0);
// First check if we have more buffered results to show
if (visibleCount < tagResults.length) {
setVisibleCount((v) => v + TAG_PAGE_SIZE);
return;
}
// Otherwise fetch next pages from sources
const sourcesToFetch = sourcesRef.current.filter(
(src) => (nextPageRef.current.get(src.id) ?? -1) > 0
);
if (sourcesToFetch.length === 0) return;
setLoadingMore(true);
abortRef.current?.abort();
async function loadMoreSource() {
if (loadingMoreSource || !sourceHasMore) return;
setLoadingMoreSource(true);
abortSourceRef.current?.abort();
const ctrl = new AbortController();
abortRef.current = ctrl;
abortSourceRef.current = ctrl;
const sources = dedupeSources(allSources, preferredLang)
.slice(0, MAX_TAG_SOURCES)
.filter((src) => (srcNextPageRef.current.get(src.id) ?? -1) > 0);
const primaryTag = activeTags[0];
try {
await runConcurrent(sourcesToFetch, async (src) => {
const page = nextPageRef.current.get(src.id)!;
await runConcurrent(sources, async (src) => {
const page = srcNextPageRef.current.get(src.id)!;
if (ctrl.signal.aborted) return;
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: activeTag },
ctrl.signal,
);
nextPageRef.current.set(src.id, d.fetchSourceManga.hasNextPage ? page + 1 : -1);
if (!ctrl.signal.aborted && d.fetchSourceManga.mangas.length > 0) {
setTagResults((prev) => dedupeMangaById([...prev, ...d.fetchSourceManga.mangas]));
}
} catch (e: any) {
if (e?.name !== "AbortError") nextPageRef.current.set(src.id, -1);
}
const ps = getPageSet(src.id, "SEARCH", activeTags);
const pageKey = CACHE_KEYS.sourceMangaPage(src.id, "SEARCH", page, activeTags);
const result = await cache
.get<{ mangas: Manga[]; hasNextPage: boolean }>(
pageKey,
() => gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type: "SEARCH", page, query: primaryTag },
ctrl.signal,
).then((d) => d.fetchSourceManga),
)
.catch((e: any) => {
if (e?.name !== "AbortError") srcNextPageRef.current.set(src.id, -1);
return null;
});
if (!result || ctrl.signal.aborted) return;
ps.add(page);
srcNextPageRef.current.set(src.id, result.hasNextPage ? page + 1 : -1);
const matching = activeTags.length > 1
? result.mangas.filter((m) => matchesAllTags(m, activeTags))
: result.mangas;
if (matching.length > 0)
setSourceResults((prev) => dedupeMangaById([...prev, ...matching]));
}, ctrl.signal);
} finally {
if (!ctrl.signal.aborted) {
setVisibleCount((v) => v + TAG_PAGE_SIZE);
setLoadingMore(false);
}
if (!ctrl.signal.aborted) setLoadingMoreSource(false);
}
}
// ── Tag toggle ────────────────────────────────────────────────────────────
function toggleTag(tag: string) {
// Clear source sessions when tags change — new query = new page buckets
srcNextPageRef.current = new Map();
setSourceResults([]);
setActiveTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
);
}
const filteredGenres = useMemo(() => {
const q = tagFilter.trim().toLowerCase();
return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES;
}, [tagFilter]);
const visibleResults = tagResults.slice(0, visibleCount);
const hasMore = visibleCount < tagResults.length ||
sourcesRef.current.some((src) => (nextPageRef.current.get(src.id) ?? -1) > 0);
const hasActiveTags = activeTags.length > 0;
// Merge local + source results (local first, source de-duped against local IDs)
const localIds = useMemo(() => new Set(localResults.map((m) => m.id)), [localResults]);
const mergedResults = searchSources
? [...localResults, ...sourceResults.filter((m) => !localIds.has(m.id))]
: localResults;
const totalVisible = localResults.length + (searchSources ? sourceResults.length : 0);
return (
<div className={s.splitRoot}>
{/* ── Sidebar ────────────────────────────────────────────────────── */}
<div className={s.splitSidebar}>
<div className={s.splitSearchWrap}>
<MagnifyingGlass size={12} className={s.splitSearchIcon} weight="light" />
@@ -586,53 +719,130 @@ function TagTab({
{filteredGenres.map((tag) => (
<button
key={tag}
className={[s.splitItem, activeTag === tag ? s.splitItemActive : ""].join(" ")}
onClick={() => drillTag(tag)}
className={[s.splitItem, activeTags.includes(tag) ? s.splitItemActive : ""].join(" ")}
onClick={() => toggleTag(tag)}
>
{tag}
<span className={s.splitItemLabel}>{tag}</span>
{activeTags.includes(tag) && <span className={s.tagCheckMark}></span>}
</button>
))}
{filteredGenres.length === 0 && <p className={s.splitEmpty}>No matching tags</p>}
</div>
</div>
{/* ── Content ────────────────────────────────────────────────────── */}
<div className={s.splitContent}>
{!activeTag ? (
{!hasActiveTags ? (
<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>
<p className={s.emptyHint}>Select one or more genre tags to find matching manga.</p>
</div>
) : (
<>
{/* Active tag pills + controls */}
<div className={s.tagActiveBar}>
<div className={s.tagPillRow}>
{activeTags.map((tag) => (
<span key={tag} className={s.tagPill}>
{tag}
<button className={s.tagPillRemove} onClick={() => toggleTag(tag)} title={`Remove ${tag}`}>×</button>
</span>
))}
</div>
<div className={s.tagBarRight}>
{activeTags.length > 1 && (
<div className={s.tagModeToggle}>
<button
className={[s.tagModeBtn, tagMode === "AND" ? s.tagModeBtnActive : ""].join(" ")}
onClick={() => setTagMode("AND")}
title="Show manga matching ALL selected tags"
>AND</button>
<button
className={[s.tagModeBtn, tagMode === "OR" ? s.tagModeBtnActive : ""].join(" ")}
onClick={() => setTagMode("OR")}
title="Show manga matching ANY selected tag"
>OR</button>
</div>
)}
{/* "Search sources" toggle — fetches from external sources */}
<button
className={[s.tagModeBtn, searchSources ? s.tagModeBtnActive : ""].join(" ")}
onClick={() => setSearchSources((v) => !v)}
title="Also search across sources (slower, requires network)"
disabled={loadingSources}
>
<Globe size={11} weight="light" style={{ marginRight: 3, verticalAlign: "middle" }} />
Sources
</button>
<button className={s.tagClearAll} onClick={() => setActiveTags([])}>Clear all</button>
</div>
</div>
{/* Result header */}
<div className={s.splitContentHeader}>
<span className={s.splitContentTitle}>{activeTag}</span>
{loadingTag
<span className={s.splitContentTitle}>
{activeTags.length === 1 ? activeTags[0] : `${activeTags.length} tags (${tagMode})`}
{searchSources && (
<span style={{ marginLeft: 6, fontWeight: 400, opacity: 0.55, fontSize: "0.9em" }}>
+ sources
</span>
)}
</span>
{(loadingLocal || loadingSourceSearch)
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
: <span className={s.splitResultCount}>
{visibleResults.length}{tagResults.length > visibleCount ? `+` : ""} of {tagResults.length} results
</span>}
{totalVisible}
{localHasNext || sourceHasMore ? "+" : ""} of {totalCount + sourceResults.length} results
</span>
}
</div>
{loadingTag ? (
<GridSkeleton count={50} />
) : tagResults.length > 0 ? (
{/* Results grid */}
{loadingLocal ? (
<GridSkeleton count={48} />
) : mergedResults.length > 0 ? (
<div className={s.tagGrid}>
{visibleResults.map((m) => (
{mergedResults.map((m) => (
<MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />
))}
{hasMore && (
{/* Inline skeletons while source results are still streaming in */}
{loadingSourceSearch && Array.from({ length: 8 }).map((_, i) => (
<div key={`sk-src-${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>
))}
{/* Show more buttons — one per data source */}
{(localHasNext || sourceHasMore) && (
<div className={s.showMoreCell}>
<button className={s.showMoreBtn} onClick={loadMore} disabled={loadingMore}>
{loadingMore
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Loading</>
: "Show more"}
</button>
{localHasNext && (
<button className={s.showMoreBtn} onClick={loadMoreLocal} disabled={loadingMoreLocal}>
{loadingMoreLocal
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Loading</>
: "Show more (library)"}
</button>
)}
{sourceHasMore && (
<button className={s.showMoreBtn} onClick={loadMoreSource} disabled={loadingMoreSource}>
{loadingMoreSource
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Loading</>
: "Show more (sources)"}
</button>
)}
</div>
)}
</div>
) : (
<div className={s.empty}>
<p className={s.emptyText}>No results for "{activeTag}"</p>
<p className={s.emptyText}>No results for {activeTags.join(` ${tagMode} `)}</p>
<p className={s.emptyHint}>
{searchSources
? "Try OR mode or broader tags."
: "Try OR mode, enable Sources, or check that these manga are in your library."}
</p>
</div>
)}
</>
@@ -643,15 +853,16 @@ function TagTab({
}
// ── Source tab ────────────────────────────────────────────────────────────────
// Unchanged from v1.
function SourceTab({
allSources, loadingSources, availableLangs, hasMultipleLangs, onMangaClick,
}: {
allSources: Source[];
loadingSources: boolean;
availableLangs: string[];
allSources: Source[];
loadingSources: boolean;
availableLangs: string[];
hasMultipleLangs: boolean;
onMangaClick: (m: Manga) => void;
onMangaClick: (m: Manga) => void;
}) {
const [selectedLang, setSelectedLang] = useState<string>("all");
const [activeSource, setActiveSource] = useState<Source | null>(null);
+126 -22
View File
@@ -1,37 +1,73 @@
/**
* Session-level request cache.
*
* Key design decisions:
* Key design decisions (v1, preserved):
* - 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).
*
* v2 additions:
* - TTL-aware get(): stale entries are re-fetched automatically (default 5 min).
* Pass Infinity to pin an entry for the session (source list, extension list).
* - getPageSet(): lightweight page-number tracker for multi-page browse sessions.
* Mirrors Suwayomi's CACHE_PAGES_KEY pattern so GenreDrillPage / Search TagTab
* can resume a session without re-fetching pages already in memory.
* - Stable multi-tag cache keys: tag arrays are sorted before joining so
* ["Action","Romance"] and ["Romance","Action"] share the same bucket.
*/
const store = new Map<string, Promise<unknown>>();
interface Entry<T> {
promise: Promise<T>;
fetchedAt: number; // ms since epoch
}
const store = new Map<string, Entry<unknown>>();
const subs = new Map<string, Set<() => void>>();
/** Default revalidation window: 5 min (matches Suwayomi's browse-page TTL). */
export const DEFAULT_TTL_MS = 5 * 60 * 1_000;
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>;
/**
* Return a cached promise.
* Re-fetches automatically once the entry is older than `ttl` ms.
* Pass `Infinity` to cache for the entire session (e.g. source/extension lists).
*/
get<T>(key: string, fetcher: () => Promise<T>, ttl: number = DEFAULT_TTL_MS): Promise<T> {
const existing = store.get(key) as Entry<T> | undefined;
if (existing && Date.now() - existing.fetchedAt < ttl) return existing.promise;
const promise = fetcher().catch((err) => {
// Only evict on real failures, not user cancellations
if (err?.name !== "AbortError") store.delete(key);
return Promise.reject(err);
}) as Promise<T>;
store.set(key, { promise, fetchedAt: Date.now() });
return promise;
},
has(key: string): boolean { return store.has(key); },
/** How old (ms) a cached entry is, or undefined if absent. */
ageOf(key: string): number | undefined {
const e = store.get(key);
return e ? Date.now() - e.fetchedAt : undefined;
},
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());
@@ -40,7 +76,8 @@ export const cache = {
},
};
// ── Cache key constants — single source of truth, prevents mismatches ─────────
// ── Cache key constants ───────────────────────────────────────────────────────
export const CACHE_KEYS = {
LIBRARY: "library",
SOURCES: "sources",
@@ -48,15 +85,45 @@ export const CACHE_KEYS = {
GENRE: (genre: string) => `genre:${genre}`,
MANGA: (id: number) => `manga:${id}`,
CHAPTERS: (id: number) => `chapters:${id}`,
/**
* Stable key for a browse session's page-number set.
* Tag arrays are sorted so order never creates duplicate buckets —
* ["Action","Romance"] and ["Romance","Action"] share one key.
*
* Examples:
* CACHE_KEYS.sourceMangaPages("src123", "POPULAR")
* CACHE_KEYS.sourceMangaPages("src123", "SEARCH", "naruto")
* CACHE_KEYS.sourceMangaPages("src123", "SEARCH", ["Action","Romance"])
*/
sourceMangaPages(
sourceId: string,
type: "POPULAR" | "LATEST" | "SEARCH",
query?: string | string[],
): string {
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
return `pages:${sourceId}:${type}:${q}`;
},
/** Per-page result key. Always pair with sourceMangaPages(). */
sourceMangaPage(
sourceId: string,
type: "POPULAR" | "LATEST" | "SEARCH",
page: number,
query?: string | string[],
): string {
const q = Array.isArray(query) ? [...query].sort().join("+") : (query ?? "");
return `page:${sourceId}:${type}:${page}:${q}`;
},
} as const;
// ── In-flight request deduplication (for non-cached calls) ───────────────────
// ── 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.
//
// a time per key.
const inflight = new Map<string, Promise<unknown>>();
export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
@@ -66,18 +133,56 @@ export function deduped<T>(key: string, fetcher: () => Promise<T>): Promise<T> {
return p;
}
// ── Source frecency helpers ────────────────────────────────────────────────────
// ── PageSet: per-session page-number tracker ──────────────────────────────────
//
// Tracks which page numbers have been fetched for a (source, type, query) bucket.
// Lives in a separate map from the TTL store so it never gets TTL-evicted while
// a browse session is actively paginating.
//
// Usage:
// const ps = getPageSet(sourceId, "SEARCH", ["Action", "Romance"]);
// ps.add(1); // after fetching page 1
// ps.next(); // → 2
// ps.pages(); // → Set {1}
// ps.clear(); // call when query/tags change
const FRECENCY_KEY = "moku-source-frecency";
const _pageSets = new Map<string, Set<number>>();
export interface PageSet {
add(page: number): void;
pages(): Set<number>;
/** Next page to fetch: max fetched + 1, or 1 if nothing fetched yet. */
next(): number;
clear(): void;
}
export function getPageSet(
sourceId: string,
type: "POPULAR" | "LATEST" | "SEARCH",
query?: string | string[],
): PageSet {
const key = CACHE_KEYS.sourceMangaPages(sourceId, type, query);
return {
add(page) {
if (!_pageSets.has(key)) _pageSets.set(key, new Set());
_pageSets.get(key)!.add(page);
},
pages() { return new Set(_pageSets.get(key) ?? []); },
next() { const s = _pageSets.get(key); return s?.size ? Math.max(...s) + 1 : 1; },
clear() { _pageSets.delete(key); },
};
}
// ── 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 {}; }
try { const r = localStorage.getItem(FRECENCY_KEY); return r ? JSON.parse(r) : {}; }
catch { return {}; }
}
function saveFrecency(map: FrecencyMap) {
@@ -95,7 +200,6 @@ 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)