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,56 +1,159 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { goto, afterNavigate } from '$app/navigation';
import { profileStore } from '$lib/client/profile.svelte';
import ProfileSwitcher from '$lib/components/ProfileSwitcher.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import type { SearchHit } from '$lib/server/recipes/search-local';
let { children } = $props();
let navQuery = $state('');
let navHits = $state<SearchHit[]>([]);
let navSearching = $state(false);
let navOpen = $state(false);
let navContainer: HTMLElement | undefined = $state();
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
const showHeaderSearch = $derived(
$page.url.pathname.startsWith('/recipes/') || $page.url.pathname === '/preview'
);
$effect(() => {
const path = $page.url.pathname;
if (path === '/search' || path === '/search/web') {
navQuery = ($page.url.searchParams.get('q') ?? '').trim();
const q = navQuery.trim();
if (debounceTimer) clearTimeout(debounceTimer);
if (q.length <= 3) {
navHits = [];
navSearching = false;
navOpen = false;
return;
}
navSearching = true;
navOpen = true;
debounceTimer = setTimeout(async () => {
try {
const res = await fetch(`/api/recipes/search?q=${encodeURIComponent(q)}`);
const body = await res.json();
if (navQuery.trim() === q) navHits = body.hits;
} finally {
if (navQuery.trim() === q) navSearching = false;
}
}, 300);
});
const showHeaderSearch = $derived($page.url.pathname !== '/');
function submitNavSearch(e: SubmitEvent) {
function submitNav(e: SubmitEvent) {
e.preventDefault();
const q = navQuery.trim();
if (!q) return;
navOpen = false;
void goto(`/search?q=${encodeURIComponent(q)}`);
}
function handleClickOutside(e: MouseEvent) {
if (navContainer && !navContainer.contains(e.target as Node)) {
navOpen = false;
}
}
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape' && navOpen) navOpen = false;
}
function pickHit() {
navOpen = false;
navQuery = '';
navHits = [];
}
afterNavigate(() => {
navQuery = '';
navHits = [];
navOpen = false;
});
onMount(() => {
profileStore.load();
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleKey);
};
});
</script>
<ConfirmDialog />
<header class="bar">
<a href="/" class="brand">Kochwas</a>
{#if showHeaderSearch}
<form class="nav-search" onsubmit={submitNavSearch} role="search">
<input
type="search"
bind:value={navQuery}
placeholder="Rezept suchen"
autocomplete="off"
inputmode="search"
aria-label="Suchbegriff"
/>
</form>
{/if}
<div class="bar-right">
<a href="/wishlist" class="nav-link" aria-label="Wunschliste">🍽️</a>
<a href="/admin" class="nav-link" aria-label="Einstellungen">⚙️</a>
<ProfileSwitcher />
<div class="bar-inner">
<a href="/" class="brand">Kochwas</a>
{#if showHeaderSearch}
<div class="nav-search-wrap" bind:this={navContainer}>
<form class="nav-search" onsubmit={submitNav} role="search">
<input
type="search"
bind:value={navQuery}
onfocus={() => {
if (navHits.length > 0 || navQuery.trim().length > 3) navOpen = true;
}}
placeholder="Rezept suchen…"
autocomplete="off"
inputmode="search"
aria-label="Suchbegriff"
/>
</form>
{#if navOpen}
<div class="dropdown" role="listbox">
{#if navSearching && navHits.length === 0}
<p class="dd-status">Suche läuft …</p>
{:else if navHits.length > 0}
<ul class="dd-list">
{#each navHits as r (r.id)}
<li>
<a
href={`/recipes/${r.id}`}
class="dd-item"
onclick={pickHit}
role="option"
aria-selected="false"
>
{#if r.image_path}
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
{:else}
<div class="dd-placeholder">🥘</div>
{/if}
<div class="dd-body">
<div class="dd-title">{r.title}</div>
{#if r.source_domain}
<div class="dd-domain">{r.source_domain}</div>
{/if}
</div>
</a>
</li>
{/each}
</ul>
{:else if navQuery.trim().length > 3 && !navSearching}
<p class="dd-status">Keine lokalen Treffer.</p>
{/if}
{#if navQuery.trim().length > 3 && !navSearching}
<a
class="dd-web"
href={`/search/web?q=${encodeURIComponent(navQuery.trim())}`}
onclick={pickHit}
>
🌐 Im Internet weitersuchen
</a>
{/if}
</div>
{/if}
</div>
{/if}
<div class="bar-right">
<a href="/wishlist" class="nav-link" aria-label="Wunschliste">🍽️</a>
<a href="/admin" class="nav-link" aria-label="Einstellungen">⚙️</a>
<ProfileSwitcher />
</div>
</div>
</header>
@@ -77,12 +180,16 @@
position: sticky;
top: 0;
z-index: 10;
background: white;
border-bottom: 1px solid #e4eae7;
}
.bar-inner {
max-width: 760px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 1rem;
background: white;
border-bottom: 1px solid #e4eae7;
}
.brand {
font-size: 1.15rem;
@@ -91,9 +198,12 @@
color: #2b6a3d;
flex-shrink: 0;
}
.nav-search {
.nav-search-wrap {
position: relative;
flex: 1;
min-width: 0;
}
.nav-search {
display: flex;
}
.nav-search input {
@@ -110,12 +220,113 @@
outline-offset: 1px;
background: white;
}
.dropdown {
position: absolute;
top: calc(100% + 0.4rem);
left: 0;
right: 0;
background: white;
border: 1px solid #e4eae7;
border-radius: 12px;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.18);
max-height: 70vh;
overflow-y: auto;
z-index: 50;
}
.dd-list {
list-style: none;
padding: 0.35rem;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.dd-item {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.45rem 0.55rem;
text-decoration: none;
color: #1a1a1a;
border-radius: 10px;
min-height: 52px;
}
.dd-item:hover {
background: #f4f8f5;
}
.dd-item img,
.dd-placeholder {
width: 44px;
height: 44px;
object-fit: cover;
border-radius: 8px;
background: #eef3ef;
display: grid;
place-items: center;
font-size: 1.3rem;
flex-shrink: 0;
}
.dd-body {
min-width: 0;
flex: 1;
}
.dd-title {
font-weight: 600;
font-size: 0.95rem;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dd-domain {
font-size: 0.78rem;
color: #888;
margin-top: 0.1rem;
}
.dd-status {
text-align: center;
color: #888;
padding: 0.9rem 0.6rem;
margin: 0;
font-size: 0.9rem;
}
.dd-web {
display: block;
padding: 0.75rem 0.85rem;
text-align: center;
border-top: 1px solid #e4eae7;
text-decoration: none;
color: #2b6a3d;
font-size: 0.95rem;
background: #fafdfb;
}
.dd-web:hover {
background: #eaf4ed;
}
.bar-right {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
}
.nav-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 999px;
text-decoration: none;
font-size: 1.15rem;
}
.nav-link:hover {
background: #f4f8f5;
}
main {
padding: 0 1rem 4rem;
max-width: 760px;
margin: 0 auto;
}
@media (max-width: 520px) {
.brand {
font-size: 0;
@@ -139,22 +350,4 @@
font-size: 1.05rem;
}
}
.nav-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 999px;
text-decoration: none;
font-size: 1.15rem;
}
.nav-link:hover {
background: #f4f8f5;
}
main {
padding: 0 1rem 4rem;
max-width: 760px;
margin: 0 auto;
}
</style>

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>