[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
+146 -138
View File
@@ -27,7 +27,7 @@ interface SourceResult {
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 MAX_TAG_SOURCES = 10;
const COMMON_GENRES = [
"Action","Adventure","Comedy","Drama","Fantasy","Romance",
@@ -37,7 +37,7 @@ const COMMON_GENRES = [
"Magic","Music","Cooking","Medical","Military","Harem","Ecchi",
];
// ── Shared helpers ────────────────────────────────────────────────────────────
// ── Helpers ───────────────────────────────────────────────────────────────────
async function runConcurrent<T>(
items: T[],
@@ -55,7 +55,6 @@ async function runConcurrent<T>(
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 {
const genres = (m.genre ?? []).map((g) => g.toLowerCase());
return tags.every((t) => genres.includes(t.toLowerCase()));
@@ -68,8 +67,10 @@ const CoverImg = memo(function CoverImg({
}: { src: string; alt: string; className?: string }) {
const [loaded, setLoaded] = useState(false);
return (
<img src={src} alt={alt} className={className}
loading="lazy" decoding="async" onLoad={() => setLoaded(true)}
<img
src={src} alt={alt} className={className}
loading="lazy" decoding="async"
onLoad={() => setLoaded(true)}
style={{ opacity: loaded ? 1 : 0, transition: loaded ? "opacity 0.15s ease" : "none" }}
/>
);
@@ -91,8 +92,8 @@ function GridSkeleton({ count = 18 }: { count?: number }) {
return (
<div className={s.tagGrid}>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className={s.skCard} style={{ width: "auto" }}>
<div className={["skeleton", s.skCover].join(" ")} style={{ aspectRatio: "2/3", width: "100%" }} />
<div key={i} className={s.skCard}>
<div className={["skeleton", s.skCover].join(" ")} />
<div className={["skeleton", s.skTitle].join(" ")} />
</div>
))}
@@ -125,10 +126,8 @@ export default function Search() {
const [allSources, setAllSources] = useState<Source[]>([]);
const [loadingSources, setLoadingSources] = useState(false);
const pendingPrefill = useRef<string>("");
// Consume searchPrefill → route to keyword tab
useEffect(() => {
if (!searchPrefill) return;
pendingPrefill.current = searchPrefill;
@@ -136,13 +135,13 @@ export default function Search() {
setSearchPrefill("");
}, [searchPrefill, setSearchPrefill]);
// Load sources once, shared across all tabs
useEffect(() => {
setLoadingSources(true);
cache.get(CACHE_KEYS.SOURCES, () =>
gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => d.sources.nodes.filter((src) => src.id !== "0")),
Infinity, // source list is stable within a session
cache.get(
CACHE_KEYS.SOURCES,
() => gql<{ sources: { nodes: Source[] } }>(GET_SOURCES)
.then((d) => d.sources.nodes.filter((src) => src.id !== "0")),
Infinity,
)
.then(setAllSources)
.catch(console.error)
@@ -150,8 +149,10 @@ export default function Search() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const availableLangs = useMemo(() =>
Array.from(new Set<string>(allSources.map((s) => s.lang))).sort(), [allSources]);
const availableLangs = useMemo(
() => Array.from(new Set<string>(allSources.map((src) => src.lang))).sort(),
[allSources],
);
const hasMultipleLangs = availableLangs.length > 1;
return (
@@ -159,13 +160,22 @@ export default function Search() {
<div className={s.header}>
<h1 className={s.heading}>Search</h1>
<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
</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
</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
</button>
</div>
@@ -204,19 +214,18 @@ 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("");
@@ -229,19 +238,22 @@ function KeywordTab({
const inputRef = useRef<HTMLInputElement>(null);
const allSourcesRef = useRef<Source[]>([]);
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(() => { includeNsfwRef.current = includeNsfw; }, [includeNsfw]);
// Set default lang selection once sources load
useEffect(() => {
if (!allSources.length) return;
const available = new Set(allSources.map((s) => s.lang));
setSelectedLangs(available.has(preferredLang)
? new Set([preferredLang])
: new Set(availableLangs.slice(0, 1))
const available = new Set(allSources.map((src) => src.lang));
setSelectedLangs(
available.has(preferredLang)
? new Set([preferredLang])
: 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
useEffect(() => {
@@ -250,7 +262,7 @@ function KeywordTab({
const q = pendingPrefill.current;
pendingPrefill.current = "";
setQuery(q);
doSearch(q);
Promise.resolve().then(() => doSearch(q));
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [loadingSources]);
@@ -259,11 +271,11 @@ function KeywordTab({
const getVisibleSources = useCallback((): Source[] => {
let filtered = allSourcesRef.current;
if (selectedLangsRef.current.size > 0)
filtered = filtered.filter((s) => selectedLangsRef.current.has(s.lang));
if (!includeNsfw)
filtered = filtered.filter((s) => !s.isNsfw);
filtered = filtered.filter((src) => selectedLangsRef.current.has(src.lang));
if (!includeNsfwRef.current)
filtered = filtered.filter((src) => !src.isNsfw);
return filtered;
}, [includeNsfw]);
}, []);
const doSearch = useCallback(async (q: string) => {
const trimmed = q.trim();
@@ -288,12 +300,12 @@ function KeywordTab({
);
if (ctrl.signal.aborted) return;
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) {
if (ctrl.signal.aborted || e?.name === "AbortError") return;
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);
@@ -310,7 +322,7 @@ function KeywordTab({
const visibleCount = getVisibleSources().length;
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 (
<>
@@ -325,6 +337,13 @@ function KeywordTab({
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && doSearch(query)}
/>
{query && (
<button
className={s.clearBtn}
onClick={() => { setQuery(""); inputRef.current?.focus(); }}
title="Clear"
>×</button>
)}
{hasMultipleLangs && (
<button
className={[s.advancedBtn, showAdvanced ? s.advancedBtnActive : ""].join(" ")}
@@ -356,7 +375,8 @@ function KeywordTab({
</div>
<div className={s.langGrid}>
{availableLangs.map((lang) => (
<button key={lang}
<button
key={lang}
className={[s.langChip, selectedLangs.has(lang) ? s.langChipActive : ""].join(" ")}
onClick={() => toggleLang(lang)}
>
@@ -377,7 +397,7 @@ function KeywordTab({
)}
</div>
{!submitted && (
{!submitted ? (
<div className={s.empty}>
<MagnifyingGlass size={36} weight="light" className={s.emptyIcon} />
<p className={s.emptyText}>Search across sources</p>
@@ -392,9 +412,7 @@ function KeywordTab({
</button>
)}
</div>
)}
{submitted && (
) : (
<div className={s.results}>
{results.length === 0 && (
<div className={s.empty}>
@@ -410,8 +428,9 @@ function KeywordTab({
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span className={s.sourceName}>{source.displayName}</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 && mangas.length > 0 && <span className={s.resultCount}>{mangas.length} results</span>}
{loading
? <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>
{error ? (
<p className={s.sourceError}>{error}</p>
@@ -429,6 +448,7 @@ function KeywordTab({
{allDone && !hasResults && (
<div className={s.empty}>
<p className={s.emptyText}>No results for "{submitted}"</p>
<p className={s.emptyHint}>Try a different spelling or fewer words</p>
</div>
)}
</div>
@@ -438,18 +458,6 @@ 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 MANGAS_BY_GENRE = `
query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) {
@@ -471,10 +479,7 @@ function buildGenreFilter(tags: string[], mode: TagMode): Record<string, unknown
}
function TagTab({
allSources,
loadingSources,
preferredLang,
onMangaClick,
allSources, loadingSources, preferredLang, onMangaClick,
}: {
allSources: Source[];
loadingSources: boolean;
@@ -485,7 +490,6 @@ function TagTab({
const [tagMode, setTagMode] = useState<TagMode>("AND");
const [tagFilter, setTagFilter] = useState("");
// ── Local DB state ────────────────────────────────────────────────────────
const [localResults, setLocalResults] = useState<Manga[]>([]);
const [totalCount, setTotalCount] = useState(0);
const [loadingLocal, setLoadingLocal] = useState(false);
@@ -494,12 +498,10 @@ function TagTab({
const [localHasNext, setLocalHasNext] = useState(false);
const abortLocalRef = useRef<AbortController | null>(null);
// ── Source search state ───────────────────────────────────────────────────
const [searchSources, setSearchSources] = useState(false);
const [sourceResults, setSourceResults] = useState<Manga[]>([]);
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 [loadingMoreSource, setLoadingMoreSource] = useState(false);
const srcNextPageRef = useRef<Map<string, number>>(new Map());
const abortSourceRef = useRef<AbortController | null>(null);
@@ -508,7 +510,6 @@ function TagTab({
abortSourceRef.current?.abort();
}, []);
// ── Local DB query ────────────────────────────────────────────────────────
useEffect(() => {
if (activeTags.length === 0) {
setLocalResults([]); setTotalCount(0); setLocalHasNext(false); setLocalOffset(0);
@@ -535,12 +536,9 @@ function TagTab({
}).finally(() => {
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(() => {
if (!searchSources || activeTags.length === 0 || loadingSources) return;
@@ -553,7 +551,7 @@ function TagTab({
setLoadingSourceSearch(true);
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);
@@ -582,23 +580,22 @@ function TagTab({
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
setLoadingSourceSearch(false);
}
}, ctrl.signal).finally(() => {
if (!ctrl.signal.aborted) setLoadingSourceSearch(false);
});
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() {
if (loadingMoreLocal || !localHasNext) return;
setLoadingMoreLocal(true);
@@ -622,7 +619,6 @@ function TagTab({
}
}
// ── Load more: sources ────────────────────────────────────────────────────
const sourceHasMore = searchSources &&
[...srcNextPageRef.current.values()].some((p) => p > 0);
@@ -677,13 +673,11 @@ function TagTab({
}
}
// ── 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]
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag],
);
}
@@ -693,18 +687,14 @@ function TagTab({
}, [tagFilter]);
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);
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" />
@@ -714,6 +704,9 @@ function TagTab({
value={tagFilter}
onChange={(e) => setTagFilter(e.target.value)}
/>
{tagFilter && (
<button className={s.splitSearchClear} onClick={() => setTagFilter("")} title="Clear">×</button>
)}
</div>
<div className={s.splitList}>
{filteredGenres.map((tag) => (
@@ -730,7 +723,6 @@ function TagTab({
</div>
</div>
{/* ── Content ────────────────────────────────────────────────────── */}
<div className={s.splitContent}>
{!hasActiveTags ? (
<div className={s.empty}>
@@ -740,7 +732,6 @@ function TagTab({
</div>
) : (
<>
{/* Active tag pills + controls */}
<div className={s.tagActiveBar}>
<div className={s.tagPillRow}>
{activeTags.map((tag) => (
@@ -756,16 +747,15 @@ function TagTab({
<button
className={[s.tagModeBtn, tagMode === "AND" ? s.tagModeBtnActive : ""].join(" ")}
onClick={() => setTagMode("AND")}
title="Show manga matching ALL selected tags"
title="Match ALL tags"
>AND</button>
<button
className={[s.tagModeBtn, tagMode === "OR" ? s.tagModeBtnActive : ""].join(" ")}
onClick={() => setTagMode("OR")}
title="Show manga matching ANY selected tag"
title="Match ANY tag"
>OR</button>
</div>
)}
{/* "Search sources" toggle — fetches from external sources */}
<button
className={[s.tagModeBtn, searchSources ? s.tagModeBtnActive : ""].join(" ")}
onClick={() => setSearchSources((v) => !v)}
@@ -779,7 +769,6 @@ function TagTab({
</div>
</div>
{/* Result header */}
<div className={s.splitContentHeader}>
<span className={s.splitContentTitle}>
{activeTags.length === 1 ? activeTags[0] : `${activeTags.length} tags (${tagMode})`}
@@ -792,13 +781,11 @@ function TagTab({
{(loadingLocal || loadingSourceSearch)
? <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />
: <span className={s.splitResultCount}>
{totalVisible}
{localHasNext || sourceHasMore ? "+" : ""} of {totalCount + sourceResults.length} results
{totalVisible}{localHasNext || sourceHasMore ? "+" : ""} of {totalCount + sourceResults.length} results
</span>
}
</div>
{/* Results grid */}
{loadingLocal ? (
<GridSkeleton count={48} />
) : mergedResults.length > 0 ? (
@@ -807,15 +794,13 @@ function TagTab({
<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) => (
<div key={`sk-src-${i}`} className={s.skCard} style={{ width: "auto" }}>
<div className={["skeleton", s.skCover].join(" ")} style={{ aspectRatio: "2/3", width: "100%" }} />
<div key={`sk-src-${i}`} className={s.skCard}>
<div className={["skeleton", s.skCover].join(" ")} />
<div className={["skeleton", s.skTitle].join(" ")} />
</div>
))}
{/* Show more buttons — one per data source */}
{(localHasNext || sourceHasMore) && (
<div className={s.showMoreCell}>
{localHasNext && (
@@ -853,7 +838,6 @@ function TagTab({
}
// ── Source tab ────────────────────────────────────────────────────────────────
// Unchanged from v1.
function SourceTab({
allSources, loadingSources, availableLangs, hasMultipleLangs, onMangaClick,
@@ -870,29 +854,33 @@ function SourceTab({
const [loadingBrowse, setLoadingBrowse] = useState(false);
const [browseQuery, setBrowseQuery] = useState("");
const [submitted, setSubmitted] = useState("");
const [hasNextPage, setHasNextPage] = useState(false);
const [currentPage, setCurrentPage] = useState(1);
const abortRef = useRef<AbortController | null>(null);
useEffect(() => () => { abortRef.current?.abort(); }, []);
const visibleSources = useMemo(() =>
selectedLang === "all" ? allSources : allSources.filter((s) => s.lang === selectedLang),
[allSources, selectedLang]
const visibleSources = useMemo(
() => selectedLang === "all" ? allSources : allSources.filter((src) => src.lang === 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();
const ctrl = new AbortController();
abortRef.current = ctrl;
setLoadingBrowse(true);
setBrowseResults([]);
if (page === 1) { setLoadingBrowse(true); setBrowseResults([]); }
try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA,
{ source: src.id, type, page: 1, query: q ?? null },
{ source: src.id, type, page, query: q ?? null },
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) {
if (e?.name !== "AbortError") console.error(e);
} finally {
@@ -970,38 +958,58 @@ function SourceTab({
<img src={thumbUrl(activeSource.iconUrl)} alt="" className={s.splitSourceIcon}
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }} />
<span className={s.splitContentTitle}>{activeSource.displayName}</span>
{loadingBrowse && <CircleNotch size={13} weight="light" className="anim-spin" style={{ color: "var(--text-faint)" }} />}
{!loadingBrowse && browseResults.length > 0 && <span className={s.splitResultCount}>{browseResults.length} results</span>}
</div>
<div className={s.sourceBrowseBar}>
<div className={s.searchBar} style={{ flex: 1 }}>
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
<input
className={s.searchInput}
placeholder={`Search ${activeSource.displayName}`}
value={browseQuery}
onChange={(e) => setBrowseQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
{submitted && (
<button className={s.clearSearchBtn} onClick={clearSearch} title="Clear search">×</button>
)}
</div>
<button className={s.searchBtn} onClick={handleSearch} disabled={!browseQuery.trim() || loadingBrowse}>
Search
</button>
{loadingBrowse
? <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>
{loadingBrowse ? <GridSkeleton /> : browseResults.length > 0 ? (
<div className={s.tagGrid}>
{browseResults.map((m) => <MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />)}
<div className={s.sourceBrowseBar}>
<div className={s.searchBar} style={{ flex: 1 }}>
<MagnifyingGlass size={12} className={s.searchIcon} weight="light" />
<input
className={s.searchInput}
placeholder={`Search ${activeSource.displayName}`}
value={browseQuery}
onChange={(e) => setBrowseQuery(e.target.value)}
onKeyDown={(e) => e.key === "Enter" && handleSearch()}
/>
{submitted && (
<button className={s.clearBtn} onClick={clearSearch} title="Clear search">×</button>
)}
</div>
) : (
<button className={s.searchBtn} onClick={handleSearch} disabled={!browseQuery.trim() || loadingBrowse}>
Search
</button>
</div>
{loadingBrowse && browseResults.length === 0 ? (
<GridSkeleton />
) : browseResults.length > 0 ? (
<>
<div className={s.tagGrid}>
{browseResults.map((m) => <MangaCard key={m.id} manga={m} onClick={() => onMangaClick(m)} />)}
</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}>
<p className={s.emptyText}>{submitted ? `No results for "${submitted}"` : "No results"}</p>
</div>
)}
) : null}
</>
)}
</div>