Files
kochwas/src/routes/recipes/+page.svelte
hsiegeln 3906781c4e
All checks were successful
Build & Publish Docker Image / build-and-push (push) Successful in 1m18s
feat(pwa): Schreib-Aktionen zeigen Offline-Toast statt stillem Fail
Neuer Helper requireOnline(action) prüft vor jedem Schreib-Fetch
den Online-Status. Offline: ein Toast erscheint ("Die Aktion braucht
eine Internet-Verbindung."), Aktion bricht sauber ab. Der Button-
State bleibt unverändert (kein optimistisches Update, das gleich
wieder zurückgedreht werden müsste).

Eingebaut in Rezept-Detail (8 Handler), Register (2), Wunschliste
(2), Admin Domains/Profile/Backup, Home-Dismiss.

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

540 lines
13 KiB
Svelte

<script lang="ts">
import { onMount, tick } from 'svelte';
import { CookingPot, Link, Plus, ChevronDown, Pencil } from 'lucide-svelte';
import { goto } from '$app/navigation';
import { alertAction } from '$lib/client/confirm.svelte';
import { requireOnline } from '$lib/client/require-online';
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let filter = $state('');
let importUrl = $state('');
let menuOpen = $state(false);
let importOpen = $state(false);
let creatingBlank = $state(false);
let menuWrap: HTMLElement | undefined = $state();
let importInput: HTMLInputElement | undefined = $state();
function toggleMenu() {
menuOpen = !menuOpen;
}
async function openImport() {
menuOpen = false;
importOpen = true;
await tick();
importInput?.focus();
}
function closeImport() {
importOpen = false;
importUrl = '';
}
function submitImport(e: SubmitEvent) {
e.preventDefault();
const url = importUrl.trim();
if (!url) return;
if (!requireOnline('Der URL-Import')) return;
importOpen = false;
goto(`/preview?url=${encodeURIComponent(url)}`);
}
async function createBlank() {
if (creatingBlank) return;
if (!requireOnline('Das Anlegen')) return;
menuOpen = false;
creatingBlank = true;
try {
const res = await fetch('/api/recipes/blank', { method: 'POST' });
if (!res.ok) {
await alertAction({
title: 'Anlegen fehlgeschlagen',
message: `HTTP ${res.status}`
});
return;
}
const body = await res.json();
goto(`/recipes/${body.id}?edit=1`);
} finally {
creatingBlank = false;
}
}
function onDocClick(e: MouseEvent) {
if (!menuOpen) return;
if (menuWrap && !menuWrap.contains(e.target as Node)) menuOpen = false;
}
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') {
if (importOpen) closeImport();
else if (menuOpen) menuOpen = false;
}
}
onMount(() => {
document.addEventListener('click', onDocClick);
document.addEventListener('keydown', onKey);
return () => {
document.removeEventListener('click', onDocClick);
document.removeEventListener('keydown', onKey);
};
});
// Umlaute und Diakritika auf Basis-Buchstaben normalisieren, damit
// "apfel" auch "Äpfel" findet und "A/Ä/O/Ö/U/Ü" im gleichen Section-Header landen.
function normalize(s: string): string {
return s
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase();
}
function sectionKey(title: string): string {
const first = normalize(title.trim()).charAt(0).toUpperCase();
return /[A-Z]/.test(first) ? first : '#';
}
const collator = new Intl.Collator('de', { sensitivity: 'base', numeric: true });
type Hit = PageData['recipes'][number];
type Section = { letter: string; recipes: Hit[] };
const sections = $derived.by<Section[]>(() => {
const f = normalize(filter.trim());
const filtered = f
? data.recipes.filter((r) => normalize(r.title).includes(f))
: data.recipes;
const sorted = [...filtered].sort((a, b) => collator.compare(a.title, b.title));
const groups = new Map<string, Hit[]>();
for (const r of sorted) {
const key = sectionKey(r.title);
const arr = groups.get(key);
if (arr) arr.push(r);
else groups.set(key, [r]);
}
// '#' am Ende, sonst alphabetisch
return [...groups.entries()]
.sort(([a], [b]) => {
if (a === '#') return 1;
if (b === '#') return -1;
return collator.compare(a, b);
})
.map(([letter, recipes]) => ({ letter, recipes }));
});
const letters = $derived(sections.map((s) => s.letter));
function scrollToLetter(letter: string) {
const el = document.getElementById(`sect-${letter}`);
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
</script>
<header class="head">
<div class="head-top">
<div class="head-titles">
<h1>Register</h1>
<p class="sub">{data.recipes.length} Rezepte insgesamt</p>
</div>
<div class="add-menu" bind:this={menuWrap}>
<button
type="button"
class="add-btn"
onclick={toggleMenu}
aria-haspopup="menu"
aria-expanded={menuOpen}
>
<Plus size={16} strokeWidth={2.2} />
<span>Rezept hinzufügen</span>
<ChevronDown size={14} strokeWidth={2.2} />
</button>
{#if menuOpen}
<div class="menu" role="menu">
<button type="button" role="menuitem" class="menu-item" onclick={openImport}>
<Link size={16} strokeWidth={2} />
<div class="menu-text">
<div class="menu-title">Von URL importieren</div>
<div class="menu-desc">Rezept aus einer Website ziehen</div>
</div>
</button>
<button
type="button"
role="menuitem"
class="menu-item"
onclick={createBlank}
disabled={creatingBlank}
>
<Pencil size={16} strokeWidth={2} />
<div class="menu-text">
<div class="menu-title">Leeres Rezept</div>
<div class="menu-desc">Manuell ausfüllen</div>
</div>
</button>
</div>
{/if}
</div>
</div>
</header>
{#if importOpen}
<div
class="modal-backdrop"
role="presentation"
onclick={(e) => {
if (e.target === e.currentTarget) closeImport();
}}
>
<div
class="modal"
role="dialog"
aria-modal="true"
aria-labelledby="import-title"
tabindex="-1"
>
<h2 id="import-title">Rezept-URL importieren</h2>
<form onsubmit={submitImport}>
<input
bind:this={importInput}
type="url"
bind:value={importUrl}
placeholder="https://…"
aria-label="Rezept-URL"
required
/>
<div class="modal-actions">
<button type="button" class="btn" onclick={closeImport}>Abbrechen</button>
<button type="submit" class="btn primary" disabled={!importUrl.trim()}>
Weiter
</button>
</div>
</form>
</div>
</div>
{/if}
<div class="filter-wrap">
<input
type="search"
bind:value={filter}
placeholder="Rezepte filtern…"
autocomplete="off"
inputmode="search"
aria-label="Rezepte filtern"
/>
</div>
{#if letters.length > 1 && !filter.trim()}
<nav class="letters" aria-label="Buchstaben-Navigation">
{#each letters as l}
<button class="letter-chip" onclick={() => scrollToLetter(l)}>{l}</button>
{/each}
</nav>
{/if}
{#if sections.length === 0}
<p class="empty">Nichts passt zu „{filter}".</p>
{:else}
{#each sections as sect (sect.letter)}
<section class="sect" id={`sect-${sect.letter}`}>
<h2 class="letter">{sect.letter}</h2>
<ul class="list">
{#each sect.recipes as r (r.id)}
<li>
<a class="item" href={`/recipes/${r.id}`}>
{#if r.image_path}
<img src={`/images/${r.image_path}`} alt="" loading="lazy" />
{:else}
<div class="placeholder"><CookingPot size={22} /></div>
{/if}
<div class="body">
<div class="title">{r.title}</div>
<div class="meta">
{#if r.source_domain}<span>{r.source_domain}</span>{/if}
{#if r.avg_stars !== null}<span>· ★ {r.avg_stars.toFixed(1)}</span>{/if}
</div>
</div>
</a>
</li>
{/each}
</ul>
</section>
{/each}
{/if}
<style>
.head {
padding: 1.25rem 0 0.5rem;
}
.head-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.head-titles {
min-width: 0;
}
.head h1 {
margin: 0;
font-size: 1.6rem;
color: #2b6a3d;
}
.sub {
margin: 0.2rem 0 0;
color: #666;
font-size: 0.9rem;
}
.add-menu {
position: relative;
flex-shrink: 0;
}
.add-btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.55rem 0.9rem;
background: #2b6a3d;
color: white;
border: 0;
border-radius: 10px;
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
font-family: inherit;
min-height: 40px;
}
.add-btn:hover {
background: #235532;
}
.menu {
position: absolute;
top: calc(100% + 0.35rem);
right: 0;
min-width: 260px;
background: white;
border: 1px solid #e4eae7;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.12);
padding: 0.3rem;
z-index: 20;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.menu-item {
display: flex;
align-items: center;
gap: 0.7rem;
padding: 0.6rem 0.75rem;
background: transparent;
border: 0;
border-radius: 8px;
text-align: left;
cursor: pointer;
font-family: inherit;
color: #1a1a1a;
width: 100%;
}
.menu-item:hover:not(:disabled) {
background: #f4f8f5;
}
.menu-item:disabled {
opacity: 0.55;
cursor: progress;
}
.menu-item :global(svg) {
color: #2b6a3d;
flex-shrink: 0;
}
.menu-text {
display: flex;
flex-direction: column;
gap: 0.1rem;
}
.menu-title {
font-weight: 600;
font-size: 0.95rem;
}
.menu-desc {
color: #888;
font-size: 0.8rem;
}
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(20, 30, 25, 0.45);
display: grid;
place-items: center;
z-index: 100;
padding: 1rem;
}
.modal {
background: white;
border-radius: 14px;
padding: 1.1rem 1.1rem 1rem;
width: min(440px, 100%);
box-shadow: 0 16px 40px rgba(0, 0, 0, 0.2);
}
.modal h2 {
margin: 0 0 0.75rem;
font-size: 1.05rem;
color: #2b6a3d;
}
.modal input {
width: 100%;
padding: 0.7rem 0.85rem;
border: 1px solid #cfd9d1;
border-radius: 10px;
font-size: 1rem;
min-height: 44px;
font-family: inherit;
box-sizing: border-box;
}
.modal input:focus {
outline: 2px solid #2b6a3d;
outline-offset: 1px;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
margin-top: 0.85rem;
}
.modal .btn {
padding: 0.6rem 1rem;
min-height: 42px;
border: 1px solid #cfd9d1;
background: white;
border-radius: 10px;
cursor: pointer;
font-size: 0.95rem;
font-family: inherit;
}
.modal .btn:hover:not(:disabled) {
background: #f4f8f5;
}
.modal .btn.primary {
background: #2b6a3d;
color: white;
border: 0;
}
.modal .btn.primary:hover:not(:disabled) {
background: #235532;
}
.modal .btn.primary:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.filter-wrap {
position: sticky;
top: 57px;
z-index: 5;
background: #f8faf8;
padding: 0.75rem 0 0.5rem;
}
.filter-wrap input {
width: 100%;
padding: 0.6rem 0.9rem;
font-size: 0.95rem;
border: 1px solid #cfd9d1;
border-radius: 999px;
background: white;
min-height: 44px;
}
.filter-wrap input:focus {
outline: 2px solid #2b6a3d;
outline-offset: 1px;
}
.letters {
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
padding: 0.25rem 0 0.75rem;
}
.letter-chip {
min-width: 32px;
min-height: 32px;
padding: 0 0.4rem;
border: 1px solid #e4eae7;
background: white;
border-radius: 8px;
color: #2b6a3d;
font-weight: 600;
font-size: 0.85rem;
cursor: pointer;
}
.letter-chip:hover {
background: #eaf4ed;
}
.empty {
color: #888;
text-align: center;
padding: 2rem 0;
}
.sect {
margin-top: 0.75rem;
scroll-margin-top: 115px;
}
.sect h2.letter {
margin: 0;
padding: 0.35rem 0.1rem;
font-size: 0.95rem;
color: #2b6a3d;
border-bottom: 1px solid #e4eae7;
font-weight: 700;
}
.list {
list-style: none;
padding: 0;
margin: 0;
}
.item {
display: flex;
gap: 0.7rem;
padding: 0.55rem 0.25rem;
text-decoration: none;
color: inherit;
border-bottom: 1px solid #f0f3f1;
min-height: 56px;
align-items: center;
}
.item:hover {
background: #f4f8f5;
}
.item img,
.placeholder {
width: 44px;
height: 44px;
object-fit: cover;
border-radius: 8px;
background: #eef3ef;
display: grid;
place-items: center;
color: #8fb097;
flex-shrink: 0;
}
.body {
min-width: 0;
flex: 1;
}
.title {
font-weight: 600;
font-size: 0.98rem;
line-height: 1.25;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.meta {
font-size: 0.8rem;
color: #888;
display: flex;
gap: 0.25rem;
margin-top: 0.1rem;
}
</style>