From 68a9331b6f643d6f376e45015779c19ade676c72 Mon Sep 17 00:00:00 2001 From: Matthew Chen Date: Wed, 15 Apr 2026 01:44:07 -0700 Subject: [PATCH 1/2] fix(ui): slow thumbnail loading --- src/components/pages/Search.svelte | 119 ++++++++++++++++++++--------- 1 file changed, 82 insertions(+), 37 deletions(-) diff --git a/src/components/pages/Search.svelte b/src/components/pages/Search.svelte index a6711c8..eb00348 100644 --- a/src/components/pages/Search.svelte +++ b/src/components/pages/Search.svelte @@ -10,7 +10,8 @@ const SEARCH_PAGES = 3; const SEARCH_LIMIT = 200; - const SEARCH_CONCUR = 6; + const SEARCH_BATCH = 20; + const SEARCH_VISIBLE_LIMIT = 30; function dKey(srcId: string, page: number) { return `${srcId}|POPULAR|All:p${page}`; @@ -18,7 +19,12 @@ let srch_results: Manga[] = $state([]); let srch_loading = $state(false); + let srch_moreLoading = $state(false); let srch_abortCtrl: AbortController | null = null; + let srch_sourcePool: Source[] = $state([]); + let srch_sourceCursor = $state(0); + let srch_hasMorePopular = $state(false); + let srch_visibleLimit = $state(SEARCH_VISIBLE_LIMIT); function srch_filterOut(mangas: Manga[]): Manga[] { return dedupeMangaByTitle( @@ -48,37 +54,37 @@ ).slice(0, SEARCH_LIMIT); } - async function srch_fanOut(sources: Source[], signal: AbortSignal) { - const srcs = srch_rotatedSources(sources); - if (!srcs.length) return; - - let i = 0; - async function worker() { - while (i < srcs.length) { - if (signal.aborted) return; - const src = srcs[i++]; - for (let page = 1; page <= SEARCH_PAGES; page++) { - if (signal.aborted) return; - const key = dKey(src.id, page); - let mangas: Manga[]; - if (store.searchCache?.has(key)) { - mangas = store.searchCache.get(key)!; - } else { - const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( - FETCH_SOURCE_MANGA, - { source: src.id, type: "POPULAR", page, query: null }, - signal, - ).then(d => d.fetchSourceManga).catch(() => null); - if (!result || signal.aborted) break; - mangas = result.mangas; - store.searchCache?.set(key, mangas); - if (!result.hasNextPage) { srch_push(mangas); break; } - } - srch_push(mangas); - } - } + async function srch_fanOut(signal: AbortSignal) { + const batch = srch_sourcePool.slice(srch_sourceCursor, srch_sourceCursor + SEARCH_BATCH); + if (!batch.length) { + srch_hasMorePopular = false; + return; } - await Promise.all(Array.from({ length: Math.min(SEARCH_CONCUR, srcs.length) }, worker)); + + await Promise.all(batch.map(async (src) => { + for (let page = 1; page <= SEARCH_PAGES; page++) { + if (signal.aborted) return; + const key = dKey(src.id, page); + let mangas: Manga[]; + if (store.searchCache?.has(key)) { + mangas = store.searchCache.get(key)!; + } else { + const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( + FETCH_SOURCE_MANGA, + { source: src.id, type: "POPULAR", page, query: null }, + signal, + ).then(d => d.fetchSourceManga).catch(() => null); + if (!result || signal.aborted) break; + mangas = result.mangas; + store.searchCache?.set(key, mangas); + if (!result.hasNextPage) { srch_push(mangas); break; } + } + srch_push(mangas); + } + })); + + srch_sourceCursor += batch.length; + srch_hasMorePopular = srch_sourceCursor < srch_sourcePool.length; } function srch_start(sources: Source[]) { @@ -86,12 +92,30 @@ srch_abortCtrl?.abort(); const ctrl = new AbortController(); srch_abortCtrl = ctrl; + srch_sourcePool = srch_rotatedSources(sources); + srch_sourceCursor = 0; + srch_hasMorePopular = false; + srch_moreLoading = false; + srch_visibleLimit = SEARCH_VISIBLE_LIMIT; srch_loading = true; - srch_fanOut(sources, ctrl.signal) + srch_fanOut(ctrl.signal) .catch(() => {}) .finally(() => { if (!ctrl.signal.aborted) srch_loading = false; }); } + function srch_loadMorePopular() { + 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 TagMode = "AND" | "OR"; @@ -355,6 +379,7 @@ let kw_results: SourceResult[] = $state([]); let kw_showAdvanced = $state(false); let kw_selectedLangs: Set = $state(new Set()); + let kw_visibleLimit = $state(SEARCH_VISIBLE_LIMIT); let kw_inputEl: HTMLInputElement | null = $state(null); let kw_abortCtrl: AbortController | null = null; let kw_debounceTimer: ReturnType | null = null; @@ -403,6 +428,7 @@ async function kwDoSearch(q: string) { const trimmed = q.trim(); if (!trimmed) return; + kw_visibleLimit = SEARCH_VISIBLE_LIMIT; const visible = kwGetVisibleSources(); if (!visible.length) return; kw_abortCtrl?.abort(); @@ -429,6 +455,10 @@ }, ctrl.signal); } + function kwLoadMore() { + kw_visibleLimit += SEARCH_VISIBLE_LIMIT; + } + function kwToggleLang(lang: string) { const next = new Set(kw_selectedLangs); if (next.has(lang)) { if (next.size === 1) return; next.delete(lang); } @@ -851,12 +881,12 @@
{/each} - {:else if srch_results.length > 0} + {:else if srch_results.length > 0}
Popular right now
- {#each srch_results as m (m.id)} + {#each srch_results.slice(0, srch_visibleLimit) as m (m.id)}
- {/each} + {/each} {#if srch_loading} {#each Array(6) as _, i (i)}
{/each} {/if} + {#if srch_hasMorePopular || srch_results.length > srch_visibleLimit} +
+ +
+ {/if} {:else}
{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""}
- {#each kw_flatResults.slice(0, store.settings.renderLimit ?? 48) as m (m.id)} + {#each kw_flatResults.slice(0, kw_visibleLimit) as m (m.id)}
- {/each} + {/each} {#if kw_anyLoading} {#each Array(6) as _, i (i)}
{/each} {/if} + {#if kw_flatResults.length > kw_visibleLimit || kw_anyLoading} +
+ +
+ {/if} {:else if kw_anyLoading}
{#each Array(12) as _, i (i)} @@ -1333,6 +1377,7 @@ .showMoreBtn:hover:not(:disabled) { background: var(--bg-raised); color: var(--text-secondary); border-color: var(--border-strong); } .showMoreBtn:disabled { opacity: 0.4; cursor: default; } .loadMoreRow { display: flex; justify-content: center; padding: var(--sp-3) var(--sp-4); flex-shrink: 0; border-top: 1px solid var(--border-dim); } + .loadMoreSticky { position: sticky; top: 0; z-index: 2; width: fit-content; margin: 0 auto; border-top: none; border-left: 1px solid var(--border-dim); border-bottom: 1px solid var(--border-dim); border-bottom-left-radius: var(--radius-md); border-bottom-right-radius: var(--radius-md); background: color-mix(in srgb, var(--bg-base) 88%, transparent); backdrop-filter: blur(8px); } .sourceBrowseBar { display: flex; align-items: center; gap: var(--sp-2); padding: var(--sp-2) var(--sp-4); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; } .srcLangRow { display: flex; align-items: center; justify-content: space-between; padding: var(--sp-2) var(--sp-3); border-bottom: 1px solid var(--border-dim); flex-shrink: 0; gap: var(--sp-2); } .langPocketLabel { font-family: var(--font-ui); font-size: var(--text-2xs); color: var(--text-faint); letter-spacing: var(--tracking-wider); text-transform: uppercase; } From 1aad4a1ff0c93fef398a58ba68429bdb5f2ad07b Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Wed, 15 Apr 2026 17:30:14 -0500 Subject: [PATCH 2/2] Fix: Removed Show-More for Preferential Loading using PR Methods --- src/components/pages/Search.svelte | 260 +++++++++++++---------------- 1 file changed, 119 insertions(+), 141 deletions(-) diff --git a/src/components/pages/Search.svelte b/src/components/pages/Search.svelte index eb00348..6b0a0fa 100644 --- a/src/components/pages/Search.svelte +++ b/src/components/pages/Search.svelte @@ -24,34 +24,38 @@ let srch_sourcePool: Source[] = $state([]); let srch_sourceCursor = $state(0); let srch_hasMorePopular = $state(false); - let srch_visibleLimit = $state(SEARCH_VISIBLE_LIMIT); - function srch_filterOut(mangas: Manga[]): Manga[] { - 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)); + function dedupeSourcesByLang(sources: Source[], lang: string, applyHide = false): Source[] { const map = new Map(); - 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); 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()); } + let srch_seenIds = new Set(); + let srch_seenTitles = new Set(); + function srch_push(incoming: Manga[]) { - const filtered = srch_filterOut(incoming); - if (!filtered.length) return; - srch_results = dedupeMangaByTitle( - dedupeMangaById([...srch_results, ...filtered]), - store.settings.mangaLinks, - ).slice(0, SEARCH_LIMIT); + const toAdd: Manga[] = []; + for (const m of incoming) { + if (shouldHideNsfw(m, store.settings)) continue; + if (srch_seenIds.has(m.id)) continue; + const norm = normalizeTitle(m.title, store.settings.mangaLinks); + 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) { @@ -61,7 +65,7 @@ return; } - await Promise.all(batch.map(async (src) => { + await runConcurrent(batch, async (src) => { for (let page = 1; page <= SEARCH_PAGES; page++) { if (signal.aborted) return; const key = dKey(src.id, page); @@ -81,7 +85,7 @@ } srch_push(mangas); } - })); + }, signal); srch_sourceCursor += batch.length; srch_hasMorePopular = srch_sourceCursor < srch_sourcePool.length; @@ -92,28 +96,21 @@ srch_abortCtrl?.abort(); const ctrl = new AbortController(); 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_hasMorePopular = false; srch_moreLoading = false; - srch_visibleLimit = SEARCH_VISIBLE_LIMIT; srch_loading = true; - srch_fanOut(ctrl.signal) - .catch(() => {}) - .finally(() => { if (!ctrl.signal.aborted) srch_loading = false; }); - } - - function srch_loadMorePopular() { - 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; }); + (async () => { + try { + while (!ctrl.signal.aborted && srch_sourceCursor < srch_sourcePool.length) { + await srch_fanOut(ctrl.signal); + } + } catch {} + if (!ctrl.signal.aborted) srch_loading = false; + })(); } type SearchTab = "keyword" | "tag" | "source"; @@ -133,6 +130,7 @@ inLibrary: boolean; status: string; genre: string[]; + lowerGenres: string[]; sourceId: string; genreEnriched: boolean; } @@ -168,26 +166,6 @@ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker)); } - function dedupSourcesByLang(sources: Source[], preferredLang: string): Source[] { - const map = new Map(); - 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(); let sourceCacheReady = $state(false); @@ -205,22 +183,31 @@ await runConcurrent(tasks, async ({ src, page }) => { if (signal.aborted) return; try { - const d = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( - FETCH_SOURCE_MANGA, - { source: src.id, type: "POPULAR", page }, - signal, - ); - if (signal.aborted) return; - for (const m of d.fetchSourceManga.mangas) { + 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 } }>( + FETCH_SOURCE_MANGA, + { source: src.id, type: "POPULAR", page }, + signal, + ); + if (signal.aborted) return; + mangas = d.fetchSourceManga.mangas; + store.searchCache?.set(cacheKey, mangas); + } + for (const m of mangas) { if (!sourceCache.has(m.id)) { sourceCache.set(m.id, { - id: m.id, - title: m.title, - thumbnailUrl: m.thumbnailUrl, - inLibrary: m.inLibrary, - status: (m as any).status ?? "UNKNOWN", - genre: (m as any).genre ?? [], - sourceId: src.id, + id: m.id, + title: m.title, + thumbnailUrl: m.thumbnailUrl, + inLibrary: m.inLibrary, + status: (m as any).status ?? "UNKNOWN", + genre: (m as any).genre ?? [], + lowerGenres: ((m as any).genre ?? []).map((g: string) => g.toLowerCase()), + sourceId: src.id, genreEnriched: ((m as any).genre?.length ?? 0) > 0, }); } @@ -308,7 +295,7 @@ let genreMatch = true; if (tags.length > 0) { - const lowerGenres = m.genre.map((g) => g.toLowerCase()); + const lowerGenres = m.lowerGenres; if (mode === "AND") { genreMatch = tags.every((t) => lowerGenres.some((g) => g.includes(t.toLowerCase()))); } else { @@ -356,8 +343,7 @@ sourceCacheLoading = true; sourceCache.clear(); - const dedupedSources = dedupSourcesByLang(allSources, preferredLang) - .filter((s) => !shouldHideSource(s, store.settings)); + const dedupedSources = dedupeSourcesByLang(allSources, preferredLang, true); buildSourceCache(dedupedSources, ctrl.signal) .then(() => { @@ -379,7 +365,6 @@ let kw_results: SourceResult[] = $state([]); let kw_showAdvanced = $state(false); let kw_selectedLangs: Set = $state(new Set()); - let kw_visibleLimit = $state(SEARCH_VISIBLE_LIMIT); let kw_inputEl: HTMLInputElement | null = $state(null); let kw_abortCtrl: AbortController | null = null; let kw_debounceTimer: ReturnType | null = null; @@ -428,37 +413,35 @@ async function kwDoSearch(q: string) { const trimmed = q.trim(); if (!trimmed) return; - kw_visibleLimit = SEARCH_VISIBLE_LIMIT; const visible = kwGetVisibleSources(); if (!visible.length) return; kw_abortCtrl?.abort(); const ctrl = new AbortController(); 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) => { if (ctrl.signal.aborted) return; + const idx = indexBySrcId.get(src.id)!; try { const d = await gql<{ fetchSourceManga: { mangas: Manga[] } }>( FETCH_SOURCE_MANGA, { source: src.id, type: "SEARCH", page: 1, query: trimmed }, ctrl.signal, ); if (ctrl.signal.aborted) return; const mangas = d.fetchSourceManga.mangas.filter((m) => !shouldHideNsfw(m, store.settings)); - kw_results = kw_results.map((r) => - r.source.id === src.id ? { ...r, mangas, loading: false } : r, - ); + const next = [...kw_results]; + next[idx] = { ...next[idx], mangas, loading: false }; + kw_results = next; } catch (e: any) { if (ctrl.signal.aborted || e?.name === "AbortError") return; - kw_results = kw_results.map((r) => - r.source.id === src.id ? { ...r, loading: false, error: e.message ?? "Error" } : r, - ); + const next = [...kw_results]; + next[idx] = { ...next[idx], loading: false, error: (e as any).message ?? "Error" }; + kw_results = next; } }, ctrl.signal); } - function kwLoadMore() { - kw_visibleLimit += SEARCH_VISIBLE_LIMIT; - } - function kwToggleLang(lang: string) { const next = new Set(kw_selectedLangs); if (next.has(lang)) { if (next.size === 1) return; next.delete(lang); } @@ -552,7 +535,11 @@ tag_sourceFanOut = []; tag_fanOutLoading = true; - const srcs = srch_rotatedSources(allSources); + const fanOutSeenIds = new Set(); + const fanOutSeenTitles = new Set(); + const genreLower = genre.toLowerCase(); + + const srcs = dedupeSourcesByLang(allSources, store.settings?.preferredExtensionLang || "en", true); const PAGES = 2; await runConcurrent(srcs, async (src) => { @@ -579,15 +566,22 @@ if (ctrl.signal.aborted) return; 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) { - tag_sourceFanOut = dedupeMangaByTitle( - dedupeMangaById([...tag_sourceFanOut, ...toAdd]), - store.settings.mangaLinks, - ).slice(0, SEARCH_LIMIT); + tag_sourceFanOut = [...tag_sourceFanOut, ...toAdd].slice(0, SEARCH_LIMIT); } if (!hasNextPage) return; @@ -599,17 +593,16 @@ let tag_autoSearchFired = $state(false); $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_localResults.length < 20) { 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[]) { if (activeTags.length === 0 && activeStatuses.length === 0) { @@ -663,6 +656,12 @@ } } + $effect(() => { + if (tag_localHasNext && !tag_loadingMoreLocal && !tag_loadingLocal) { + tagLoadMoreLocal(); + } + }); + function tagToggleTag(tag: string) { tag_activeTags = tag_activeTags.includes(tag) ? tag_activeTags.filter((t) => t !== tag) @@ -886,7 +885,7 @@ Popular right now
- {#each srch_results.slice(0, srch_visibleLimit) as m (m.id)} + {#each srch_results as m (m.id)}
- {/each} + {/each} {#if srch_loading} - {#each Array(6) as _, i (i)} + {#each Array(12) as _, i (i)}
{/each} {/if} - {#if srch_hasMorePopular || srch_results.length > srch_visibleLimit} -
- -
- {/if} {:else}
{kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""}
- {#each kw_flatResults.slice(0, kw_visibleLimit) as m (m.id)} + {#each kw_flatResults as m (m.id)}
- {/each} + {/each} {#if kw_anyLoading} {#each Array(6) as _, i (i)}
{/each} {/if} - {#if kw_flatResults.length > kw_visibleLimit || kw_anyLoading} -
- -
- {/if} {:else if kw_anyLoading}
{#each Array(12) as _, i (i)} @@ -1105,18 +1090,10 @@ {/each} - {#if tag_localHasNext} -
- -
+ {#if tag_loadingMoreLocal} + {#each Array(12) as _, i (i)} +
+ {/each} {/if}
{: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:hover :global(.cover) { filter: brightness(1.06); } .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); } - :global(.cover) { width: 100%; height: 100%; object-fit: cover; transition: filter var(--t-base); } - .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); } + .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; } + .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); } .skCard { display: flex; flex-direction: column; gap: var(--sp-2); flex-shrink: 0; width: 110px; } .tagGrid .card { 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); } .skTitle { height: 10px; width: 80%; } .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: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; } - .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; } .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); } @@ -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; } .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; } - .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:hover :global(.cover) { filter: brightness(1.08) saturate(1.05); transform: scale(1.02); } - .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); } + .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); 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; } .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); } @@ -1403,4 +1381,4 @@ + \ No newline at end of file