refactor(layout): Header-Dropdown nutzt SearchStore

Ersetzt die 10 lokalen $state-Felder, den Debounce-$effect und die
lokalen Search-Funktionen durch eine SearchStore-Instanz. Nav-Open-
Toggle, Click-outside und Menu-State bleiben lokal — UI-Concerns.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
hsiegeln
2026-04-19 12:51:11 +02:00
parent 4b17f19038
commit 0ca42f3329

View File

@@ -17,26 +17,20 @@
import { network } from '$lib/client/network.svelte'; import { network } from '$lib/client/network.svelte';
import { installPrompt } from '$lib/client/install-prompt.svelte'; import { installPrompt } from '$lib/client/install-prompt.svelte';
import { registerServiceWorker } from '$lib/client/sw-register'; import { registerServiceWorker } from '$lib/client/sw-register';
import type { SearchHit } from '$lib/server/recipes/search-local'; import { SearchStore } from '$lib/client/search.svelte';
import type { WebHit } from '$lib/server/search/searxng';
let { children } = $props(); let { children } = $props();
const NAV_PAGE_SIZE = 30; const navStore = new SearchStore({
pageSize: 30,
filterParam: () => {
const p = searchFilterStore.queryParam;
return p ? `&domains=${encodeURIComponent(p)}` : '';
}
});
let navQuery = $state('');
let navHits = $state<SearchHit[]>([]);
let navWebHits = $state<WebHit[]>([]);
let navSearching = $state(false);
let navWebSearching = $state(false);
let navWebError = $state<string | null>(null);
let navOpen = $state(false); let navOpen = $state(false);
let navLocalExhausted = $state(false);
let navWebPageno = $state(0);
let navWebExhausted = $state(false);
let navLoadingMore = $state(false);
let navContainer: HTMLElement | undefined = $state(); let navContainer: HTMLElement | undefined = $state();
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
let menuOpen = $state(false); let menuOpen = $state(false);
let menuContainer: HTMLElement | undefined = $state(); let menuContainer: HTMLElement | undefined = $state();
@@ -44,123 +38,21 @@
$page.url.pathname.startsWith('/recipes/') || $page.url.pathname === '/preview' $page.url.pathname.startsWith('/recipes/') || $page.url.pathname === '/preview'
); );
function filterParam(): string {
const p = searchFilterStore.queryParam;
return p ? `&domains=${encodeURIComponent(p)}` : '';
}
$effect(() => { $effect(() => {
const q = navQuery.trim(); // Bare reads register the reactive deps; then kick the store.
if (debounceTimer) clearTimeout(debounceTimer); const q = navStore.query;
if (q.length <= 3) { navStore.runDebounced();
navHits = []; // navOpen follows query length: open while typing, close when cleared.
navWebHits = []; navOpen = q.trim().length > 3;
navSearching = false;
navWebSearching = false;
navWebError = null;
navOpen = false;
navLocalExhausted = false;
navWebPageno = 0;
navWebExhausted = false;
return;
}
navSearching = true;
navWebHits = [];
navWebSearching = false;
navWebError = null;
navOpen = true;
navLocalExhausted = false;
navWebPageno = 0;
navWebExhausted = false;
debounceTimer = setTimeout(async () => {
try {
const res = await fetch(
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${NAV_PAGE_SIZE}${filterParam()}`
);
const body = await res.json();
if (navQuery.trim() !== q) return;
navHits = body.hits;
if (navHits.length < NAV_PAGE_SIZE) navLocalExhausted = true;
if (navHits.length === 0) {
navWebSearching = true;
try {
const wres = await fetch(
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=1${filterParam()}`
);
if (navQuery.trim() !== q) return;
if (!wres.ok) {
const err = await wres.json().catch(() => ({}));
navWebError = err.message ?? `HTTP ${wres.status}`;
navWebExhausted = true;
} else {
const wbody = await wres.json();
navWebHits = wbody.hits;
navWebPageno = 1;
if (navWebHits.length === 0) navWebExhausted = true;
}
} finally {
if (navQuery.trim() === q) navWebSearching = false;
}
}
} finally {
if (navQuery.trim() === q) navSearching = false;
}
}, 300);
}); });
async function loadMoreNav() { function loadMoreNav() {
if (navLoadingMore) return; return navStore.loadMore();
const q = navQuery.trim();
if (!q) return;
navLoadingMore = true;
try {
if (!navLocalExhausted) {
const res = await fetch(
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${NAV_PAGE_SIZE}&offset=${navHits.length}${filterParam()}`
);
const body = await res.json();
if (navQuery.trim() !== q) return;
const more = body.hits as SearchHit[];
const seen = new Set(navHits.map((h) => h.id));
const deduped = more.filter((h) => !seen.has(h.id));
navHits = [...navHits, ...deduped];
if (more.length < NAV_PAGE_SIZE) navLocalExhausted = true;
} else if (!navWebExhausted) {
const nextPage = navWebPageno + 1;
navWebSearching = navWebHits.length === 0;
try {
const wres = await fetch(
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}${filterParam()}`
);
if (navQuery.trim() !== q) return;
if (!wres.ok) {
const err = await wres.json().catch(() => ({}));
navWebError = err.message ?? `HTTP ${wres.status}`;
navWebExhausted = true;
return;
}
const wbody = await wres.json();
const more = wbody.hits as WebHit[];
const seen = new Set(navWebHits.map((h) => h.url));
const deduped = more.filter((h) => !seen.has(h.url));
if (deduped.length === 0) {
navWebExhausted = true;
} else {
navWebHits = [...navWebHits, ...deduped];
navWebPageno = nextPage;
}
} finally {
if (navQuery.trim() === q) navWebSearching = false;
}
}
} finally {
navLoadingMore = false;
}
} }
function submitNav(e: SubmitEvent) { function submitNav(e: SubmitEvent) {
e.preventDefault(); e.preventDefault();
const q = navQuery.trim(); const q = navStore.query.trim();
if (!q) return; if (!q) return;
navOpen = false; navOpen = false;
void goto(`/?q=${encodeURIComponent(q)}`); void goto(`/?q=${encodeURIComponent(q)}`);
@@ -184,15 +76,11 @@
function pickHit() { function pickHit() {
navOpen = false; navOpen = false;
navQuery = ''; navStore.reset();
navHits = [];
navWebHits = [];
} }
afterNavigate(() => { afterNavigate(() => {
navQuery = ''; navStore.reset();
navHits = [];
navWebHits = [];
navOpen = false; navOpen = false;
menuOpen = false; menuOpen = false;
// Badge nach jeder Client-Navigation frisch halten — sonst kann er // Badge nach jeder Client-Navigation frisch halten — sonst kann er
@@ -239,9 +127,9 @@
<SearchFilter inline /> <SearchFilter inline />
<input <input
type="search" type="search"
bind:value={navQuery} bind:value={navStore.query}
onfocus={() => { onfocus={() => {
if (navHits.length > 0 || navQuery.trim().length > 3) navOpen = true; if (navStore.hits.length > 0 || navStore.query.trim().length > 3) navOpen = true;
}} }}
placeholder="Rezept suchen…" placeholder="Rezept suchen…"
autocomplete="off" autocomplete="off"
@@ -251,12 +139,12 @@
</form> </form>
{#if navOpen} {#if navOpen}
<div class="dropdown" role="listbox"> <div class="dropdown" role="listbox">
{#if navSearching && navHits.length === 0 && navWebHits.length === 0} {#if navStore.searching && navStore.hits.length === 0 && navStore.webHits.length === 0}
<SearchLoader scope="local" size="sm" /> <SearchLoader scope="local" size="sm" />
{:else} {:else}
{#if navHits.length > 0} {#if navStore.hits.length > 0}
<ul class="dd-list"> <ul class="dd-list">
{#each navHits as r (r.id)} {#each navStore.hits as r (r.id)}
<li> <li>
<a <a
href={`/recipes/${r.id}`} href={`/recipes/${r.id}`}
@@ -282,14 +170,14 @@
</ul> </ul>
{/if} {/if}
{#if navWebHits.length > 0} {#if navStore.webHits.length > 0}
{#if navHits.length > 0} {#if navStore.hits.length > 0}
<p class="dd-section">Aus dem Internet</p> <p class="dd-section">Aus dem Internet</p>
{:else} {:else}
<p class="dd-section">Keine lokalen Rezepte aus dem Internet:</p> <p class="dd-section">Keine lokalen Rezepte aus dem Internet:</p>
{/if} {/if}
<ul class="dd-list"> <ul class="dd-list">
{#each navWebHits as w (w.url)} {#each navStore.webHits as w (w.url)}
<li> <li>
<a <a
href={`/preview?url=${encodeURIComponent(w.url)}`} href={`/preview?url=${encodeURIComponent(w.url)}`}
@@ -313,23 +201,23 @@
</ul> </ul>
{/if} {/if}
{#if navWebSearching} {#if navStore.webSearching}
<SearchLoader scope="web" size="sm" /> <SearchLoader scope="web" size="sm" />
{:else if navWebError && navWebHits.length === 0} {:else if navStore.webError && navStore.webHits.length === 0}
<p class="dd-status dd-error">Internet-Suche zurzeit nicht möglich.</p> <p class="dd-status dd-error">Internet-Suche zurzeit nicht möglich.</p>
{:else if navHits.length === 0 && navWebHits.length === 0 && !navSearching} {:else if navStore.hits.length === 0 && navStore.webHits.length === 0 && !navStore.searching}
<p class="dd-status">Auch im Internet nichts gefunden.</p> <p class="dd-status">Auch im Internet nichts gefunden.</p>
{/if} {/if}
{#if !(navLocalExhausted && navWebExhausted) && (navHits.length > 0 || navWebHits.length > 0)} {#if !(navStore.localExhausted && navStore.webExhausted) && (navStore.hits.length > 0 || navStore.webHits.length > 0)}
<button <button
class="dd-web" class="dd-web"
type="button" type="button"
onclick={loadMoreNav} onclick={loadMoreNav}
disabled={navLoadingMore || navWebSearching} disabled={navStore.loadingMore || navStore.webSearching}
> >
<span <span
>{navLoadingMore || navWebSearching >{navStore.loadingMore || navStore.webSearching
? 'Lade …' ? 'Lade …'
: '+ weitere Ergebnisse'}</span : '+ weitere Ergebnisse'}</span
> >