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
@@ -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} />;
|
||||
}
|
||||
Reference in new issue
Block a user