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 { installPrompt } from '$lib/client/install-prompt.svelte';
|
||||
import { registerServiceWorker } from '$lib/client/sw-register';
|
||||
import type { SearchHit } from '$lib/server/recipes/search-local';
|
||||
import type { WebHit } from '$lib/server/search/searxng';
|
||||
import { SearchStore } from '$lib/client/search.svelte';
|
||||
|
||||
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 navLocalExhausted = $state(false);
|
||||
let navWebPageno = $state(0);
|
||||
let navWebExhausted = $state(false);
|
||||
let navLoadingMore = $state(false);
|
||||
let navContainer: HTMLElement | undefined = $state();
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let menuOpen = $state(false);
|
||||
let menuContainer: HTMLElement | undefined = $state();
|
||||
|
||||
@@ -44,123 +38,21 @@
|
||||
$page.url.pathname.startsWith('/recipes/') || $page.url.pathname === '/preview'
|
||||
);
|
||||
|
||||
function filterParam(): string {
|
||||
const p = searchFilterStore.queryParam;
|
||||
return p ? `&domains=${encodeURIComponent(p)}` : '';
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
const q = navQuery.trim();
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
if (q.length <= 3) {
|
||||
navHits = [];
|
||||
navWebHits = [];
|
||||
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);
|
||||
// Bare reads register the reactive deps; then kick the store.
|
||||
const q = navStore.query;
|
||||
navStore.runDebounced();
|
||||
// navOpen follows query length: open while typing, close when cleared.
|
||||
navOpen = q.trim().length > 3;
|
||||
});
|
||||
|
||||
async function loadMoreNav() {
|
||||
if (navLoadingMore) return;
|
||||
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 loadMoreNav() {
|
||||
return navStore.loadMore();
|
||||
}
|
||||
|
||||
function submitNav(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const q = navQuery.trim();
|
||||
const q = navStore.query.trim();
|
||||
if (!q) return;
|
||||
navOpen = false;
|
||||
void goto(`/?q=${encodeURIComponent(q)}`);
|
||||
@@ -184,15 +76,11 @@
|
||||
|
||||
function pickHit() {
|
||||
navOpen = false;
|
||||
navQuery = '';
|
||||
navHits = [];
|
||||
navWebHits = [];
|
||||
navStore.reset();
|
||||
}
|
||||
|
||||
afterNavigate(() => {
|
||||
navQuery = '';
|
||||
navHits = [];
|
||||
navWebHits = [];
|
||||
navStore.reset();
|
||||
navOpen = false;
|
||||
menuOpen = false;
|
||||
// Badge nach jeder Client-Navigation frisch halten — sonst kann er
|
||||
@@ -239,9 +127,9 @@
|
||||
<SearchFilter inline />
|
||||
<input
|
||||
type="search"
|
||||
bind:value={navQuery}
|
||||
bind:value={navStore.query}
|
||||
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…"
|
||||
autocomplete="off"
|
||||
@@ -251,12 +139,12 @@
|
||||
</form>
|
||||
{#if navOpen}
|
||||
<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" />
|
||||
{:else}
|
||||
{#if navHits.length > 0}
|
||||
{#if navStore.hits.length > 0}
|
||||
<ul class="dd-list">
|
||||
{#each navHits as r (r.id)}
|
||||
{#each navStore.hits as r (r.id)}
|
||||
<li>
|
||||
<a
|
||||
href={`/recipes/${r.id}`}
|
||||
@@ -282,14 +170,14 @@
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if navWebHits.length > 0}
|
||||
{#if navHits.length > 0}
|
||||
{#if navStore.webHits.length > 0}
|
||||
{#if navStore.hits.length > 0}
|
||||
<p class="dd-section">Aus dem Internet</p>
|
||||
{:else}
|
||||
<p class="dd-section">Keine lokalen Rezepte – aus dem Internet:</p>
|
||||
{/if}
|
||||
<ul class="dd-list">
|
||||
{#each navWebHits as w (w.url)}
|
||||
{#each navStore.webHits as w (w.url)}
|
||||
<li>
|
||||
<a
|
||||
href={`/preview?url=${encodeURIComponent(w.url)}`}
|
||||
@@ -313,23 +201,23 @@
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
{#if navWebSearching}
|
||||
{#if navStore.webSearching}
|
||||
<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>
|
||||
{: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>
|
||||
{/if}
|
||||
|
||||
{#if !(navLocalExhausted && navWebExhausted) && (navHits.length > 0 || navWebHits.length > 0)}
|
||||
{#if !(navStore.localExhausted && navStore.webExhausted) && (navStore.hits.length > 0 || navStore.webHits.length > 0)}
|
||||
<button
|
||||
class="dd-web"
|
||||
type="button"
|
||||
onclick={loadMoreNav}
|
||||
disabled={navLoadingMore || navWebSearching}
|
||||
disabled={navStore.loadingMore || navStore.webSearching}
|
||||
>
|
||||
<span
|
||||
>{navLoadingMore || navWebSearching
|
||||
>{navStore.loadingMore || navStore.webSearching
|
||||
? 'Lade …'
|
||||
: '+ weitere Ergebnisse'}</span
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user