import type { SearchHit } from '$lib/server/recipes/search-local'; import type { WebHit } from '$lib/server/search/searxng'; export type SearchSnapshot = { query: string; hits: SearchHit[]; webHits: WebHit[]; searchedFor: string | null; webError: string | null; localExhausted: boolean; webPageno: number; webExhausted: boolean; }; export type SearchStoreOptions = { pageSize?: number; debounceMs?: number; filterDebounceMs?: number; minQueryLength?: number; filterParam?: () => string; fetchImpl?: typeof fetch; }; export class SearchStore { query = $state(''); hits = $state([]); webHits = $state([]); searching = $state(false); webSearching = $state(false); webError = $state(null); searchedFor = $state(null); localExhausted = $state(false); webPageno = $state(0); webExhausted = $state(false); loadingMore = $state(false); private readonly pageSize: number; private readonly debounceMs: number; private readonly filterDebounceMs: number; private readonly minQueryLength: number; private readonly filterParam: () => string; private readonly fetchImpl: typeof fetch; private debounceTimer: ReturnType | null = null; private skipNextDebounce = false; constructor(opts: SearchStoreOptions = {}) { this.pageSize = opts.pageSize ?? 30; this.debounceMs = opts.debounceMs ?? 300; this.filterDebounceMs = opts.filterDebounceMs ?? 150; this.minQueryLength = opts.minQueryLength ?? 4; this.filterParam = opts.filterParam ?? (() => ''); this.fetchImpl = opts.fetchImpl ?? ((...a) => fetch(...a)); } runDebounced(): void { if (this.debounceTimer) clearTimeout(this.debounceTimer); if (this.skipNextDebounce) { this.skipNextDebounce = false; return; } const q = this.query.trim(); if (q.length < this.minQueryLength) { this.resetResults(); return; } this.searching = true; this.webHits = []; this.webSearching = false; this.webError = null; this.debounceTimer = setTimeout(() => { void this.runSearch(q); }, this.debounceMs); } async runSearch(q: string): Promise { if (this.debounceTimer) clearTimeout(this.debounceTimer); this.debounceTimer = null; this.localExhausted = false; this.webPageno = 0; this.webExhausted = false; try { const res = await this.fetchImpl( `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}${this.filterParam()}` ); const body = (await res.json()) as { hits: SearchHit[] }; if (this.query.trim() !== q) return; this.hits = body.hits; this.searchedFor = q; if (this.hits.length < this.pageSize) this.localExhausted = true; if (this.hits.length === 0) { await this.runWebSearch(q, 1); } } finally { if (this.query.trim() === q) this.searching = false; } } private async runWebSearch(q: string, pageno: number): Promise { this.webSearching = true; try { const res = await this.fetchImpl( `/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${pageno}${this.filterParam()}` ); if (this.query.trim() !== q) return; if (!res.ok) { const err = (await res.json().catch(() => ({}))) as { message?: string }; this.webError = err.message ?? `HTTP ${res.status}`; this.webExhausted = true; return; } const body = (await res.json()) as { hits: WebHit[] }; this.webHits = pageno === 1 ? body.hits : [...this.webHits, ...body.hits]; this.webPageno = pageno; if (body.hits.length === 0) this.webExhausted = true; } finally { if (this.query.trim() === q) this.webSearching = false; } } async loadMore(): Promise { if (this.loadingMore) return; const q = this.query.trim(); if (!q) return; this.loadingMore = true; try { if (!this.localExhausted) { const res = await this.fetchImpl( `/api/recipes/search?q=${encodeURIComponent(q)}&limit=${this.pageSize}&offset=${this.hits.length}${this.filterParam()}` ); const body = (await res.json()) as { hits: SearchHit[] }; if (this.query.trim() !== q) return; const more = body.hits; const seen = new Set(this.hits.map((h) => h.id)); const deduped = more.filter((h) => !seen.has(h.id)); this.hits = [...this.hits, ...deduped]; if (more.length < this.pageSize) this.localExhausted = true; } else if (!this.webExhausted) { const nextPage = this.webPageno + 1; const wasEmpty = this.webHits.length === 0; if (wasEmpty) this.webSearching = true; try { const res = await this.fetchImpl( `/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${this.filterParam()}` ); if (this.query.trim() !== q) return; if (!res.ok) { const err = (await res.json().catch(() => ({}))) as { message?: string }; this.webError = err.message ?? `HTTP ${res.status}`; this.webExhausted = true; return; } const body = (await res.json()) as { hits: WebHit[] }; const more = body.hits; const seen = new Set(this.webHits.map((h) => h.url)); const deduped = more.filter((h) => !seen.has(h.url)); if (deduped.length === 0) { this.webExhausted = true; } else { this.webHits = [...this.webHits, ...deduped]; this.webPageno = nextPage; } } finally { if (this.query.trim() === q) this.webSearching = false; } } } finally { this.loadingMore = false; } } reSearch(): void { const q = this.query.trim(); if (q.length < this.minQueryLength) return; if (this.debounceTimer) clearTimeout(this.debounceTimer); this.searching = true; this.webHits = []; this.webSearching = false; this.webError = null; this.debounceTimer = setTimeout(() => void this.runSearch(q), this.filterDebounceMs); } reset(): void { if (this.debounceTimer) clearTimeout(this.debounceTimer); this.debounceTimer = null; this.query = ''; this.resetResults(); } private resetResults(): void { this.hits = []; this.webHits = []; this.searchedFor = null; this.searching = false; this.webSearching = false; this.webError = null; this.localExhausted = false; this.webPageno = 0; this.webExhausted = false; } captureSnapshot(): SearchSnapshot { return { query: this.query, hits: this.hits, webHits: this.webHits, searchedFor: this.searchedFor, webError: this.webError, localExhausted: this.localExhausted, webPageno: this.webPageno, webExhausted: this.webExhausted }; } restoreSnapshot(s: SearchSnapshot): void { this.skipNextDebounce = true; this.query = s.query; this.hits = s.hits; this.webHits = s.webHits; this.searchedFor = s.searchedFor; this.webError = s.webError; this.localExhausted = s.localExhausted; this.webPageno = s.webPageno; this.webExhausted = s.webExhausted; } }