feat(ui): build responsive cookbook, editor and persistent cooking assistant

This commit is contained in:
OpenCode committed 2026-10-24 15:40:57 +02:00
1 parent 5d71d32e20
commit a58516e424
29 files changed
+6430

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512"><rect width="512" height="512" rx="110" fill="#365747"/><path d="M157 265c-56-5-57-88-4-100 14-72 110-79 132-22 52-21 102 20 93 70-4 27-25 45-49 49v87H157z" fill="none" stroke="#faf8f4" stroke-width="18" stroke-linejoin="round"/><path d="M159 311h169M217 208v54m57-54v54" fill="none" stroke="#faf8f4" stroke-width="15" stroke-linecap="round"/><circle cx="363" cy="351" r="17" fill="#d38968"/></svg>

After

Width:  |  Height:  |  Size: 486 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 217 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 165 KiB

+21
View File
@@ -0,0 +1,21 @@
{
"id": "/",
"name": "HamHam – Dein persönliches Kochbuch",
"short_name": "HamHam",
"description": "Lieblingsrezepte sammeln und entspannt kochen.",
"lang": "de",
"start_url": "/",
"scope": "/",
"display": "standalone",
"background_color": "#faf8f4",
"theme_color": "#365747",
"icons": [
{ "src": "/icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
{
"src": "/icons/icon-512.png",
"sizes": "512x512",
"type": "image/png",
"purpose": "any maskable"
}
]
}
+70
View File
@@ -0,0 +1,70 @@
<!doctype html>
<html lang="de">
<meta charset="utf-8" /><meta name="viewport" content="width=device-width,initial-scale=1" /><meta
name="theme-color"
content="#365747"
/><title>HamHam · Offline</title
><style>
body {
background: #faf8f4;
color: #253b32;
font: 16px/1.7 system-ui;
margin: 0;
padding: 50px 25px;
}
main {
max-width: 600px;
margin: auto;
}
h1 {
font: 44px Georgia;
}
a {
color: #365747;
}
li {
margin: 12px 0;
}
button {
background: #365747;
color: white;
border: 0;
padding: 14px 22px;
border-radius: 9px;
font: inherit;
}
</style>
<main>
<p>HAMHAM · DEIN KOCHBUCH</p>
<h1>Ein Moment ohne Netz.</h1>
<p>
Diese Seite ist noch nicht auf deinem Gerät gespeichert. Bereits online geöffnete Rezept- und
Kochseiten findest du hier. Für neue Rezepte und die Verwaltung brauchst du eine Verbindung.
</p>
<ul id="recipes"></ul>
<button onclick="location.reload()">Verbindung erneut prüfen</button>
</main>
<script>
caches
.open('hamham-public-v1')
.then((c) => c.keys())
.then((keys) => {
const list = document.querySelector('#recipes');
keys
.filter((r) => new URL(r.url).pathname.startsWith('/rezepte/'))
.forEach((r) => {
let li = document.createElement('li'),
a = document.createElement('a'),
url = new URL(r.url);
a.href = r.url;
let cook = url.pathname.endsWith('/kochen');
a.textContent =
(cook ? 'Kochvorgang fortsetzen' : 'Gespeichertes Rezept öffnen') +
' · ' +
url.pathname.split('/')[2].slice(0, 8);
li.append(a);
list.append(li);
});
});
</script>
</html>
+94
View File
@@ -0,0 +1,94 @@
const CACHE = 'hamham-public-v1';
async function savePage(address) {
const url = new URL(address, self.location.origin);
if (url.origin !== self.location.origin) return;
const match = /^\/rezepte\/([a-f0-9-]{36})(?:\/kochen)?$/.exec(url.pathname);
if (match) {
const recipe = await fetch(`/api/recipes/${match[1]}`).then((r) => (r.ok ? r.json() : null));
if (!recipe?.published) return;
} else if (!['/', '/favoriten', '/einkaufsliste'].includes(url.pathname)) return;
const response = await fetch(url.href, { headers: { Accept: 'text/html' } });
if (!response.ok || !response.headers.get('content-type')?.includes('text/html')) return;
const cache = await caches.open(CACHE),
html = await response.clone().text();
await cache.put(url.href, response);
const paths = [
...new Set(
Array.from(
html.matchAll(/(?:src|href)="(\/(?:_next\/static\/|images\/|icons\/|media\/)[^"<>]+)"/g),
(m) => m[1].replaceAll('&amp;', '&'),
),
),
];
await Promise.all(
paths.map(async (path) => {
try {
const r = await fetch(path);
if (r.ok) await cache.put(path, r);
} catch {}
}),
);
}
self.addEventListener('message', (event) => {
if (event.data?.type === 'CACHE_PAGE' && typeof event.data.url === 'string')
event.waitUntil(savePage(event.data.url).catch(() => {}));
});
self.addEventListener('install', (event) => {
event.waitUntil(
caches.open(CACHE).then((cache) => cache.addAll(['/offline.html', '/icons/icon-192.png'])),
);
self.skipWaiting();
});
self.addEventListener('activate', (event) => {
event.waitUntil(
caches
.keys()
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k)))),
);
self.clients.claim();
});
self.addEventListener('fetch', (event) => {
const req = event.request,
url = new URL(req.url);
if (
req.method !== 'GET' ||
url.origin !== self.location.origin ||
url.pathname.startsWith('/admin') ||
url.pathname.startsWith('/api') ||
url.searchParams.has('_rsc') ||
req.headers.get('rsc')
)
return;
const asset = /^\/(?:_next\/static\/|images\/|icons\/|media\/)/.test(url.pathname);
const recipe = /^\/rezepte\/([a-f0-9-]{36})(?:\/kochen)?$/.exec(url.pathname);
if (!asset && req.mode !== 'navigate') return;
event.respondWith(
(async () => {
const cache = await caches.open(CACHE);
if (asset) {
const cached = await cache.match(req);
if (cached) return cached;
}
try {
const response = await fetch(req);
if (response.ok && asset) await cache.put(req, response.clone());
if (response.ok && recipe) {
// Never persist a draft or an administrator page. Only published recipes.
const data = await fetch(`/api/recipes/${recipe[1]}`)
.then((r) => (r.ok ? r.json() : null))
.catch(() => null);
if (data?.published) await cache.put(req, response.clone());
else await cache.delete(req);
}
return response;
} catch {
const saved =
(await cache.match(req)) ??
(req.mode === 'navigate' ? await cache.match(req, { ignoreSearch: true }) : null);
if (saved) return saved;
if (req.mode === 'navigate') return await cache.match('/offline.html');
return new Response('', { status: 503 });
}
})(),
);
});
+13
View File
@@ -0,0 +1,13 @@
import { admin } from '@/lib/server';
import { listRecipes } from '@/lib/repository';
import { Login, AdminDashboard } from '@/components/admin';
export const dynamic = 'force-dynamic';
export const metadata = { title: 'Deine Küche', robots: { index: false, follow: false } };
export default async function Admin() {
const user = await admin();
return user ? (
<AdminDashboard initialRecipes={await listRecipes(true)} username={user.username} />
) : (
<Login />
);
}
+14
View File
@@ -0,0 +1,14 @@
import { redirect, notFound } from 'next/navigation';
import { admin } from '@/lib/server';
import { getRecipe } from '@/lib/repository';
import { RecipeEditor } from '@/components/editor';
export const dynamic = 'force-dynamic';
export const metadata = { title: 'Rezepteditor', robots: { index: false, follow: false } };
export default async function Edit({ params }: { params: Promise<{ id: string }> }) {
if (!(await admin())) redirect('/admin');
const { id } = await params;
if (id === 'neu') return <RecipeEditor />;
const recipe = await getRecipe(id, true);
if (!recipe) notFound();
return <RecipeEditor initial={recipe} />;
}
+97
View File
@@ -0,0 +1,97 @@
'use client';
import Link from 'next/link';
import { Check, Copy, Printer, ShoppingBasket, Trash2, ArrowRight } from 'lucide-react';
import { useLocal, toast } from '@/lib/client';
import { formatAmount, type ShoppingItem } from '@/lib/calculator';
export default function Shopping() {
const [items, setItems] = useLocal<ShoppingItem[]>('hamham:shopping', []);
const label = (i: ShoppingItem) =>
`${formatAmount(i.amount)} ${i.unit} ${i.name}${i.note ? ` (${i.note})` : ''}`.trim();
return (
<div className="container narrow">
<header className="page-heading">
<span className="eyebrow">GUT VORBEREITET SCHMECKT’S BESSER</span>
<h1>
Deine Einkaufsliste<span className="accent">.</span>
</h1>
<p>Alles an einem Ort. Passende Mengen werden automatisch zusammengezählt.</p>
</header>
{items.length ? (
<>
<div className="shopping-toolbar no-print">
<span>
{items.filter((i) => i.checked).length} von {items.length} erledigt
</span>
<div className="button-row">
<button
className="button secondary"
onClick={async () => {
try {
await navigator.clipboard.writeText(
items.map((i) => `${i.checked ? '✓' : '□'} ${label(i)}`).join('\n'),
);
toast('Einkaufsliste kopiert.');
} catch {
toast('Kopieren nicht möglich. Nutze die Druckansicht.');
}
}}
>
<Copy size={17} /> Kopieren
</button>
<button className="button secondary" onClick={() => print()}>
<Printer size={17} /> Drucken
</button>
</div>
</div>
<ul className="shopping-list">
{items.map((i) => (
<li key={i.id} className={i.checked ? 'is-checked' : ''}>
<button
className="shopping-item"
onClick={() =>
setItems(items.map((x) => (x.id === i.id ? { ...x, checked: !x.checked } : x)))
}
>
<span className="check-box">{i.checked && <Check size={17} />}</span>
<span>
{i.name}
{i.note && <small>{i.note}</small>}
</span>
<strong>
{formatAmount(i.amount)} {i.unit}
</strong>
</button>
<button
className="icon-button no-print"
aria-label={`${i.name} entfernen`}
onClick={() => setItems(items.filter((x) => x.id !== i.id))}
>
<Trash2 size={16} />
</button>
</li>
))}
</ul>
<button
className="text-button no-print"
onClick={() => setItems(items.filter((i) => !i.checked))}
>
<Trash2 size={16} /> Erledigte Zutaten entfernen
</button>
</>
) : (
<div className="empty-state">
<ShoppingBasket size={45} strokeWidth={1.3} />
<h2>Platz für frische Ideen.</h2>
<p>
Füge Zutaten direkt aus einem Rezept hinzu.
<br />
Deine eingestellten Portionen werden übernommen.
</p>
<Link href="/" className="button primary">
Rezepte entdecken <ArrowRight size={18} />
</Link>
</div>
)}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
'use client';
export default function ErrorPage({ reset }: { reset: () => void }) {
return (
<div className="empty-state">
<h1>Ein kleiner Küchenstopp.</h1>
<p>
Die Seite konnte nicht geladen werden. Bitte prüfe die Verbindung und versuche es erneut.
</p>
<button className="button primary" onClick={reset}>
Erneut versuchen
</button>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Suspense } from 'react';
import { Library } from '@/components/library';
import { listRecipes } from '@/lib/repository';
export const dynamic = 'force-dynamic';
export const metadata = { title: 'Lieblingsrezepte' };
export default async function Favorites() {
return (
<Suspense>
<Library favoritesOnly recipes={await listRecipes()} />
</Suspense>
);
}
+3147
View File
File diff suppressed because it is too large. Load diff
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata, Viewport } from 'next';
import '@fontsource/dm-sans/400.css';
import '@fontsource/dm-sans/500.css';
import '@fontsource/dm-sans/600.css';
import '@fontsource/dm-sans/700.css';
import '@fontsource/fraunces/400.css';
import '@fontsource/fraunces/500.css';
import './globals.css';
import { Shell } from '@/components/shell';
export const metadata: Metadata = {
title: { default: 'HamHam · Zuhause schmeckt’s am besten', template: '%s · HamHam' },
description:
'Dein persönliches Kochbuch. Lieblingsrezepte sammeln, clever planen und entspannt kochen.',
manifest: '/manifest.webmanifest',
appleWebApp: { capable: true, title: 'HamHam', statusBarStyle: 'default' },
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
themeColor: '#365747',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="de" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `try{let t=JSON.parse(localStorage.getItem('hamham:theme')||'"system"');document.documentElement.dataset.theme=t==='system'?(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):t}catch{}`,
}}
/>
</head>
<body>
<Shell>{children}</Shell>
</body>
</html>
);
}
+12
View File
@@ -0,0 +1,12 @@
export default function Loading() {
return (
<div className="container">
<div className="skeleton skeleton-hero" />
<div className="recipe-grid">
{[1, 2, 3].map((n) => (
<div className="skeleton skeleton-card" key={n} />
))}
</div>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import Link from 'next/link';
export default function NotFound() {
return (
<div className="empty-state">
<span className="eyebrow">404 · Nicht auf der Karte</span>
<h1>Dieses Rezept ist gerade nicht da.</h1>
<p>Es wurde möglicherweise entfernt oder noch nicht veröffentlicht.</p>
<Link href="/" className="button primary">
Rezepte entdecken
</Link>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { Suspense } from 'react';
import { listRecipes } from '@/lib/repository';
import { Library } from '@/components/library';
import { db } from '@/db';
import { settings } from '@/db/schema';
import { eq } from 'drizzle-orm';
export const dynamic = 'force-dynamic';
export default async function Home() {
const [collection] = await db.select().from(settings).where(eq(settings.key, 'household'));
return (
<Suspense>
<Library
recipes={await listRecipes()}
household={typeof collection?.value === 'string' ? collection.value : undefined}
/>
</Suspense>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { notFound } from 'next/navigation';
import { getRecipe } from '@/lib/repository';
import { admin } from '@/lib/server';
import { Cooking } from '@/components/cooking';
export const dynamic = 'force-dynamic';
export const metadata = { title: 'Jetzt kochen' };
export default async function Cook({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ faktor?: string }>;
}) {
const r = await getRecipe((await params).id, !!(await admin()));
if (!r) notFound();
return <Cooking recipe={r} initialFactor={(await searchParams).faktor ?? '1'} />;
}
+22
View File
@@ -0,0 +1,22 @@
import { notFound } from 'next/navigation';
import { getRecipe, listRecipes } from '@/lib/repository';
import { admin } from '@/lib/server';
import { RecipeDetail } from '@/components/recipe-detail';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const r = await getRecipe((await params).id, !!(await admin()));
return { title: r?.title ?? 'Rezept nicht gefunden' };
}
export default async function RecipePage({ params }: { params: Promise<{ id: string }> }) {
const r = await getRecipe((await params).id, !!(await admin()));
if (!r) notFound();
const related = (await listRecipes())
.filter((x) => x.id !== r.id)
.sort(
(a, b) =>
b.categories.filter((c) => r.categories.includes(c)).length -
a.categories.filter((c) => r.categories.includes(c)).length,
)
.slice(0, 3);
return <RecipeDetail recipe={r} related={related} />;
}
+620
View File
@@ -0,0 +1,620 @@
'use client';
import { useEffect, useState } from 'react';
import { useRouter } from 'next/navigation';
import Link from 'next/link';
import {
Plus,
Upload,
Download,
Pencil,
Trash2,
LogOut,
LockKeyhole,
ArrowRight,
BookOpen,
ImageIcon,
Settings,
Check,
} from 'lucide-react';
import { api, toast } from '@/lib/client';
import type { SavedRecipe, Recipe } from '@/lib/recipe';
import { renderStep, ingredientLabel } from '@/lib/calculator';
import { Dialog } from './dialog';
export function Login() {
const router = useRouter(),
[error, setError] = useState(''),
[busy, setBusy] = useState(false);
return (
<div className="login-layout">
<div className="login-story">
<span className="eyebrow">DEIN KOCHBUCH. DEINE HANDSCHRIFT.</span>
<h1>
Gute Rezepte
<br />
sind zum
<br />
<em>Behalten da.</em>
</h1>
<p>
Ein besonderer Platz für alles,
<br />
was bei euch auf den Tisch kommt.
</p>
</div>
<form
className="login-card"
onSubmit={async (e) => {
e.preventDefault();
setBusy(true);
setError('');
const data = new FormData(e.currentTarget);
try {
await api('auth/login', 'POST', Object.fromEntries(data));
router.refresh();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}}
>
<span className="note-icon">
<LockKeyhole size={24} />
</span>
<h2>Willkommen zurück.</h2>
<p className="muted">Melde dich an, um eure Sammlung zu pflegen.</p>
<label>
Benutzername
<input name="username" autoComplete="username" required autoFocus />
</label>
<label>
Passwort
<input type="password" name="password" autoComplete="current-password" required />
</label>
{error && (
<p className="error" role="alert">
{error}
</p>
)}
<button className="button primary full" disabled={busy}>
{busy ? 'Wird angemeldet …' : 'Anmelden'}
<ArrowRight size={18} />
</button>
<p className="small muted">
Erster Besuch? Erstelle den Administrator mit der HamHam-CLI. Eine öffentliche
Registrierung gibt es nicht.
</p>
</form>
</div>
);
}
export function AdminDashboard({
initialRecipes,
username,
}: {
initialRecipes: SavedRecipe[];
username: string;
}) {
const router = useRouter(),
[recipes, setRecipes] = useState(initialRecipes),
[tab, setTab] = useState('recipes'),
[remove, setRemove] = useState<SavedRecipe | null>(null),
[error, setError] = useState('');
useEffect(() => setRecipes(initialRecipes), [initialRecipes]);
return (
<div className="container admin-page">
<div className="admin-heading">
<div>
<span className="eyebrow">DEIN PLATZ HINTER DEN KULISSEN</span>
<h1>
Hallo, {username}
<span className="accent">.</span>
</h1>
<p className="muted">Hier wachsen eure Lieblingsrezepte.</p>
</div>
<button
className="button secondary"
onClick={async () => {
await api('auth/logout', 'POST');
router.refresh();
}}
>
<LogOut size={17} /> Abmelden
</button>
</div>
<div className="admin-tabs">
{[
{ id: 'recipes', label: 'Rezepte', icon: BookOpen },
{ id: 'import', label: 'Importieren', icon: Upload },
{ id: 'media', label: 'Bilder', icon: ImageIcon },
{ id: 'settings', label: 'Einstellungen', icon: Settings },
].map((t) => (
<button key={t.id} className={tab === t.id ? 'active' : ''} onClick={() => setTab(t.id)}>
<t.icon size={18} />
{t.label}
</button>
))}
</div>
{tab === 'recipes' && (
<>
<div className="section-heading">
<h2>
Deine Sammlung <span className="count-badge">{recipes.length}</span>
</h2>
<div className="button-row">
<a className="button secondary" href="/api/export">
<Download size={17} /> Alles exportieren
</a>
<Link className="button primary" href="/admin/rezepte/neu">
<Plus size={18} /> Neues Rezept
</Link>
</div>
</div>
{recipes.length ? (
<div className="admin-recipe-list">
{recipes.map((r) => (
<article key={r.id}>
<div className="admin-thumb">
{r.image ? <img src={r.image} alt="" /> : <BookOpen size={25} />}
</div>
<div className="admin-recipe-name">
<Link href={`/admin/rezepte/${r.id}`}>
<h3>{r.title}</h3>
</Link>
<span className={`status-pill ${r.published ? 'published' : ''}`}>
{r.published ? 'Veröffentlicht' : 'Entwurf'}
</span>
<small>Geändert am {new Date(r.updatedAt).toLocaleDateString('de-AT')}</small>
</div>
<div className="button-row">
<Link
className="icon-button"
aria-label={`${r.title} bearbeiten`}
href={`/admin/rezepte/${r.id}`}
>
<Pencil size={18} />
</Link>
<a
className="icon-button"
title="JSON exportieren"
aria-label={`${r.title} als JSON exportieren`}
href={`/api/export?id=${r.id}`}
>
<Download size={18} />
</a>
<a className="small text-button" href={`/api/export?id=${r.id}&format=xml`}>
XML
</a>
<button
className="icon-button danger"
aria-label={`${r.title} löschen`}
onClick={() => setRemove(r)}
>
<Trash2 size={18} />
</button>
</div>
</article>
))}
</div>
) : (
<div className="empty-state">
<BookOpen size={40} />
<h3>Das erste Rezept wartet auf dich.</h3>
<p>Schreibe es selbst oder importiere eine JSON- oder XML-Datei.</p>
</div>
)}
</>
)}
{tab === 'import' && (
<ImportPanel
onImported={() => {
router.refresh();
setTab('recipes');
}}
/>
)}
{tab === 'settings' && <SettingsPanel />}
{tab === 'media' && <MediaPanel />}
{remove && (
<Dialog
title="Rezept löschen?"
onClose={() => {
setRemove(null);
setError('');
}}
>
<h2 id="delete-title">Rezept löschen?</h2>
<p>„{remove.title}“ wird dauerhaft aus der Sammlung entfernt.</p>
{error && (
<p role="alert" className="error">
{error}
</p>
)}
<div className="button-row">
<button
autoFocus
className="button secondary"
onClick={() => {
setRemove(null);
setError('');
}}
>
Abbrechen
</button>
<button
className="button danger-solid"
onClick={async () => {
try {
await api(`recipes/${remove.id}`, 'DELETE');
setRecipes(recipes.filter((r) => r.id !== remove.id));
setRemove(null);
toast('Rezept gelöscht.');
} catch (e) {
setError((e as Error).message);
}
}}
>
Endgültig löschen
</button>
</div>
</Dialog>
)}
</div>
);
}
function ImportPanel({ onImported }: { onImported: () => void }) {
const [format, setFormat] = useState<'json' | 'xml'>('json'),
[raw, setRaw] = useState(''),
[preview, setPreview] = useState<(Recipe & { duplicate: boolean })[] | null>(null),
[error, setError] = useState(''),
[busy, setBusy] = useState(false);
async function process(isPreview: boolean) {
setBusy(true);
setError('');
try {
const result = await api<(Recipe & { duplicate: boolean })[]>('import', 'POST', {
raw,
format,
preview: isPreview,
});
if (isPreview) setPreview(result);
else {
toast(`${result.length} Rezept(e) importiert.`);
onImported();
}
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
return (
<section className="import-panel">
<div className="section-heading">
<div>
<h2>Ein neues Rezept zieht ein.</h2>
<p className="muted">JSON oder XML einfügen, prüfen und in deine Sammlung aufnehmen.</p>
</div>
<a className="text-button" href="/examples/pljeskavica.json" download>
<Download size={17} /> JSON-Vorlage
</a>
</div>
<div className="import-controls">
<label>
Importformat
<select
value={format}
onChange={(e) => {
setFormat(e.target.value as 'json' | 'xml');
setPreview(null);
}}
>
<option value="json">JSON · Version 1</option>
<option value="xml">XML · Version 1</option>
</select>
</label>
<label className="upload-area">
<Upload size={23} />
<span>Datei auswählen · maximal 2 MB</span>
<input
aria-label="Importdatei"
type="file"
accept=".json,.xml"
onChange={async (e) => {
const file = e.target.files?.[0];
if (!file) return;
if (file.size > 2 * 1024 * 1024) {
setError('Datei ist größer als 2 MB.');
return;
}
setRaw(await file.text());
setFormat(file.name.toLowerCase().endsWith('.xml') ? 'xml' : 'json');
setPreview(null);
}}
/>
</label>
</div>
<label>
Rezeptdaten
<textarea
className="code-input"
rows={12}
value={raw}
onChange={(e) => {
setRaw(e.target.value);
setPreview(null);
}}
placeholder={
format === 'json'
? '{ "version": 1, "recipes": […] }'
: '<hamham version="1">…</hamham>'
}
/>
</label>
{error && (
<pre className="error" role="alert">
{error}
</pre>
)}
<button
className="button primary"
disabled={busy || !raw.trim()}
onClick={() => process(true)}
>
<Check size={18} /> {busy ? 'Wird geprüft …' : 'Importvorschau prüfen'}
</button>
{preview && (
<div className="import-preview">
<h2>Vor dem Einräumen.</h2>
{preview.map((r, n) => (
<article key={n} className="panel">
<div className="section-heading">
<h3>{r.title}</h3>
<span className={r.duplicate ? 'error' : 'status-pill published'}>
{r.duplicate
? 'Duplikat – Import gesperrt'
: r.published
? 'Wird veröffentlicht'
: 'Wird als Entwurf importiert'}
</span>
</div>
<p>{r.description}</p>
<div className="preview-columns">
<ul>
{r.ingredients.map((i) => (
<li key={i.id}>{ingredientLabel(i, '1')}</li>
))}
</ul>
<ol>
{r.steps.map((s) => (
<li key={s.id}>{renderStep(s.text, r, '1')}</li>
))}
</ol>
</div>
</article>
))}
<button
className="button primary"
disabled={busy || preview.some((r) => r.duplicate)}
onClick={() => process(false)}
>
<Upload size={18} /> {preview.length} Rezept(e) jetzt importieren
</button>
</div>
)}
</section>
);
}
function SettingsPanel() {
const [settings, setSettings] = useState<{
categories: string[];
tags: string[];
household: string;
} | null>(null),
[error, setError] = useState('');
useEffect(() => {
api<typeof settings>('settings')
.then(setSettings)
.catch((e) => setError(e.message));
}, []);
return (
<div className="settings-grid">
<form
className="panel"
onSubmit={async (e) => {
e.preventDefault();
try {
const fd = new FormData(e.currentTarget);
const data = {
household: fd.get('household'),
categories: String(fd.get('categories'))
.split('\n')
.map((s) => s.trim())
.filter(Boolean),
tags: String(fd.get('tags'))
.split('\n')
.map((s) => s.trim())
.filter(Boolean),
};
await api('settings', 'PUT', data);
toast('Einstellungen gespeichert.');
} catch (e) {
setError((e as Error).message);
}
}}
>
<h2>Deine Küche</h2>
{settings && (
<>
<label>
Name der Sammlung
<input name="household" defaultValue={settings.household} required maxLength={80} />
</label>
<label>
Kategorien · eine pro Zeile
<textarea name="categories" rows={6} defaultValue={settings.categories.join('\n')} />
</label>
<label>
Tags · einer pro Zeile
<textarea name="tags" rows={6} defaultValue={settings.tags.join('\n')} />
</label>
<p className="small muted">
Die Auswahllisten gelten für den Editor. Bereits zugeordnete Begriffe bleiben in
Rezepten erhalten und können dort geändert werden.
</p>
<button className="button primary">Einstellungen speichern</button>
</>
)}
</form>
<form
className="panel"
onSubmit={async (e) => {
e.preventDefault();
const form = e.currentTarget;
try {
await api('auth/password', 'POST', Object.fromEntries(new FormData(form)));
form.reset();
toast('Passwort geändert. Andere Sitzungen wurden beendet.');
} catch (e) {
setError((e as Error).message);
}
}}
>
<h2>Passwort ändern</h2>
<label>
Aktuelles Passwort
<input name="current" type="password" autoComplete="current-password" required />
</label>
<label>
Neues Passwort
<input
name="password"
type="password"
autoComplete="new-password"
minLength={12}
maxLength={200}
required
/>
</label>
<p className="small muted">
Mindestens 12 Zeichen. Alle anderen aktiven Sitzungen werden abgemeldet.
</p>
<button className="button primary">Passwort ändern</button>
{error && (
<p className="error" role="alert">
{error}
</p>
)}
</form>
</div>
);
}
export async function uploadImage(file: File) {
if (file.size > 10 * 1024 * 1024) throw new Error('Bild darf höchstens 10 MB groß sein.');
const response = await fetch('/api/media', {
method: 'POST',
headers: { 'Content-Type': file.type },
body: file,
});
const result = await response.json();
if (!response.ok) throw new Error(result.error);
return result.url as string;
}
function MediaPanel() {
const [images, setImages] = useState<string[]>([]),
[error, setError] = useState(''),
[remove, setRemove] = useState('');
useEffect(() => {
api<string[]>('media')
.then(setImages)
.catch((e) => setError(e.message));
}, []);
return (
<section>
<div className="section-heading">
<h2>Deine Bilder</h2>
<label className="button secondary">
<Upload size={18} /> Hochladen
<input
className="visually-hidden"
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={async (e) => {
const f = e.target.files?.[0];
if (f)
try {
const url = await uploadImage(f);
setImages([...images, url]);
} catch (e) {
setError((e as Error).message);
}
}}
/>
</label>
</div>
<p className="muted">
Bilder werden lokal gespeichert, neu als WebP kodiert und von Metadaten befreit. Verwendete
Bilder können nicht gelöscht werden.
</p>
{error && (
<p className="error" role="alert">
{error}
</p>
)}
<div className="media-grid">
{images.map((src) => (
<article className="panel" key={src}>
<img src={src} alt="Hochgeladenes Rezeptbild" />
<button
className="text-button"
onClick={async () => {
await navigator.clipboard.writeText(src);
toast('Bildpfad kopiert.');
}}
>
Bildpfad kopieren
</button>
<button
className="icon-button danger"
aria-label="Bild löschen"
onClick={() => setRemove(src)}
>
<Trash2 size={17} />
</button>
</article>
))}
</div>
{images.length === 0 && (
<div className="empty-state">
<ImageIcon size={40} />
<h3>Dein Rezept verdient ein schönes Bild.</h3>
<p>Lade ein erstes Foto hoch oder füge es direkt im Rezepteditor hinzu.</p>
</div>
)}
{remove && (
<Dialog title="Bild endgültig löschen?" onClose={() => setRemove('')}>
<h2>Bild endgültig löschen?</h2>
<div className="button-row">
<button autoFocus className="button secondary" onClick={() => setRemove('')}>
Abbrechen
</button>
<button
className="button danger-solid"
onClick={async () => {
try {
await api('media', 'DELETE', { name: remove.split('/').pop() });
setImages(images.filter((i) => i !== remove));
} catch (e) {
setError((e as Error).message);
}
setRemove('');
}}
>
Löschen
</button>
</div>
</Dialog>
)}
</section>
);
}
+359
View File
@@ -0,0 +1,359 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { motion, AnimatePresence } from 'motion/react';
import {
ArrowLeft,
ArrowRight,
Check,
ChefHat,
Clock3,
Pause,
Play,
RotateCcw,
Sun,
Volume2,
X,
PartyPopper,
} from 'lucide-react';
import Decimal from 'decimal.js';
import type { SavedRecipe } from '@/lib/recipe';
import { renderStep, ingredientLabel, formatAmount, positive } from '@/lib/calculator';
import {
type CookingProgress,
type TimerState,
remainingMs,
startTimer,
pauseTimer,
} from '@/lib/cooking';
import { useLocal, toast } from '@/lib/client';
export function Cooking({
recipe: r,
initialFactor,
}: {
recipe: SavedRecipe;
initialFactor: string;
}) {
let safeFactor = '1';
try {
safeFactor = positive(initialFactor).toString();
} catch {}
const blank = (): CookingProgress => ({
revision: r.updatedAt,
step: 0,
factor: safeFactor,
checked: [],
timers: {},
startedAt: Date.now(),
finishedAt: null,
});
const [state, setState, ready] = useLocal<CookingProgress>(`hamham:cook:${r.id}`, blank());
const [now, setNow] = useState(Date.now()),
[wake, setWake] = useState(false),
[sound, setSound] = useState(false),
[showIngredients, setShowIngredients] = useState(false);
const audio = useRef<AudioContext | null>(null);
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), 250);
return () => clearInterval(id);
}, []);
useEffect(() => {
if (!ready) return;
if (state.revision !== r.updatedAt || state.step > r.steps.length) {
setState(blank());
toast('Das Rezept wurde aktualisiert. Der Kochmodus startet mit der neuen Fassung.');
}
}, [ready, r.updatedAt]);
useEffect(() => {
let lock: WakeLockSentinel | undefined,
disposed = false;
const acquire = async () => {
if ('wakeLock' in navigator && document.visibilityState === 'visible')
try {
const acquired = await navigator.wakeLock.request('screen');
if (disposed) {
await acquired.release();
return;
}
lock = acquired;
setWake(true);
lock.addEventListener('release', () => setWake(false));
} catch {
setWake(false);
}
};
void acquire();
document.addEventListener('visibilitychange', acquire);
return () => {
disposed = true;
document.removeEventListener('visibilitychange', acquire);
void lock?.release();
};
}, []);
useEffect(() => {
if (!ready) return;
for (const [id, timer] of Object.entries(state.timers))
if (timer.endAt && remainingMs(timer, now) === 0 && !timer.notified) {
setState((s) => ({ ...s, timers: { ...s.timers, [id]: { ...timer, notified: true } } }));
const label = r.steps.flatMap((s) => s.timers).find((t) => t.id === id)?.label ?? 'Timer';
toast(`${label} ist fertig!`);
if (sound && audio.current) {
const ctx = audio.current;
const oscillator = ctx.createOscillator(),
gain = ctx.createGain();
oscillator.connect(gain);
gain.connect(ctx.destination);
oscillator.frequency.value = 880;
gain.gain.setValueAtTime(0.18, ctx.currentTime);
gain.gain.exponentialRampToValueAtTime(0.001, ctx.currentTime + 1.5);
oscillator.start();
oscillator.stop(ctx.currentTime + 1.5);
}
navigator.vibrate?.([200, 100, 200]);
}
}, [now, ready, state.timers, sound, r.steps, setState]);
function updateTimer(id: string, timer: TimerState) {
setState((s) => ({ ...s, timers: { ...s.timers, [id]: timer } }));
}
const timers = r.steps.flatMap((s) => s.timers),
current = r.steps[Math.min(state.step, r.steps.length - 1)],
finished = state.step === r.steps.length;
if (!ready) return <div className="skeleton skeleton-hero" />;
return (
<div className="cooking-shell">
<header className="cooking-header">
<Link className="brand" href="/">
<ChefHat size={25} />
HamHam<span className="brand-dot">.</span>
</Link>
<span className="cooking-recipe-title">{r.title}</span>
<Link
href={`/rezepte/${r.id}`}
className="button secondary"
title="Fortschritt und Timer werden gespeichert"
>
<Pause size={17} />
<span>Unterbrechen</span>
</Link>
</header>
<div className="cooking-tools">
<span>
<span className="live-dot" /> KÜCHENMODUS
</span>
<div>
<button
className={sound ? 'active' : ''}
onClick={() => {
if (!audio.current) audio.current = new AudioContext();
void audio.current.resume();
setSound(!sound);
}}
>
<Volume2 size={17} /> Ton {sound ? 'an' : 'aus'}
</button>
<button
aria-label="Kochmodus Theme wechseln"
onClick={() => {
document.documentElement.dataset.theme =
document.documentElement.dataset.theme === 'dark' ? 'light' : 'dark';
}}
>
<Sun size={17} /> Licht
</button>
</div>
</div>
{finished ? (
<section className="cooking-complete">
<span className="completion-icon">
<PartyPopper size={54} strokeWidth={1.2} />
</span>
<span className="eyebrow">GESCHAFFT. JETZT WIRD GENOSSEN.</span>
<h1>Guten Appetit!</h1>
<p>
{r.title}
<br />
{formatAmount(new Decimal(r.servings).mul(state.factor))} Portionen voller Vorfreude.
</p>
<p className="muted">
{r.steps.length} Schritte ·{' '}
{Math.max(1, Math.round(((state.finishedAt ?? now) - state.startedAt) / 60000))} Minuten
seit Kochbeginn
</p>
<div className="button-row">
<Link className="button primary" href={`/rezepte/${r.id}`}>
Zurück zum Rezept <ArrowRight size={18} />
</Link>
<button className="button secondary" onClick={() => setState(blank())}>
<RotateCcw size={18} /> Erneut kochen
</button>
</div>
</section>
) : (
<>
<div className="cooking-progress">
<div>
<span>
Schritt <strong>{state.step + 1}</strong> von {r.steps.length}
</span>
<span>{formatAmount(new Decimal(r.servings).mul(state.factor))} Portionen</span>
</div>
<div className="progress-track">
<span style={{ width: `${(state.step / r.steps.length) * 100}%` }} />
</div>
</div>
{state.step === 0 && state.factor !== safeFactor && (
<div className="notice">
Dein gespeicherter Kochvorgang wurde fortgesetzt.{' '}
<button onClick={() => setState(blank())}>Mit neuer Portionszahl starten</button>
</div>
)}
<AnimatePresence mode="wait">
<motion.section
className="cooking-step"
key={current.id}
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 0.18 }}
>
<span className="eyebrow">JETZT GEHT’S ANS KOCHEN</span>
<h1>{current.title || `Schritt ${state.step + 1}`}</h1>
<p className="cooking-instruction">{renderStep(current.text, r, state.factor)}</p>
<div className="step-ingredients">
{current.ingredients.map((id) => {
const i = r.ingredients.find((i) => i.id === id)!;
return (
<button
className={state.checked.includes(id) ? 'checked' : ''}
key={id}
onClick={() =>
setState((s) => ({
...s,
checked: s.checked.includes(id)
? s.checked.filter((x) => x !== id)
: [...s.checked, id],
}))
}
>
<span className="check-box">
{state.checked.includes(id) && <Check size={15} />}
</span>
{ingredientLabel(i, state.factor)}
</button>
);
})}
</div>
</motion.section>
</AnimatePresence>
<section className="timer-grid" aria-label="Küchentimer">
{timers
.filter((t) => current.timers.some((x) => x.id === t.id) || state.timers[t.id])
.map((t) => {
const timer = state.timers[t.id] ?? { remaining: t.seconds * 1000, endAt: null },
remaining = remainingMs(timer, now),
seconds = Math.ceil(remaining / 1000);
return (
<div className={`timer-card ${remaining === 0 ? 'timer-done' : ''}`} key={t.id}>
<Clock3 size={24} />
<div>
<span>{t.label}</span>
<strong role="timer">
{remaining === 0
? 'Fertig!'
: `${Math.floor(seconds / 60)
.toString()
.padStart(2, '0')}:${(seconds % 60).toString().padStart(2, '0')}`}
</strong>
</div>
<button
className="icon-button"
aria-label={`${t.label} ${timer.endAt && remaining > 0 ? 'pausieren' : 'starten'}`}
onClick={() =>
updateTimer(
t.id,
timer.endAt && remaining > 0
? pauseTimer(timer)
: startTimer(
remaining > 0
? { ...timer, remaining }
: { remaining: t.seconds * 1000, endAt: null },
),
)
}
>
{timer.endAt && remaining > 0 ? <Pause size={22} /> : <Play size={22} />}
</button>
<button
className="icon-button"
aria-label={`${t.label} zurücksetzen`}
onClick={() =>
updateTimer(t.id, { remaining: t.seconds * 1000, endAt: null })
}
>
<RotateCcw size={18} />
</button>
</div>
);
})}
</section>
<div className="cooking-bottom-actions">
<button className="text-button" onClick={() => setShowIngredients(!showIngredients)}>
Alle Zutaten {showIngredients ? 'ausblenden' : 'anzeigen'}
</button>
<span className="small muted">
{wake ? 'Bildschirm bleibt wach' : 'Bildschirmsperre bei Bedarf manuell deaktivieren'}{' '}
· Automatisch gespeichert
</span>
</div>
{showIngredients && (
<ul className="cooking-all-ingredients">
{r.ingredients.map((i) => (
<li key={i.id}>{ingredientLabel(i, state.factor)}</li>
))}
</ul>
)}
<nav className="cooking-navigation">
<button
className="button secondary"
disabled={state.step === 0}
onClick={() => setState((s) => ({ ...s, step: Math.max(0, s.step - 1) }))}
>
<ArrowLeft size={21} /> Zurück
</button>
<span>
{String(state.step + 1).padStart(2, '0')} / {String(r.steps.length).padStart(2, '0')}
</span>
<button
className="button primary"
onClick={() =>
setState((s) => ({
...s,
step: s.step + 1,
finishedAt: s.step + 1 === r.steps.length ? Date.now() : null,
}))
}
>
{state.step === r.steps.length - 1 ? 'Fertig gekocht' : 'Nächster Schritt'}
<ArrowRight size={21} />
</button>
</nav>
</>
)}
{finished &&
timers.some(
(t) => state.timers[t.id]?.endAt && remainingMs(state.timers[t.id], now) > 0,
) && (
<div className="notice">
Es laufen noch Timer.{' '}
<button
onClick={() =>
setState((s) => ({ ...s, step: r.steps.length - 1, finishedAt: null }))
}
>
Timer ansehen
</button>
</div>
)}
</div>
);
}
+62
View File
@@ -0,0 +1,62 @@
'use client';
import { useEffect, useRef } from 'react';
export function Dialog({
title,
onClose,
children,
}: {
title: string;
onClose: () => void;
children: React.ReactNode;
}) {
const ref = useRef<HTMLDivElement>(null),
closeRef = useRef(onClose);
closeRef.current = onClose;
useEffect(() => {
const previous = document.activeElement as HTMLElement | null;
const focusable = () =>
Array.from(
ref.current?.querySelectorAll<HTMLElement>(
'button:not(:disabled), a[href], input, textarea, select, [tabindex="0"]',
) ?? [],
);
focusable()[0]?.focus();
const keydown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
closeRef.current();
}
if (e.key !== 'Tab') return;
const items = focusable(),
first = items[0],
last = items.at(-1);
if (e.shiftKey && document.activeElement === first) {
e.preventDefault();
last?.focus();
} else if (!e.shiftKey && document.activeElement === last) {
e.preventDefault();
first?.focus();
}
};
const overflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
document.addEventListener('keydown', keydown);
return () => {
document.body.style.overflow = overflow;
document.removeEventListener('keydown', keydown);
previous?.focus();
};
}, []);
return (
<div
className="dialog-backdrop"
onMouseDown={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div ref={ref} role="dialog" aria-modal="true" aria-label={title} className="dialog">
{children}
</div>
</div>
);
}
+795
View File
@@ -0,0 +1,795 @@
'use client';
import { useEffect, useRef, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
ArrowLeft,
Plus,
Trash2,
GripVertical,
ArrowUp,
ArrowDown,
Upload,
Save,
Eye,
Check,
X,
} from 'lucide-react';
import {
type Recipe,
type SavedRecipe,
type Ingredient,
type Step,
emptyRecipe,
units,
recipeSchema,
} from '@/lib/recipe';
import { api, readLocal, writeLocal, toast } from '@/lib/client';
import { uploadImage } from './admin';
import { renderStep, ingredientLabel } from '@/lib/calculator';
export function RecipeEditor({ initial }: { initial?: SavedRecipe }) {
const router = useRouter(),
key = `hamham:draft:${initial?.id ?? 'new'}`;
const [recipe, setRecipe] = useState<Recipe>(initial ?? emptyRecipe()),
[ready, setReady] = useState(false),
[busy, setBusy] = useState(false),
[error, setError] = useState(''),
[preview, setPreview] = useState(false),
[saved, setSaved] = useState(''),
[tab, setTab] = useState('basics'),
[options, setOptions] = useState<{ categories: string[]; tags: string[] }>({
categories: [],
tags: [],
});
const dirty = useRef(false),
dragging = useRef<number | null>(null);
useEffect(() => {
const draft = readLocal<{ recipe: Recipe; time: string } | null>(key, null);
if (draft) {
setRecipe(draft.recipe);
dirty.current = true;
setSaved(
`Lokaler Entwurf vom ${new Date(draft.time).toLocaleString('de-AT')} wiederhergestellt`,
);
}
setReady(true);
api<typeof options>('settings')
.then(setOptions)
.catch(() => {});
}, [key]);
useEffect(() => {
if (!ready || !dirty.current) return;
const time = new Date().toISOString();
writeLocal(key, { recipe, time });
setSaved(
`Auf diesem Gerät gesichert · ${new Date(time).toLocaleTimeString('de-AT', { hour: '2-digit', minute: '2-digit' })}`,
);
}, [recipe, ready, key]);
useEffect(() => {
const prevent = (e: BeforeUnloadEvent) => {
if (dirty.current) {
e.preventDefault();
}
};
window.addEventListener('beforeunload', prevent);
return () => window.removeEventListener('beforeunload', prevent);
}, []);
const change = <K extends keyof Recipe>(key: K, value: Recipe[K]) => {
dirty.current = true;
setRecipe((r) => ({ ...r, [key]: value }));
};
const updateIngredient = (n: number, patch: Partial<Ingredient>) =>
change(
'ingredients',
recipe.ingredients.map((i, index) => (index === n ? { ...i, ...patch } : i)),
);
const updateStep = (n: number, patch: Partial<Step>) =>
change(
'steps',
recipe.steps.map((s, index) => (index === n ? { ...s, ...patch } : s)),
);
function move(kind: 'ingredients' | 'steps', from: number, to: number) {
if (to < 0 || to >= recipe[kind].length) return;
if (kind === 'ingredients') {
const a = [...recipe.ingredients];
a.splice(to, 0, a.splice(from, 1)[0]);
change(kind, a);
} else {
const a = [...recipe.steps];
a.splice(to, 0, a.splice(from, 1)[0]);
change(kind, a);
}
}
async function save() {
setBusy(true);
setError('');
try {
const parsed = recipeSchema.safeParse(recipe);
if (!parsed.success)
throw new Error(
parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`).join('\n'),
);
const result = await api<SavedRecipe>(
initial ? `recipes/${initial.id}` : 'recipes',
initial ? 'PUT' : 'POST',
parsed.data,
);
dirty.current = false;
localStorage.removeItem(key);
setRecipe(result);
setSaved('Im Kochbuch gespeichert');
toast('Rezept erfolgreich gespeichert.');
if (!initial) router.replace(`/admin/rezepte/${result.id}`);
router.refresh();
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
async function upload(file: File, additional = false) {
setBusy(true);
try {
const url = await uploadImage(file);
if (additional) change('images', [...recipe.images, url]);
else change('image', url);
} catch (e) {
setError((e as Error).message);
} finally {
setBusy(false);
}
}
const reorder = (kind: 'ingredients' | 'steps', n: number) => (
<div className="reorder">
<span
draggable
onDragStart={() => {
dragging.current = n;
}}
onDragEnd={() => {
dragging.current = null;
}}
title="Zum Sortieren ziehen"
className="drag-handle"
>
<GripVertical size={18} />
</span>
<button
type="button"
aria-label={`${kind === 'ingredients' ? 'Zutat' : 'Schritt'} ${n + 1} nach oben`}
onClick={() => move(kind, n, n - 1)}
>
<ArrowUp size={14} />
</button>
<button
type="button"
aria-label={`${kind === 'ingredients' ? 'Zutat' : 'Schritt'} ${n + 1} nach unten`}
onClick={() => move(kind, n, n + 1)}
>
<ArrowDown size={14} />
</button>
</div>
);
return (
<div className="container editor">
<Link className="back-link" href="/admin">
<ArrowLeft size={17} /> Zur Sammlung
</Link>
<div className="editor-heading">
<div>
<span className="eyebrow">GUTES ESSEN BEGINNT MIT EINER IDEE</span>
<h1>{initial ? 'Feinschliff fürs Rezept.' : 'Ein neues Lieblingsrezept.'}</h1>
<span className="save-status">
<Check size={14} />
{saved || 'Entwürfe werden automatisch auf diesem Gerät gesichert.'}
</span>
</div>
<div className="button-row">
<button className="button secondary" onClick={() => setPreview(!preview)}>
<Eye size={17} />
{preview ? 'Editor' : 'Live-Vorschau'}
</button>
<button className="button primary" disabled={busy} onClick={save}>
<Save size={17} />
{busy ? 'Wird gespeichert …' : 'Rezept speichern'}
</button>
</div>
</div>
{error && (
<pre role="alert" className="error">
{error}
</pre>
)}
<div className={`editor-layout ${preview ? 'with-preview' : ''}`}>
<div>
<div className="admin-tabs">
{[
['basics', 'Das Rezept'],
['ingredients', 'Zutaten'],
['steps', 'Zubereitung'],
['extras', 'Feine Details'],
].map(([id, name]) => (
<button key={id} className={tab === id ? 'active' : ''} onClick={() => setTab(id)}>
{name}
</button>
))}
</div>
{tab === 'basics' && (
<section className="panel editor-panel">
<h2>Was kommt auf den Tisch?</h2>
<label>
Rezepttitel
<input
value={recipe.title}
onChange={(e) => change('title', e.target.value)}
placeholder="Zum Beispiel: Cremige Zitronenpasta"
maxLength={180}
/>
</label>
<label>
Untertitel
<input
value={recipe.subtitle}
onChange={(e) => change('subtitle', e.target.value)}
placeholder="Ein kleiner Vorgeschmack …"
/>
</label>
<label>
Beschreibung
<textarea
rows={4}
value={recipe.description}
onChange={(e) => change('description', e.target.value)}
/>
</label>
<div
className="image-upload"
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => {
e.preventDefault();
const file = e.dataTransfer.files[0];
if (file) void upload(file);
}}
>
{recipe.image ? (
<>
<img src={recipe.image} alt="Titelbild" />
<button
className="icon-button"
aria-label="Titelbild entfernen"
onClick={() => change('image', '')}
>
<X size={18} />
</button>
</>
) : (
<>
<Upload size={30} />
<h3>Ein Bild macht Appetit.</h3>
<p>Foto hier ablegen oder auswählen · JPEG, PNG, WebP · bis 10 MB</p>
</>
)}
<label className="button secondary">
{recipe.image ? 'Titelbild ersetzen' : 'Titelbild hochladen'}
<input
className="visually-hidden"
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={(e) => {
if (e.target.files?.[0]) void upload(e.target.files[0]);
}}
/>
</label>
</div>
<label>
Oder vorhandenen lokalen Bildpfad verwenden
<input
value={recipe.image}
onChange={(e) => change('image', e.target.value)}
placeholder="/media/…webp"
/>
</label>
<div className="form-grid">
<label>
Standardportionen
<input
type="number"
min="0.25"
step="0.25"
value={recipe.servings}
onChange={(e) => change('servings', +e.target.value)}
/>
</label>
<label>
Schwierigkeit
<select
value={recipe.difficulty}
onChange={(e) => change('difficulty', e.target.value as Recipe['difficulty'])}
>
{['Einfach', 'Mittel', 'Anspruchsvoll'].map((s) => (
<option key={s}>{s}</option>
))}
</select>
</label>
<label>
Küche / Herkunft
<input
value={recipe.cuisine}
onChange={(e) => change('cuisine', e.target.value)}
/>
</label>
</div>
<div className="form-grid">
{(['prepMinutes', 'cookMinutes', 'restMinutes'] as const).map((key, n) => (
<label key={key}>
{['Vorbereitung (Min.)', 'Kochzeit (Min.)', 'Ruhezeit (Min.)'][n]}
<input
type="number"
min="0"
value={recipe[key]}
onChange={(e) => change(key, +e.target.value)}
/>
</label>
))}
</div>
<div className="form-grid two">
<label>
Kategorien · mit Komma trennen
<input
list="categories"
value={recipe.categories.join(', ')}
onChange={(e) =>
change(
'categories',
e.target.value ? e.target.value.split(',').map((x) => x.trimStart()) : [],
)
}
/>
<datalist id="categories">
{options.categories.map((c) => (
<option key={c}>{c}</option>
))}
</datalist>
</label>
<label>
Tags · mit Komma trennen
<input
list="tags"
value={recipe.tags.join(', ')}
onChange={(e) =>
change(
'tags',
e.target.value ? e.target.value.split(',').map((x) => x.trimStart()) : [],
)
}
/>
<datalist id="tags">
{options.tags.map((t) => (
<option key={t}>{t}</option>
))}
</datalist>
</label>
</div>
<label className="checkbox-label">
<input
type="checkbox"
checked={recipe.published}
onChange={(e) => change('published', e.target.checked)}
/>
<span>
Rezept veröffentlichen
<small>Wird erst nach „Rezept speichern“ öffentlich sichtbar.</small>
</span>
</label>
</section>
)}
{tab === 'ingredients' && (
<section className="panel editor-panel">
<h2>Alles, was du brauchst.</h2>
<p className="muted">
Menge leer lassen für „nach Geschmack“. Sortieren per Ziehen oder Pfeiltasten.
</p>
<div className="editor-ingredient-list">
{recipe.ingredients.map((i, n) => (
<div
className="editor-ingredient"
key={i.id}
onDragOver={(e) => e.preventDefault()}
onDrop={() => {
if (dragging.current !== null) move('ingredients', dragging.current, n);
}}
>
{reorder('ingredients', n)}
<div className="ingredient-fields">
<div className="ingredient-main-fields">
<label>
Menge
<input
aria-label={`Menge ${n + 1}`}
type="number"
step="any"
min="0.001"
value={i.amount ?? ''}
onChange={(e) =>
updateIngredient(n, {
amount: e.target.value === '' ? null : +e.target.value,
})
}
/>
</label>
<label>
Einheit
<select
aria-label={`Einheit ${n + 1}`}
value={i.unit}
onChange={(e) =>
updateIngredient(n, { unit: e.target.value as Ingredient['unit'] })
}
>
{units.map((u) => (
<option key={u} value={u}>
{u || 'ohne'}
</option>
))}
</select>
</label>
<label>
Zutat
<input
aria-label={`Zutat ${n + 1}`}
value={i.name}
onChange={(e) => updateIngredient(n, { name: e.target.value })}
placeholder="z. B. Kartoffeln"
/>
</label>
</div>
<div className="ingredient-secondary-fields">
<input
aria-label={`Abschnitt ${n + 1}`}
value={i.section}
onChange={(e) => updateIngredient(n, { section: e.target.value })}
placeholder="Abschnitt, z. B. Sauce"
/>
<input
aria-label={`Hinweis ${n + 1}`}
value={i.note}
onChange={(e) => updateIngredient(n, { note: e.target.value })}
placeholder="Hinweis, z. B. fein gehackt"
/>
<label className="checkbox-label">
<input
type="checkbox"
checked={i.optional}
onChange={(e) => updateIngredient(n, { optional: e.target.checked })}
/>
Optional
</label>
</div>
<small className="muted">Referenz: {`{{${i.id}}}`}</small>
</div>
<button
className="icon-button danger"
aria-label={`Zutat ${n + 1} entfernen`}
disabled={recipe.ingredients.length === 1}
onClick={() => {
const used = recipe.steps.some(
(s) => s.ingredients.includes(i.id) || s.text.includes(`{{${i.id}}}`),
);
if (used) {
setError(
'Diese Zutat wird in einem Schritt verwendet. Entferne zuerst die Referenz.',
);
return;
}
change(
'ingredients',
recipe.ingredients.filter((x) => x.id !== i.id),
);
}}
>
<Trash2 size={17} />
</button>
</div>
))}
</div>
<button
className="button secondary"
onClick={() =>
change('ingredients', [
...recipe.ingredients,
{
id: crypto.randomUUID(),
name: '',
amount: null,
unit: 'g',
section: '',
note: '',
optional: false,
},
])
}
>
<Plus size={18} /> Zutat hinzufügen
</button>
</section>
)}
{tab === 'steps' && (
<section className="panel editor-panel">
<h2>Schritt für Schritt zum Genuss.</h2>
<p className="muted">
Füge Zutaten als Referenz ein, damit die Mengen beim Skalieren mitwachsen.
</p>
{recipe.steps.map((s, n) => (
<article
className="editor-step"
key={s.id}
onDragOver={(e) => e.preventDefault()}
onDrop={() => {
if (dragging.current !== null) move('steps', dragging.current, n);
}}
>
<div className="section-heading">
<div className="button-row">
{reorder('steps', n)}
<h3>Schritt {n + 1}</h3>
</div>
<button
className="icon-button danger"
aria-label={`Schritt ${n + 1} entfernen`}
disabled={recipe.steps.length === 1}
onClick={() =>
change(
'steps',
recipe.steps.filter((x) => x.id !== s.id),
)
}
>
<Trash2 size={17} />
</button>
</div>
<label>
Schritttitel
<input
aria-label={`Schritttitel ${n + 1}`}
value={s.title}
onChange={(e) => updateStep(n, { title: e.target.value })}
/>
</label>
<label>
Anleitung
<textarea
aria-label={`Anleitung ${n + 1}`}
rows={4}
value={s.text}
onChange={(e) => updateStep(n, { text: e.target.value })}
/>
</label>
<div className="reference-picker">
<span className="small muted">Zutatenreferenz einfügen:</span>
{recipe.ingredients
.filter((i) => i.name)
.map((i) => (
<button
key={i.id}
className="chip"
onClick={() =>
updateStep(n, {
text: `${s.text}${s.text ? ' ' : ''}{{${i.id}}}`,
ingredients: Array.from(new Set([...s.ingredients, i.id])),
})
}
>
<Plus size={13} />
{i.name}
</button>
))}
</div>
<details>
<summary>Benötigte Zutaten in diesem Schritt</summary>
<div className="reference-picker">
{recipe.ingredients.map((i) => (
<label key={i.id} className="checkbox-label">
<input
type="checkbox"
checked={s.ingredients.includes(i.id)}
onChange={(e) =>
updateStep(n, {
ingredients: e.target.checked
? [...s.ingredients, i.id]
: s.ingredients.filter((id) => id !== i.id),
})
}
/>
{i.name || i.id}
</label>
))}
</div>
</details>
<div className="editor-timers">
{s.timers.map((t, ti) => (
<div className="timer-input" key={t.id}>
<label>
Timername
<input
value={t.label}
onChange={(e) =>
updateStep(n, {
timers: s.timers.map((x, j) =>
j === ti ? { ...x, label: e.target.value } : x,
),
})
}
/>
</label>
<label>
Dauer in Sekunden
<input
type="number"
min="1"
value={t.seconds}
onChange={(e) =>
updateStep(n, {
timers: s.timers.map((x, j) =>
j === ti ? { ...x, seconds: +e.target.value } : x,
),
})
}
/>
</label>
<button
className="icon-button"
aria-label="Timer entfernen"
onClick={() =>
updateStep(n, { timers: s.timers.filter((x) => x.id !== t.id) })
}
>
<X size={17} />
</button>
</div>
))}
<button
className="text-button"
onClick={() =>
updateStep(n, {
timers: [
...s.timers,
{ id: crypto.randomUUID(), label: 'Küchentimer', seconds: 600 },
],
})
}
>
<Plus size={16} /> Timer hinzufügen
</button>
</div>
</article>
))}
<button
className="button secondary"
onClick={() =>
change('steps', [
...recipe.steps,
{ id: crypto.randomUUID(), title: '', text: '', ingredients: [], timers: [] },
])
}
>
<Plus size={18} /> Schritt hinzufügen
</button>
</section>
)}
{tab === 'extras' && (
<section className="panel editor-panel">
<h2>Das macht es zu deinem Rezept.</h2>
<label>
Tipps & Variationen
<textarea
rows={5}
value={recipe.tips}
onChange={(e) => change('tips', e.target.value)}
/>
</label>
<label>
Aufbewahrung
<textarea
rows={3}
value={recipe.storage}
onChange={(e) => change('storage', e.target.value)}
/>
</label>
<label className="checkbox-label">
<input
type="checkbox"
checked={recipe.nutrition !== null}
onChange={(e) =>
change(
'nutrition',
e.target.checked ? { kcal: 0, protein: 0, carbs: 0, fat: 0 } : null,
)
}
/>
Nährwerte pro Portion angeben
</label>
{recipe.nutrition && (
<div className="form-grid">
{(['kcal', 'protein', 'carbs', 'fat'] as const).map((k) => (
<label key={k}>
{
{
kcal: 'Kalorien (kcal)',
protein: 'Eiweiß (g)',
carbs: 'Kohlenhydrate (g)',
fat: 'Fett (g)',
}[k]
}
<input
type="number"
min="0"
step="any"
value={recipe.nutrition![k]}
onChange={(e) =>
change('nutrition', { ...recipe.nutrition!, [k]: +e.target.value })
}
/>
</label>
))}
</div>
)}
<h3>Weitere Bilder</h3>
<div className="image-gallery">
{recipe.images.map((src) => (
<div key={src}>
<img src={src} alt="Zusätzliches Rezeptbild" />
<button
className="text-button danger"
onClick={() =>
change(
'images',
recipe.images.filter((i) => i !== src),
)
}
>
Entfernen
</button>
</div>
))}
</div>
<label className="button secondary">
<Upload size={18} /> Weiteres Bild hochladen
<input
className="visually-hidden"
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={(e) => {
if (e.target.files?.[0]) void upload(e.target.files[0], true);
}}
/>
</label>
</section>
)}
</div>
{preview && (
<aside className="editor-preview panel">
<span className="eyebrow">LIVE-VORSCHAU</span>
{recipe.image && <img src={recipe.image} alt="Rezeptvorschau" />}
<h2>{recipe.title || 'Dein Rezepttitel'}</h2>
<p>{recipe.description}</p>
<h3>Zutaten für {recipe.servings} Portionen</h3>
<ul>
{recipe.ingredients.map((i) => (
<li key={i.id}>{ingredientLabel(i, '1')}</li>
))}
</ul>
<h3>Zubereitung</h3>
<ol>
{recipe.steps.map((s) => (
<li key={s.id}>
<strong>{s.title}</strong>
<p>{renderStep(s.text, recipe, '1')}</p>
</li>
))}
</ol>
</aside>
)}
</div>
</div>
);
}
+345
View File
@@ -0,0 +1,345 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import {
Search,
ArrowUpRight,
ArrowRight,
Clock3,
Heart,
SlidersHorizontal,
Leaf,
Utensils,
X,
ChefHat,
} from 'lucide-react';
import { motion } from 'motion/react';
import { type SavedRecipe, totalMinutes } from '@/lib/recipe';
import { useLocal } from '@/lib/client';
export function RecipeCard({ recipe: r }: { recipe: SavedRecipe }) {
const [favorites, setFavorites] = useLocal<string[]>('hamham:favorites', []);
return (
<motion.article
className="recipe-card"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Link href={`/rezepte/${r.id}`} className="card-image">
{r.image ? (
<img src={r.image} alt={r.title} loading="lazy" />
) : (
<div className="recipe-art">
<Utensils size={56} strokeWidth={1} />
<span>Mit Liebe gekocht</span>
</div>
)}
<span className="card-category">{r.categories[0] ?? r.cuisine ?? 'Aus unserer Küche'}</span>
</Link>
<button
className={`favorite-button ${favorites.includes(r.id) ? 'selected' : ''}`}
aria-label={`${r.title} ${favorites.includes(r.id) ? 'aus Favoriten entfernen' : 'als Favorit merken'}`}
onClick={() =>
setFavorites(
favorites.includes(r.id) ? favorites.filter((id) => id !== r.id) : [...favorites, r.id],
)
}
>
<Heart size={19} fill={favorites.includes(r.id) ? 'currentColor' : 'none'} />
</button>
<div className="card-body">
<div className="card-meta">
<span>
<Clock3 size={14} />
{totalMinutes(r)} Min.
</span>
<span className="tiny-dot" />
<span>{r.difficulty}</span>
</div>
<Link href={`/rezepte/${r.id}`}>
<h3>{r.title}</h3>
</Link>
<p>{r.subtitle || r.description}</p>
<div className="card-bottom">
<span>{r.tags[0] || 'Für deinen Genusstag'}</span>
<Link href={`/rezepte/${r.id}`} aria-label={`${r.title} öffnen`}>
<ArrowUpRight size={22} />
</Link>
</div>
</div>
</motion.article>
);
}
export function Library({
recipes,
favoritesOnly = false,
household = 'Unsere Küche',
}: {
recipes: SavedRecipe[];
favoritesOnly?: boolean;
household?: string;
}) {
const params = useSearchParams(),
router = useRouter();
const [query, setQuery] = useState(params.get('q') ?? ''),
[category, setCategory] = useState(params.get('kategorie') ?? ''),
[tag, setTag] = useState(params.get('tag') ?? ''),
[time, setTime] = useState(params.get('zeit') ?? ''),
[difficulty, setDifficulty] = useState(params.get('niveau') ?? ''),
[sort, setSort] = useState(params.get('sort') ?? 'neu'),
[filters, setFilters] = useState(false);
const [favorites] = useLocal<string[]>('hamham:favorites', []);
const categories = Array.from(new Set(recipes.flatMap((r) => r.categories))).sort(),
tags = Array.from(new Set(recipes.flatMap((r) => r.tags))).sort();
useEffect(() => {
const t = setTimeout(() => {
const p = new URLSearchParams();
for (const [k, v] of Object.entries({
q: query,
kategorie: category,
tag,
zeit: time,
niveau: difficulty,
sort: sort === 'neu' ? '' : sort,
}))
if (v) p.set(k, v);
router.replace(`${favoritesOnly ? '/favoriten' : '/'}${p.size ? `?${p}` : ''}`, {
scroll: false,
});
}, 250);
return () => clearTimeout(t);
}, [query, category, tag, time, difficulty, sort, favoritesOnly, router]);
const filtered = useMemo(
() =>
recipes
.filter(
(r) =>
(!favoritesOnly || favorites.includes(r.id)) &&
(!category || r.categories.includes(category)) &&
(!tag || r.tags.includes(tag)) &&
(!time || totalMinutes(r) <= +time) &&
(!difficulty || r.difficulty === difficulty) &&
`${r.title} ${r.description} ${r.ingredients.map((i) => i.name).join(' ')}`
.toLowerCase()
.includes(query.toLowerCase()),
)
.sort((a, b) =>
sort === 'name'
? a.title.localeCompare(b.title, 'de')
: b.createdAt.localeCompare(a.createdAt),
),
[recipes, favoritesOnly, favorites, category, tag, time, difficulty, query, sort],
);
return (
<div className="container library">
{!favoritesOnly ? (
<section className="home-hero">
<div className="hero-copy">
<span className="eyebrow">
<span className="live-dot" />{' '}
{household === 'Unsere Küche'
? 'DEIN PERSÖNLICHES KOCHBUCH'
: household.toLocaleUpperCase('de')}
</span>
<h1>
Zuhause schmeckt’s
<br />
<em>am besten.</em>
</h1>
<p>
Für die schnellen Abende. Die langen Sonntage.
<br className="desktop-only" /> Und alles, was wir gerne zusammen genießen.
</p>
<a href="#rezepte" className="button primary">
Was kochen wir heute? <ArrowRight size={18} />
</a>
<div className="hero-note">
<span className="note-icon">
<Leaf size={20} />
</span>
Lieblingsrezepte. Mit Liebe gesammelt.
</div>
</div>
<div className="hero-visual">
<img
src="/images/kitchen.jpg"
alt="Frische Pasta mit Tomaten und Kräutern auf einem gedeckten Tisch"
fetchPriority="high"
/>
<div className="hero-image-shade" />
<span className="hero-stamp">
EINFACH
<br />
<span>gut essen.</span>
<Leaf size={23} />
</span>
<div className="hero-caption">
<span>WENIGER ALLTAG. MEHR GENUSS.</span>
<p>
Ein guter Tag beginnt
<br />
mit etwas Gutem.
</p>
</div>
</div>
</section>
) : (
<section className="page-heading">
<span className="eyebrow">IMMER WIEDER GERN</span>
<h1>
Deine Lieblingsrezepte<span className="accent">.</span>
</h1>
<p>Die Gerichte, die bleiben dürfen. Auf diesem Gerät für dich gemerkt.</p>
</section>
)}
<section id="rezepte" className="library-section">
<div className="section-heading">
<div>
<span className="eyebrow">INSPIRATION FÜR DEINE KÜCHE</span>
<h2>
{favoritesOnly ? 'Deine kleine Bestenliste' : 'Was darf’s heute sein?'}{' '}
<span className="count-badge">{filtered.length}</span>
</h2>
</div>
<span className="handwritten">
Mit Appetit aussuchen <span>↙</span>
</span>
</div>
<div className="search-row">
<label className="search-box">
<Search size={20} />
<input
aria-label="Rezepte suchen"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Rezept oder Zutat suchen …"
/>
{query && (
<button aria-label="Suche löschen" onClick={() => setQuery('')}>
<X size={16} />
</button>
)}
</label>
<button
className={`button secondary ${filters ? 'selected' : ''}`}
onClick={() => setFilters(!filters)}
aria-expanded={filters}
>
<SlidersHorizontal size={18} /> Filter
{(time || difficulty || tag) && <span className="live-dot" />}
</button>
<select
aria-label="Rezepte sortieren"
value={sort}
onChange={(e) => setSort(e.target.value)}
>
<option value="neu">Zuletzt hinzugefügt</option>
<option value="name">Name A–Z</option>
</select>
</div>
{filters && (
<div className="filter-panel">
<label>
Gesamtzeit
<select value={time} onChange={(e) => setTime(e.target.value)}>
<option value="">Jede Zeit</option>
<option value="30">Bis 30 Minuten</option>
<option value="60">Bis 60 Minuten</option>
<option value="120">Bis 2 Stunden</option>
</select>
</label>
<label>
Schwierigkeit
<select value={difficulty} onChange={(e) => setDifficulty(e.target.value)}>
<option value="">Jedes Niveau</option>
{['Einfach', 'Mittel', 'Anspruchsvoll'].map((x) => (
<option key={x}>{x}</option>
))}
</select>
</label>
<label>
Tags
<select value={tag} onChange={(e) => setTag(e.target.value)}>
<option value="">Alle Tags</option>
{tags.map((x) => (
<option key={x}>{x}</option>
))}
</select>
</label>
<button
className="text-button"
onClick={() => {
setTime('');
setDifficulty('');
setTag('');
setCategory('');
setQuery('');
}}
>
Zurücksetzen
</button>
</div>
)}
<div className="category-chips">
<button className={!category ? 'chip active' : 'chip'} onClick={() => setCategory('')}>
<Utensils size={15} /> Alle Rezepte
</button>
{categories.map((c) => (
<button
key={c}
className={category === c ? 'chip active' : 'chip'}
onClick={() => setCategory(c)}
>
{c}
</button>
))}
</div>
{filtered.length ? (
<div className="recipe-grid">
{filtered.map((r) => (
<RecipeCard key={r.id} recipe={r} />
))}
</div>
) : (
<div className="empty-state">
<ChefHat size={40} strokeWidth={1.3} />
<h3>
{favoritesOnly
? 'Hier ist Platz für deine Lieblinge.'
: recipes.length
? 'Noch nicht das Richtige gefunden?'
: 'Ein neues Kochbuch beginnt.'}
</h3>
<p>
{favoritesOnly
? 'Tippe bei einem Rezept auf das Herz und finde es hier wieder.'
: recipes.length
? 'Probiere eine andere Zutat oder weniger Filter.'
: 'Lege im geschützten Bereich dein erstes Rezept an oder importiere deine Sammlung.'}
</p>
<Link
className="button secondary"
href={recipes.length || favoritesOnly ? '/' : '/admin'}
>
{recipes.length || favoritesOnly ? 'Alle Rezepte entdecken' : 'Kochbuch einrichten'}
<ArrowRight size={17} />
</Link>
</div>
)}
</section>
<section className="home-bottom">
<span className="note-icon">
<ChefHat size={28} />
</span>
<div>
<h3>Ein bisschen Planung. Viel mehr Genuss.</h3>
<p>Portionen anpassen, Einkaufsliste füllen, Schritt für Schritt kochen.</p>
</div>
<Link href="/einkaufsliste" className="text-button">
Zur Einkaufsliste <ArrowRight size={18} />
</Link>
</section>
</div>
);
}
+423
View File
@@ -0,0 +1,423 @@
'use client';
import { useState } from 'react';
import Link from 'next/link';
import Decimal from 'decimal.js';
import {
ArrowLeft,
ArrowRight,
Clock3,
Users,
ChefHat,
Heart,
Printer,
ShoppingBasket,
RotateCcw,
Minus,
Plus,
Sparkles,
Check,
Flame,
Leaf,
} from 'lucide-react';
import { type SavedRecipe, type Ingredient, totalMinutes } from '@/lib/recipe';
import {
portionFactor,
ingredientFactor,
scaledAmount,
formatAmount,
renderStep,
mergeShopping,
type ShoppingItem,
} from '@/lib/calculator';
import { readLocal, writeLocal, useLocal, toast } from '@/lib/client';
import { RecipeCard } from './library';
function AmountInput({
ingredient: i,
factor,
onChange,
}: {
ingredient: Ingredient;
factor: string;
onChange: (f: string) => void;
}) {
const [editing, setEditing] = useState(false),
[value, setValue] = useState(''),
[unit, setUnit] = useState(i.unit),
[error, setError] = useState('');
if (i.amount === null) return <span className="amount-unset">{i.note || 'nach Geschmack'}</span>;
return editing ? (
<form
className="amount-edit"
onSubmit={(e) => {
e.preventDefault();
try {
onChange(ingredientFactor(i, value, unit));
setEditing(false);
setError('');
} catch (e) {
setError((e as Error).message);
}
}}
>
<input
autoFocus
aria-label={`Neue Menge ${i.name}`}
inputMode="decimal"
value={value}
onChange={(e) => setValue(e.target.value)}
/>
<select
aria-label={`Einheit ${i.name}`}
value={unit}
onChange={(e) => setUnit(e.target.value as typeof unit)}
>
{(['g', 'kg'].includes(i.unit)
? ['g', 'kg']
: ['ml', 'l', 'TL', 'EL'].includes(i.unit)
? ['ml', 'l', 'TL', 'EL']
: [i.unit]
).map((u) => (
<option key={u}>{u}</option>
))}
</select>
<button aria-label="Menge übernehmen" type="submit">
<Check size={18} />
</button>
<button type="button" onClick={() => setEditing(false)} aria-label="Mengenänderung abbrechen">
×
</button>
{error && <small role="alert">{error}</small>}
</form>
) : (
<button
className="amount-button"
aria-label={`Menge ${i.name} ändern`}
title="Vorhandene Menge eingeben"
onClick={() => {
setValue(new Decimal(scaledAmount(i, factor)!).toDecimalPlaces(6).toString());
setUnit(i.unit);
setEditing(true);
}}
>
{formatAmount(scaledAmount(i, factor))} <span>{i.unit}</span>
</button>
);
}
export function RecipeDetail({
recipe: r,
related = [],
}: {
recipe: SavedRecipe;
related?: SavedRecipe[];
}) {
const [factor, setFactor] = useState('1'),
[mode, setMode] = useState<'portions' | 'ingredient'>('portions'),
[error, setError] = useState('');
const [favorites, setFavorites] = useLocal<string[]>('hamham:favorites', []),
[checked, setChecked] = useState<string[]>([]);
const servings = new Decimal(r.servings).mul(factor),
isFavorite = favorites.includes(r.id);
function setPortions(value: string | number) {
try {
setFactor(portionFactor(r.servings, value));
setError('');
} catch {
setError('Bitte eine gültige Portionszahl größer als 0 eingeben.');
}
}
function addShopping() {
writeLocal(
'hamham:shopping',
mergeShopping(readLocal<ShoppingItem[]>('hamham:shopping', []), r.ingredients, factor),
);
toast('Alle Zutaten wurden zur Einkaufsliste hinzugefügt.');
}
return (
<div className="container recipe-page">
<Link className="back-link no-print" href="/">
<ArrowLeft size={16} /> Zur Rezeptsammlung
</Link>
{!r.published && <div className="notice">Entwurf · Nur für Administratoren sichtbar</div>}
<section className="recipe-hero">
<div className="recipe-intro">
<div className="eyebrow">
{r.cuisine || 'AUS UNSERER KÜCHE'} {r.categories.length > 0 && ` / ${r.categories[0]}`}
</div>
<h1>{r.title}</h1>
{r.subtitle && <p className="recipe-subtitle">{r.subtitle}</p>}
<p className="description">{r.description}</p>
<div className="recipe-stats">
<div>
<Clock3 size={21} />
<strong>{totalMinutes(r)} Min.</strong>
<span>Gesamtzeit</span>
</div>
<div>
<ChefHat size={21} />
<strong>{r.difficulty}</strong>
<span>Schwierigkeit</span>
</div>
<div>
<Users size={21} />
<strong>{formatAmount(servings)} Portionen</strong>
<span>Dein Appetit entscheidet</span>
</div>
</div>
<div className="recipe-actions no-print">
<Link className="button primary" href={`/rezepte/${r.id}/kochen?faktor=${factor}`}>
<ChefHat size={20} /> Jetzt kochen <ArrowRight size={18} />
</Link>
<button
className={`icon-button bordered ${isFavorite ? 'selected' : ''}`}
aria-label="Rezept als Favorit merken"
onClick={() =>
setFavorites(
isFavorite ? favorites.filter((id) => id !== r.id) : [...favorites, r.id],
)
}
>
<Heart size={20} fill={isFavorite ? 'currentColor' : 'none'} />
</button>
<button
className="icon-button bordered"
aria-label="Rezept drucken"
onClick={() => print()}
>
<Printer size={20} />
</button>
</div>
</div>
<div className="recipe-cover">
{r.image ? (
<img src={r.image} alt={r.title} />
) : (
<div className="recipe-art">
<ChefHat size={90} strokeWidth={1} />
<span>Ein Lieblingsrezept von dir.</span>
</div>
)}
<span className="image-label">
<Leaf size={16} /> Selbst gekocht. Zusammen genossen.
</span>
</div>
</section>
<div className="recipe-columns">
<aside className="ingredients-panel">
<div className="panel-title">
<h2>Zutaten</h2>
<span className="eyebrow">FÜR {formatAmount(servings)} PORTIONEN</span>
</div>
<div className="calculator no-print">
<div className="segmented">
<button
className={mode === 'portions' ? 'active' : ''}
onClick={() => setMode('portions')}
>
Nach Portionen
</button>
<button
className={mode === 'ingredient' ? 'active' : ''}
onClick={() => setMode('ingredient')}
>
Nach Zutat
</button>
</div>
{mode === 'portions' ? (
<div className="portion-control">
<button
aria-label="Weniger Portionen"
onClick={() => setPortions(Decimal.max(0.25, servings.minus(1)).toString())}
>
<Minus size={18} />
</button>
<label>
<input
aria-label="Portionen"
type="number"
min="0.25"
step="0.25"
value={servings.toDecimalPlaces(4).toString()}
onChange={(e) => setPortions(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') e.currentTarget.blur();
}}
/>
<span>Portionen</span>
</label>
<button
aria-label="Mehr Portionen"
onClick={() => setPortions(servings.plus(1).toString())}
>
<Plus size={18} />
</button>
</div>
) : (
<p className="calculator-hint">
<Sparkles size={18} /> Tippe auf eine Zutatenmenge und gib ein, wie viel du hast.
Alles andere passt sich an.
</p>
)}
{error && (
<p className="error" role="alert">
{error}
</p>
)}
<button
className="reset-link"
onClick={() => {
setFactor('1');
setError('');
}}
>
<RotateCcw size={13} /> Originalmengen wiederherstellen
</button>
</div>
<ul className="ingredient-list">
{r.ingredients.map((i, n) => (
<li key={i.id} className={checked.includes(i.id) ? 'is-checked' : ''}>
{i.section && (n === 0 || r.ingredients[n - 1].section !== i.section) && (
<h4>{i.section}</h4>
)}
<div className="ingredient-line">
<button
className="check-box no-print"
aria-label={`${i.name} abhaken`}
aria-pressed={checked.includes(i.id)}
onClick={() =>
setChecked(
checked.includes(i.id)
? checked.filter((x) => x !== i.id)
: [...checked, i.id],
)
}
>
{checked.includes(i.id) && <Check size={14} />}
</button>
<span className="ingredient-name">
{i.name}
{i.optional && <small>optional</small>}
{i.note && i.amount !== null && <small>{i.note}</small>}
</span>
<AmountInput
ingredient={i}
factor={factor}
onChange={(f) => {
setMode('ingredient');
setFactor(f);
}}
/>
</div>
</li>
))}
</ul>
<button className="button secondary full no-print" onClick={addShopping}>
<ShoppingBasket size={18} /> Zur Einkaufsliste hinzufügen
</button>
<p className="small muted no-print">Mengen werden immer vom Original berechnet.</p>
</aside>
<article className="preparation">
<div className="section-heading">
<h2>So wird’s gemacht.</h2>
<span className="eyebrow">SCHRITT FÜR SCHRITT</span>
</div>
<div className="time-breakdown">
<span>
Vorbereiten <strong>{r.prepMinutes} Min.</strong>
</span>
<span>
Kochen <strong>{r.cookMinutes} Min.</strong>
</span>
{r.restMinutes > 0 && (
<span>
Ruhen <strong>{r.restMinutes} Min.</strong>
</span>
)}
</div>
<ol className="steps-list">
{r.steps.map((s, n) => (
<li key={s.id}>
<span className="step-number">{String(n + 1).padStart(2, '0')}</span>
<div>
<h3>{s.title || `Schritt ${n + 1}`}</h3>
<p>{renderStep(s.text, r, factor)}</p>
{s.timers.map((t) => (
<span className="timer-tag" key={t.id}>
<Clock3 size={14} />
{t.label} · {formatAmount(t.seconds / 60)} Min.
</span>
))}
</div>
</li>
))}
</ol>
{r.tips && (
<section className="tip-card">
<Sparkles size={22} />
<div>
<h3>Das kleine Extra.</h3>
<p>{r.tips}</p>
</div>
</section>
)}
{r.storage && (
<section className="storage-card">
<h3>Für später aufheben</h3>
<p>{r.storage}</p>
</section>
)}
{r.nutrition && (
<section className="nutrition">
<h3>Nährwerte pro Originalportion</h3>
<div>
{Object.entries(r.nutrition).map(([key, v]) => (
<span key={key}>
<strong>
{v}
{key === 'kcal' ? '' : ' g'}
</strong>
{{ kcal: 'kcal', protein: 'Eiweiß', carbs: 'Kohlenhydrate', fat: 'Fett' }[key]}
</span>
))}
</div>
</section>
)}
{r.images.length > 0 && (
<div className="image-gallery">
{r.images.map((image, n) => (
<a href={image} key={image} target="_blank" rel="noreferrer">
<img src={image} alt={`${r.title} – Ansicht ${n + 1}`} loading="lazy" />
</a>
))}
</div>
)}
<div className="tag-list">
{r.tags.map((t) => (
<Link href={`/?tag=${encodeURIComponent(t)}`} className="chip" key={t}>
{t}
</Link>
))}
</div>
</article>
</div>
{related.length > 0 && (
<section className="related no-print">
<span className="eyebrow">DAS KÖNNTE DIR AUCH SCHMECKEN</span>
<h2>Noch eine gute Idee.</h2>
<div className="recipe-grid">
{related.map((r) => (
<RecipeCard key={r.id} recipe={r} />
))}
</div>
</section>
)}
<div className="mobile-cook-cta no-print">
<span>
{formatAmount(servings)} Portionen · {totalMinutes(r)} Min.
</span>
<Link href={`/rezepte/${r.id}/kochen?faktor=${factor}`} className="button primary">
<Flame size={18} /> Jetzt kochen
</Link>
</div>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
'use client';
import Link from 'next/link';
import { usePathname } from 'next/navigation';
import { useEffect, useState } from 'react';
import {
BookOpen,
Heart,
ShoppingBasket,
Moon,
Sun,
ChefHat,
Settings,
WifiOff,
X,
} from 'lucide-react';
import { readLocal, writeLocal } from '@/lib/client';
export function Shell({ children }: { children: React.ReactNode }) {
const pathname = usePathname(),
[theme, setTheme] = useState('system'),
[offline, setOffline] = useState(false),
[message, setMessage] = useState('');
useEffect(() => {
setTheme(readLocal('hamham:theme', 'system'));
const network = () => setOffline(!navigator.onLine);
network();
window.addEventListener('online', network);
window.addEventListener('offline', network);
const notification = (e: Event) => setMessage((e as CustomEvent).detail);
window.addEventListener('hamham:toast', notification);
if ('serviceWorker' in navigator) navigator.serviceWorker.register('/sw.js').catch(() => {});
return () => {
window.removeEventListener('online', network);
window.removeEventListener('offline', network);
window.removeEventListener('hamham:toast', notification);
};
}, []);
useEffect(() => {
const media = matchMedia('(prefers-color-scheme: dark)');
const update = () =>
(document.documentElement.dataset.theme =
theme === 'system' ? (media.matches ? 'dark' : 'light') : theme);
update();
media.addEventListener('change', update);
writeLocal('hamham:theme', theme);
return () => media.removeEventListener('change', update);
}, [theme]);
useEffect(() => {
if (!message) return;
const t = setTimeout(() => setMessage(''), 5500);
return () => clearTimeout(t);
}, [message]);
useEffect(() => {
if ('serviceWorker' in navigator && navigator.onLine)
navigator.serviceWorker.ready.then((registration) => {
registration.active?.postMessage({ type: 'CACHE_PAGE', url: window.location.href });
});
const navigateOffline = (event: MouseEvent) => {
if (
navigator.onLine ||
event.defaultPrevented ||
event.button !== 0 ||
event.metaKey ||
event.ctrlKey ||
event.shiftKey ||
event.altKey
)
return;
const anchor = (event.target as Element)?.closest('a');
if (
!anchor ||
anchor.target === '_blank' ||
anchor.hasAttribute('download') ||
new URL(anchor.href).origin !== location.origin
)
return;
event.preventDefault();
event.stopPropagation();
location.assign(anchor.href);
};
document.addEventListener('click', navigateOffline, true);
return () => document.removeEventListener('click', navigateOffline, true);
}, [pathname]);
const cooking = pathname.endsWith('/kochen');
const nav = [
{ href: '/', title: 'Entdecken', icon: BookOpen },
{ href: '/favoriten', title: 'Lieblingsrezepte', icon: Heart },
{ href: '/einkaufsliste', title: 'Einkaufsliste', icon: ShoppingBasket },
];
return (
<>
{!cooking && (
<header className="site-header">
<div className="header-inner">
<Link href="/" className="brand" aria-label="HamHam Startseite">
<span className="brand-icon">
<ChefHat size={25} />
</span>
HamHam<span className="brand-dot">.</span>
</Link>
<nav className="desktop-nav" aria-label="Hauptnavigation">
{nav.map((n) => (
<Link key={n.href} href={n.href} className={pathname === n.href ? 'active' : ''}>
{n.title}
</Link>
))}
</nav>
<div className="header-actions">
<button
className="icon-button"
title={`Darstellung: ${theme}. Klicken zum Wechseln.`}
aria-label="Theme wechseln"
onClick={() =>
setTheme(theme === 'system' ? 'dark' : theme === 'dark' ? 'light' : 'system')
}
>
{theme === 'dark' ? <Moon size={20} /> : <Sun size={20} />}
</button>
<Link href="/admin" className="icon-button" aria-label="Administration">
<Settings size={20} />
</Link>
</div>
</div>
</header>
)}
{offline && (
<div className="offline-banner" role="status">
<WifiOff size={16} /> Du bist offline. Bereits geöffnete Rezepte und dein Kochfortschritt
bleiben verfügbar.
</div>
)}
<main className={cooking ? 'cooking-main' : 'main'}>{children}</main>
{!cooking && (
<>
<footer className="site-footer">
<span className="serif">Gutes Essen. Gute Zeit.</span>
<span>
Deine Rezepte. Dein Zuhause. <span className="muted">HamHam</span>
</span>
</footer>
<nav className="mobile-nav" aria-label="Mobile Navigation">
{nav.map((n) => (
<Link key={n.href} href={n.href} className={pathname === n.href ? 'active' : ''}>
<n.icon size={21} />
<span>{n.title === 'Lieblingsrezepte' ? 'Favoriten' : n.title}</span>
</Link>
))}
</nav>
</>
)}
{message && (
<div className="toast" role="status">
{message}
<button aria-label="Meldung schließen" onClick={() => setMessage('')}>
<X size={18} />
</button>
</div>
)}
</>
);
}
+63
View File
@@ -0,0 +1,63 @@
'use client';
import { useEffect, useState } from 'react';
export async function api<T = unknown>(path: string, method = 'GET', body?: unknown): Promise<T> {
const response = await fetch(`/api/${path}`, {
method,
headers: body === undefined ? {} : { 'Content-Type': 'application/json' },
body: body === undefined ? undefined : JSON.stringify(body),
cache: 'no-store',
});
const data = await response.json();
if (!response.ok) throw new Error(data.error ?? 'Anfrage fehlgeschlagen.');
return data;
}
export function readLocal<T>(key: string, fallback: T): T {
try {
const v = localStorage.getItem(key);
return v ? JSON.parse(v) : fallback;
} catch {
return fallback;
}
}
export function writeLocal(key: string, value: unknown) {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch {
window.dispatchEvent(
new CustomEvent('hamham:toast', {
detail:
'Browserspeicher ist voll oder gesperrt. Änderungen können nicht lokal gesichert werden.',
}),
);
}
}
export function useLocal<T>(key: string, initial: T) {
const [value, setValue] = useState(initial),
[ready, setReady] = useState(false);
useEffect(() => {
setValue(readLocal(key, initial));
setReady(true);
const sync = (event: Event) => {
const changedKey = event instanceof StorageEvent ? event.key : (event as CustomEvent).detail;
if (changedKey !== key) return;
const next = readLocal(key, initial);
setValue((previous) => (JSON.stringify(previous) === JSON.stringify(next) ? previous : next));
};
window.addEventListener('storage', sync);
window.addEventListener('hamham:storage', sync);
return () => {
window.removeEventListener('storage', sync);
window.removeEventListener('hamham:storage', sync);
};
}, [key]);
useEffect(() => {
if (ready) {
writeLocal(key, value);
window.dispatchEvent(new CustomEvent('hamham:storage', { detail: key }));
}
}, [key, value, ready]);
return [value, setValue, ready] as const;
}
export function toast(message: string) {
window.dispatchEvent(new CustomEvent('hamham:toast', { detail: message }));
}