Fix: Removed Show-More for Preferential Loading using PR Methods

This commit is contained in:
Youwes09
2026-04-15 17:30:14 -05:00
parent 68a9331b6f
commit 1aad4a1ff0
+101 -123
View File
@@ -24,34 +24,38 @@
let srch_sourcePool: Source[] = $state([]); let srch_sourcePool: Source[] = $state([]);
let srch_sourceCursor = $state(0); let srch_sourceCursor = $state(0);
let srch_hasMorePopular = $state(false); let srch_hasMorePopular = $state(false);
let srch_visibleLimit = $state(SEARCH_VISIBLE_LIMIT);
function srch_filterOut(mangas: Manga[]): Manga[] { function dedupeSourcesByLang(sources: Source[], lang: string, applyHide = false): Source[] {
return dedupeMangaByTitle(
dedupeMangaById(mangas.filter(m => !shouldHideNsfw(m, store.settings))),
store.settings.mangaLinks,
);
}
function srch_rotatedSources(sources: Source[]): Source[] {
const lang = store.settings?.preferredExtensionLang || "en";
const eligible = sources.filter(s => s.id !== "0" && !shouldHideSource(s, store.settings));
const map = new Map<string, Source>(); const map = new Map<string, Source>();
for (const s of eligible) { for (const s of sources) {
if (s.id === "0") continue;
if (applyHide && shouldHideSource(s, store.settings)) continue;
const existing = map.get(s.name); const existing = map.get(s.name);
if (!existing) { map.set(s.name, s); continue; } if (!existing) { map.set(s.name, s); continue; }
if (s.lang === lang && existing.lang !== lang) map.set(s.name, s); const existingPref = existing.lang === lang;
const newPref = s.lang === lang;
if (newPref && !existingPref) map.set(s.name, s);
else if (!existingPref && !newPref && s.lang < existing.lang) map.set(s.name, s);
} }
return Array.from(map.values()); return Array.from(map.values());
} }
let srch_seenIds = new Set<number>();
let srch_seenTitles = new Set<string>();
function srch_push(incoming: Manga[]) { function srch_push(incoming: Manga[]) {
const filtered = srch_filterOut(incoming); const toAdd: Manga[] = [];
if (!filtered.length) return; for (const m of incoming) {
srch_results = dedupeMangaByTitle( if (shouldHideNsfw(m, store.settings)) continue;
dedupeMangaById([...srch_results, ...filtered]), if (srch_seenIds.has(m.id)) continue;
store.settings.mangaLinks, const norm = normalizeTitle(m.title, store.settings.mangaLinks);
).slice(0, SEARCH_LIMIT); if (srch_seenTitles.has(norm)) continue;
srch_seenIds.add(m.id);
srch_seenTitles.add(norm);
toAdd.push(m);
}
if (!toAdd.length) return;
srch_results = [...srch_results, ...toAdd].slice(0, SEARCH_LIMIT);
} }
async function srch_fanOut(signal: AbortSignal) { async function srch_fanOut(signal: AbortSignal) {
@@ -61,7 +65,7 @@
return; return;
} }
await Promise.all(batch.map(async (src) => { await runConcurrent(batch, async (src) => {
for (let page = 1; page <= SEARCH_PAGES; page++) { for (let page = 1; page <= SEARCH_PAGES; page++) {
if (signal.aborted) return; if (signal.aborted) return;
const key = dKey(src.id, page); const key = dKey(src.id, page);
@@ -81,7 +85,7 @@
} }
srch_push(mangas); srch_push(mangas);
} }
})); }, signal);
srch_sourceCursor += batch.length; srch_sourceCursor += batch.length;
srch_hasMorePopular = srch_sourceCursor < srch_sourcePool.length; srch_hasMorePopular = srch_sourceCursor < srch_sourcePool.length;
@@ -92,28 +96,21 @@
srch_abortCtrl?.abort(); srch_abortCtrl?.abort();
const ctrl = new AbortController(); const ctrl = new AbortController();
srch_abortCtrl = ctrl; srch_abortCtrl = ctrl;
srch_sourcePool = srch_rotatedSources(sources); srch_seenIds.clear();
srch_seenTitles.clear();
srch_sourcePool = dedupeSourcesByLang(sources, store.settings?.preferredExtensionLang || "en", true);
srch_sourceCursor = 0; srch_sourceCursor = 0;
srch_hasMorePopular = false; srch_hasMorePopular = false;
srch_moreLoading = false; srch_moreLoading = false;
srch_visibleLimit = SEARCH_VISIBLE_LIMIT;
srch_loading = true; srch_loading = true;
srch_fanOut(ctrl.signal) (async () => {
.catch(() => {}) try {
.finally(() => { if (!ctrl.signal.aborted) srch_loading = false; }); while (!ctrl.signal.aborted && srch_sourceCursor < srch_sourcePool.length) {
await srch_fanOut(ctrl.signal);
} }
} catch {}
function srch_loadMorePopular() { if (!ctrl.signal.aborted) srch_loading = false;
if (srch_moreLoading) return; })();
srch_visibleLimit += SEARCH_VISIBLE_LIMIT;
if (!srch_hasMorePopular) return;
srch_abortCtrl?.abort();
const ctrl = new AbortController();
srch_abortCtrl = ctrl;
srch_moreLoading = true;
srch_fanOut(ctrl.signal)
.catch(() => {})
.finally(() => { if (!ctrl.signal.aborted) srch_moreLoading = false; });
} }
type SearchTab = "keyword" | "tag" | "source"; type SearchTab = "keyword" | "tag" | "source";
@@ -133,6 +130,7 @@
inLibrary: boolean; inLibrary: boolean;
status: string; status: string;
genre: string[]; genre: string[];
lowerGenres: string[];
sourceId: string; sourceId: string;
genreEnriched: boolean; genreEnriched: boolean;
} }
@@ -168,26 +166,6 @@
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker)); await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker));
} }
function dedupSourcesByLang(sources: Source[], preferredLang: string): Source[] {
const map = new Map<string, Source>();
for (const s of sources) {
if (s.id === "0") continue;
const key = s.name;
const existing = map.get(key);
if (!existing) {
map.set(key, s);
continue;
}
const existingIsPreferred = existing.lang === preferredLang;
const newIsPreferred = s.lang === preferredLang;
if (newIsPreferred && !existingIsPreferred) {
map.set(key, s);
} else if (!existingIsPreferred && !newIsPreferred && s.lang < existing.lang) {
map.set(key, s);
}
}
return Array.from(map.values());
}
const sourceCache = new Map<number, CachedManga>(); const sourceCache = new Map<number, CachedManga>();
let sourceCacheReady = $state(false); let sourceCacheReady = $state(false);
@@ -205,13 +183,21 @@
await runConcurrent(tasks, async ({ src, page }) => { await runConcurrent(tasks, async ({ src, page }) => {
if (signal.aborted) return; if (signal.aborted) return;
try { try {
const cacheKey = `${src.id}|POPULAR|All:p${page}`;
let mangas: Manga[];
if (store.searchCache?.has(cacheKey)) {
mangas = store.searchCache.get(cacheKey)!;
} else {
const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>(
FETCH_SOURCE_MANGA, FETCH_SOURCE_MANGA,
{ source: src.id, type: "POPULAR", page }, { source: src.id, type: "POPULAR", page },
signal, signal,
); );
if (signal.aborted) return; if (signal.aborted) return;
for (const m of d.fetchSourceManga.mangas) { mangas = d.fetchSourceManga.mangas;
store.searchCache?.set(cacheKey, mangas);
}
for (const m of mangas) {
if (!sourceCache.has(m.id)) { if (!sourceCache.has(m.id)) {
sourceCache.set(m.id, { sourceCache.set(m.id, {
id: m.id, id: m.id,
@@ -220,6 +206,7 @@
inLibrary: m.inLibrary, inLibrary: m.inLibrary,
status: (m as any).status ?? "UNKNOWN", status: (m as any).status ?? "UNKNOWN",
genre: (m as any).genre ?? [], genre: (m as any).genre ?? [],
lowerGenres: ((m as any).genre ?? []).map((g: string) => g.toLowerCase()),
sourceId: src.id, sourceId: src.id,
genreEnriched: ((m as any).genre?.length ?? 0) > 0, genreEnriched: ((m as any).genre?.length ?? 0) > 0,
}); });
@@ -308,7 +295,7 @@
let genreMatch = true; let genreMatch = true;
if (tags.length > 0) { if (tags.length > 0) {
const lowerGenres = m.genre.map((g) => g.toLowerCase()); const lowerGenres = m.lowerGenres;
if (mode === "AND") { if (mode === "AND") {
genreMatch = tags.every((t) => lowerGenres.some((g) => g.includes(t.toLowerCase()))); genreMatch = tags.every((t) => lowerGenres.some((g) => g.includes(t.toLowerCase())));
} else { } else {
@@ -356,8 +343,7 @@
sourceCacheLoading = true; sourceCacheLoading = true;
sourceCache.clear(); sourceCache.clear();
const dedupedSources = dedupSourcesByLang(allSources, preferredLang) const dedupedSources = dedupeSourcesByLang(allSources, preferredLang, true);
.filter((s) => !shouldHideSource(s, store.settings));
buildSourceCache(dedupedSources, ctrl.signal) buildSourceCache(dedupedSources, ctrl.signal)
.then(() => { .then(() => {
@@ -379,7 +365,6 @@
let kw_results: SourceResult[] = $state([]); let kw_results: SourceResult[] = $state([]);
let kw_showAdvanced = $state(false); let kw_showAdvanced = $state(false);
let kw_selectedLangs: Set<string> = $state(new Set()); let kw_selectedLangs: Set<string> = $state(new Set());
let kw_visibleLimit = $state(SEARCH_VISIBLE_LIMIT);
let kw_inputEl: HTMLInputElement | null = $state(null); let kw_inputEl: HTMLInputElement | null = $state(null);
let kw_abortCtrl: AbortController | null = null; let kw_abortCtrl: AbortController | null = null;
let kw_debounceTimer: ReturnType<typeof setTimeout> | null = null; let kw_debounceTimer: ReturnType<typeof setTimeout> | null = null;
@@ -428,37 +413,35 @@
async function kwDoSearch(q: string) { async function kwDoSearch(q: string) {
const trimmed = q.trim(); const trimmed = q.trim();
if (!trimmed) return; if (!trimmed) return;
kw_visibleLimit = SEARCH_VISIBLE_LIMIT;
const visible = kwGetVisibleSources(); const visible = kwGetVisibleSources();
if (!visible.length) return; if (!visible.length) return;
kw_abortCtrl?.abort(); kw_abortCtrl?.abort();
const ctrl = new AbortController(); const ctrl = new AbortController();
kw_abortCtrl = ctrl; kw_abortCtrl = ctrl;
kw_results = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null })); const initial: SourceResult[] = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null }));
kw_results = initial;
const indexBySrcId = new Map(visible.map((src, i) => [src.id, i]));
await runConcurrent(visible, async (src) => { await runConcurrent(visible, async (src) => {
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
const idx = indexBySrcId.get(src.id)!;
try { try {
const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>( const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>(
FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page: 1, query: trimmed }, ctrl.signal, FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page: 1, query: trimmed }, ctrl.signal,
); );
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
const mangas = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings)); const mangas = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings));
kw_results = kw_results.map((r) => const next = [...kw_results];
r.source.id === src.id ? { ...r, mangas, loading: false } : r, next[idx] = { ...next[idx], mangas, loading: false };
); kw_results = next;
} catch (e: any) { } catch (e: any) {
if (ctrl.signal.aborted || e?.name === "AbortError") return; if (ctrl.signal.aborted || e?.name === "AbortError") return;
kw_results = kw_results.map((r) => const next = [...kw_results];
r.source.id === src.id ? { ...r, loading: false, error: e.message ?? "Error" } : r, next[idx] = { ...next[idx], loading: false, error: (e as any).message ?? "Error" };
); kw_results = next;
} }
}, ctrl.signal); }, ctrl.signal);
} }
function kwLoadMore() {
kw_visibleLimit += SEARCH_VISIBLE_LIMIT;
}
function kwToggleLang(lang: string) { function kwToggleLang(lang: string) {
const next = new Set(kw_selectedLangs); const next = new Set(kw_selectedLangs);
if (next.has(lang)) { if (next.size === 1) return; next.delete(lang); } if (next.has(lang)) { if (next.size === 1) return; next.delete(lang); }
@@ -552,7 +535,11 @@
tag_sourceFanOut = []; tag_sourceFanOut = [];
tag_fanOutLoading = true; tag_fanOutLoading = true;
const srcs = srch_rotatedSources(allSources); const fanOutSeenIds = new Set<number>();
const fanOutSeenTitles = new Set<string>();
const genreLower = genre.toLowerCase();
const srcs = dedupeSourcesByLang(allSources, store.settings?.preferredExtensionLang || "en", true);
const PAGES = 2; const PAGES = 2;
await runConcurrent(srcs, async (src) => { await runConcurrent(srcs, async (src) => {
@@ -579,15 +566,22 @@
if (ctrl.signal.aborted) return; if (ctrl.signal.aborted) return;
const matching = mangas.filter(m => const matching = mangas.filter(m =>
((m as any).genre ?? []).some((g: string) => g.toLowerCase() === genre.toLowerCase()) ((m as any).genre ?? []).some((g: string) => g.toLowerCase() === genreLower)
); );
const toAdd = (matching.length ? matching : mangas).filter(m => !shouldHideNsfw(m, store.settings)); const candidates = (matching.length ? matching : mangas).filter(m => !shouldHideNsfw(m, store.settings));
const toAdd: Manga[] = [];
for (const m of candidates) {
if (fanOutSeenIds.has(m.id)) continue;
const norm = normalizeTitle(m.title, store.settings.mangaLinks);
if (fanOutSeenTitles.has(norm)) continue;
fanOutSeenIds.add(m.id);
fanOutSeenTitles.add(norm);
toAdd.push(m);
}
if (toAdd.length) { if (toAdd.length) {
tag_sourceFanOut = dedupeMangaByTitle( tag_sourceFanOut = [...tag_sourceFanOut, ...toAdd].slice(0, SEARCH_LIMIT);
dedupeMangaById([...tag_sourceFanOut, ...toAdd]),
store.settings.mangaLinks,
).slice(0, SEARCH_LIMIT);
} }
if (!hasNextPage) return; if (!hasNextPage) return;
@@ -599,17 +593,16 @@
let tag_autoSearchFired = $state(false); let tag_autoSearchFired = $state(false);
$effect(() => { $effect(() => {
// Track filter changes to reset the guard, then auto-enable source search when local results are sparse.
const _tags = tag_activeTags;
const _statuses = tag_activeStatuses;
untrack(() => { tag_autoSearchFired = false; });
if (!tag_loadingLocal && tag_hasActiveFilters && !tag_autoSearchFired && !tag_searchSources && sourceCacheReady) { if (!tag_loadingLocal && tag_hasActiveFilters && !tag_autoSearchFired && !tag_searchSources && sourceCacheReady) {
if (tag_localResults.length < 20) { if (tag_localResults.length < 20) {
untrack(() => { tag_autoSearchFired = true; tag_searchSources = true; }); untrack(() => { tag_autoSearchFired = true; tag_searchSources = true; });
} }
} }
}); });
$effect(() => {
const _ = tag_activeTags;
const __ = tag_activeStatuses;
untrack(() => { tag_autoSearchFired = false; });
});
async function tagFetchLocal(activeTags: string[], tagMode: TagMode, activeStatuses: string[]) { async function tagFetchLocal(activeTags: string[], tagMode: TagMode, activeStatuses: string[]) {
if (activeTags.length === 0 && activeStatuses.length === 0) { if (activeTags.length === 0 && activeStatuses.length === 0) {
@@ -663,6 +656,12 @@
} }
} }
$effect(() => {
if (tag_localHasNext && !tag_loadingMoreLocal && !tag_loadingLocal) {
tagLoadMoreLocal();
}
});
function tagToggleTag(tag: string) { function tagToggleTag(tag: string) {
tag_activeTags = tag_activeTags.includes(tag) tag_activeTags = tag_activeTags.includes(tag)
? tag_activeTags.filter((t) => t !== tag) ? tag_activeTags.filter((t) => t !== tag)
@@ -886,7 +885,7 @@
<span class="searchLabel">Popular right now</span> <span class="searchLabel">Popular right now</span>
</div> </div>
<div class="searchGrid"> <div class="searchGrid">
{#each srch_results.slice(0, srch_visibleLimit) as m (m.id)} {#each srch_results as m (m.id)}
<button class="srchCard" onclick={() => setPreviewManga(m)}> <button class="srchCard" onclick={() => setPreviewManga(m)}>
<div class="srchCoverWrap"> <div class="srchCoverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
@@ -900,18 +899,11 @@
</button> </button>
{/each} {/each}
{#if srch_loading} {#if srch_loading}
{#each Array(6) as _, i (i)} {#each Array(12) as _, i (i)}
<div class="skCard"><div class="skeleton skCover"></div></div> <div class="skCard"><div class="skeleton skCover"></div></div>
{/each} {/each}
{/if} {/if}
</div> </div>
{#if srch_hasMorePopular || srch_results.length > srch_visibleLimit}
<div class="loadMoreRow loadMoreSticky">
<button class="showMoreBtn" onclick={srch_loadMorePopular} disabled={srch_moreLoading}>
{srch_moreLoading ? "Loading…" : "Show more"}
</button>
</div>
{/if}
{:else} {:else}
<div class="empty"> <div class="empty">
<svg width="36" height="36" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true"> <svg width="36" height="36" viewBox="0 0 256 256" fill="currentColor" class="emptyIcon" aria-hidden="true">
@@ -933,7 +925,7 @@
<span class="searchLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""}</span> <span class="searchLabel">{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""}</span>
</div> </div>
<div class="searchGrid"> <div class="searchGrid">
{#each kw_flatResults.slice(0, kw_visibleLimit) as m (m.id)} {#each kw_flatResults as m (m.id)}
<button class="srchCard" onclick={() => setPreviewManga(m)}> <button class="srchCard" onclick={() => setPreviewManga(m)}>
<div class="srchCoverWrap"> <div class="srchCoverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" /> <Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
@@ -952,13 +944,6 @@
{/each} {/each}
{/if} {/if}
</div> </div>
{#if kw_flatResults.length > kw_visibleLimit || kw_anyLoading}
<div class="loadMoreRow loadMoreSticky">
<button class="showMoreBtn" onclick={kwLoadMore}>
Show more
</button>
</div>
{/if}
{:else if kw_anyLoading} {:else if kw_anyLoading}
<div class="searchGrid"> <div class="searchGrid">
{#each Array(12) as _, i (i)} {#each Array(12) as _, i (i)}
@@ -1105,18 +1090,10 @@
</button> </button>
{/each} {/each}
{#if tag_localHasNext}
<div class="showMoreCell">
<button class="showMoreBtn" onclick={tagLoadMoreLocal} disabled={tag_loadingMoreLocal}>
{#if tag_loadingMoreLocal} {#if tag_loadingMoreLocal}
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" aria-hidden="true"> {#each Array(12) as _, i (i)}
<path d="M232,128a104,104,0,0,1-208,0c0-41,23.81-78.36,60.66-95.27a8,8,0,0,1,6.68,14.54C60.15,61.59,40,93.27,40,128a88,88,0,0,0,176,0c0-34.73-20.15-66.41-51.34-80.73a8,8,0,0,1,6.68-14.54C208.19,49.64,232,87,232,128Z"/> <div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
</svg> Loading… {/each}
{:else}
Show more (library)
{/if}
</button>
</div>
{/if} {/if}
</div> </div>
{:else} {:else}
@@ -1319,14 +1296,15 @@
.card { display: flex; flex-direction: column; gap: var(--sp-2); cursor: pointer; flex-shrink: 0; width: 110px; text-align: left; background: none; border: none; padding: 0; } .card { display: flex; flex-direction: column; gap: var(--sp-2); cursor: pointer; flex-shrink: 0; width: 110px; text-align: left; background: none; border: none; padding: 0; }
.card:hover :global(.cover) { filter: brightness(1.06); } .card:hover :global(.cover) { filter: brightness(1.06); }
.card:hover .cardTitle { color: var(--text-primary); } .card:hover .cardTitle { color: var(--text-primary); }
.coverWrap { position: relative; width: 100%; aspect-ratio: 2 / 3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); } .coverWrap { position: relative; width: 100%; aspect-ratio: 2 / 3; border-radius: var(--radius-md); overflow: hidden; background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); contain: layout style; }
:global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); } :global(.cover) { width: 100%; height: 100%; object-fit: cover; }
.inLibBadge { position: absolute; bottom: var(--sp-1); right: var(--sp-1); background: var(--accent-dim); color: var(--accent-fg); font-family: var(--font-ui); font-size: 9px; font-weight: var(--weight-medium); letter-spacing: var(--tracking-wide); padding: 1px 5px; border-radius: var(--radius-sm); border: 1px solid var(--accent-muted); } .inLibBadge { position: absolute; top: var(--sp-1); right: var(--sp-1); background: var(--accent-dim); color: var(--accent-fg); font-family: var(--font-ui); font-size: 9px; font-weight: var(--weight-medium); letter-spacing: var(--tracking-wide); padding: 1px 5px; border-radius: var(--radius-sm); border: 1px solid var(--accent-muted); }
.cardTitle { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); } .cardTitle { font-size: var(--text-sm); color: var(--text-secondary); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; transition: color var(--t-base); }
.skCard { display: flex; flex-direction: column; gap: var(--sp-2); flex-shrink: 0; width: 110px; } .skCard { display: flex; flex-direction: column; gap: var(--sp-2); flex-shrink: 0; width: 110px; }
.tagGrid .card { width: 100%; } .tagGrid .card { width: 100%; }
.tagGrid .skCard { width: 100%; } .tagGrid .skCard { width: 100%; }
.skeleton { border-radius: var(--radius-sm); } @keyframes shimmer { from { background-position: -200% 0 } to { background-position: 200% 0 } }
.skeleton { border-radius: var(--radius-sm); background: linear-gradient(90deg, var(--bg-raised) 25%, var(--bg-overlay, color-mix(in srgb, var(--bg-raised) 80%, var(--text-primary) 6%)) 50%, var(--bg-raised) 75%); background-size: 200% 100%; animation: shimmer 1.6s ease-in-out infinite; }
.skCover { aspect-ratio: 2 / 3; width: 100%; border-radius: var(--radius-md); } .skCover { aspect-ratio: 2 / 3; width: 100%; border-radius: var(--radius-md); }
.skTitle { height: 10px; width: 80%; } .skTitle { height: 10px; width: 80%; }
.splitRoot { flex: 1; display: flex; overflow: hidden; } .splitRoot { flex: 1; display: flex; overflow: hidden; }
@@ -1371,7 +1349,7 @@
.tagClearAll { display: flex; align-items: center; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); padding: 4px 8px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); } .tagClearAll { display: flex; align-items: center; font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--text-faint); padding: 4px 8px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; cursor: pointer; transition: color var(--t-base), border-color var(--t-base), background var(--t-base); }
.tagClearAll:hover { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, transparent); background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent)); } .tagClearAll:hover { color: var(--color-error); border-color: color-mix(in srgb, var(--color-error) 40%, transparent); background: var(--color-error-bg, color-mix(in srgb, var(--color-error) 8%, transparent)); }
.tagCheckMark { font-size: var(--text-xs); color: var(--accent-fg); margin-left: auto; } .tagCheckMark { font-size: var(--text-xs); color: var(--accent-fg); margin-left: auto; }
.tagGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: var(--sp-4); padding: var(--sp-4); overflow-y: auto; flex: 1; align-content: start; } .tagGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(110px, 1fr)); gap: var(--sp-4); padding: var(--sp-4); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; }
.showMoreCell { grid-column: 1 / -1; display: flex; justify-content: center; gap: var(--sp-2); padding: var(--sp-2) 0; } .showMoreCell { grid-column: 1 / -1; display: flex; justify-content: center; gap: var(--sp-2); padding: var(--sp-2) 0; }
.showMoreBtn { display: inline-flex; align-items: center; gap: var(--sp-1); padding: 5px 12px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); } .showMoreBtn { display: inline-flex; align-items: center; gap: var(--sp-1); padding: 5px 12px; border-radius: var(--radius-md); border: 1px solid var(--border-dim); background: none; font-family: var(--font-ui); font-size: var(--text-xs); letter-spacing: var(--tracking-wide); color: var(--text-muted); cursor: pointer; transition: background var(--t-base), color var(--t-base), border-color var(--t-base); }
.showMoreBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); border-color: var(--border-strong); } .showMoreBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); border-color: var(--border-strong); }
@@ -1388,10 +1366,10 @@
.nsfwBadge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--color-error); background: var(--color-error-bg, rgba(180, 60, 60, 0.08)); border: 1px solid rgba(180, 60, 60, 0.25); border-radius: var(--radius-sm); padding: 1px 5px; margin-left: auto; flex-shrink: 0; } .nsfwBadge { font-family: var(--font-ui); font-size: var(--text-2xs); letter-spacing: var(--tracking-wide); color: var(--color-error); background: var(--color-error-bg, rgba(180, 60, 60, 0.08)); border: 1px solid rgba(180, 60, 60, 0.25); border-radius: var(--radius-sm); padding: 1px 5px; margin-left: auto; flex-shrink: 0; }
.searchHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-1); flex-shrink: 0; } .searchHeader { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-3) var(--sp-4) var(--sp-1); flex-shrink: 0; }
.searchLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; } .searchLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; }
.searchGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); padding: var(--sp-2) var(--sp-4) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; } .searchGrid { display: grid; grid-template-columns: repeat(auto-fill, minmax(clamp(90px, 11vw, 130px), 1fr)); gap: var(--sp-2); padding: var(--sp-2) var(--sp-4) var(--sp-6); overflow-y: auto; flex: 1; align-content: start; will-change: scroll-position; }
.srchCard { background: none; border: none; padding: 0; cursor: pointer; text-align: left; } .srchCard { background: none; border: none; padding: 0; cursor: pointer; text-align: left; }
.srchCard:hover :global(.cover) { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); } .srchCard:hover .srchCoverWrap { filter: brightness(1.08) saturate(1.05); }
.srchCoverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); box-shadow: 0 2px 8px rgba(0,0,0,0.25); } .srchCoverWrap { position: relative; aspect-ratio: 2/3; overflow: hidden; border-radius: var(--radius-md); background: var(--bg-raised); border: 1px solid var(--border-dim); transform: translateZ(0); transition: filter var(--t-base); contain: layout style; }
.srchGradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; } .srchGradient { position: absolute; inset: 0; background: linear-gradient(to top, rgba(0,0,0,0.82) 0%, rgba(0,0,0,0.15) 50%, transparent 72%); pointer-events: none; }
.srchFooter { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; } .srchFooter { position: absolute; bottom: 0; left: 0; right: 0; padding: var(--sp-2); pointer-events: none; }
.srchTitle { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); } .srchTitle { font-size: var(--text-xs); font-weight: var(--weight-medium); color: rgba(255,255,255,0.92); line-height: var(--leading-snug); display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; text-shadow: 0 1px 4px rgba(0,0,0,0.7); }