refactor(home): Live-Search auf SearchStore migriert
Entfernt die duplizierten $state-Felder, runSearch, loadMore und beide Debounce-Effekte. URL-Sync, Snapshot und Filter-Re-Search bleiben hier — delegieren aber an den Store. All-Recipes-Infinite- Scroll unberuehrt (separate UI-Concern). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,32 +4,27 @@
|
|||||||
import { CookingPot, X } from 'lucide-svelte';
|
import { CookingPot, X } from 'lucide-svelte';
|
||||||
import type { Snapshot } from './$types';
|
import type { Snapshot } from './$types';
|
||||||
import type { SearchHit } from '$lib/server/recipes/search-local';
|
import type { SearchHit } from '$lib/server/recipes/search-local';
|
||||||
import type { WebHit } from '$lib/server/search/searxng';
|
|
||||||
import { randomQuote } from '$lib/quotes';
|
import { randomQuote } from '$lib/quotes';
|
||||||
import SearchLoader from '$lib/components/SearchLoader.svelte';
|
import SearchLoader from '$lib/components/SearchLoader.svelte';
|
||||||
import SearchFilter from '$lib/components/SearchFilter.svelte';
|
import SearchFilter from '$lib/components/SearchFilter.svelte';
|
||||||
import { profileStore } from '$lib/client/profile.svelte';
|
import { profileStore } from '$lib/client/profile.svelte';
|
||||||
import { searchFilterStore } from '$lib/client/search-filter.svelte';
|
import { searchFilterStore } from '$lib/client/search-filter.svelte';
|
||||||
import { requireOnline } from '$lib/client/require-online';
|
import { requireOnline } from '$lib/client/require-online';
|
||||||
|
import { SearchStore, type SearchSnapshot } from '$lib/client/search.svelte';
|
||||||
|
|
||||||
const LOCAL_PAGE = 30;
|
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 quote = $state('');
|
||||||
let recent = $state<SearchHit[]>([]);
|
let recent = $state<SearchHit[]>([]);
|
||||||
let favorites = $state<SearchHit[]>([]);
|
let favorites = $state<SearchHit[]>([]);
|
||||||
let hits = $state<SearchHit[]>([]);
|
|
||||||
let webHits = $state<WebHit[]>([]);
|
|
||||||
let searching = $state(false);
|
|
||||||
let webSearching = $state(false);
|
|
||||||
let webError = $state<string | null>(null);
|
|
||||||
let searchedFor = $state<string | null>(null);
|
|
||||||
let localExhausted = $state(false);
|
|
||||||
let webPageno = $state(0);
|
|
||||||
let webExhausted = $state(false);
|
|
||||||
let loadingMore = $state(false);
|
|
||||||
let skipNextSearch = false;
|
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
|
||||||
|
|
||||||
const ALL_PAGE = 10;
|
const ALL_PAGE = 10;
|
||||||
type AllSort = 'name' | 'rating' | 'cooked' | 'created';
|
type AllSort = 'name' | 'rating' | 'cooked' | 'created';
|
||||||
@@ -47,39 +42,9 @@
|
|||||||
let allChips: HTMLElement | undefined = $state();
|
let allChips: HTMLElement | undefined = $state();
|
||||||
let allObserver: IntersectionObserver | null = null;
|
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<SearchSnapshot> = {
|
export const snapshot: Snapshot<SearchSnapshot> = {
|
||||||
capture: () => ({
|
capture: () => store.captureSnapshot(),
|
||||||
query,
|
restore: (s) => store.restoreSnapshot(s)
|
||||||
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;
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
async function loadRecent() {
|
async function loadRecent() {
|
||||||
@@ -152,7 +117,7 @@
|
|||||||
// Restore query from URL so history.back() from preview/recipe
|
// Restore query from URL so history.back() from preview/recipe
|
||||||
// brings the user back to the same search results.
|
// brings the user back to the same search results.
|
||||||
const urlQ = ($page.url.searchParams.get('q') ?? '').trim();
|
const urlQ = ($page.url.searchParams.get('q') ?? '').trim();
|
||||||
if (urlQ) query = urlQ;
|
if (urlQ) store.query = urlQ;
|
||||||
void loadRecent();
|
void loadRecent();
|
||||||
void searchFilterStore.load();
|
void searchFilterStore.load();
|
||||||
const saved = localStorage.getItem('kochwas.allSort');
|
const saved = localStorage.getItem('kochwas.allSort');
|
||||||
@@ -188,14 +153,7 @@
|
|||||||
$effect(() => {
|
$effect(() => {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
searchFilterStore.active;
|
searchFilterStore.active;
|
||||||
const q = query.trim();
|
store.reSearch();
|
||||||
if (!q || q.length <= 3) return;
|
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
|
||||||
searching = true;
|
|
||||||
webHits = [];
|
|
||||||
webSearching = false;
|
|
||||||
webError = null;
|
|
||||||
debounceTimer = setTimeout(() => void runSearch(q), 150);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Sync current query back into the URL as ?q=... via replaceState,
|
// 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.
|
// when the user clicks a result or otherwise navigates away.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (typeof window === 'undefined') return;
|
if (typeof window === 'undefined') return;
|
||||||
const q = query.trim();
|
const q = store.query.trim();
|
||||||
const url = new URL(window.location.href);
|
const url = new URL(window.location.href);
|
||||||
const current = url.searchParams.get('q') ?? '';
|
const current = url.searchParams.get('q') ?? '';
|
||||||
if (q === current) return;
|
if (q === current) return;
|
||||||
@@ -221,138 +179,17 @@
|
|||||||
void loadFavorites(active.id);
|
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(() => {
|
$effect(() => {
|
||||||
const q = query.trim();
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
store.query; // register reactive dep
|
||||||
if (skipNextSearch) {
|
store.runDebounced();
|
||||||
// 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);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function submit(e: SubmitEvent) {
|
function submit(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const q = query.trim();
|
const q = store.query.trim();
|
||||||
if (q.length <= 3) return;
|
if (q.length <= 3) return;
|
||||||
if (debounceTimer) clearTimeout(debounceTimer);
|
void store.runSearch(q);
|
||||||
searching = true;
|
|
||||||
void runSearch(q);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function dismissFromRecent(recipeId: number, e: MouseEvent) {
|
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);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<section class="hero">
|
<section class="hero">
|
||||||
@@ -378,7 +215,7 @@
|
|||||||
<SearchFilter inline />
|
<SearchFilter inline />
|
||||||
<input
|
<input
|
||||||
type="search"
|
type="search"
|
||||||
bind:value={query}
|
bind:value={store.query}
|
||||||
placeholder="Rezept suchen…"
|
placeholder="Rezept suchen…"
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
inputmode="search"
|
inputmode="search"
|
||||||
@@ -390,12 +227,12 @@
|
|||||||
|
|
||||||
{#if activeSearch}
|
{#if activeSearch}
|
||||||
<section class="results">
|
<section class="results">
|
||||||
{#if searching && hits.length === 0 && webHits.length === 0}
|
{#if store.searching && store.hits.length === 0 && store.webHits.length === 0}
|
||||||
<SearchLoader scope="local" />
|
<SearchLoader scope="local" />
|
||||||
{:else}
|
{:else}
|
||||||
{#if hits.length > 0}
|
{#if store.hits.length > 0}
|
||||||
<ul class="cards">
|
<ul class="cards">
|
||||||
{#each hits as r (r.id)}
|
{#each store.hits as r (r.id)}
|
||||||
<li>
|
<li>
|
||||||
<a href={`/recipes/${r.id}`} class="card">
|
<a href={`/recipes/${r.id}`} class="card">
|
||||||
{#if r.image_path}
|
{#if r.image_path}
|
||||||
@@ -413,20 +250,20 @@
|
|||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
{:else if searchedFor === query.trim() && !webSearching && webHits.length === 0 && !webError}
|
{:else if store.searchedFor === store.query.trim() && !store.webSearching && store.webHits.length === 0 && !store.webError}
|
||||||
<p class="muted no-local-msg">Keine lokalen Rezepte für „{searchedFor}".</p>
|
<p class="muted no-local-msg">Keine lokalen Rezepte für „{store.searchedFor}".</p>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if webHits.length > 0}
|
{#if store.webHits.length > 0}
|
||||||
{#if hits.length > 0}
|
{#if store.hits.length > 0}
|
||||||
<h3 class="sep">Aus dem Internet</h3>
|
<h3 class="sep">Aus dem Internet</h3>
|
||||||
{:else if searchedFor === query.trim()}
|
{:else if store.searchedFor === store.query.trim()}
|
||||||
<p class="muted no-local-msg">
|
<p class="muted no-local-msg">
|
||||||
Keine lokalen Rezepte für „{searchedFor}" — Ergebnisse aus dem Internet:
|
Keine lokalen Rezepte für „{store.searchedFor}" — Ergebnisse aus dem Internet:
|
||||||
</p>
|
</p>
|
||||||
{/if}
|
{/if}
|
||||||
<ul class="cards">
|
<ul class="cards">
|
||||||
{#each webHits as w (w.url)}
|
{#each store.webHits as w (w.url)}
|
||||||
<li>
|
<li>
|
||||||
<a class="card" href={`/preview?url=${encodeURIComponent(w.url)}`}>
|
<a class="card" href={`/preview?url=${encodeURIComponent(w.url)}`}>
|
||||||
{#if w.thumbnail}
|
{#if w.thumbnail}
|
||||||
@@ -444,16 +281,16 @@
|
|||||||
</ul>
|
</ul>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
{#if webSearching}
|
{#if store.webSearching}
|
||||||
<SearchLoader scope="web" />
|
<SearchLoader scope="web" />
|
||||||
{:else if webError && webHits.length === 0}
|
{:else if store.webError && store.webHits.length === 0}
|
||||||
<p class="error">Internet-Suche zurzeit nicht möglich: {webError}</p>
|
<p class="error">Internet-Suche zurzeit nicht möglich: {store.webError}</p>
|
||||||
{/if}
|
{/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)}
|
||||||
<div class="more-cta">
|
<div class="more-cta">
|
||||||
<button class="more-btn" onclick={loadMore} disabled={loadingMore || webSearching}>
|
<button class="more-btn" onclick={() => store.loadMore()} disabled={store.loadingMore || store.webSearching}>
|
||||||
{loadingMore || webSearching ? 'Lade …' : '+ weitere Ergebnisse'}
|
{store.loadingMore || store.webSearching ? 'Lade …' : '+ weitere Ergebnisse'}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
Reference in New Issue
Block a user