[V1] Search Overhaul + Tag Fixes

This commit is contained in:
Youwes09
2026-02-26 23:55:39 -06:00
parent 8c38330143
commit 1fa1c3a2e0
2 changed files with 896 additions and 349 deletions
File diff suppressed because it is too large Load Diff
+115 -107
View File
@@ -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>