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
+119 -141
View File
@@ -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<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);
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<number>();
let srch_seenTitles = new Set<string>();
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<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>();
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<string> = $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<typeof setTimeout> | 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<number>();
const fanOutSeenTitles = new Set<string>();
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 @@
<span class="searchLabel">Popular right now</span>
</div>
<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)}>
<div class="srchCoverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
@@ -898,20 +897,13 @@
</div>
</div>
</button>
{/each}
{/each}
{#if srch_loading}
{#each Array(6) as _, i (i)}
{#each Array(12) as _, i (i)}
<div class="skCard"><div class="skeleton skCover"></div></div>
{/each}
{/if}
</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}
<div class="empty">
<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>
</div>
<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)}>
<div class="srchCoverWrap">
<Thumbnail src={m.thumbnailUrl} alt={m.title} class="cover" />
@@ -945,20 +937,13 @@
</div>
</div>
</button>
{/each}
{/each}
{#if kw_anyLoading}
{#each Array(6) as _, i (i)}
<div class="skCard"><div class="skeleton skCover"></div></div>
{/each}
{/if}
</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}
<div class="searchGrid">
{#each Array(12) as _, i (i)}
@@ -1105,18 +1090,10 @@
</button>
{/each}
{#if tag_localHasNext}
<div class="showMoreCell">
<button class="showMoreBtn" onclick={tagLoadMoreLocal} disabled={tag_loadingMoreLocal}>
{#if tag_loadingMoreLocal}
<svg width="13" height="13" viewBox="0 0 256 256" fill="currentColor" class="anim-spin" aria-hidden="true">
<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"/>
</svg> Loading…
{:else}
Show more (library)
{/if}
</button>
</div>
{#if tag_loadingMoreLocal}
{#each Array(12) as _, i (i)}
<div class="skCard"><div class="skeleton skCover"></div><div class="skeleton skTitle"></div></div>
{/each}
{/if}
</div>
{: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 @@
<script module>
function focusOnMount(node: HTMLElement) { node.focus(); }
</script>
</script>