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

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:
hsiegeln
2026-04-17 17:47:26 +02:00
parent 76110f9841
commit d693cb422d
2 changed files with 153 additions and 30 deletions

View File

@@ -6,12 +6,16 @@
import ProfileSwitcher from '$lib/components/ProfileSwitcher.svelte'; import ProfileSwitcher from '$lib/components/ProfileSwitcher.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte'; import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import type { SearchHit } from '$lib/server/recipes/search-local'; import type { SearchHit } from '$lib/server/recipes/search-local';
import type { WebHit } from '$lib/server/search/searxng';
let { children } = $props(); let { children } = $props();
let navQuery = $state(''); let navQuery = $state('');
let navHits = $state<SearchHit[]>([]); let navHits = $state<SearchHit[]>([]);
let navWebHits = $state<WebHit[]>([]);
let navSearching = $state(false); let navSearching = $state(false);
let navWebSearching = $state(false);
let navWebError = $state<string | null>(null);
let navOpen = $state(false); let navOpen = $state(false);
let navContainer: HTMLElement | undefined = $state(); let navContainer: HTMLElement | undefined = $state();
let debounceTimer: ReturnType<typeof setTimeout> | null = null; let debounceTimer: ReturnType<typeof setTimeout> | null = null;
@@ -25,17 +29,40 @@
if (debounceTimer) clearTimeout(debounceTimer); if (debounceTimer) clearTimeout(debounceTimer);
if (q.length <= 3) { if (q.length <= 3) {
navHits = []; navHits = [];
navWebHits = [];
navSearching = false; navSearching = false;
navWebSearching = false;
navWebError = null;
navOpen = false; navOpen = false;
return; return;
} }
navSearching = true; navSearching = true;
navWebHits = [];
navWebSearching = false;
navWebError = null;
navOpen = true; navOpen = true;
debounceTimer = setTimeout(async () => { debounceTimer = setTimeout(async () => {
try { try {
const res = await fetch(`/api/recipes/search?q=${encodeURIComponent(q)}`); const res = await fetch(`/api/recipes/search?q=${encodeURIComponent(q)}`);
const body = await res.json(); 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 { } finally {
if (navQuery.trim() === q) navSearching = false; if (navQuery.trim() === q) navSearching = false;
} }
@@ -64,11 +91,13 @@
navOpen = false; navOpen = false;
navQuery = ''; navQuery = '';
navHits = []; navHits = [];
navWebHits = [];
} }
afterNavigate(() => { afterNavigate(() => {
navQuery = ''; navQuery = '';
navHits = []; navHits = [];
navWebHits = [];
navOpen = false; navOpen = false;
}); });
@@ -133,10 +162,6 @@
</li> </li>
{/each} {/each}
</ul> </ul>
{:else if navQuery.trim().length > 3 && !navSearching}
<p class="dd-status">Keine lokalen Treffer.</p>
{/if}
{#if navQuery.trim().length > 3 && !navSearching}
<a <a
class="dd-web" class="dd-web"
href={`/search/web?q=${encodeURIComponent(navQuery.trim())}`} href={`/search/web?q=${encodeURIComponent(navQuery.trim())}`}
@@ -144,6 +169,39 @@
> >
🌐 Im Internet weitersuchen 🌐 Im Internet weitersuchen
</a> </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} {/if}
</div> </div>
{/if} {/if}
@@ -290,6 +348,18 @@
margin: 0; margin: 0;
font-size: 0.9rem; 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 { .dd-web {
display: block; display: block;
padding: 0.75rem 0.85rem; padding: 0.75rem 0.85rem;

View File

@@ -2,11 +2,15 @@
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import type { SearchHit } from '$lib/server/recipes/search-local'; import type { SearchHit } from '$lib/server/recipes/search-local';
import type { WebHit } from '$lib/server/search/searxng';
let query = $state(''); let query = $state('');
let recent = $state<SearchHit[]>([]); let recent = $state<SearchHit[]>([]);
let hits = $state<SearchHit[]>([]); let hits = $state<SearchHit[]>([]);
let webHits = $state<WebHit[]>([]);
let searching = $state(false); let searching = $state(false);
let webSearching = $state(false);
let webError = $state<string | null>(null);
let searchedFor = $state<string | null>(null); let searchedFor = $state<string | null>(null);
onMount(async () => { onMount(async () => {
@@ -22,18 +26,39 @@
if (debounceTimer) clearTimeout(debounceTimer); if (debounceTimer) clearTimeout(debounceTimer);
if (q.length <= 3) { if (q.length <= 3) {
hits = []; hits = [];
webHits = [];
searchedFor = null; searchedFor = null;
searching = false; searching = false;
webSearching = false;
webError = null;
return; return;
} }
searching = true; searching = true;
webHits = [];
webSearching = false;
webError = null;
debounceTimer = setTimeout(async () => { debounceTimer = setTimeout(async () => {
try { try {
const res = await fetch(`/api/recipes/search?q=${encodeURIComponent(q)}`); const res = await fetch(`/api/recipes/search?q=${encodeURIComponent(q)}`);
const body = await res.json(); const body = await res.json();
if (query.trim() === q) { if (query.trim() !== q) return;
hits = body.hits; hits = body.hits;
searchedFor = q; 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 { } finally {
if (query.trim() === q) searching = false; if (query.trim() === q) searching = false;
@@ -90,13 +115,36 @@
</li> </li>
{/each} {/each}
</ul> </ul>
<a class="web-more" href={`/search/web?q=${encodeURIComponent(query.trim())}`}>
🌐 Im Internet weitersuchen
</a>
{:else if searchedFor === query.trim()} {:else if searchedFor === query.trim()}
<div class="no-local"> <p class="muted no-local-msg">Keine lokalen Rezepte für „{searchedFor}" — Ergebnisse aus dem Internet:</p>
<p>Keine lokalen Rezepte für „{searchedFor}".</p> {#if webSearching}
<a class="web-btn" href={`/search/web?q=${encodeURIComponent(searchedFor)}`}> <p class="muted">Suche im Internet läuft …</p>
🌐 Im Internet weitersuchen {:else if webError}
</a> <p class="error">Internet-Suche zurzeit nicht möglich: {webError}</p>
</div> {: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} {/if}
</section> </section>
{:else if recent.length > 0} {:else if recent.length > 0}
@@ -174,7 +222,16 @@
.muted { .muted {
color: #888; color: #888;
text-align: center; 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 { .cards {
list-style: none; list-style: none;
@@ -220,22 +277,18 @@
color: #888; color: #888;
margin-top: 0.25rem; margin-top: 0.25rem;
} }
.no-local { .web-more {
text-align: center;
padding: 1.5rem 0;
}
.no-local p {
color: #666;
margin: 0 0 1rem;
}
.web-btn {
display: inline-block; display: inline-block;
padding: 0.8rem 1.25rem; margin-top: 1rem;
background: #2b6a3d; padding: 0.7rem 1.1rem;
color: white; border: 1px solid #b7d6c2;
text-decoration: none;
border-radius: 10px; border-radius: 10px;
font-size: 1rem; text-decoration: none;
min-height: 48px; color: #2b6a3d;
background: #eaf4ed;
font-size: 0.95rem;
}
.web-more:hover {
background: #d8e8df;
} }
</style> </style>