Files
kochwas/src/routes/+layout.svelte
hsiegeln c87196cd67
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m15s
feat(header): Filter-Dropdown auch in der Header-Suche
Der SearchFilter-Store galt schon global, aber das UI zum Ändern gab es
nur auf der Home-Seite — auf Rezept-/Preview-Seiten konnte man den
Filter nicht sehen, geschweige denn setzen. Jetzt zeigt die Header-Suche
denselben inline-Trigger links vom Input. Nav-Form bekommt Border +
Background als Container, Input wird transparent, Fokus-Outline landet
auf dem Container (wie auf der Home-Seite).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-18 08:53:54 +02:00

650 lines
18 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto, afterNavigate } from '$app/navigation';
import { Settings, CookingPot, Utensils, Menu, BookOpen } from 'lucide-svelte';
import { profileStore } from '$lib/client/profile.svelte';
import { wishlistStore } from '$lib/client/wishlist.svelte';
import { pwaStore } from '$lib/client/pwa.svelte';
import { searchFilterStore } from '$lib/client/search-filter.svelte';
import ProfileSwitcher from '$lib/components/ProfileSwitcher.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import SearchLoader from '$lib/components/SearchLoader.svelte';
import SearchFilter from '$lib/components/SearchFilter.svelte';
import UpdateToast from '$lib/components/UpdateToast.svelte';
import type { SearchHit } from '$lib/server/recipes/search-local';
import type { WebHit } from '$lib/server/search/searxng';
let { children } = $props();
const NAV_PAGE_SIZE = 30;
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 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);
let menuContainer: HTMLElement | undefined = $state();
const showHeaderSearch = $derived(
$page.url.pathname.startsWith('/recipes/') || $page.url.pathname === '/preview'
);
function filterParam(): string {
const p = searchFilterStore.queryParam;
return p ? `&domains=${encodeURIComponent(p)}` : '';
}
$effect(() => {
const q = navQuery.trim();
if (debounceTimer) clearTimeout(debounceTimer);
if (q.length <= 3) {
navHits = [];
navWebHits = [];
navSearching = false;
navWebSearching = false;
navWebError = null;
navOpen = false;
navLocalExhausted = false;
navWebPageno = 0;
navWebExhausted = false;
return;
}
navSearching = true;
navWebHits = [];
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)}&limit=${NAV_PAGE_SIZE}${filterParam()}`
);
const body = await res.json();
if (navQuery.trim() !== q) return;
navHits = body.hits;
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)}&pageno=1${filterParam()}`
);
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;
}
}
} finally {
if (navQuery.trim() === q) navSearching = false;
}
}, 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}${filterParam()}`
);
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}${filterParam()}`
);
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();
if (!q) return;
navOpen = false;
void goto(`/?q=${encodeURIComponent(q)}`);
}
function handleClickOutside(e: MouseEvent) {
if (navContainer && !navContainer.contains(e.target as Node)) {
navOpen = false;
}
if (menuContainer && !menuContainer.contains(e.target as Node)) {
menuOpen = false;
}
}
function handleKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (navOpen) navOpen = false;
if (menuOpen) menuOpen = false;
}
}
function pickHit() {
navOpen = false;
navQuery = '';
navHits = [];
navWebHits = [];
}
afterNavigate(() => {
navQuery = '';
navHits = [];
navWebHits = [];
navOpen = false;
menuOpen = false;
// Badge nach jeder Client-Navigation frisch halten — sonst kann er
// hinter den tatsächlichen Wunschliste-Einträgen herlaufen, wenn
// auf einem anderen Gerät oder in einem anderen Tab etwas geändert
// wurde.
void wishlistStore.refresh();
});
onMount(() => {
profileStore.load();
void wishlistStore.refresh();
void searchFilterStore.load();
void pwaStore.init();
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleKey);
};
});
</script>
<ConfirmDialog />
<UpdateToast />
<header class="bar">
<div class="bar-inner">
<a href="/" class="brand">Kochwas</a>
{#if showHeaderSearch}
<div class="nav-search-wrap" bind:this={navContainer}>
<form class="nav-search" onsubmit={submitNav} role="search">
<SearchFilter inline />
<input
type="search"
bind:value={navQuery}
onfocus={() => {
if (navHits.length > 0 || navQuery.trim().length > 3) navOpen = true;
}}
placeholder="Rezept suchen…"
autocomplete="off"
inputmode="search"
aria-label="Suchbegriff"
/>
</form>
{#if navOpen}
<div class="dropdown" role="listbox">
{#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>
{/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>
<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"><Utensils size={22} /></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>
{/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}
</div>
{/if}
<div class="bar-right">
<a
href="/wishlist"
class="nav-link wishlist-link"
aria-label={wishlistStore.count > 0
? `Wunschliste (${wishlistStore.count})`
: 'Wunschliste'}
>
<CookingPot size={20} strokeWidth={2} />
{#if wishlistStore.count > 0}
<span class="badge">{wishlistStore.count}</span>
{/if}
</a>
<div class="menu-wrap" bind:this={menuContainer}>
<button
class="nav-link"
aria-label="Menü"
aria-haspopup="menu"
aria-expanded={menuOpen}
onclick={() => (menuOpen = !menuOpen)}
>
<Menu size={22} strokeWidth={2} />
</button>
{#if menuOpen}
<div class="menu" role="menu">
<a href="/recipes" class="menu-item" role="menuitem" onclick={() => (menuOpen = false)}>
<BookOpen size={18} strokeWidth={2} />
<span>Register</span>
</a>
<a href="/admin" class="menu-item" role="menuitem" onclick={() => (menuOpen = false)}>
<Settings size={18} strokeWidth={2} />
<span>Einstellungen</span>
</a>
</div>
{/if}
</div>
<ProfileSwitcher />
</div>
</div>
</header>
<main>
{@render children()}
</main>
<style>
:global(html, body) {
margin: 0;
padding: 0;
background: #f8faf8;
color: #1a1a1a;
font-family: system-ui, -apple-system, 'Segoe UI', sans-serif;
-webkit-font-smoothing: antialiased;
}
:global(a) {
color: #2b6a3d;
}
:global(*) {
box-sizing: border-box;
}
.bar {
position: sticky;
top: 0;
z-index: 10;
background: white;
border-bottom: 1px solid #e4eae7;
}
.bar-inner {
max-width: 760px;
margin: 0 auto;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.6rem 1rem;
position: relative;
}
.brand {
font-size: 1.15rem;
font-weight: 700;
text-decoration: none;
color: #2b6a3d;
flex-shrink: 0;
}
.nav-search-wrap {
position: relative;
flex: 1;
min-width: 0;
}
.nav-search {
display: flex;
align-items: stretch;
border: 1px solid #cfd9d1;
border-radius: 999px;
background: #f4f8f5;
min-height: 40px;
}
.nav-search:focus-within {
outline: 2px solid #2b6a3d;
outline-offset: 1px;
background: white;
}
.nav-search input {
flex: 1;
width: 100%;
padding: 0.55rem 0.85rem;
font-size: 0.95rem;
border: 0;
background: transparent;
min-width: 0;
}
.nav-search input:focus {
outline: none;
}
.dropdown {
position: absolute;
top: calc(100% + 0.4rem);
left: 0;
right: 0;
background: white;
border: 1px solid #e4eae7;
border-radius: 12px;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.18);
max-height: 70vh;
overflow-y: auto;
z-index: 50;
}
.dd-list {
list-style: none;
padding: 0.35rem;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.dd-item {
display: flex;
align-items: center;
gap: 0.65rem;
padding: 0.45rem 0.55rem;
text-decoration: none;
color: #1a1a1a;
border-radius: 10px;
min-height: 52px;
}
.dd-item:hover {
background: #f4f8f5;
}
.dd-item img,
.dd-placeholder {
width: 44px;
height: 44px;
object-fit: cover;
border-radius: 8px;
background: #eef3ef;
display: grid;
place-items: center;
font-size: 1.3rem;
flex-shrink: 0;
}
.dd-body {
min-width: 0;
flex: 1;
}
.dd-title {
font-weight: 600;
font-size: 0.95rem;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dd-domain {
font-size: 0.78rem;
color: #888;
margin-top: 0.1rem;
}
.dd-status {
text-align: center;
color: #888;
padding: 0.9rem 0.6rem;
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: flex;
align-items: center;
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:not(:disabled) {
background: #eaf4ed;
}
.dd-web:disabled {
opacity: 0.6;
cursor: progress;
}
.bar-right {
display: flex;
align-items: center;
gap: 0.4rem;
flex-shrink: 0;
margin-left: auto;
}
.menu-wrap {
position: relative;
}
.menu-wrap > .nav-link {
background: transparent;
border: 0;
cursor: pointer;
color: #2b6a3d;
}
.menu {
position: absolute;
top: calc(100% + 0.35rem);
right: 0;
background: white;
border: 1px solid #e4eae7;
border-radius: 12px;
box-shadow: 0 14px 40px rgba(0, 0, 0, 0.18);
min-width: 180px;
padding: 0.3rem;
z-index: 55;
display: flex;
flex-direction: column;
}
.menu-item {
display: flex;
align-items: center;
gap: 0.55rem;
padding: 0.6rem 0.75rem;
border-radius: 8px;
text-decoration: none;
color: #1a1a1a;
font-size: 0.95rem;
min-height: 44px;
}
.menu-item:hover {
background: #f4f8f5;
}
.nav-link {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 999px;
text-decoration: none;
font-size: 1.15rem;
position: relative;
}
.nav-link:hover {
background: #f4f8f5;
}
.badge {
position: absolute;
top: -2px;
right: -2px;
min-width: 18px;
height: 18px;
padding: 0 5px;
border-radius: 999px;
background: #c53030;
color: white;
font-size: 0.7rem;
font-weight: 700;
line-height: 18px;
text-align: center;
box-shadow: 0 0 0 2px white;
pointer-events: none;
}
main {
padding: 0 1rem 4rem;
max-width: 760px;
margin: 0 auto;
}
@media (max-width: 520px) {
/* App-Icon auf engen Screens komplett aus — die Suche bekommt den Platz. */
.brand {
display: none;
}
.nav-link {
width: 36px;
height: 36px;
font-size: 1.05rem;
}
/* Beim Tippen auf engen Screens nach rechts ausdehnen
und die Action-Icons dahinter verschwinden lassen. */
.nav-search-wrap:focus-within {
position: absolute;
top: 0.6rem;
bottom: 0.6rem;
left: 1rem;
right: 1rem;
z-index: 60;
}
.nav-search-wrap:focus-within .nav-search input {
background: white;
}
}
</style>