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();
|
let { children } = $props();
|
||||||
|
|
||||||
|
const NAV_PAGE_SIZE = 30;
|
||||||
|
|
||||||
let navQuery = $state('');
|
let navQuery = $state('');
|
||||||
let navHits = $state<SearchHit[]>([]);
|
let navHits = $state<SearchHit[]>([]);
|
||||||
let navWebHits = $state<WebHit[]>([]);
|
let navWebHits = $state<WebHit[]>([]);
|
||||||
@@ -22,6 +24,10 @@
|
|||||||
let navWebSearching = $state(false);
|
let navWebSearching = $state(false);
|
||||||
let navWebError = $state<string | null>(null);
|
let navWebError = $state<string | null>(null);
|
||||||
let navOpen = $state(false);
|
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 navContainer: HTMLElement | undefined = $state();
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
let menuOpen = $state(false);
|
let menuOpen = $state(false);
|
||||||
@@ -41,6 +47,9 @@
|
|||||||
navWebSearching = false;
|
navWebSearching = false;
|
||||||
navWebError = null;
|
navWebError = null;
|
||||||
navOpen = false;
|
navOpen = false;
|
||||||
|
navLocalExhausted = false;
|
||||||
|
navWebPageno = 0;
|
||||||
|
navWebExhausted = false;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
navSearching = true;
|
navSearching = true;
|
||||||
@@ -48,23 +57,34 @@
|
|||||||
navWebSearching = false;
|
navWebSearching = false;
|
||||||
navWebError = null;
|
navWebError = null;
|
||||||
navOpen = true;
|
navOpen = true;
|
||||||
|
navLocalExhausted = false;
|
||||||
|
navWebPageno = 0;
|
||||||
|
navWebExhausted = false;
|
||||||
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)}&limit=${NAV_PAGE_SIZE}`
|
||||||
|
);
|
||||||
const body = await res.json();
|
const body = await res.json();
|
||||||
if (navQuery.trim() !== q) return;
|
if (navQuery.trim() !== q) return;
|
||||||
navHits = body.hits;
|
navHits = body.hits;
|
||||||
if (body.hits.length === 0) {
|
if (navHits.length < NAV_PAGE_SIZE) navLocalExhausted = true;
|
||||||
|
if (navHits.length === 0) {
|
||||||
navWebSearching = true;
|
navWebSearching = true;
|
||||||
try {
|
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 (navQuery.trim() !== q) return;
|
||||||
if (!wres.ok) {
|
if (!wres.ok) {
|
||||||
const err = await wres.json().catch(() => ({}));
|
const err = await wres.json().catch(() => ({}));
|
||||||
navWebError = err.message ?? `HTTP ${wres.status}`;
|
navWebError = err.message ?? `HTTP ${wres.status}`;
|
||||||
|
navWebExhausted = true;
|
||||||
} else {
|
} else {
|
||||||
const wbody = await wres.json();
|
const wbody = await wres.json();
|
||||||
navWebHits = wbody.hits;
|
navWebHits = wbody.hits;
|
||||||
|
navWebPageno = 1;
|
||||||
|
if (navWebHits.length === 0) navWebExhausted = true;
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
if (navQuery.trim() === q) navWebSearching = false;
|
if (navQuery.trim() === q) navWebSearching = false;
|
||||||
@@ -76,6 +96,56 @@
|
|||||||
}, 300);
|
}, 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) {
|
function submitNav(e: SubmitEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const q = navQuery.trim();
|
const q = navQuery.trim();
|
||||||
@@ -156,48 +226,43 @@
|
|||||||
</form>
|
</form>
|
||||||
{#if navOpen}
|
{#if navOpen}
|
||||||
<div class="dropdown" role="listbox">
|
<div class="dropdown" role="listbox">
|
||||||
{#if navSearching && navHits.length === 0}
|
{#if navSearching && navHits.length === 0 && navWebHits.length === 0}
|
||||||
<SearchLoader scope="local" size="sm" />
|
<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}
|
{:else}
|
||||||
<p class="dd-section">Keine lokalen Rezepte – aus dem Internet:</p>
|
{#if navHits.length > 0}
|
||||||
{#if navWebSearching}
|
<ul class="dd-list">
|
||||||
<SearchLoader scope="web" size="sm" />
|
{#each navHits as r (r.id)}
|
||||||
{:else if navWebError}
|
<li>
|
||||||
<p class="dd-status dd-error">Internet-Suche zurzeit nicht möglich.</p>
|
<a
|
||||||
{:else if navWebHits.length > 0}
|
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">
|
<ul class="dd-list">
|
||||||
{#each navWebHits as w (w.url)}
|
{#each navWebHits as w (w.url)}
|
||||||
<li>
|
<li>
|
||||||
@@ -221,16 +286,30 @@
|
|||||||
</li>
|
</li>
|
||||||
{/each}
|
{/each}
|
||||||
</ul>
|
</ul>
|
||||||
<a
|
{/if}
|
||||||
class="dd-web"
|
|
||||||
href={`/?q=${encodeURIComponent(navQuery.trim())}`}
|
{#if navWebSearching}
|
||||||
onclick={pickHit}
|
<SearchLoader scope="web" size="sm" />
|
||||||
>
|
{:else if navWebError && navWebHits.length === 0}
|
||||||
<span>+ weitere Ergebnisse</span>
|
<p class="dd-status dd-error">Internet-Suche zurzeit nicht möglich.</p>
|
||||||
</a>
|
{:else if navHits.length === 0 && navWebHits.length === 0 && !navSearching}
|
||||||
{:else}
|
|
||||||
<p class="dd-status">Auch im Internet nichts gefunden.</p>
|
<p class="dd-status">Auch im Internet nichts gefunden.</p>
|
||||||
{/if}
|
{/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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
@@ -429,15 +508,23 @@
|
|||||||
justify-content: center;
|
justify-content: center;
|
||||||
gap: 0.4rem;
|
gap: 0.4rem;
|
||||||
padding: 0.75rem 0.85rem;
|
padding: 0.75rem 0.85rem;
|
||||||
|
border: 0;
|
||||||
border-top: 1px solid #e4eae7;
|
border-top: 1px solid #e4eae7;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
color: #2b6a3d;
|
color: #2b6a3d;
|
||||||
font-size: 0.95rem;
|
font-size: 0.95rem;
|
||||||
background: #fafdfb;
|
background: #fafdfb;
|
||||||
|
width: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
font-family: inherit;
|
||||||
}
|
}
|
||||||
.dd-web:hover {
|
.dd-web:hover:not(:disabled) {
|
||||||
background: #eaf4ed;
|
background: #eaf4ed;
|
||||||
}
|
}
|
||||||
|
.dd-web:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: progress;
|
||||||
|
}
|
||||||
.bar-right {
|
.bar-right {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user