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
+146
-138
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user