diff --git a/src/routes/+page.svelte b/src/routes/+page.svelte index e906e02..73a0003 100644 --- a/src/routes/+page.svelte +++ b/src/routes/+page.svelte @@ -4,32 +4,27 @@ import { CookingPot, X } from 'lucide-svelte'; import type { Snapshot } from './$types'; import type { SearchHit } from '$lib/server/recipes/search-local'; - import type { WebHit } from '$lib/server/search/searxng'; import { randomQuote } from '$lib/quotes'; import SearchLoader from '$lib/components/SearchLoader.svelte'; import SearchFilter from '$lib/components/SearchFilter.svelte'; import { profileStore } from '$lib/client/profile.svelte'; import { searchFilterStore } from '$lib/client/search-filter.svelte'; import { requireOnline } from '$lib/client/require-online'; + import { SearchStore, type SearchSnapshot } from '$lib/client/search.svelte'; const LOCAL_PAGE = 30; - let query = $state(''); + const store = new SearchStore({ + pageSize: LOCAL_PAGE, + filterParam: () => { + const p = searchFilterStore.queryParam; + return p ? `&domains=${encodeURIComponent(p)}` : ''; + } + }); + let quote = $state(''); let recent = $state([]); let favorites = $state([]); - let hits = $state([]); - let webHits = $state([]); - let searching = $state(false); - let webSearching = $state(false); - let webError = $state(null); - let searchedFor = $state(null); - let localExhausted = $state(false); - let webPageno = $state(0); - let webExhausted = $state(false); - let loadingMore = $state(false); - let skipNextSearch = false; - let debounceTimer: ReturnType | null = null; const ALL_PAGE = 10; type AllSort = 'name' | 'rating' | 'cooked' | 'created'; @@ -47,39 +42,9 @@ let allChips: HTMLElement | undefined = $state(); let allObserver: IntersectionObserver | null = null; - type SearchSnapshot = { - query: string; - hits: SearchHit[]; - webHits: WebHit[]; - searchedFor: string | null; - webError: string | null; - localExhausted: boolean; - webPageno: number; - webExhausted: boolean; - }; - export const snapshot: Snapshot = { - capture: () => ({ - query, - hits, - webHits, - searchedFor, - webError, - localExhausted, - webPageno, - webExhausted - }), - restore: (v) => { - query = v.query; - hits = v.hits; - webHits = v.webHits; - searchedFor = v.searchedFor; - webError = v.webError; - localExhausted = v.localExhausted; - webPageno = v.webPageno; - webExhausted = v.webExhausted; - skipNextSearch = true; - } + capture: () => store.captureSnapshot(), + restore: (s) => store.restoreSnapshot(s) }; async function loadRecent() { @@ -152,7 +117,7 @@ // Restore query from URL so history.back() from preview/recipe // brings the user back to the same search results. const urlQ = ($page.url.searchParams.get('q') ?? '').trim(); - if (urlQ) query = urlQ; + if (urlQ) store.query = urlQ; void loadRecent(); void searchFilterStore.load(); const saved = localStorage.getItem('kochwas.allSort'); @@ -188,14 +153,7 @@ $effect(() => { // eslint-disable-next-line @typescript-eslint/no-unused-expressions searchFilterStore.active; - const q = query.trim(); - if (!q || q.length <= 3) return; - if (debounceTimer) clearTimeout(debounceTimer); - searching = true; - webHits = []; - webSearching = false; - webError = null; - debounceTimer = setTimeout(() => void runSearch(q), 150); + store.reSearch(); }); // Sync current query back into the URL as ?q=... via replaceState, @@ -203,7 +161,7 @@ // when the user clicks a result or otherwise navigates away. $effect(() => { if (typeof window === 'undefined') return; - const q = query.trim(); + const q = store.query.trim(); const url = new URL(window.location.href); const current = url.searchParams.get('q') ?? ''; if (q === current) return; @@ -221,138 +179,17 @@ void loadFavorites(active.id); }); - function filterParam(): string { - const p = searchFilterStore.queryParam; - return p ? `&domains=${encodeURIComponent(p)}` : ''; - } - - async function runSearch(q: string) { - localExhausted = false; - webPageno = 0; - webExhausted = false; - try { - const res = await fetch( - `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${LOCAL_PAGE}${filterParam()}` - ); - const body = await res.json(); - if (query.trim() !== q) return; - hits = body.hits; - searchedFor = q; - if (hits.length < LOCAL_PAGE) localExhausted = true; - if (hits.length === 0) { - // Gar keine lokalen Treffer → erste Web-Seite gleich laden, - // damit der User nicht extra auf „+ weitere" klicken muss. - webSearching = true; - try { - const wres = await fetch( - `/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=1${filterParam()}` - ); - if (query.trim() !== q) return; - if (!wres.ok) { - const err = await wres.json().catch(() => ({})); - webError = err.message ?? `HTTP ${wres.status}`; - webExhausted = true; - } else { - const wbody = await wres.json(); - webHits = wbody.hits; - webPageno = 1; - if (wbody.hits.length === 0) webExhausted = true; - } - } finally { - if (query.trim() === q) webSearching = false; - } - } - } finally { - if (query.trim() === q) searching = false; - } - } - - async function loadMore() { - if (loadingMore) return; - const q = query.trim(); - if (!q) return; - loadingMore = true; - try { - if (!localExhausted) { - // Noch mehr lokale Treffer holen. - const res = await fetch( - `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${LOCAL_PAGE}&offset=${hits.length}${filterParam()}` - ); - const body = await res.json(); - if (query.trim() !== q) return; - const more = body.hits as SearchHit[]; - const seen = new Set(hits.map((h) => h.id)); - const deduped = more.filter((h) => !seen.has(h.id)); - hits = [...hits, ...deduped]; - if (more.length < LOCAL_PAGE) localExhausted = true; - } else if (!webExhausted) { - // Lokale erschöpft → auf Web umschalten / weiterblättern. - const nextPage = webPageno + 1; - webSearching = webHits.length === 0; - try { - const wres = await fetch( - `/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${filterParam()}` - ); - if (query.trim() !== q) return; - if (!wres.ok) { - const err = await wres.json().catch(() => ({})); - webError = err.message ?? `HTTP ${wres.status}`; - webExhausted = true; - return; - } - const wbody = await wres.json(); - const more = wbody.hits as WebHit[]; - const seen = new Set(webHits.map((h) => h.url)); - const deduped = more.filter((h) => !seen.has(h.url)); - if (deduped.length === 0) { - webExhausted = true; - } else { - webHits = [...webHits, ...deduped]; - webPageno = nextPage; - } - } finally { - if (query.trim() === q) webSearching = false; - } - } - } finally { - loadingMore = false; - } - } - $effect(() => { - const q = query.trim(); - if (debounceTimer) clearTimeout(debounceTimer); - if (skipNextSearch) { - // Snapshot-Restore hat hits/webHits/searchedFor wiederhergestellt — - // nicht erneut fetchen. - skipNextSearch = false; - return; - } - if (q.length <= 3) { - hits = []; - webHits = []; - searchedFor = null; - searching = false; - webSearching = false; - webError = null; - return; - } - searching = true; - webHits = []; - webSearching = false; - webError = null; - debounceTimer = setTimeout(() => { - void runSearch(q); - }, 300); + // eslint-disable-next-line @typescript-eslint/no-unused-expressions + store.query; // register reactive dep + store.runDebounced(); }); function submit(e: SubmitEvent) { e.preventDefault(); - const q = query.trim(); + const q = store.query.trim(); if (q.length <= 3) return; - if (debounceTimer) clearTimeout(debounceTimer); - searching = true; - void runSearch(q); + void store.runSearch(q); } async function dismissFromRecent(recipeId: number, e: MouseEvent) { @@ -367,7 +204,7 @@ }); } - const activeSearch = $derived(query.trim().length > 3); + const activeSearch = $derived(store.query.trim().length > 3);
@@ -378,7 +215,7 @@ - {#if searching && hits.length === 0 && webHits.length === 0} + {#if store.searching && store.hits.length === 0 && store.webHits.length === 0} {:else} - {#if hits.length > 0} + {#if store.hits.length > 0} - {:else if searchedFor === query.trim() && !webSearching && webHits.length === 0 && !webError} -

Keine lokalen Rezepte für „{searchedFor}".

+ {:else if store.searchedFor === store.query.trim() && !store.webSearching && store.webHits.length === 0 && !store.webError} +

Keine lokalen Rezepte für „{store.searchedFor}".

{/if} - {#if webHits.length > 0} - {#if hits.length > 0} + {#if store.webHits.length > 0} + {#if store.hits.length > 0}

Aus dem Internet

- {:else if searchedFor === query.trim()} + {:else if store.searchedFor === store.query.trim()}

- Keine lokalen Rezepte für „{searchedFor}" — Ergebnisse aus dem Internet: + Keine lokalen Rezepte für „{store.searchedFor}" — Ergebnisse aus dem Internet:

{/if}
{/if} - {#if webSearching} + {#if store.webSearching} - {:else if webError && webHits.length === 0} -

Internet-Suche zurzeit nicht möglich: {webError}

+ {:else if store.webError && store.webHits.length === 0} +

Internet-Suche zurzeit nicht möglich: {store.webError}

{/if} - {#if searchedFor === query.trim() && !(localExhausted && webExhausted) && !(searching && hits.length === 0)} + {#if store.searchedFor === store.query.trim() && !(store.localExhausted && store.webExhausted) && !(store.searching && store.hits.length === 0)}
-
{/if}