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 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;
|
||||||
|
|||||||
@@ -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>
|
||||||
{:else if searchedFor === query.trim()}
|
<a class="web-more" href={`/search/web?q=${encodeURIComponent(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
|
🌐 Im Internet weitersuchen
|
||||||
</a>
|
</a>
|
||||||
|
{:else if searchedFor === query.trim()}
|
||||||
|
<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>
|
</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>
|
||||||
|
|||||||
Reference in New Issue
Block a user