Files
kochwas/src/routes/+layout.svelte
hsiegeln 02df0331b7
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
feat(pwa): SyncIndicator-Pill mit Overlay-Karte
Bottom-right Pill zeigt Sync-Fortschritt (Sync N/M) oder Offline-
Status. Klick öffnet Overlay mit "Zuletzt synchronisiert: vor
N Min" + manuellem Refresh-Button (postMessage type=sync-check an
den SW). prefers-reduced-motion noch nicht gehandhabt — Spin-Icon
dreht sich bewusst; kein UX-Schaden.

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

675 lines
19 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, ArrowLeft } 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 Toast from '$lib/components/Toast.svelte';
import SyncIndicator from '$lib/components/SyncIndicator.svelte';
import { network } from '$lib/client/network.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();
network.init();
document.addEventListener('click', handleClickOutside);
document.addEventListener('keydown', handleKey);
return () => {
document.removeEventListener('click', handleClickOutside);
document.removeEventListener('keydown', handleKey);
};
});
</script>
<Toast />
<SyncIndicator />
<ConfirmDialog />
<UpdateToast />
<header class="bar">
<div class="bar-inner">
{#if $page.url.pathname === '/'}
<a href="/" class="brand">Kochwas</a>
{:else}
<a href="/" class="home-back" aria-label="Zurück zur Startseite">
<ArrowLeft size={22} strokeWidth={2} />
</a>
{/if}
{#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: 1040px;
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;
}
.home-back {
display: inline-flex;
align-items: center;
justify-content: center;
width: 40px;
height: 40px;
border-radius: 999px;
color: #2b6a3d;
text-decoration: none;
flex-shrink: 0;
}
.home-back:hover {
background: #f4f8f5;
}
.nav-search-wrap {
position: relative;
flex: 1;
min-width: 0;
}
.nav-search {
display: flex;
align-items: stretch;
border: 1px solid #cfd9d1;
border-radius: 12px;
background: white;
min-height: 40px;
}
.nav-search:focus-within {
outline: 2px solid #2b6a3d;
outline-offset: 1px;
}
.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: 1040px;
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>