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:
@@ -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
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user