mirror of
https://github.com/moku-project/Moku.git
synced 2026-06-13 09:19:56 -05:00
[V1] Search Overhaul + Tag Fixes
This commit is contained in:
File diff suppressed because it is too large
Load Diff
+115
-107
@@ -27,7 +27,7 @@ interface SourceResult {
|
|||||||
const CONCURRENCY = 4;
|
const CONCURRENCY = 4;
|
||||||
const RESULTS_PER_SOURCE = 8;
|
const RESULTS_PER_SOURCE = 8;
|
||||||
const TAG_PAGE_SIZE = 48;
|
const TAG_PAGE_SIZE = 48;
|
||||||
const MAX_TAG_SOURCES = 10; // sources queried when "Search sources" is toggled on
|
const MAX_TAG_SOURCES = 10;
|
||||||
|
|
||||||
const COMMON_GENRES = [
|
const COMMON_GENRES = [
|
||||||
"Action","Adventure","Comedy","Drama","Fantasy","Romance",
|
"Action","Adventure","Comedy","Drama","Fantasy","Romance",
|
||||||
@@ -37,7 +37,7 @@ const COMMON_GENRES = [
|
|||||||
"Magic","Music","Cooking","Medical","Military","Harem","Ecchi",
|
"Magic","Music","Cooking","Medical","Military","Harem","Ecchi",
|
||||||
];
|
];
|
||||||
|
|
||||||
// ── Shared helpers ────────────────────────────────────────────────────────────
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async function runConcurrent<T>(
|
async function runConcurrent<T>(
|
||||||
items: T[],
|
items: T[],
|
||||||
@@ -55,7 +55,6 @@ async function runConcurrent<T>(
|
|||||||
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Keep only manga whose genre array includes every tag (case-insensitive). */
|
|
||||||
function matchesAllTags(m: Manga, tags: string[]): boolean {
|
function matchesAllTags(m: Manga, tags: string[]): boolean {
|
||||||
const genres = (m.genre ?? []).map((g) => g.toLowerCase());
|
const genres = (m.genre ?? []).map((g) => g.toLowerCase());
|
||||||
return tags.every((t) => genres.includes(t.toLowerCase()));
|
return tags.every((t) => genres.includes(t.toLowerCase()));
|
||||||
@@ -68,8 +67,10 @@ const CoverImg = memo(function CoverImg({
|
|||||||
}: { src: string; alt: string; className?: string }) {
|
}: { src: string; alt: string; className?: string }) {
|
||||||
const [loaded, setLoaded] = useState(false);
|
const [loaded, setLoaded] = useState(false);
|
||||||
return (
|
return (
|
||||||
<img src={src} alt={alt} className={className}
|
<img
|
||||||
loading="lazy" decoding="async" onLoad={() => setLoaded(true)}
|
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" }}
|
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
@@ -91,8 +92,8 @@ function GridSkeleton({ count = 18 }: { count?: number }) {
|
|||||||
return (
|
return (
|
||||||
<div className={s.tagGrid}>
|
<div className={s.tagGrid}>
|
||||||
{Array.from({ length: count }).map((_, i) => (
|
{Array.from({ length: count }).map((_, i) => (
|
||||||
<div key={i} className={s.skCard} style={{ width: "auto" }}>
|
<div key={i} className={s.skCard}>
|
||||||
<div className={["skeleton", s.skCover].join(" ")} style={{ aspectRatio: "2/3", width: "100%" }} />
|
<div className={["skeleton", s.skCover].join(" ")} />
|
||||||
<div className={["skeleton", s.skTitle].join(" ")} />
|
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
@@ -125,10 +126,8 @@ export default function Search() {
|
|||||||
|
|
||||||
const [allSources, setAllSources] = useState<Source[]>([]);
|
const [allSources, setAllSources] = useState<Source[]>([]);
|
||||||
const [loadingSources, setLoadingSources] = useState(false);
|
const [loadingSources, setLoadingSources] = useState(false);
|
||||||
|
|
||||||
const pendingPrefill = useRef<string>("");
|
const pendingPrefill = useRef<string>("");
|
||||||
|
|
||||||
// Consume searchPrefill → route to keyword tab
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!searchPrefill) return;
|
if (!searchPrefill) return;
|
||||||
pendingPrefill.current = searchPrefill;
|
pendingPrefill.current = searchPrefill;
|
||||||
@@ -136,13 +135,13 @@ export default function Search() {
|
|||||||
setSearchPrefill("");
|
setSearchPrefill("");
|
||||||
}, [searchPrefill, setSearchPrefill]);
|
}, [searchPrefill, setSearchPrefill]);
|
||||||
|
|
||||||
// Load sources once, shared across all tabs
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setLoadingSources(true);
|
setLoadingSources(true);
|
||||||
cache.get(CACHE_KEYS.SOURCES, () =>
|
cache.get(
|
||||||
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
|
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
|
Infinity,
|
||||||
)
|
)
|
||||||
.then(setAllSources)
|
.then(setAllSources)
|
||||||
.catch(console.error)
|
.catch(console.error)
|
||||||
@@ -150,8 +149,10 @@ export default function Search() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const availableLangs = useMemo(() =>
|
const availableLangs = useMemo(
|
||||||
Array.from(new Set<string>(allSources.map((s) => s.lang))).sort(), [allSources]);
|
() => Array.from(new Set<string>(allSources.map((src) => src.lang))).sort(),
|
||||||
|
[allSources],
|
||||||
|
);
|
||||||
const hasMultipleLangs = availableLangs.length > 1;
|
const hasMultipleLangs = availableLangs.length > 1;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -159,13 +160,22 @@ export default function Search() {
|
|||||||
<div className={s.header}>
|
<div className={s.header}>
|
||||||
<h1 className={s.heading}>Search</h1>
|
<h1 className={s.heading}>Search</h1>
|
||||||
<div className={s.tabs}>
|
<div className={s.tabs}>
|
||||||
<button className={[s.tab, tab === "keyword" ? s.tabActive : ""].join(" ")} onClick={() => setTab("keyword")}>
|
<button
|
||||||
|
className={[s.tab, tab === "keyword" ? s.tabActive : ""].join(" ")}
|
||||||
|
onClick={() => setTab("keyword")}
|
||||||
|
>
|
||||||
<MagnifyingGlass size={11} weight="bold" /> Keyword
|
<MagnifyingGlass size={11} weight="bold" /> Keyword
|
||||||
</button>
|
</button>
|
||||||
<button className={[s.tab, tab === "tag" ? s.tabActive : ""].join(" ")} onClick={() => setTab("tag")}>
|
<button
|
||||||
|
className={[s.tab, tab === "tag" ? s.tabActive : ""].join(" ")}
|
||||||
|
onClick={() => setTab("tag")}
|
||||||
|
>
|
||||||
<Hash size={11} weight="bold" /> Tags
|
<Hash size={11} weight="bold" /> Tags
|
||||||
</button>
|
</button>
|
||||||
<button className={[s.tab, tab === "source" ? s.tabActive : ""].join(" ")} onClick={() => setTab("source")}>
|
<button
|
||||||
|
className={[s.tab, tab === "source" ? s.tabActive : ""].join(" ")}
|
||||||
|
onClick={() => setTab("source")}
|
||||||
|
>
|
||||||
<List size={11} weight="bold" /> Sources
|
<List size={11} weight="bold" /> Sources
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -204,7 +214,6 @@ export default function Search() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Keyword tab ───────────────────────────────────────────────────────────────
|
// ── Keyword tab ───────────────────────────────────────────────────────────────
|
||||||
// Unchanged from v1.
|
|
||||||
|
|
||||||
function KeywordTab({
|
function KeywordTab({
|
||||||
allSources, loadingSources, availableLangs, hasMultipleLangs,
|
allSources, loadingSources, availableLangs, hasMultipleLangs,
|
||||||
@@ -229,19 +238,22 @@ function KeywordTab({
|
|||||||
const inputRef = useRef<HTMLInputElement>(null);
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
const allSourcesRef = useRef<Source[]>([]);
|
const allSourcesRef = useRef<Source[]>([]);
|
||||||
const selectedLangsRef = useRef<Set<string>>(new Set());
|
const selectedLangsRef = useRef<Set<string>>(new Set());
|
||||||
|
const includeNsfwRef = useRef(false);
|
||||||
|
|
||||||
useEffect(() => { allSourcesRef.current = allSources; }, [allSources]);
|
useEffect(() => { allSourcesRef.current = allSources; }, [allSources]);
|
||||||
useEffect(() => { selectedLangsRef.current = selectedLangs; }, [selectedLangs]);
|
useEffect(() => { selectedLangsRef.current = selectedLangs; }, [selectedLangs]);
|
||||||
|
useEffect(() => { includeNsfwRef.current = includeNsfw; }, [includeNsfw]);
|
||||||
|
|
||||||
// Set default lang selection once sources load
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!allSources.length) return;
|
if (!allSources.length) return;
|
||||||
const available = new Set(allSources.map((s) => s.lang));
|
const available = new Set(allSources.map((src) => src.lang));
|
||||||
setSelectedLangs(available.has(preferredLang)
|
setSelectedLangs(
|
||||||
|
available.has(preferredLang)
|
||||||
? new Set([preferredLang])
|
? new Set([preferredLang])
|
||||||
: new Set(availableLangs.slice(0, 1))
|
: new Set(availableLangs.slice(0, 1)),
|
||||||
);
|
);
|
||||||
}, [allSources]); // eslint-disable-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [allSources]);
|
||||||
|
|
||||||
// Consume prefill once sources are ready
|
// Consume prefill once sources are ready
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -250,7 +262,7 @@ function KeywordTab({
|
|||||||
const q = pendingPrefill.current;
|
const q = pendingPrefill.current;
|
||||||
pendingPrefill.current = "";
|
pendingPrefill.current = "";
|
||||||
setQuery(q);
|
setQuery(q);
|
||||||
doSearch(q);
|
Promise.resolve().then(() => doSearch(q));
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [loadingSources]);
|
}, [loadingSources]);
|
||||||
|
|
||||||
@@ -259,11 +271,11 @@ function KeywordTab({
|
|||||||
const getVisibleSources = useCallback((): Source[] => {
|
const getVisibleSources = useCallback((): Source[] => {
|
||||||
let filtered = allSourcesRef.current;
|
let filtered = allSourcesRef.current;
|
||||||
if (selectedLangsRef.current.size > 0)
|
if (selectedLangsRef.current.size > 0)
|
||||||
filtered = filtered.filter((s) => selectedLangsRef.current.has(s.lang));
|
filtered = filtered.filter((src) => selectedLangsRef.current.has(src.lang));
|
||||||
if (!includeNsfw)
|
if (!includeNsfwRef.current)
|
||||||
filtered = filtered.filter((s) => !s.isNsfw);
|
filtered = filtered.filter((src) => !src.isNsfw);
|
||||||
return filtered;
|
return filtered;
|
||||||
}, [includeNsfw]);
|
}, []);
|
||||||
|
|
||||||
const doSearch = useCallback(async (q: string) => {
|
const doSearch = useCallback(async (q: string) => {
|
||||||
const trimmed = q.trim();
|
const trimmed = q.trim();
|
||||||
@@ -288,12 +300,12 @@ function KeywordTab({
|
|||||||
);
|
);
|
||||||
if (ctrl.signal.aborted) return;
|
if (ctrl.signal.aborted) return;
|
||||||
setResults((prev) => prev.map((r) =>
|
setResults((prev) => prev.map((r) =>
|
||||||
r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r
|
r.source.id === src.id ? { ...r, mangas: d.fetchSourceManga.mangas, loading: false } : r,
|
||||||
));
|
));
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (ctrl.signal.aborted || e?.name === "AbortError") return;
|
if (ctrl.signal.aborted || e?.name === "AbortError") return;
|
||||||
setResults((prev) => prev.map((r) =>
|
setResults((prev) => prev.map((r) =>
|
||||||
r.source.id === src.id ? { ...r, loading: false, error: e.message } : r
|
r.source.id === src.id ? { ...r, loading: false, error: e.message ?? "Error" } : r,
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
}, ctrl.signal);
|
}, ctrl.signal);
|
||||||
@@ -310,7 +322,7 @@ function KeywordTab({
|
|||||||
|
|
||||||
const visibleCount = getVisibleSources().length;
|
const visibleCount = getVisibleSources().length;
|
||||||
const hasResults = results.some((r) => r.mangas.length > 0);
|
const hasResults = results.some((r) => r.mangas.length > 0);
|
||||||
const allDone = results.every((r) => !r.loading);
|
const allDone = results.length > 0 && results.every((r) => !r.loading);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
@@ -325,6 +337,13 @@ function KeywordTab({
|
|||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && doSearch(query)}
|
onKeyDown={(e) => e.key === "Enter" && doSearch(query)}
|
||||||
/>
|
/>
|
||||||
|
{query && (
|
||||||
|
<button
|
||||||
|
className={s.clearBtn}
|
||||||
|
onClick={() => { setQuery(""); inputRef.current?.focus(); }}
|
||||||
|
title="Clear"
|
||||||
|
>×</button>
|
||||||
|
)}
|
||||||
{hasMultipleLangs && (
|
{hasMultipleLangs && (
|
||||||
<button
|
<button
|
||||||
className={[s.advancedBtn, showAdvanced ? s.advancedBtnActive : ""].join(" ")}
|
className={[s.advancedBtn, showAdvanced ? s.advancedBtnActive : ""].join(" ")}
|
||||||
@@ -356,7 +375,8 @@ function KeywordTab({
|
|||||||
</div>
|
</div>
|
||||||
<div className={s.langGrid}>
|
<div className={s.langGrid}>
|
||||||
{availableLangs.map((lang) => (
|
{availableLangs.map((lang) => (
|
||||||
<button key={lang}
|
<button
|
||||||
|
key={lang}
|
||||||
className={[s.langChip, selectedLangs.has(lang) ? s.langChipActive : ""].join(" ")}
|
className={[s.langChip, selectedLangs.has(lang) ? s.langChipActive : ""].join(" ")}
|
||||||
onClick={() => toggleLang(lang)}
|
onClick={() => toggleLang(lang)}
|
||||||
>
|
>
|
||||||
@@ -377,7 +397,7 @@ function KeywordTab({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{!submitted && (
|
{!submitted ? (
|
||||||
<div className={s.empty}>
|
<div className={s.empty}>
|
||||||
<MagnifyingGlass size={36} weight="light" className={s.emptyIcon} />
|
<MagnifyingGlass size={36} weight="light" className={s.emptyIcon} />
|
||||||
<p className={s.emptyText}>Search across sources</p>
|
<p className={s.emptyText}>Search across sources</p>
|
||||||
@@ -392,9 +412,7 @@ function KeywordTab({
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : (
|
||||||
|
|
||||||
{submitted && (
|
|
||||||
<div className={s.results}>
|
<div className={s.results}>
|
||||||
{results.length === 0 && (
|
{results.length === 0 && (
|
||||||
<div className={s.empty}>
|
<div className={s.empty}>
|
||||||
@@ -410,8 +428,9 @@ function KeywordTab({
|
|||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
<span className={s.sourceName}>{source.displayName}</span>
|
<span className={s.sourceName}>{source.displayName}</span>
|
||||||
{hasMultipleLangs && <span className={s.sourceLang}>{source.lang.toUpperCase()}</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
|
||||||
{!loading && mangas.length > 0 && <span className={s.resultCount}>{mangas.length} results</span>}
|
? <CircleNotch size={12} weight="light" className="anim-spin" style={{ color: "var(--text-faint)", marginLeft: "auto" }} />
|
||||||
|
: mangas.length > 0 && <span className={s.resultCount}>{mangas.length} results</span>}
|
||||||
</div>
|
</div>
|
||||||
{error ? (
|
{error ? (
|
||||||
<p className={s.sourceError}>{error}</p>
|
<p className={s.sourceError}>{error}</p>
|
||||||
@@ -429,6 +448,7 @@ function KeywordTab({
|
|||||||
{allDone && !hasResults && (
|
{allDone && !hasResults && (
|
||||||
<div className={s.empty}>
|
<div className={s.empty}>
|
||||||
<p className={s.emptyText}>No results for "{submitted}"</p>
|
<p className={s.emptyText}>No results for "{submitted}"</p>
|
||||||
|
<p className={s.emptyHint}>Try a different spelling or fewer words</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -438,18 +458,6 @@ function KeywordTab({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Tag tab ───────────────────────────────────────────────────────────────────
|
// ── 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 MANGAS_BY_GENRE = `
|
const MANGAS_BY_GENRE = `
|
||||||
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
|
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
|
||||||
@@ -471,10 +479,7 @@ function buildGenreFilter(tags: string[], mode: TagMode): Record<string, unknown
|
|||||||
}
|
}
|
||||||
|
|
||||||
function TagTab({
|
function TagTab({
|
||||||
allSources,
|
allSources, loadingSources, preferredLang, onMangaClick,
|
||||||
loadingSources,
|
|
||||||
preferredLang,
|
|
||||||
onMangaClick,
|
|
||||||
}: {
|
}: {
|
||||||
allSources: Source[];
|
allSources: Source[];
|
||||||
loadingSources: boolean;
|
loadingSources: boolean;
|
||||||
@@ -485,7 +490,6 @@ function TagTab({
|
|||||||
const [tagMode, setTagMode] = useState<TagMode>("AND");
|
const [tagMode, setTagMode] = useState<TagMode>("AND");
|
||||||
const [tagFilter, setTagFilter] = useState("");
|
const [tagFilter, setTagFilter] = useState("");
|
||||||
|
|
||||||
// ── Local DB state ────────────────────────────────────────────────────────
|
|
||||||
const [localResults, setLocalResults] = useState<Manga[]>([]);
|
const [localResults, setLocalResults] = useState<Manga[]>([]);
|
||||||
const [totalCount, setTotalCount] = useState(0);
|
const [totalCount, setTotalCount] = useState(0);
|
||||||
const [loadingLocal, setLoadingLocal] = useState(false);
|
const [loadingLocal, setLoadingLocal] = useState(false);
|
||||||
@@ -494,12 +498,10 @@ function TagTab({
|
|||||||
const [localHasNext, setLocalHasNext] = useState(false);
|
const [localHasNext, setLocalHasNext] = useState(false);
|
||||||
const abortLocalRef = useRef<AbortController | null>(null);
|
const abortLocalRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
// ── Source search state ───────────────────────────────────────────────────
|
|
||||||
const [searchSources, setSearchSources] = useState(false);
|
const [searchSources, setSearchSources] = useState(false);
|
||||||
const [sourceResults, setSourceResults] = useState<Manga[]>([]);
|
const [sourceResults, setSourceResults] = useState<Manga[]>([]);
|
||||||
const [loadingSourceSearch, setLoadingSourceSearch] = useState(false);
|
const [loadingSourceSearch, setLoadingSourceSearch] = useState(false);
|
||||||
const [loadingMoreSource, setLoadingMoreSource] = useState(false);
|
const [loadingMoreSource, setLoadingMoreSource] = useState(false);
|
||||||
// Per-source next-page tracker; -1 = exhausted
|
|
||||||
const srcNextPageRef = useRef<Map<string, number>>(new Map());
|
const srcNextPageRef = useRef<Map<string, number>>(new Map());
|
||||||
const abortSourceRef = useRef<AbortController | null>(null);
|
const abortSourceRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
@@ -508,7 +510,6 @@ function TagTab({
|
|||||||
abortSourceRef.current?.abort();
|
abortSourceRef.current?.abort();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// ── Local DB query ────────────────────────────────────────────────────────
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (activeTags.length === 0) {
|
if (activeTags.length === 0) {
|
||||||
setLocalResults([]); setTotalCount(0); setLocalHasNext(false); setLocalOffset(0);
|
setLocalResults([]); setTotalCount(0); setLocalHasNext(false); setLocalOffset(0);
|
||||||
@@ -535,12 +536,9 @@ function TagTab({
|
|||||||
}).finally(() => {
|
}).finally(() => {
|
||||||
if (!ctrl.signal.aborted) setLoadingLocal(false);
|
if (!ctrl.signal.aborted) setLoadingLocal(false);
|
||||||
});
|
});
|
||||||
}, [activeTags, tagMode]); // eslint-disable-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [activeTags, tagMode]);
|
||||||
|
|
||||||
// ── 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(() => {
|
useEffect(() => {
|
||||||
if (!searchSources || activeTags.length === 0 || loadingSources) return;
|
if (!searchSources || activeTags.length === 0 || loadingSources) return;
|
||||||
|
|
||||||
@@ -553,7 +551,7 @@ function TagTab({
|
|||||||
setLoadingSourceSearch(true);
|
setLoadingSourceSearch(true);
|
||||||
|
|
||||||
const sources = dedupeSources(allSources, preferredLang).slice(0, MAX_TAG_SOURCES);
|
const sources = dedupeSources(allSources, preferredLang).slice(0, MAX_TAG_SOURCES);
|
||||||
const primaryTag = activeTags[0]; // sources only support a single query string
|
const primaryTag = activeTags[0];
|
||||||
|
|
||||||
for (const src of sources) srcNextPageRef.current.set(src.id, -1);
|
for (const src of sources) srcNextPageRef.current.set(src.id, -1);
|
||||||
|
|
||||||
@@ -582,23 +580,22 @@ function TagTab({
|
|||||||
ps.add(1);
|
ps.add(1);
|
||||||
srcNextPageRef.current.set(src.id, result.hasNextPage ? 2 : -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
|
const matching = activeTags.length > 1
|
||||||
? result.mangas.filter((m) => matchesAllTags(m, activeTags))
|
? result.mangas.filter((m) => matchesAllTags(m, activeTags))
|
||||||
: result.mangas;
|
: result.mangas;
|
||||||
|
|
||||||
if (matching.length > 0) {
|
if (matching.length > 0) {
|
||||||
setSourceResults((prev) => dedupeMangaById([...prev, ...matching]));
|
setSourceResults((prev) => dedupeMangaById([...prev, ...matching]));
|
||||||
setLoadingSourceSearch(false); // reveal as results arrive
|
setLoadingSourceSearch(false);
|
||||||
}
|
}
|
||||||
}, ctrl.signal).finally(() => {
|
}, ctrl.signal).finally(() => {
|
||||||
if (!ctrl.signal.aborted) setLoadingSourceSearch(false);
|
if (!ctrl.signal.aborted) setLoadingSourceSearch(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
return () => { ctrl.abort(); };
|
return () => { ctrl.abort(); };
|
||||||
}, [searchSources, activeTags, allSources, loadingSources]); // eslint-disable-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [searchSources, activeTags, allSources, loadingSources]);
|
||||||
|
|
||||||
// ── Load more: local ──────────────────────────────────────────────────────
|
|
||||||
async function loadMoreLocal() {
|
async function loadMoreLocal() {
|
||||||
if (loadingMoreLocal || !localHasNext) return;
|
if (loadingMoreLocal || !localHasNext) return;
|
||||||
setLoadingMoreLocal(true);
|
setLoadingMoreLocal(true);
|
||||||
@@ -622,7 +619,6 @@ function TagTab({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Load more: sources ────────────────────────────────────────────────────
|
|
||||||
const sourceHasMore = searchSources &&
|
const sourceHasMore = searchSources &&
|
||||||
[...srcNextPageRef.current.values()].some((p) => p > 0);
|
[...srcNextPageRef.current.values()].some((p) => p > 0);
|
||||||
|
|
||||||
@@ -677,13 +673,11 @@ function TagTab({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Tag toggle ────────────────────────────────────────────────────────────
|
|
||||||
function toggleTag(tag: string) {
|
function toggleTag(tag: string) {
|
||||||
// Clear source sessions when tags change — new query = new page buckets
|
|
||||||
srcNextPageRef.current = new Map();
|
srcNextPageRef.current = new Map();
|
||||||
setSourceResults([]);
|
setSourceResults([]);
|
||||||
setActiveTags((prev) =>
|
setActiveTags((prev) =>
|
||||||
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
|
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -693,18 +687,14 @@ function TagTab({
|
|||||||
}, [tagFilter]);
|
}, [tagFilter]);
|
||||||
|
|
||||||
const hasActiveTags = activeTags.length > 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 localIds = useMemo(() => new Set(localResults.map((m) => m.id)), [localResults]);
|
||||||
const mergedResults = searchSources
|
const mergedResults = searchSources
|
||||||
? [...localResults, ...sourceResults.filter((m) => !localIds.has(m.id))]
|
? [...localResults, ...sourceResults.filter((m) => !localIds.has(m.id))]
|
||||||
: localResults;
|
: localResults;
|
||||||
|
|
||||||
const totalVisible = localResults.length + (searchSources ? sourceResults.length : 0);
|
const totalVisible = localResults.length + (searchSources ? sourceResults.length : 0);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={s.splitRoot}>
|
<div className={s.splitRoot}>
|
||||||
{/* ── Sidebar ────────────────────────────────────────────────────── */}
|
|
||||||
<div className={s.splitSidebar}>
|
<div className={s.splitSidebar}>
|
||||||
<div className={s.splitSearchWrap}>
|
<div className={s.splitSearchWrap}>
|
||||||
<MagnifyingGlass size={12} className={s.splitSearchIcon} weight="light" />
|
<MagnifyingGlass size={12} className={s.splitSearchIcon} weight="light" />
|
||||||
@@ -714,6 +704,9 @@ function TagTab({
|
|||||||
value={tagFilter}
|
value={tagFilter}
|
||||||
onChange={(e) => setTagFilter(e.target.value)}
|
onChange={(e) => setTagFilter(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
{tagFilter && (
|
||||||
|
<button className={s.splitSearchClear} onClick={() => setTagFilter("")} title="Clear">×</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className={s.splitList}>
|
<div className={s.splitList}>
|
||||||
{filteredGenres.map((tag) => (
|
{filteredGenres.map((tag) => (
|
||||||
@@ -730,7 +723,6 @@ function TagTab({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Content ────────────────────────────────────────────────────── */}
|
|
||||||
<div className={s.splitContent}>
|
<div className={s.splitContent}>
|
||||||
{!hasActiveTags ? (
|
{!hasActiveTags ? (
|
||||||
<div className={s.empty}>
|
<div className={s.empty}>
|
||||||
@@ -740,7 +732,6 @@ function TagTab({
|
|||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{/* Active tag pills + controls */}
|
|
||||||
<div className={s.tagActiveBar}>
|
<div className={s.tagActiveBar}>
|
||||||
<div className={s.tagPillRow}>
|
<div className={s.tagPillRow}>
|
||||||
{activeTags.map((tag) => (
|
{activeTags.map((tag) => (
|
||||||
@@ -756,16 +747,15 @@ function TagTab({
|
|||||||
<button
|
<button
|
||||||
className={[s.tagModeBtn, tagMode === "AND" ? s.tagModeBtnActive : ""].join(" ")}
|
className={[s.tagModeBtn, tagMode === "AND" ? s.tagModeBtnActive : ""].join(" ")}
|
||||||
onClick={() => setTagMode("AND")}
|
onClick={() => setTagMode("AND")}
|
||||||
title="Show manga matching ALL selected tags"
|
title="Match ALL tags"
|
||||||
>AND</button>
|
>AND</button>
|
||||||
<button
|
<button
|
||||||
className={[s.tagModeBtn, tagMode === "OR" ? s.tagModeBtnActive : ""].join(" ")}
|
className={[s.tagModeBtn, tagMode === "OR" ? s.tagModeBtnActive : ""].join(" ")}
|
||||||
onClick={() => setTagMode("OR")}
|
onClick={() => setTagMode("OR")}
|
||||||
title="Show manga matching ANY selected tag"
|
title="Match ANY tag"
|
||||||
>OR</button>
|
>OR</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{/* "Search sources" toggle — fetches from external sources */}
|
|
||||||
<button
|
<button
|
||||||
className={[s.tagModeBtn, searchSources ? s.tagModeBtnActive : ""].join(" ")}
|
className={[s.tagModeBtn, searchSources ? s.tagModeBtnActive : ""].join(" ")}
|
||||||
onClick={() => setSearchSources((v) => !v)}
|
onClick={() => setSearchSources((v) => !v)}
|
||||||
@@ -779,7 +769,6 @@ function TagTab({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Result header */}
|
|
||||||
<div className={s.splitContentHeader}>
|
<div className={s.splitContentHeader}>
|
||||||
<span className={s.splitContentTitle}>
|
<span className={s.splitContentTitle}>
|
||||||
{activeTags.length === 1 ? activeTags[0] : `${activeTags.length} tags (${tagMode})`}
|
{activeTags.length === 1 ? activeTags[0] : `${activeTags.length} tags (${tagMode})`}
|
||||||
@@ -792,13 +781,11 @@ function TagTab({
|
|||||||
{(loadingLocal || loadingSourceSearch)
|
{(loadingLocal || loadingSourceSearch)
|
||||||
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||||
: <span className={s.splitResultCount}>
|
: <span className={s.splitResultCount}>
|
||||||
{totalVisible}
|
{totalVisible}{localHasNext || sourceHasMore ? "+" : ""} of {totalCount + sourceResults.length} results
|
||||||
{localHasNext || sourceHasMore ? "+" : ""} of {totalCount + sourceResults.length} results
|
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Results grid */}
|
|
||||||
{loadingLocal ? (
|
{loadingLocal ? (
|
||||||
<GridSkeleton count={48} />
|
<GridSkeleton count={48} />
|
||||||
) : mergedResults.length > 0 ? (
|
) : mergedResults.length > 0 ? (
|
||||||
@@ -807,15 +794,13 @@ function TagTab({
|
|||||||
<MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />
|
<MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Inline skeletons while source results are still streaming in */}
|
|
||||||
{loadingSourceSearch && Array.from({ length: 8 }).map((_, i) => (
|
{loadingSourceSearch && Array.from({ length: 8 }).map((_, i) => (
|
||||||
<div key={`sk-src-${i}`} className={s.skCard} style={{ width: "auto" }}>
|
<div key={`sk-src-${i}`} className={s.skCard}>
|
||||||
<div className={["skeleton", s.skCover].join(" ")} style={{ aspectRatio: "2/3", width: "100%" }} />
|
<div className={["skeleton", s.skCover].join(" ")} />
|
||||||
<div className={["skeleton", s.skTitle].join(" ")} />
|
<div className={["skeleton", s.skTitle].join(" ")} />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
{/* Show more buttons — one per data source */}
|
|
||||||
{(localHasNext || sourceHasMore) && (
|
{(localHasNext || sourceHasMore) && (
|
||||||
<div className={s.showMoreCell}>
|
<div className={s.showMoreCell}>
|
||||||
{localHasNext && (
|
{localHasNext && (
|
||||||
@@ -853,7 +838,6 @@ function TagTab({
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ── Source tab ────────────────────────────────────────────────────────────────
|
// ── Source tab ────────────────────────────────────────────────────────────────
|
||||||
// Unchanged from v1.
|
|
||||||
|
|
||||||
function SourceTab({
|
function SourceTab({
|
||||||
allSources, loadingSources, availableLangs, hasMultipleLangs, onMangaClick,
|
allSources, loadingSources, availableLangs, hasMultipleLangs, onMangaClick,
|
||||||
@@ -870,29 +854,33 @@ function SourceTab({
|
|||||||
const [loadingBrowse, setLoadingBrowse] = useState(false);
|
const [loadingBrowse, setLoadingBrowse] = useState(false);
|
||||||
const [browseQuery, setBrowseQuery] = useState("");
|
const [browseQuery, setBrowseQuery] = useState("");
|
||||||
const [submitted, setSubmitted] = useState("");
|
const [submitted, setSubmitted] = useState("");
|
||||||
|
const [hasNextPage, setHasNextPage] = useState(false);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const abortRef = useRef<AbortController | null>(null);
|
const abortRef = useRef<AbortController | null>(null);
|
||||||
|
|
||||||
useEffect(() => () => { abortRef.current?.abort(); }, []);
|
useEffect(() => () => { abortRef.current?.abort(); }, []);
|
||||||
|
|
||||||
const visibleSources = useMemo(() =>
|
const visibleSources = useMemo(
|
||||||
selectedLang === "all" ? allSources : allSources.filter((s) => s.lang === selectedLang),
|
() => selectedLang === "all" ? allSources : allSources.filter((src) => src.lang === selectedLang),
|
||||||
[allSources, selectedLang]
|
[allSources, selectedLang],
|
||||||
);
|
);
|
||||||
|
|
||||||
async function fetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string) {
|
async function fetchBrowse(src: Source, type: "POPULAR" | "SEARCH", q?: string, page = 1) {
|
||||||
abortRef.current?.abort();
|
abortRef.current?.abort();
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
abortRef.current = ctrl;
|
abortRef.current = ctrl;
|
||||||
setLoadingBrowse(true);
|
if (page === 1) { setLoadingBrowse(true); setBrowseResults([]); }
|
||||||
setBrowseResults([]);
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
|
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
|
||||||
FETCH_SOURCE_MANGA,
|
FETCH_SOURCE_MANGA,
|
||||||
{ source: src.id, type, page: 1, query: q ?? null },
|
{ source: src.id, type, page, query: q ?? null },
|
||||||
ctrl.signal,
|
ctrl.signal,
|
||||||
);
|
);
|
||||||
if (!ctrl.signal.aborted) setBrowseResults(d.fetchSourceManga.mangas);
|
if (ctrl.signal.aborted) return;
|
||||||
|
setBrowseResults((prev) => page === 1 ? d.fetchSourceManga.mangas : [...prev, ...d.fetchSourceManga.mangas]);
|
||||||
|
setHasNextPage(d.fetchSourceManga.hasNextPage);
|
||||||
|
setCurrentPage(page);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
if (e?.name !== "AbortError") console.error(e);
|
if (e?.name !== "AbortError") console.error(e);
|
||||||
} finally {
|
} finally {
|
||||||
@@ -970,9 +958,13 @@ function SourceTab({
|
|||||||
<img src={thumbUrl(activeSource.iconUrl)} alt="" className={s.splitSourceIcon}
|
<img src={thumbUrl(activeSource.iconUrl)} alt="" className={s.splitSourceIcon}
|
||||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
|
||||||
<span className={s.splitContentTitle}>{activeSource.displayName}</span>
|
<span className={s.splitContentTitle}>{activeSource.displayName}</span>
|
||||||
{loadingBrowse && <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />}
|
{loadingBrowse
|
||||||
{!loadingBrowse && browseResults.length > 0 && <span className={s.splitResultCount}>{browseResults.length} results</span>}
|
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
|
||||||
|
: browseResults.length > 0 && <span className={s.splitResultCount}>{browseResults.length} results</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={s.sourceBrowseBar}>
|
<div className={s.sourceBrowseBar}>
|
||||||
<div className={s.searchBar} style={{ flex: 1 }}>
|
<div className={s.searchBar} style={{ flex: 1 }}>
|
||||||
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
|
||||||
@@ -984,24 +976,40 @@ function SourceTab({
|
|||||||
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
|
||||||
/>
|
/>
|
||||||
{submitted && (
|
{submitted && (
|
||||||
<button className={s.clearSearchBtn} onClick={clearSearch} title="Clear search">×</button>
|
<button className={s.clearBtn} onClick={clearSearch} title="Clear search">×</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<button className={s.searchBtn} onClick={handleSearch} disabled={!browseQuery.trim() || loadingBrowse}>
|
<button className={s.searchBtn} onClick={handleSearch} disabled={!browseQuery.trim() || loadingBrowse}>
|
||||||
Search
|
Search
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{loadingBrowse ? <GridSkeleton /> : browseResults.length > 0 ? (
|
{loadingBrowse && browseResults.length === 0 ? (
|
||||||
|
<GridSkeleton />
|
||||||
|
) : browseResults.length > 0 ? (
|
||||||
|
<>
|
||||||
<div className={s.tagGrid}>
|
<div className={s.tagGrid}>
|
||||||
{browseResults.map((m) => <MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />)}
|
{browseResults.map((m) => <MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />)}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
{hasNextPage && (
|
||||||
|
<div className={s.loadMoreRow}>
|
||||||
|
<button
|
||||||
|
className={s.showMoreBtn}
|
||||||
|
onClick={() => activeSource && fetchBrowse(activeSource, submitted ? "SEARCH" : "POPULAR", submitted || undefined, currentPage + 1)}
|
||||||
|
disabled={loadingBrowse}
|
||||||
|
>
|
||||||
|
{loadingBrowse
|
||||||
|
? <><CircleNotch size={13} weight="light" className="anim-spin" /> Loading…</>
|
||||||
|
: "Load more"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : !loadingBrowse ? (
|
||||||
<div className={s.empty}>
|
<div className={s.empty}>
|
||||||
<p className={s.emptyText}>{submitted ? `No results for "${submitted}"` : "No results"}</p>
|
<p className={s.emptyText}>{submitted ? `No results for "${submitted}"` : "No results"}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
) : null}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user