Files
kochwas/src/routes/+layout.svelte
hsiegeln 5a291a53dd
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m22s
refactor(ui): --pill-radius CSS-Variable (Item F)
border-radius: 999px war 15x im CSS dupliziert. Ausgelagert als
:root --pill-radius Variable im globalen :root-Block in +layout.svelte,
Call-Sites auf var(--pill-radius) umgestellt.

Bewusst NICHT angefasst (plan war "nur Werte die mehrfach vorkommen"):
- z-index: 10 Distinct Values in 14 Sites, bilden ein implizites
  Layer-System. Konsolidieren = behavior-change-Risiko ohne konkreten
  Nutzen. Wenn kuenftig einheitliche Modal-/Popover-Layer noetig,
  separate Phase.
- setTimeout(): 3 Sites, jeder mit eigener Semantik (Debounce/Print/
  Spinner). Kein DRY-Nutzen durch Extraktion.

Gate: svelte-check 0 Warnings, 184/184 Tests, Build clean, kein
sichtbarer Unterschied (einzige Aenderung: selber Wert ueber Variable).

Refs docs/superpowers/plans/2026-04-19-post-review-roadmap.md Item F.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 11:43:19 +02:00

682 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 { installPrompt } from '$lib/client/install-prompt.svelte';
import { registerServiceWorker } from '$lib/client/sw-register';
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();
installPrompt.init();
void registerServiceWorker();
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(:root) {
--pill-radius: 999px;
}
: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: var(--pill-radius);
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: var(--pill-radius);
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: var(--pill-radius);
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>