From 0ff148f720c1ca48f060c07e2269c2810b8ede55 Mon Sep 17 00:00:00 2001 From: Youwes09 Date: Tue, 14 Apr 2026 11:09:53 -0500 Subject: [PATCH] Chore: Merge Discover into Search (WIP) --- src/App.svelte | 26 +- src/components/pages/Search.svelte | 507 +++++++++++++++++++++-------- 2 files changed, 389 insertions(+), 144 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index 18f6244..8a1f22a 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -157,11 +157,31 @@ $effect(() => { if (!appReady) return; - const poll = () => gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS) - .then(d => applyQueue(d.downloadStatus.queue)).catch(console.error); + + let paused = false; + + const poll = () => { + if (paused) return; + gql<{ downloadStatus: DownloadStatus }>(GET_DOWNLOAD_STATUS) + .then(d => applyQueue(d.downloadStatus.queue)).catch(console.error); + }; + poll(); pollInterval = setInterval(poll, 2000); - return () => clearInterval(pollInterval); + + const onVisibility = () => { paused = document.hidden; }; + document.addEventListener("visibilitychange", onVisibility); + + let unlistenFocus: (() => void) | undefined; + win.onFocusChanged(({ payload: focused }) => { + paused = !focused; + }).then(fn => { unlistenFocus = fn; }); + + return () => { + clearInterval(pollInterval); + document.removeEventListener("visibilitychange", onVisibility); + unlistenFocus?.(); + }; }); async function checkForUpdateSilently() { diff --git a/src/components/pages/Search.svelte b/src/components/pages/Search.svelte index 9ba8a19..f54d5ac 100644 --- a/src/components/pages/Search.svelte +++ b/src/components/pages/Search.svelte @@ -2,11 +2,97 @@ import { onDestroy, untrack } from "svelte"; import { gql } from "../../lib/client"; import Thumbnail from "../shared/Thumbnail.svelte"; - import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_MANGA } from "../../lib/queries"; + import ContextMenu from "../shared/ContextMenu.svelte"; + import type { MenuEntry } from "../shared/ContextMenu.svelte"; + import { GET_SOURCES, FETCH_SOURCE_MANGA, FETCH_MANGA, UPDATE_MANGA, GET_CATEGORIES, CREATE_CATEGORY, UPDATE_MANGA_CATEGORIES } from "../../lib/queries"; import { cache, CACHE_KEYS, getPageSet } from "../../lib/cache"; import { dedupeMangaById, dedupeMangaByTitle, shouldHideNsfw, shouldHideSource, normalizeTitle } from "../../lib/util"; - import { store, setSearchPrefill, setPreviewManga } from "../../store/state.svelte"; - import type { Manga, Source } from "../../lib/types"; + import { store, setSearchPrefill, setPreviewManga, clearDiscoverCache } from "../../store/state.svelte"; + import type { Manga, Source, Category } from "../../lib/types"; + + const DISCOVER_PAGES = 3; + const DISCOVER_LIMIT = 200; + const DISCOVER_CONCUR = 6; + + function dKey(srcId: string, page: number) { + return `${srcId}|POPULAR|All:p${page}`; + } + + let disc_results: Manga[] = $state([]); + let disc_loading = $state(false); + let disc_abortCtrl: AbortController | null = null; + + function disc_filterOut(mangas: Manga[]): Manga[] { + return dedupeMangaByTitle( + dedupeMangaById(mangas.filter(m => !shouldHideNsfw(m, store.settings))), + store.settings.mangaLinks, + ); + } + + function disc_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(); + for (const s of eligible) { + 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); + } + return Array.from(map.values()); + } + + function disc_push(incoming: Manga[]) { + const filtered = disc_filterOut(incoming); + if (!filtered.length) return; + disc_results = dedupeMangaByTitle( + dedupeMangaById([...disc_results, ...filtered]), + store.settings.mangaLinks, + ).slice(0, DISCOVER_LIMIT); + } + + async function disc_fanOut(sources: Source[], signal: AbortSignal) { + const srcs = disc_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 <= DISCOVER_PAGES; page++) { + if (signal.aborted) return; + const key = dKey(src.id, page); + let mangas: Manga[]; + if (store.discoverCache?.has(key)) { + mangas = store.discoverCache.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.discoverCache?.set(key, mangas); + if (!result.hasNextPage) { disc_push(mangas); break; } + } + disc_push(mangas); + } + } + } + await Promise.all(Array.from({ length: Math.min(DISCOVER_CONCUR, srcs.length) }, worker)); + } + + function disc_start(sources: Source[]) { + if (disc_results.length > 0) return; + disc_abortCtrl?.abort(); + const ctrl = new AbortController(); + disc_abortCtrl = ctrl; + disc_loading = true; + disc_fanOut(sources, ctrl.signal) + .catch(() => {}) + .finally(() => { if (!ctrl.signal.aborted) disc_loading = false; }); + } type SearchTab = "keyword" | "tag" | "source"; type TagMode = "AND" | "OR"; @@ -18,7 +104,6 @@ error: string | null; } - // ── Cached manga entry for tag/source browsing ──────────────────────────── interface CachedManga { id: number; title: string; @@ -27,11 +112,11 @@ status: string; genre: string[]; sourceId: string; - genreEnriched: boolean; // true once fetchManga has been called for this entry + genreEnriched: boolean; } - const CONCURRENCY = 6; - const POPULAR_PAGES = 3; // pages to pre-fetch per source + const CONCURRENCY = 6; + const POPULAR_PAGES = 3; const COMMON_GENRES = [ "Action","Adventure","Comedy","Drama","Fantasy","Romance", @@ -49,7 +134,6 @@ { value: "UNKNOWN", label: "Unknown" }, ]; - // ── Concurrency helper ──────────────────────────────────────────────────── async function runConcurrent(items: T[], fn: (item: T) => Promise, signal: AbortSignal): Promise { let i = 0; async function worker() { @@ -62,21 +146,16 @@ await Promise.all(Array.from({ length: Math.min(CONCURRENCY, items.length) }, worker)); } - // ── Source dedup by preferred lang ──────────────────────────────────────── - // For each unique source name, keep only the preferred-lang variant (or the - // first alphabetically if preferred lang isn't available). This collapses - // MangaDex (60+ lang variants) and Manga Ball (40+) down to one each. function dedupSourcesByLang(sources: Source[], preferredLang: string): Source[] { const map = new Map(); for (const s of sources) { - if (s.id === "0") continue; // skip local source + if (s.id === "0") continue; const key = s.name; const existing = map.get(key); if (!existing) { map.set(key, s); continue; } - // Prefer the preferred lang; otherwise keep alphabetically first lang const existingIsPreferred = existing.lang === preferredLang; const newIsPreferred = s.lang === preferredLang; if (newIsPreferred && !existingIsPreferred) { @@ -88,15 +167,12 @@ return Array.from(map.values()); } - // ── In-memory source manga cache ───────────────────────────────────────── - // Keyed by manga id. Shared across tag searches for the session. const sourceCache = new Map(); - let sourceCacheReady = $state(false); // true once phase 1 (popular fetch) is done - let sourceCacheLoading = $state(false); - let sourceCacheEnriching = $state(false); // true while background genre enrichment runs + let sourceCacheReady = $state(false); + let sourceCacheLoading = $state(false); + let sourceCacheEnriching = $state(false); let sourceCacheAbort: AbortController | null = null; - // Phase 1: fetch 3 pages of POPULAR per deduped source, store in sourceCache async function buildSourceCache(sources: Source[], signal: AbortSignal) { const pages = [1, 2, 3]; const tasks: { src: Source; page: number }[] = []; @@ -129,12 +205,10 @@ } } catch (e: any) { if (e?.name === "AbortError") return; - // Individual source failures are silently skipped } }, signal); } - // Phase 2: background genre enrichment — only for entries with empty genre[] async function enrichGenres(signal: AbortSignal) { const unenriched = [...sourceCache.values()].filter((m) => !m.genreEnriched); if (!unenriched.length) return; @@ -150,13 +224,12 @@ if (signal.aborted) return; const updated = sourceCache.get(entry.id); if (updated) { - updated.genre = d.fetchManga.manga.genre ?? []; - updated.status = d.fetchManga.manga.status ?? updated.status; + updated.genre = d.fetchManga.manga.genre ?? []; + updated.status = d.fetchManga.manga.status ?? updated.status; updated.genreEnriched = true; } } catch (e: any) { if (e?.name === "AbortError") return; - // Mark as enriched anyway so we don't retry endlessly const updated = sourceCache.get(entry.id); if (updated) updated.genreEnriched = true; } @@ -164,7 +237,6 @@ if (!signal.aborted) sourceCacheEnriching = false; } - // MANGAS_BY_GENRE — local library query const MANGAS_BY_GENRE = ` query MangasByGenre($filter: MangaFilterInput, $first: Int, $offset: Int) { mangas(filter: $filter, first: $first, offset: $offset, orderBy: IN_LIBRARY_AT, orderByType: DESC) { @@ -178,7 +250,6 @@ } `; - // Build GraphQL filter for local library query function buildTagFilter( tags: string[], mode: TagMode, @@ -202,15 +273,12 @@ return { and: [genrePart, statusPart] }; } - // Filter the in-memory source cache by active tags + statuses function filterSourceCache( tags: string[], mode: TagMode, statuses: string[], ): CachedManga[] { return [...sourceCache.values()].filter((m) => { - if (!shouldHideNsfw(m as any, store.settings)) return false; // keep non-nsfw - // Actually: shouldHideNsfw returns true when we SHOULD hide, so: if (shouldHideNsfw(m as any, store.settings)) return false; const statusMatch = @@ -230,7 +298,6 @@ }); } - // ── Global state ────────────────────────────────────────────────────────── let tab: SearchTab = $state("keyword"); let preferredLang = store.settings?.preferredExtensionLang ?? "en"; @@ -249,13 +316,12 @@ } }); - // Load sources then kick off the cache build loadingSources = true; gql<{ sources: { nodes: Source[] } }>(GET_SOURCES) .then((d) => { allSources = d.sources.nodes.filter((src: Source) => src.id !== "0"); - // Kick off source cache build immediately after sources load startSourceCacheBuild(); + disc_start(allSources); }) .catch(console.error) .finally(() => { loadingSources = false; }); @@ -276,7 +342,6 @@ if (ctrl.signal.aborted) return; sourceCacheReady = true; sourceCacheLoading = false; - // Phase 2: enrich genres in background at low priority enrichGenres(ctrl.signal); }) .catch((e) => { @@ -290,12 +355,12 @@ // ── Keyword tab ─────────────────────────────────────────────────────────── let kw_query = $state(""); - let kw_submitted = $state(""); let kw_results: SourceResult[] = $state([]); let kw_showAdvanced = $state(false); let kw_selectedLangs: Set = $state(new Set()); let kw_inputEl: HTMLInputElement | null = $state(null); let kw_abortCtrl: AbortController | null = null; + let kw_debounceTimer: ReturnType | null = null; $effect(() => { if (allSources.length) { @@ -307,7 +372,7 @@ }); $effect(() => { - if (!loadingSources && pendingPrefill && !kw_submitted && allSources.length) { + if (!loadingSources && pendingPrefill && allSources.length) { const q = pendingPrefill; pendingPrefill = ""; kw_query = q; @@ -315,6 +380,20 @@ } }); + $effect(() => { + const q = kw_query; + if (kw_debounceTimer) clearTimeout(kw_debounceTimer); + if (!q.trim()) { + kw_abortCtrl?.abort(); + kw_results = []; + return; + } + kw_debounceTimer = setTimeout(() => { + kwDoSearch(q); + }, 350); + return () => { if (kw_debounceTimer) clearTimeout(kw_debounceTimer); }; + }); + function kwGetVisibleSources(): Source[] { let filtered = allSources; if (kw_selectedLangs.size > 0) @@ -332,8 +411,7 @@ kw_abortCtrl?.abort(); const ctrl = new AbortController(); kw_abortCtrl = ctrl; - kw_submitted = trimmed; - kw_results = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null })); + kw_results = visible.map((src) => ({ source: src, mangas: [], loading: true, error: null })); await runConcurrent(visible, async (src) => { if (ctrl.signal.aborted) return; try { @@ -364,6 +442,17 @@ const kw_visibleCount = $derived(kwGetVisibleSources().length); const kw_hasResults = $derived(kw_results.some((r) => r.mangas.length > 0)); const kw_allDone = $derived(kw_results.length > 0 && kw_results.every((r) => !r.loading)); + const kw_anyLoading = $derived(kw_results.some((r) => r.loading)); + + const kw_flatResults = $derived.by(() => { + const all = kw_results.flatMap((r) => + r.mangas.map((m) => ({ ...m, _sourceName: r.source.displayName })) + ); + return dedupeMangaByTitle( + dedupeMangaById(all), + store.settings.mangaLinks, + ) as (Manga & { _sourceName?: string })[]; + }); // ── Tag tab ─────────────────────────────────────────────────────────────── let tag_activeTags: string[] = $state([]); @@ -371,7 +460,6 @@ let tag_tagMode: TagMode = $state("AND"); let tag_tagFilter = $state(""); - // Local library results let tag_localResults: Manga[] = $state([]); let tag_totalCount = $state(0); let tag_loadingLocal = $state(false); @@ -380,10 +468,19 @@ let tag_localHasNext = $state(false); let tag_abortLocal: AbortController | null = null; - // Source cache results (filtered client-side from sourceCache) let tag_searchSources = $state(false); let tag_sourceFiltered: CachedManga[] = $state([]); + // Active source fan-out results (Discover-style live fetch per genre tag) + let tag_sourceFanOut: Manga[] = $state([]); + let tag_fanOutLoading = $state(false); + let tag_fanOutAbort: AbortController | null = null; + + // Context menu state + let ctx: { x: number; y: number; manga: Manga } | null = $state(null); + let categories: Category[] = $state([]); + let catsLoaded = false; + const tag_filteredGenres = $derived.by(() => { const q = tag_tagFilter.trim().toLowerCase(); return q ? COMMON_GENRES.filter((g) => g.toLowerCase().includes(q)) : COMMON_GENRES; @@ -391,7 +488,6 @@ const tag_hasActiveFilters = $derived(tag_activeTags.length > 0 || tag_activeStatuses.length > 0); - // Local library fetch — triggered when tags or statuses change $effect(() => { const _tags = tag_activeTags; const _mode = tag_tagMode; @@ -399,7 +495,6 @@ untrack(() => tagFetchLocal(_tags, _mode, _statuses)); }); - // Source cache filter — reactive to filters + cache readiness $effect(() => { const _tags = tag_activeTags; const _mode = tag_tagMode; @@ -415,7 +510,74 @@ }); }); - // Auto-enable source search when local results are sparse + // Fan-out live source search when a single genre tag is active + sources enabled + $effect(() => { + const _tags = tag_activeTags; + const _search = tag_searchSources; + untrack(() => { + if (_search && _tags.length === 1 && tag_activeStatuses.length === 0) { + tagStartFanOut(_tags[0]); + } else { + tag_fanOutAbort?.abort(); + tag_fanOutAbort = null; + tag_sourceFanOut = []; + tag_fanOutLoading = false; + } + }); + }); + + async function tagStartFanOut(genre: string) { + tag_fanOutAbort?.abort(); + const ctrl = new AbortController(); + tag_fanOutAbort = ctrl; + tag_sourceFanOut = []; + tag_fanOutLoading = true; + + const srcs = disc_rotatedSources(allSources); + const PAGES = 2; + + await runConcurrent(srcs, async (src) => { + for (let page = 1; page <= PAGES; page++) { + if (ctrl.signal.aborted) return; + const cacheKey = `${src.id}|SEARCH|${genre}:p${page}`; + let mangas: Manga[]; + let hasNextPage = false; + + if (store.discoverCache?.has(cacheKey)) { + mangas = store.discoverCache.get(cacheKey)!; + } else { + const result = await gql<{ fetchSourceManga: { mangas: Manga[]; hasNextPage: boolean } }>( + FETCH_SOURCE_MANGA, + { source: src.id, type: "SEARCH", page, query: genre }, + ctrl.signal, + ).then(d => d.fetchSourceManga).catch(() => null); + if (!result || ctrl.signal.aborted) return; + mangas = result.mangas; + hasNextPage = result.hasNextPage; + store.discoverCache?.set(cacheKey, mangas); + } + + if (ctrl.signal.aborted) return; + + const matching = mangas.filter(m => + ((m as any).genre ?? []).some((g: string) => g.toLowerCase() === genre.toLowerCase()) + ); + const toAdd = (matching.length ? matching : mangas).filter(m => !shouldHideNsfw(m, store.settings)); + + if (toAdd.length) { + tag_sourceFanOut = dedupeMangaByTitle( + dedupeMangaById([...tag_sourceFanOut, ...toAdd]), + store.settings.mangaLinks, + ).slice(0, DISCOVER_LIMIT); + } + + if (!hasNextPage) return; + } + }, ctrl.signal); + + if (!ctrl.signal.aborted) tag_fanOutLoading = false; + } + let tag_autoSearchFired = $state(false); $effect(() => { if (!tag_loadingLocal && tag_hasActiveFilters && !tag_autoSearchFired && !tag_searchSources && sourceCacheReady) { @@ -498,13 +660,59 @@ tag_searchSources = !tag_searchSources; } - // Deduplicate merged results: local library wins over source cache on id, - // then dedupe by title to avoid cross-source duplicates. + function openCtx(e: MouseEvent, m: Manga) { + e.preventDefault(); e.stopPropagation(); + ctx = { x: e.clientX, y: e.clientY, manga: m }; + if (!catsLoaded) { + catsLoaded = true; + gql<{ categories: { nodes: Category[] } }>(GET_CATEGORIES) + .then(d => { categories = d.categories.nodes.filter(c => c.id !== 0); }) + .catch(console.error); + } + } + + function buildCtxItems(m: Manga): MenuEntry[] { + return [ + { + label: m.inLibrary ? "In Library" : "Add to library", + disabled: m.inLibrary, + onClick: () => gql(UPDATE_MANGA, { id: m.id, inLibrary: true }) + .then(() => { + cache.clear(CACHE_KEYS.LIBRARY); + store.discoverLibraryIds = new Set([...store.discoverLibraryIds, m.id]); + }).catch(console.error), + }, + ...(categories.length > 0 ? [ + { separator: true } as MenuEntry, + ...categories.map(cat => ({ + label: (cat.mangas?.nodes ?? []).some((x: any) => x.id === m.id) ? `✓ ${cat.name}` : cat.name, + onClick: () => gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error), + })), + ] : []), + { separator: true }, + { + label: "New folder & add", + onClick: async () => { + const n = prompt("Folder name:"); + if (!n?.trim()) return; + const res = await gql<{ createCategory: { category: Category } }>(CREATE_CATEGORY, { name: n.trim() }).catch(console.error); + if (res) { + const cat = res.createCategory.category; + categories = [...categories, cat]; + await gql(UPDATE_MANGA_CATEGORIES, { mangaId: m.id, addTo: [cat.id], removeFrom: [] }).catch(console.error); + } + }, + }, + ]; + } + const tag_localIds = $derived(new Set(tag_localResults.map((m) => m.id))); + const tag_mergedResults = $derived.by(() => { const localMapped = tag_localResults; - const sourceMapped: Manga[] = tag_sourceFiltered - .filter((m) => !tag_localIds.has(m.id)) + const fanOutMapped = tag_sourceFanOut.filter(m => !tag_localIds.has(m.id)); + const cacheMapped: Manga[] = tag_sourceFiltered + .filter((m) => !tag_localIds.has(m.id) && !fanOutMapped.some(f => f.id === m.id)) .map((m) => ({ id: m.id, title: m.title, @@ -514,7 +722,7 @@ status: m.status, } as Manga)); return dedupeMangaByTitle( - dedupeMangaById([...localMapped, ...sourceMapped]), + dedupeMangaById([...localMapped, ...fanOutMapped, ...cacheMapped]), store.settings.mangaLinks, ); }); @@ -540,13 +748,11 @@ } }); - // Source tab visible sources — deduped by preferred lang when showing "all" const src_visibleSources = $derived.by(() => { const hide = (s: Source) => shouldHideSource(s, store.settings); if (src_selectedLang !== "all") { return allSources.filter((s) => s.lang === src_selectedLang && !hide(s)); } - // Dedup by name, prefer preferredLang const map = new Map(); for (const s of allSources) { if (hide(s)) continue; @@ -603,9 +809,12 @@ onDestroy(() => { kw_abortCtrl?.abort(); + if (kw_debounceTimer) clearTimeout(kw_debounceTimer); tag_abortLocal?.abort(); + tag_fanOutAbort?.abort(); src_abortCtrl?.abort(); sourceCacheAbort?.abort(); + disc_abortCtrl?.abort(); }); @@ -646,10 +855,14 @@ bind:value={kw_query} class="searchInput" placeholder="Search across sources…" - onkeydown={(e) => e.key === "Enter" && kwDoSearch(kw_query)} + use:focusOnMount /> - {#if kw_query} - + {#if kw_anyLoading} + + {:else if kw_query} + {/if} {#if hasMultipleLangs} {/if} - {#if hasMultipleLangs && kw_showAdvanced} @@ -698,83 +902,89 @@ {/if} - {#if !kw_submitted} -
- -

Search across sources

-

- {#if hasMultipleLangs} - {kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""} · {kw_selectedLangs.size} language{kw_selectedLangs.size !== 1 ? "s" : ""} - {:else} - {kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""} + {#if !kw_query.trim()} + {#if disc_loading && disc_results.length === 0} +

+ {#each Array(24) as _, i (i)} +
+ {/each} +
+ {:else if disc_results.length > 0} +
+ Popular right now +
+
+ {#each disc_results as m (m.id)} + + {/each} + {#if disc_loading} + {#each Array(6) as _, i (i)} +
+ {/each} {/if} -

- {#if hasMultipleLangs && !kw_showAdvanced} - - {/if} -
- {:else} -
- {#if kw_results.length === 0} -
- -
- {/if} - - {#each kw_results.filter((r) => r.mangas.length > 0 || r.loading || r.error) as { source, mangas, loading, error } (source.id)} -
-
- { (e.target as HTMLImageElement).style.display = "none"; }} /> - {source.displayName} - {#if hasMultipleLangs}{source.lang.toUpperCase()}{/if} - {#if loading} - - {:else if mangas.length > 0} - {mangas.length} results - {/if} -
- {#if error} -

{error}

- {:else if loading} -
- {#each Array(4) as _, i (i)} -
- {/each} -
- {:else if mangas.length > 0} -
- {#each mangas.slice(0, (store.settings.renderLimit ?? 48)) as m (m.id)} - - {/each} -
+
+ {:else} +
+ +

Search across sources

+

+ {#if hasMultipleLangs} + {kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""} · {kw_selectedLangs.size} language{kw_selectedLangs.size !== 1 ? "s" : ""} + {:else} + {kw_visibleCount} source{kw_visibleCount !== 1 ? "s" : ""} {/if} -

- {/each} - - {#if kw_allDone && !kw_hasResults} -
-

No results for "{kw_submitted}"

-

Try a different spelling or fewer words

-
- {/if} -
+

+
+ {/if} + {:else} + {#if kw_flatResults.length > 0} +
+ {kw_flatResults.length} result{kw_flatResults.length !== 1 ? "s" : ""} +
+
+ {#each kw_flatResults.slice(0, store.settings.renderLimit ?? 48) as m (m.id)} + + {/each} + {#if kw_anyLoading} + {#each Array(6) as _, i (i)} +
+ {/each} + {/if} +
+ {:else if kw_anyLoading} +
+ {#each Array(12) as _, i (i)} +
+ {/each} +
+ {:else if kw_allDone && !kw_hasResults} +
+

No results for "{kw_query.trim()}"

+

Try a different spelling or fewer words

+
+ {/if} {/if} {:else if tab === "tag"} @@ -849,7 +1059,7 @@ disabled={!sourceCacheReady && !sourceCacheLoading} onclick={tagToggleSearchSources} > - {#if sourceCacheLoading} + {#if sourceCacheLoading || tag_fanOutLoading} @@ -900,7 +1110,7 @@ {:else if tag_mergedResults.length > 0}
{#each tag_mergedResults as m (m.id)} -