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