feat(search): live debounced search with inline hits and header dropdown
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 53s

Homepage (/):
- Tippen > 3 Zeichen + 300 ms Debounce → lokale Suche feuert automatisch
- Treffer erscheinen direkt unter dem Suchfeld als Karten-Grid
- "Zuletzt hinzugefügt" wird ausgeblendet, sobald aktiv gesucht wird
- 0 Treffer + fertig gesucht → Inline-Button "Im Internet weitersuchen"

Header (nur auf /recipes/[id] und /preview):
- Gleiche Debounce-Logik, aber Treffer in einem Dropdown unterm Feld
- Dropdown: kompakte Zeilen mit Thumbnail, Titel, Domain
- Fußzeile des Dropdown: "Im Internet weitersuchen"
- Click-outside und Escape schließen das Dropdown
- afterNavigate setzt Query nach dem Klick auf einen Treffer zurück
- Header-Breite ist jetzt auf 760 px begrenzt (gleich wie Rezept-Content),
  damit die Suchleiste nie breiter wird als das Rezept darunter

Race-Safety: Ein zweites Tippen während laufender Fetch überschreibt
die Ergebnisse des ersten Requests nicht (Query-Vergleich vor Write).
This commit is contained in:
hsiegeln
2026-04-17 17:41:10 +02:00
parent 84655151be
commit d737618312
2 changed files with 337 additions and 49 deletions

View File

@@ -1,23 +1,61 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import type { SearchHit } from '$lib/server/recipes/search-local';
let query = $state('');
let recent = $state<SearchHit[]>([]);
let hits = $state<SearchHit[]>([]);
let searching = $state(false);
let searchedFor = $state<string | null>(null);
onMount(async () => {
const res = await fetch('/api/recipes/search');
const body = await res.json();
recent = body.hits;
});
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
const q = query.trim();
if (debounceTimer) clearTimeout(debounceTimer);
if (q.length <= 3) {
hits = [];
searchedFor = null;
searching = false;
return;
}
searching = true;
debounceTimer = setTimeout(async () => {
try {
const res = await fetch(`/api/recipes/search?q=${encodeURIComponent(q)}`);
const body = await res.json();
if (query.trim() === q) {
hits = body.hits;
searchedFor = q;
}
} finally {
if (query.trim() === q) searching = false;
}
}, 300);
});
function submit(e: SubmitEvent) {
e.preventDefault();
const q = query.trim();
if (!q) return;
void goto(`/search?q=${encodeURIComponent(q)}`);
}
const activeSearch = $derived(query.trim().length > 3);
</script>
<section class="hero">
<h1>Kochwas</h1>
<form method="GET" action="/search">
<form onsubmit={submit}>
<input
type="search"
name="q"
bind:value={query}
placeholder="Rezept suchen…"
autocomplete="off"
@@ -28,7 +66,40 @@
</form>
</section>
{#if recent.length > 0}
{#if activeSearch}
<section class="results">
{#if searching && hits.length === 0}
<p class="muted">Suche läuft …</p>
{:else if hits.length > 0}
<ul class="cards">
{#each hits as r (r.id)}
<li>
<a href={`/recipes/${r.id}`} class="card">
{#if r.image_path}
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
{:else}
<div class="placeholder">🥘</div>
{/if}
<div class="card-body">
<div class="title">{r.title}</div>
{#if r.source_domain}
<div class="domain">{r.source_domain}</div>
{/if}
</div>
</a>
</li>
{/each}
</ul>
{:else if searchedFor === query.trim()}
<div class="no-local">
<p>Keine lokalen Rezepte für „{searchedFor}".</p>
<a class="web-btn" href={`/search/web?q=${encodeURIComponent(searchedFor)}`}>
🌐 Im Internet weitersuchen
</a>
</div>
{/if}
</section>
{:else if recent.length > 0}
<section class="recent">
<h2>Zuletzt hinzugefügt</h2>
<ul class="cards">
@@ -91,14 +162,20 @@
min-height: 48px;
cursor: pointer;
}
.results,
.recent {
margin-top: 2rem;
margin-top: 1.5rem;
}
.recent h2 {
font-size: 1.05rem;
color: #444;
margin: 0 0 0.75rem;
}
.muted {
color: #888;
text-align: center;
padding: 2rem 0;
}
.cards {
list-style: none;
padding: 0;
@@ -143,4 +220,22 @@
color: #888;
margin-top: 0.25rem;
}
.no-local {
text-align: center;
padding: 1.5rem 0;
}
.no-local p {
color: #666;
margin: 0 0 1rem;
}
.web-btn {
display: inline-block;
padding: 0.8rem 1.25rem;
background: #2b6a3d;
color: white;
text-decoration: none;
border-radius: 10px;
font-size: 1rem;
min-height: 48px;
}
</style>