feat(search): SearchStore fuer Live-Search mit Web-Fallback
Extrahiert die duplizierte Such-Logik aus +page.svelte und +layout.svelte in eine gemeinsame Klasse. Pure Datenschicht mit injizierbarem fetch — UI-Concerns (URL-Sync, Dropdown, Snapshot) bleiben in den Komponenten. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
238
src/lib/client/search.svelte.ts
Normal file
238
src/lib/client/search.svelte.ts
Normal file
@@ -0,0 +1,238 @@
|
||||
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<SearchHit[]>([]);
|
||||
webHits = $state<WebHit[]>([]);
|
||||
searching = $state(false);
|
||||
webSearching = $state(false);
|
||||
webError = $state<string | null>(null);
|
||||
searchedFor = $state<string | null>(null);
|
||||
localExhausted = $state(false);
|
||||
webPageno = $state(0);
|
||||
webExhausted = $state(false);
|
||||
loadingMore = $state(false);
|
||||
|
||||
get query(): string {
|
||||
return this.#query;
|
||||
}
|
||||
|
||||
set query(v: string) {
|
||||
this.#query = v;
|
||||
// Race-guard: when the query diverges from the last committed search,
|
||||
// any currently shown hits are stale and must be cleared immediately.
|
||||
if (this.searchedFor !== null && v.trim() !== this.searchedFor) {
|
||||
this.hits = [];
|
||||
this.webHits = [];
|
||||
this.searchedFor = null;
|
||||
}
|
||||
}
|
||||
|
||||
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<typeof setTimeout> | 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(_q: string): 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user