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

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

No files matched your search

+13
View File
@@ -0,0 +1,13 @@
import { admin } from '@/lib/server';
import { listRecipes } from '@/lib/repository';
import { Login, AdminDashboard } from '@/components/admin';
export const dynamic = 'force-dynamic';
export const metadata = { title: 'Deine Küche', robots: { index: false, follow: false } };
export default async function Admin() {
const user = await admin();
return user ? (
<AdminDashboard initialRecipes={await listRecipes(true)} username={user.username} />
) : (
<Login />
);
}
+14
View File
@@ -0,0 +1,14 @@
import { redirect, notFound } from 'next/navigation';
import { admin } from '@/lib/server';
import { getRecipe } from '@/lib/repository';
import { RecipeEditor } from '@/components/editor';
export const dynamic = 'force-dynamic';
export const metadata = { title: 'Rezepteditor', robots: { index: false, follow: false } };
export default async function Edit({ params }: { params: Promise<{ id: string }> }) {
if (!(await admin())) redirect('/admin');
const { id } = await params;
if (id === 'neu') return <RecipeEditor />;
const recipe = await getRecipe(id, true);
if (!recipe) notFound();
return <RecipeEditor initial={recipe} />;
}
+97
View File
@@ -0,0 +1,97 @@
'use client';
import Link from 'next/link';
import { Check, Copy, Printer, ShoppingBasket, Trash2, ArrowRight } from 'lucide-react';
import { useLocal, toast } from '@/lib/client';
import { formatAmount, type ShoppingItem } from '@/lib/calculator';
export default function Shopping() {
const [items, setItems] = useLocal<ShoppingItem[]>('hamham:shopping', []);
const label = (i: ShoppingItem) =>
`${formatAmount(i.amount)} ${i.unit} ${i.name}${i.note ? ` (${i.note})` : ''}`.trim();
return (
<div className="container narrow">
<header className="page-heading">
<span className="eyebrow">GUT VORBEREITET SCHMECKT’S BESSER</span>
<h1>
Deine Einkaufsliste<span className="accent">.</span>
</h1>
<p>Alles an einem Ort. Passende Mengen werden automatisch zusammengezählt.</p>
</header>
{items.length ? (
<>
<div className="shopping-toolbar no-print">
<span>
{items.filter((i) => i.checked).length} von {items.length} erledigt
</span>
<div className="button-row">
<button
className="button secondary"
onClick={async () => {
try {
await navigator.clipboard.writeText(
items.map((i) => `${i.checked ? '✓' : '□'} ${label(i)}`).join('\n'),
);
toast('Einkaufsliste kopiert.');
} catch {
toast('Kopieren nicht möglich. Nutze die Druckansicht.');
}
}}
>
<Copy size={17} /> Kopieren
</button>
<button className="button secondary" onClick={() => print()}>
<Printer size={17} /> Drucken
</button>
</div>
</div>
<ul className="shopping-list">
{items.map((i) => (
<li key={i.id} className={i.checked ? 'is-checked' : ''}>
<button
className="shopping-item"
onClick={() =>
setItems(items.map((x) => (x.id === i.id ? { ...x, checked: !x.checked } : x)))
}
>
<span className="check-box">{i.checked && <Check size={17} />}</span>
<span>
{i.name}
{i.note && <small>{i.note}</small>}
</span>
<strong>
{formatAmount(i.amount)} {i.unit}
</strong>
</button>
<button
className="icon-button no-print"
aria-label={`${i.name} entfernen`}
onClick={() => setItems(items.filter((x) => x.id !== i.id))}
>
<Trash2 size={16} />
</button>
</li>
))}
</ul>
<button
className="text-button no-print"
onClick={() => setItems(items.filter((i) => !i.checked))}
>
<Trash2 size={16} /> Erledigte Zutaten entfernen
</button>
</>
) : (
<div className="empty-state">
<ShoppingBasket size={45} strokeWidth={1.3} />
<h2>Platz für frische Ideen.</h2>
<p>
Füge Zutaten direkt aus einem Rezept hinzu.
<br />
Deine eingestellten Portionen werden übernommen.
</p>
<Link href="/" className="button primary">
Rezepte entdecken <ArrowRight size={18} />
</Link>
</div>
)}
</div>
);
}
+14
View File
@@ -0,0 +1,14 @@
'use client';
export default function ErrorPage({ reset }: { reset: () => void }) {
return (
<div className="empty-state">
<h1>Ein kleiner Küchenstopp.</h1>
<p>
Die Seite konnte nicht geladen werden. Bitte prüfe die Verbindung und versuche es erneut.
</p>
<button className="button primary" onClick={reset}>
Erneut versuchen
</button>
</div>
);
}
+12
View File
@@ -0,0 +1,12 @@
import { Suspense } from 'react';
import { Library } from '@/components/library';
import { listRecipes } from '@/lib/repository';
export const dynamic = 'force-dynamic';
export const metadata = { title: 'Lieblingsrezepte' };
export default async function Favorites() {
return (
<Suspense>
<Library favoritesOnly recipes={await listRecipes()} />
</Suspense>
);
}
+3147
View File
File diff suppressed because it is too large. Load diff
+38
View File
@@ -0,0 +1,38 @@
import type { Metadata, Viewport } from 'next';
import '@fontsource/dm-sans/400.css';
import '@fontsource/dm-sans/500.css';
import '@fontsource/dm-sans/600.css';
import '@fontsource/dm-sans/700.css';
import '@fontsource/fraunces/400.css';
import '@fontsource/fraunces/500.css';
import './globals.css';
import { Shell } from '@/components/shell';
export const metadata: Metadata = {
title: { default: 'HamHam · Zuhause schmeckt’s am besten', template: '%s · HamHam' },
description:
'Dein persönliches Kochbuch. Lieblingsrezepte sammeln, clever planen und entspannt kochen.',
manifest: '/manifest.webmanifest',
appleWebApp: { capable: true, title: 'HamHam', statusBarStyle: 'default' },
};
export const viewport: Viewport = {
width: 'device-width',
initialScale: 1,
viewportFit: 'cover',
themeColor: '#365747',
};
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="de" suppressHydrationWarning>
<head>
<script
dangerouslySetInnerHTML={{
__html: `try{let t=JSON.parse(localStorage.getItem('hamham:theme')||'"system"');document.documentElement.dataset.theme=t==='system'?(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'):t}catch{}`,
}}
/>
</head>
<body>
<Shell>{children}</Shell>
</body>
</html>
);
}
+12
View File
@@ -0,0 +1,12 @@
export default function Loading() {
return (
<div className="container">
<div className="skeleton skeleton-hero" />
<div className="recipe-grid">
{[1, 2, 3].map((n) => (
<div className="skeleton skeleton-card" key={n} />
))}
</div>
</div>
);
}
+13
View File
@@ -0,0 +1,13 @@
import Link from 'next/link';
export default function NotFound() {
return (
<div className="empty-state">
<span className="eyebrow">404 · Nicht auf der Karte</span>
<h1>Dieses Rezept ist gerade nicht da.</h1>
<p>Es wurde möglicherweise entfernt oder noch nicht veröffentlicht.</p>
<Link href="/" className="button primary">
Rezepte entdecken
</Link>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import { Suspense } from 'react';
import { listRecipes } from '@/lib/repository';
import { Library } from '@/components/library';
import { db } from '@/db';
import { settings } from '@/db/schema';
import { eq } from 'drizzle-orm';
export const dynamic = 'force-dynamic';
export default async function Home() {
const [collection] = await db.select().from(settings).where(eq(settings.key, 'household'));
return (
<Suspense>
<Library
recipes={await listRecipes()}
household={typeof collection?.value === 'string' ? collection.value : undefined}
/>
</Suspense>
);
}
+17
View File
@@ -0,0 +1,17 @@
import { notFound } from 'next/navigation';
import { getRecipe } from '@/lib/repository';
import { admin } from '@/lib/server';
import { Cooking } from '@/components/cooking';
export const dynamic = 'force-dynamic';
export const metadata = { title: 'Jetzt kochen' };
export default async function Cook({
params,
searchParams,
}: {
params: Promise<{ id: string }>;
searchParams: Promise<{ faktor?: string }>;
}) {
const r = await getRecipe((await params).id, !!(await admin()));
if (!r) notFound();
return <Cooking recipe={r} initialFactor={(await searchParams).faktor ?? '1'} />;
}
+22
View File
@@ -0,0 +1,22 @@
import { notFound } from 'next/navigation';
import { getRecipe, listRecipes } from '@/lib/repository';
import { admin } from '@/lib/server';
import { RecipeDetail } from '@/components/recipe-detail';
export const dynamic = 'force-dynamic';
export async function generateMetadata({ params }: { params: Promise<{ id: string }> }) {
const r = await getRecipe((await params).id, !!(await admin()));
return { title: r?.title ?? 'Rezept nicht gefunden' };
}
export default async function RecipePage({ params }: { params: Promise<{ id: string }> }) {
const r = await getRecipe((await params).id, !!(await admin()));
if (!r) notFound();
const related = (await listRecipes())
.filter((x) => x.id !== r.id)
.sort(
(a, b) =>
b.categories.filter((c) => r.categories.includes(c)).length -
a.categories.filter((c) => r.categories.includes(c)).length,
)
.slice(0, 3);
return <RecipeDetail recipe={r} related={related} />;
}