feat(ui): build responsive cookbook, editor and persistent cooking assistant
This commit is contained in:
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 |
@@ -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 |
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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('&', '&'),
|
||||
),
|
||||
),
|
||||
];
|
||||
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 });
|
||||
}
|
||||
})(),
|
||||
);
|
||||
});
|
||||
@@ -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 />
|
||||
);
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
File diff suppressed because it is too large.
Load diff
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'} />;
|
||||
}
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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 }));
|
||||
}
|
||||
Reference in new issue
Block a user