feat(header-search): „+ weitere Ergebnisse" lädt inline im Dropdown
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m21s
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m21s
Vorher war der „+ weitere"-Link im Header-Dropdown ein Navigations-Link auf /?q=. Jetzt blättert der Button stattdessen im offenen Dropdown direkt nach — erst weitere lokale Treffer, dann (wenn lokal erschöpft) SearXNG-Seiten. Lokale und Web-Treffer werden beide im Dropdown angezeigt, getrennt durch „Aus dem Internet"-Zwischenüberschrift. Identische Logik wie auf der Home-Seite, nur im Dropdown-Scope. Dedup per ID (lokal) bzw. URL (web) gegen SearXNG-Doppler. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -15,6 +15,8 @@
|
||||
|
||||
let { children } = $props();
|
||||
|
||||
const NAV_PAGE_SIZE = 30;
|
||||
|
||||
let navQuery = $state('');
|
||||
let navHits = $state<SearchHit[]>([]);
|
||||
let navWebHits = $state<WebHit[]>([]);
|
||||
@@ -22,6 +24,10 @@
|
||||
let navWebSearching = $state(false);
|
||||
let navWebError = $state<string | null>(null);
|
||||
let navOpen = $state(false);
|
||||
let navLocalExhausted = $state(false);
|
||||
let navWebPageno = $state(0);
|
||||
let navWebExhausted = $state(false);
|
||||
let navLoadingMore = $state(false);
|
||||
let navContainer: HTMLElement | undefined = $state();
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let menuOpen = $state(false);
|
||||
@@ -41,6 +47,9 @@
|
||||
navWebSearching = false;
|
||||
navWebError = null;
|
||||
navOpen = false;
|
||||
navLocalExhausted = false;
|
||||
navWebPageno = 0;
|
||||
navWebExhausted = false;
|
||||
return;
|
||||
}
|
||||
navSearching = true;
|
||||
@@ -48,23 +57,34 @@
|
||||
navWebSearching = false;
|
||||
navWebError = null;
|
||||
navOpen = true;
|
||||
navLocalExhausted = false;
|
||||
navWebPageno = 0;
|
||||
navWebExhausted = false;
|
||||
debounceTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await fetch(`/api/recipes/search?q=${encodeURIComponent(q)}`);
|
||||
const res = await fetch(
|
||||
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${NAV_PAGE_SIZE}`
|
||||
);
|
||||
const body = await res.json();
|
||||
if (navQuery.trim() !== q) return;
|
||||
navHits = body.hits;
|
||||
if (body.hits.length === 0) {
|
||||
if (navHits.length < NAV_PAGE_SIZE) navLocalExhausted = true;
|
||||
if (navHits.length === 0) {
|
||||
navWebSearching = true;
|
||||
try {
|
||||
const wres = await fetch(`/api/recipes/search/web?q=${encodeURIComponent(q)}`);
|
||||
const wres = await fetch(
|
||||
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=1`
|
||||
);
|
||||
if (navQuery.trim() !== q) return;
|
||||
if (!wres.ok) {
|
||||
const err = await wres.json().catch(() => ({}));
|
||||
navWebError = err.message ?? `HTTP ${wres.status}`;
|
||||
navWebExhausted = true;
|
||||
} else {
|
||||
const wbody = await wres.json();
|
||||
navWebHits = wbody.hits;
|
||||
navWebPageno = 1;
|
||||
if (navWebHits.length === 0) navWebExhausted = true;
|
||||
}
|
||||
} finally {
|
||||
if (navQuery.trim() === q) navWebSearching = false;
|
||||
@@ -76,6 +96,56 @@
|
||||
}, 300);
|
||||
});
|
||||
|
||||
async function loadMoreNav() {
|
||||
if (navLoadingMore) return;
|
||||
const q = navQuery.trim();
|
||||
if (!q) return;
|
||||
navLoadingMore = true;
|
||||
try {
|
||||
if (!navLocalExhausted) {
|
||||
const res = await fetch(
|
||||
`/api/recipes/search?q=${encodeURIComponent(q)}&limit=${NAV_PAGE_SIZE}&offset=${navHits.length}`
|
||||
);
|
||||
const body = await res.json();
|
||||
if (navQuery.trim() !== q) return;
|
||||
const more = body.hits as SearchHit[];
|
||||
const seen = new Set(navHits.map((h) => h.id));
|
||||
const deduped = more.filter((h) => !seen.has(h.id));
|
||||
navHits = [...navHits, ...deduped];
|
||||
if (more.length < NAV_PAGE_SIZE) navLocalExhausted = true;
|
||||
} else if (!navWebExhausted) {
|
||||
const nextPage = navWebPageno + 1;
|
||||
navWebSearching = navWebHits.length === 0;
|
||||
try {
|
||||
const wres = await fetch(
|
||||
`/api/recipes/search/web?q=${encodeURIComponent(q)}&pageno=${nextPage}`
|
||||
);
|
||||
if (navQuery.trim() !== q) return;
|
||||
if (!wres.ok) {
|
||||
const err = await wres.json().catch(() => ({}));
|
||||
navWebError = err.message ?? `HTTP ${wres.status}`;
|
||||
navWebExhausted = true;
|
||||
return;
|
||||
}
|
||||
const wbody = await wres.json();
|
||||
const more = wbody.hits as WebHit[];
|
||||
const seen = new Set(navWebHits.map((h) => h.url));
|
||||
const deduped = more.filter((h) => !seen.has(h.url));
|
||||
if (deduped.length === 0) {
|
||||
navWebExhausted = true;
|
||||
} else {
|
||||
navWebHits = [...navWebHits, ...deduped];
|
||||
navWebPageno = nextPage;
|
||||
}
|
||||
} finally {
|
||||
if (navQuery.trim() === q) navWebSearching = false;
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
navLoadingMore = false;
|
||||
}
|
||||
}
|
||||
|
||||
function submitNav(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const q = navQuery.trim();
|
||||
@@ -156,48 +226,43 @@
|
||||
</form>
|
||||
{#if navOpen}
|
||||
<div class="dropdown" role="listbox">
|
||||
{#if navSearching && navHits.length === 0}
|
||||
{#if navSearching && navHits.length === 0 && navWebHits.length === 0}
|
||||
<SearchLoader scope="local" size="sm" />
|
||||
{: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"><CookingPot size={22} /></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>
|
||||
<a
|
||||
class="dd-web"
|
||||
href={`/?q=${encodeURIComponent(navQuery.trim())}`}
|
||||
onclick={pickHit}
|
||||
>
|
||||
<span>+ weitere Ergebnisse</span>
|
||||
</a>
|
||||
{:else}
|
||||
<p class="dd-section">Keine lokalen Rezepte – aus dem Internet:</p>
|
||||
{#if navWebSearching}
|
||||
<SearchLoader scope="web" size="sm" />
|
||||
{:else if navWebError}
|
||||
<p class="dd-status dd-error">Internet-Suche zurzeit nicht möglich.</p>
|
||||
{:else if navWebHits.length > 0}
|
||||
{#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"><CookingPot size={22} /></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>
|
||||
{/if}
|
||||
|
||||
{#if navWebHits.length > 0}
|
||||
{#if navHits.length > 0}
|
||||
<p class="dd-section">Aus dem Internet</p>
|
||||
{:else}
|
||||
<p class="dd-section">Keine lokalen Rezepte – aus dem Internet:</p>
|
||||
{/if}
|
||||
<ul class="dd-list">
|
||||
{#each navWebHits as w (w.url)}
|
||||
<li>
|
||||
@@ -221,16 +286,30 @@
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
<a
|
||||
class="dd-web"
|
||||
href={`/?q=${encodeURIComponent(navQuery.trim())}`}
|
||||
onclick={pickHit}
|
||||
>
|
||||
<span>+ weitere Ergebnisse</span>
|
||||
</a>
|
||||
{:else}
|
||||
{/if}
|
||||
|
||||
{#if navWebSearching}
|
||||
<SearchLoader scope="web" size="sm" />
|
||||
{:else if navWebError && navWebHits.length === 0}
|
||||
<p class="dd-status dd-error">Internet-Suche zurzeit nicht möglich.</p>
|
||||
{:else if navHits.length === 0 && navWebHits.length === 0 && !navSearching}
|
||||
<p class="dd-status">Auch im Internet nichts gefunden.</p>
|
||||
{/if}
|
||||
|
||||
{#if !(navLocalExhausted && navWebExhausted) && (navHits.length > 0 || navWebHits.length > 0)}
|
||||
<button
|
||||
class="dd-web"
|
||||
type="button"
|
||||
onclick={loadMoreNav}
|
||||
disabled={navLoadingMore || navWebSearching}
|
||||
>
|
||||
<span
|
||||
>{navLoadingMore || navWebSearching
|
||||
? 'Lade …'
|
||||
: '+ weitere Ergebnisse'}</span
|
||||
>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -429,15 +508,23 @@
|
||||
justify-content: center;
|
||||
gap: 0.4rem;
|
||||
padding: 0.75rem 0.85rem;
|
||||
border: 0;
|
||||
border-top: 1px solid #e4eae7;
|
||||
text-decoration: none;
|
||||
color: #2b6a3d;
|
||||
font-size: 0.95rem;
|
||||
background: #fafdfb;
|
||||
width: 100%;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.dd-web:hover {
|
||||
.dd-web:hover:not(:disabled) {
|
||||
background: #eaf4ed;
|
||||
}
|
||||
.dd-web:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: progress;
|
||||
}
|
||||
.bar-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
Reference in New Issue
Block a user