feat(search): auto web search when no local hits, offer link otherwise
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 55s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 55s
Homepage (/):
- Keine lokalen Treffer → automatisch die Internet-Suche auslösen und
die Ergebnisse als Karten unterhalb der Suche anzeigen.
- Mindestens ein lokaler Treffer → Karten zeigen + darunter ein
dezenter Link "🌐 Im Internet weitersuchen" (geht zur /search/web
Vollseite), keine automatische Internet-Suche.
Header-Dropdown (auf Rezept- und Vorschau-Seiten):
- Gleiche Logik: lokale Treffer oben + Fuß-Link; keine lokalen
Treffer → Internet-Ergebnisse werden direkt im Dropdown angezeigt.
- Abschnittsüberschrift "Keine lokalen Rezepte – aus dem Internet:"
trennt den Fallback visuell ab.
Race-Safety bleibt bestehen: Query-Vergleich vor jedem State-Write,
sodass spät ankommende Antworten keinen neueren Suchstand überschreiben.
This commit is contained in:
@@ -6,12 +6,16 @@
|
||||
import ProfileSwitcher from '$lib/components/ProfileSwitcher.svelte';
|
||||
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
|
||||
import type { SearchHit } from '$lib/server/recipes/search-local';
|
||||
import type { WebHit } from '$lib/server/search/searxng';
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
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 navContainer: HTMLElement | undefined = $state();
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
@@ -25,17 +29,40 @@
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
if (q.length <= 3) {
|
||||
navHits = [];
|
||||
navWebHits = [];
|
||||
navSearching = false;
|
||||
navWebSearching = false;
|
||||
navWebError = null;
|
||||
navOpen = false;
|
||||
return;
|
||||
}
|
||||
navSearching = true;
|
||||
navWebHits = [];
|
||||
navWebSearching = false;
|
||||
navWebError = null;
|
||||
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;
|
||||
if (navQuery.trim() !== q) return;
|
||||
navHits = body.hits;
|
||||
if (body.hits.length === 0) {
|
||||
navWebSearching = true;
|
||||
try {
|
||||
const wres = await fetch(`/api/recipes/search/web?q=${encodeURIComponent(q)}`);
|
||||
if (navQuery.trim() !== q) return;
|
||||
if (!wres.ok) {
|
||||
const err = await wres.json().catch(() => ({}));
|
||||
navWebError = err.message ?? `HTTP ${wres.status}`;
|
||||
} else {
|
||||
const wbody = await wres.json();
|
||||
navWebHits = wbody.hits;
|
||||
}
|
||||
} finally {
|
||||
if (navQuery.trim() === q) navWebSearching = false;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (navQuery.trim() === q) navSearching = false;
|
||||
}
|
||||
@@ -64,11 +91,13 @@
|
||||
navOpen = false;
|
||||
navQuery = '';
|
||||
navHits = [];
|
||||
navWebHits = [];
|
||||
}
|
||||
|
||||
afterNavigate(() => {
|
||||
navQuery = '';
|
||||
navHits = [];
|
||||
navWebHits = [];
|
||||
navOpen = false;
|
||||
});
|
||||
|
||||
@@ -133,10 +162,6 @@
|
||||
</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())}`}
|
||||
@@ -144,6 +169,39 @@
|
||||
>
|
||||
🌐 Im Internet weitersuchen
|
||||
</a>
|
||||
{:else}
|
||||
<p class="dd-section">Keine lokalen Rezepte – aus dem Internet:</p>
|
||||
{#if navWebSearching}
|
||||
<p class="dd-status">Suche im Internet läuft …</p>
|
||||
{:else if navWebError}
|
||||
<p class="dd-status dd-error">Internet-Suche zurzeit nicht möglich.</p>
|
||||
{:else if navWebHits.length > 0}
|
||||
<ul class="dd-list">
|
||||
{#each navWebHits as w (w.url)}
|
||||
<li>
|
||||
<a
|
||||
href={`/preview?url=${encodeURIComponent(w.url)}`}
|
||||
class="dd-item"
|
||||
onclick={pickHit}
|
||||
role="option"
|
||||
aria-selected="false"
|
||||
>
|
||||
{#if w.thumbnail}
|
||||
<img src={w.thumbnail} alt="" loading="lazy" />
|
||||
{:else}
|
||||
<div class="dd-placeholder">🍽️</div>
|
||||
{/if}
|
||||
<div class="dd-body">
|
||||
<div class="dd-title">{w.title}</div>
|
||||
<div class="dd-domain">{w.domain}</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="dd-status">Auch im Internet nichts gefunden.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -290,6 +348,18 @@
|
||||
margin: 0;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.dd-error {
|
||||
color: #c53030;
|
||||
}
|
||||
.dd-section {
|
||||
margin: 0;
|
||||
padding: 0.6rem 0.85rem 0.3rem;
|
||||
font-size: 0.8rem;
|
||||
color: #888;
|
||||
border-bottom: 1px solid #f0f3f1;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.dd-web {
|
||||
display: block;
|
||||
padding: 0.75rem 0.85rem;
|
||||
|
||||
@@ -2,11 +2,15 @@
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import type { SearchHit } from '$lib/server/recipes/search-local';
|
||||
import type { WebHit } from '$lib/server/search/searxng';
|
||||
|
||||
let query = $state('');
|
||||
let recent = $state<SearchHit[]>([]);
|
||||
let hits = $state<SearchHit[]>([]);
|
||||
let webHits = $state<WebHit[]>([]);
|
||||
let searching = $state(false);
|
||||
let webSearching = $state(false);
|
||||
let webError = $state<string | null>(null);
|
||||
let searchedFor = $state<string | null>(null);
|
||||
|
||||
onMount(async () => {
|
||||
@@ -22,18 +26,39 @@
|
||||
if (debounceTimer) clearTimeout(debounceTimer);
|
||||
if (q.length <= 3) {
|
||||
hits = [];
|
||||
webHits = [];
|
||||
searchedFor = null;
|
||||
searching = false;
|
||||
webSearching = false;
|
||||
webError = null;
|
||||
return;
|
||||
}
|
||||
searching = true;
|
||||
webHits = [];
|
||||
webSearching = false;
|
||||
webError = null;
|
||||
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;
|
||||
if (query.trim() !== q) return;
|
||||
hits = body.hits;
|
||||
searchedFor = q;
|
||||
if (body.hits.length === 0) {
|
||||
webSearching = true;
|
||||
try {
|
||||
const wres = await fetch(`/api/recipes/search/web?q=${encodeURIComponent(q)}`);
|
||||
if (query.trim() !== q) return;
|
||||
if (!wres.ok) {
|
||||
const err = await wres.json().catch(() => ({}));
|
||||
webError = err.message ?? `HTTP ${wres.status}`;
|
||||
} else {
|
||||
const wbody = await wres.json();
|
||||
webHits = wbody.hits;
|
||||
}
|
||||
} finally {
|
||||
if (query.trim() === q) webSearching = false;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (query.trim() === q) searching = false;
|
||||
@@ -90,13 +115,36 @@
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<a class="web-more" href={`/search/web?q=${encodeURIComponent(query.trim())}`}>
|
||||
🌐 Im Internet weitersuchen
|
||||
</a>
|
||||
{: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>
|
||||
<p class="muted no-local-msg">Keine lokalen Rezepte für „{searchedFor}" — Ergebnisse aus dem Internet:</p>
|
||||
{#if webSearching}
|
||||
<p class="muted">Suche im Internet läuft …</p>
|
||||
{:else if webError}
|
||||
<p class="error">Internet-Suche zurzeit nicht möglich: {webError}</p>
|
||||
{:else if webHits.length > 0}
|
||||
<ul class="cards">
|
||||
{#each webHits as w (w.url)}
|
||||
<li>
|
||||
<a class="card" href={`/preview?url=${encodeURIComponent(w.url)}`}>
|
||||
{#if w.thumbnail}
|
||||
<img src={w.thumbnail} alt="" loading="lazy" />
|
||||
{:else}
|
||||
<div class="placeholder">🍽️</div>
|
||||
{/if}
|
||||
<div class="card-body">
|
||||
<div class="title">{w.title}</div>
|
||||
<div class="domain">{w.domain}</div>
|
||||
</div>
|
||||
</a>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{:else}
|
||||
<p class="muted">Auch im Internet nichts gefunden.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
</section>
|
||||
{:else if recent.length > 0}
|
||||
@@ -174,7 +222,16 @@
|
||||
.muted {
|
||||
color: #888;
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.no-local-msg {
|
||||
font-size: 0.95rem;
|
||||
padding: 0.25rem 0 1rem;
|
||||
}
|
||||
.error {
|
||||
color: #c53030;
|
||||
text-align: center;
|
||||
padding: 1rem 0;
|
||||
}
|
||||
.cards {
|
||||
list-style: none;
|
||||
@@ -220,22 +277,18 @@
|
||||
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 {
|
||||
.web-more {
|
||||
display: inline-block;
|
||||
padding: 0.8rem 1.25rem;
|
||||
background: #2b6a3d;
|
||||
color: white;
|
||||
text-decoration: none;
|
||||
margin-top: 1rem;
|
||||
padding: 0.7rem 1.1rem;
|
||||
border: 1px solid #b7d6c2;
|
||||
border-radius: 10px;
|
||||
font-size: 1rem;
|
||||
min-height: 48px;
|
||||
text-decoration: none;
|
||||
color: #2b6a3d;
|
||||
background: #eaf4ed;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
.web-more:hover {
|
||||
background: #d8e8df;
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user