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