From de567f93dbdeb8adf2fbb18e18da7d67e4faaa2b Mon Sep 17 00:00:00 2001 From: clawd Date: Wed, 18 Feb 2026 10:23:22 +0000 Subject: [PATCH] feat: v1 release - serving calculator, notes UI, tags, random recipe, confetti, favorites section, PWA icons, category icons, animations --- backend/data/recipes.db-shm | Bin 32768 -> 32768 bytes backend/data/recipes.db-wal | Bin 1615072 -> 1800472 bytes .../src/db/migrations/002_category_icons.sql | 11 ++ backend/src/routes/recipes.ts | 6 + backend/src/schemas/recipe.schema.ts | 1 + backend/src/services/recipe.service.ts | 52 ++++++ frontend/public/icon-192.svg | 6 + frontend/public/icon-512.svg | 6 + frontend/src/api/notes.ts | 17 ++ frontend/src/api/recipes.ts | 5 + frontend/src/api/tags.ts | 12 ++ frontend/src/components/ui/Confetti.tsx | 56 ++++++ frontend/src/pages/HomePage.tsx | 131 ++++++++++++-- frontend/src/pages/RecipeFormPage.tsx | 38 ++++- frontend/src/pages/RecipePage.tsx | 160 +++++++++++++++--- frontend/src/styles/globals.css | 10 ++ frontend/vite.config.ts | 4 +- 17 files changed, 476 insertions(+), 39 deletions(-) create mode 100644 backend/src/db/migrations/002_category_icons.sql create mode 100644 frontend/public/icon-192.svg create mode 100644 frontend/public/icon-512.svg create mode 100644 frontend/src/api/notes.ts create mode 100644 frontend/src/api/tags.ts create mode 100644 frontend/src/components/ui/Confetti.tsx diff --git a/backend/data/recipes.db-shm b/backend/data/recipes.db-shm index fc6066b5f1cb10176935f892af1babe9e92f6f44..60343033011f596e14cdef2f5f480e5507ae677a 100644 GIT binary patch delta 515 zcmb7;-7AAp9LJyUW9JbwCaFnnDQhXTYj;XS5w$B#N-0HZgft(uoYhOl0yd#b)|_lPe-hXNh88lL1xp~z+oc3x;`1^Y48!^ z%3TO7=zh^izNq_o6L~=Q)8=|GjwNhU4nUl=l!HxR89S5(c(m2ZX0QoCF&RA8W@VFD t!7jxx_;I_H&0-6YM3-5lAofV_!6Tl0Hir<7C;}`;TE%{%BMv)ijNhMOtvvt$ delta 331 zcmZo@U}|V!s+V}A%K!q55G>FEWM~4}`+rMSYjeImsk&-Mv*(4y)6I5$J{h5q{EJle zAhSULWbS_?01;(iVrbdesLr-Ig}uOPasm6E%~u?CST-lIUtrvP!Cj9L!aC;#WlcWg zYcQF~?H4N>0~bTc=0x`MjFV3VfQ2@H31ndc$!-1=@{bj$?-JwYLoqra6M(D(@i5lD zBp7Q?Y82yUCbuPwn|Ecnfmk~jC-2CX12T9RCqK#oF}5*I-j)XuVUz$`cbReWmO>Sv Q1REoxB$&T>Q;8op00_x;lK=n! diff --git a/backend/data/recipes.db-wal b/backend/data/recipes.db-wal index 399994332c2a9a71a1f8b6ad22f5e2b4d52d8a44..a4b8b97a703c984533ad8163640d6a5b09062fd7 100644 GIT binary patch delta 4676 zcmdT{3s6+o8NSbb-+PwVKIAPBitNhWMPS)PaRm*IO|l4z&>93pL&Ay$93P?bx*eys z7@bXe9SJT*6C>@!D2Xrah~8wp5kdFXt7!l2`Ez&+>p*^M1TPAHWClLA-|7 z{#JKs-*gPaX!wqcX>D&<*t1voIAX;IhpPjhVZbp=#_YkEJ)*)w3yeG+m!|3ULGnZ= zIHZr7S6KW)$(kbf=YVT}otZNy8#%MlloDY!TRF~}lq7yRpZ*+|!{j!-9`nRG2V15` zj;WWj`)KWz@KhEpjuF8Wn}%i2_Js_0HlJt^8?Hnidrm zG;&B2IpBJIJLv=Bgl6VWwwY6j=bkK=k1cJw8N&O?3aMUnSK^V&fDfwhUz<Z*c?LUvT6H z91j;W8?-xta4lW8L6)*NK)m|Lkbio35tirZf_{@a?(9Loqk)4__w?pg!8$r;NQEX}cHWaTYQ%FJ85z@7t*5s_24x)KT1SOtGLN>-MxbBt^+JhK2T*Z38H zhwA-lKbz557n|lTwhk2&6Nda}65`J)bsBuET{jx9zER#9@^`T(A;=CiNHh}wNrXZk zgb6>srTwM)&WUqx%cs98N7T{4iHo(n{MG{@VRK-GnIXe*FJ=kywi>>@N?6s~b>;4l zK2>^TyA1=UK-*w%zOt>y9!J%r5TwoKmB0iH-ZK*65uo|VT>S~z zeuqaEjR;^PT+TXBlS(qi}mQF2u!eP0*u;T?F+TlO7`l9vn;Qko7#)k@HC#vu<*uD2TuY~OX z-MXiX>&4m}m?>n{7wvD%G)YXuceHP^vT$^*F66uG;x3W+#p&?mYIM_`jEnv1>tep> z+kH_12H{Nxx|r+ITn7p4x48HB>+*wJj$8iV9)!@>VB7!1y>Mw#Cu4hG{ut}t%I2aW zbyA=iX#x!%&^UGXB6L%BuK~IDhuuq3_BAABAonsJ_cl&+ukr(t`;C8_lv182-$MpI zLb3_az$;NtgmN0DLN#1g4Yu28Df8m&(6 M>t`^U%-8aqFI$fj!vFvP diff --git a/backend/src/db/migrations/002_category_icons.sql b/backend/src/db/migrations/002_category_icons.sql new file mode 100644 index 0000000..5bc6a2b --- /dev/null +++ b/backend/src/db/migrations/002_category_icons.sql @@ -0,0 +1,11 @@ +-- Add category icons and Vegan category +UPDATE categories SET icon = '🧁' WHERE slug = 'backen'; +UPDATE categories SET icon = '🎂' WHERE slug = 'torten'; +UPDATE categories SET icon = '🥐' WHERE slug = 'fruehstueck'; +UPDATE categories SET icon = '🍝' WHERE slug = 'mittag'; +UPDATE categories SET icon = '🥘' WHERE slug = 'abend'; +UPDATE categories SET icon = '🥨' WHERE slug = 'snacks'; +UPDATE categories SET icon = '🍮' WHERE slug = 'desserts'; + +INSERT OR IGNORE INTO categories (id, name, slug, icon, sort_order) VALUES + ('01VEGAN000000000000000000', 'Vegan', 'vegan', '🌱', 8); diff --git a/backend/src/routes/recipes.ts b/backend/src/routes/recipes.ts index 0df0957..1934c39 100644 --- a/backend/src/routes/recipes.ts +++ b/backend/src/routes/recipes.ts @@ -2,6 +2,12 @@ import { FastifyInstance } from 'fastify'; import * as svc from '../services/recipe.service.js'; export async function recipeRoutes(app: FastifyInstance) { + app.get('/api/recipes/random', async (request, reply) => { + const recipe = svc.getRandomRecipe(); + if (!recipe) return reply.status(404).send({ error: 'No recipes found' }); + return recipe; + }); + app.get('/api/recipes/search', async (request) => { const { q } = request.query as { q?: string }; if (!q) return { data: [], total: 0 }; diff --git a/backend/src/schemas/recipe.schema.ts b/backend/src/schemas/recipe.schema.ts index bed7f58..1b702db 100644 --- a/backend/src/schemas/recipe.schema.ts +++ b/backend/src/schemas/recipe.schema.ts @@ -26,6 +26,7 @@ export const createRecipeSchema = z.object({ source_url: z.string().optional(), ingredients: z.array(ingredientSchema).optional(), steps: z.array(stepSchema).optional(), + tags: z.array(z.string()).optional(), }); export const updateRecipeSchema = createRecipeSchema.partial(); diff --git a/backend/src/services/recipe.service.ts b/backend/src/services/recipe.service.ts index 63bbd20..8d0302a 100644 --- a/backend/src/services/recipe.service.ts +++ b/backend/src/services/recipe.service.ts @@ -1,6 +1,22 @@ import { getDb } from '../db/connection.js'; import { ulid } from 'ulid'; +function syncTags(db: any, recipeId: string, tags: string[]) { + db.prepare('DELETE FROM recipe_tags WHERE recipe_id = ?').run(recipeId); + for (const tagName of tags) { + const trimmed = tagName.trim(); + if (!trimmed) continue; + const slug = trimmed.toLowerCase().replace(/ä/g,'ae').replace(/ö/g,'oe').replace(/ü/g,'ue').replace(/ß/g,'ss').replace(/[^a-z0-9]+/g,'-').replace(/^-|-$/g,''); + let tag = db.prepare('SELECT id FROM tags WHERE slug = ?').get(slug) as any; + if (!tag) { + const tagId = ulid(); + db.prepare('INSERT INTO tags (id, name, slug) VALUES (?, ?, ?)').run(tagId, trimmed, slug); + tag = { id: tagId }; + } + db.prepare('INSERT OR IGNORE INTO recipe_tags (recipe_id, tag_id) VALUES (?, ?)').run(recipeId, tag.id); + } +} + function slugify(text: string): string { return text .toLowerCase() @@ -34,6 +50,7 @@ export interface CreateRecipeInput { source_url?: string; ingredients?: { amount?: number; unit?: string; name: string; group_name?: string; sort_order?: number }[]; steps?: { step_number: number; instruction: string; duration_minutes?: number }[]; + tags?: string[]; } function mapTimeFields(row: any) { @@ -127,6 +144,10 @@ export function createRecipe(input: CreateRecipeInput) { insertStep.run(ulid(), id, step.step_number, step.instruction, step.duration_minutes || null); } } + + if (input.tags && input.tags.length > 0) { + syncTags(db, id, input.tags); + } }); transaction(); @@ -152,6 +173,30 @@ export function updateRecipe(id: string, input: Partial) { input.source_url ?? existing.source_url, id ); + // Replace ingredients if provided + if (input.ingredients) { + db.prepare('DELETE FROM ingredients WHERE recipe_id = ?').run(id); + for (let i = 0; i < input.ingredients.length; i++) { + const ing = input.ingredients[i]; + db.prepare('INSERT INTO ingredients (id, recipe_id, amount, unit, name, group_name, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)') + .run(ulid(), id, ing.amount || null, ing.unit || null, ing.name, ing.group_name || null, ing.sort_order ?? i); + } + } + + // Replace steps if provided + if (input.steps) { + db.prepare('DELETE FROM steps WHERE recipe_id = ?').run(id); + for (const step of input.steps) { + db.prepare('INSERT INTO steps (id, recipe_id, step_number, instruction, duration_minutes) VALUES (?, ?, ?, ?, ?)') + .run(ulid(), id, step.step_number, step.instruction, step.duration_minutes || null); + } + } + + // Sync tags if provided + if (input.tags) { + syncTags(db, id, input.tags); + } + return getRecipeBySlug(slug); } @@ -169,6 +214,13 @@ export function toggleFavorite(id: string) { return { id, is_favorite: newVal }; } +export function getRandomRecipe() { + const db = getDb(); + const recipe = db.prepare('SELECT slug FROM recipes ORDER BY RANDOM() LIMIT 1').get() as any; + if (!recipe) return null; + return getRecipeBySlug(recipe.slug); +} + export function searchRecipes(query: string) { const db = getDb(); // Add * for prefix matching diff --git a/frontend/public/icon-192.svg b/frontend/public/icon-192.svg new file mode 100644 index 0000000..ee63071 --- /dev/null +++ b/frontend/public/icon-192.svg @@ -0,0 +1,6 @@ + + + + 🧁 + Luna + diff --git a/frontend/public/icon-512.svg b/frontend/public/icon-512.svg new file mode 100644 index 0000000..c200eac --- /dev/null +++ b/frontend/public/icon-512.svg @@ -0,0 +1,6 @@ + + + + 🧁 + Luna + diff --git a/frontend/src/api/notes.ts b/frontend/src/api/notes.ts new file mode 100644 index 0000000..edebf3f --- /dev/null +++ b/frontend/src/api/notes.ts @@ -0,0 +1,17 @@ +import { apiFetch } from './client' +import type { Note } from './types' + +export function fetchNotes(recipeId: string) { + return apiFetch(`/recipes/${recipeId}/notes`) +} + +export function createNote(recipeId: string, content: string) { + return apiFetch(`/recipes/${recipeId}/notes`, { + method: 'POST', + body: JSON.stringify({ content }), + }) +} + +export function deleteNote(noteId: string) { + return apiFetch<{ ok: boolean }>(`/notes/${noteId}`, { method: 'DELETE' }) +} diff --git a/frontend/src/api/recipes.ts b/frontend/src/api/recipes.ts index ae0b861..15267db 100644 --- a/frontend/src/api/recipes.ts +++ b/frontend/src/api/recipes.ts @@ -24,6 +24,10 @@ export function fetchRecipe(slug: string) { return apiFetch(`/recipes/${slug}`) } +export function fetchRandomRecipe() { + return apiFetch('/recipes/random') +} + export function searchRecipes(q: string) { return apiFetch>(`/recipes/search?q=${encodeURIComponent(q)}`) } @@ -44,6 +48,7 @@ export interface RecipeFormData { source_url?: string ingredients?: { amount?: number; unit?: string; name: string; group_name?: string; sort_order?: number }[] steps?: { step_number: number; instruction: string; duration_minutes?: number }[] + tags?: string[] } export function createRecipe(data: RecipeFormData) { diff --git a/frontend/src/api/tags.ts b/frontend/src/api/tags.ts new file mode 100644 index 0000000..b20a03f --- /dev/null +++ b/frontend/src/api/tags.ts @@ -0,0 +1,12 @@ +import { apiFetch } from './client' + +export interface Tag { + id: string + name: string + slug: string + recipe_count: number +} + +export function fetchTags() { + return apiFetch('/tags') +} diff --git a/frontend/src/components/ui/Confetti.tsx b/frontend/src/components/ui/Confetti.tsx new file mode 100644 index 0000000..7a5d557 --- /dev/null +++ b/frontend/src/components/ui/Confetti.tsx @@ -0,0 +1,56 @@ +import { useEffect, useState } from 'react' + +const COLORS = ['#C4737E', '#D4A574', '#7BAE7F', '#C94C4C', '#F2D7DB', '#FFD700'] +const EMOJIS = ['🎉', '🧁', '🎂', '✨', '💖', '🍰'] + +interface Particle { + id: number + x: number + emoji: string + color: string + delay: number + duration: number + size: number +} + +export function Confetti({ active }: { active: boolean }) { + const [particles, setParticles] = useState([]) + + useEffect(() => { + if (!active) return + const p: Particle[] = Array.from({ length: 30 }, (_, i) => ({ + id: i, + x: Math.random() * 100, + emoji: EMOJIS[Math.floor(Math.random() * EMOJIS.length)], + color: COLORS[Math.floor(Math.random() * COLORS.length)], + delay: Math.random() * 0.5, + duration: 1.5 + Math.random() * 1.5, + size: 16 + Math.random() * 16, + })) + setParticles(p) + const timer = setTimeout(() => setParticles([]), 3500) + return () => clearTimeout(timer) + }, [active]) + + if (particles.length === 0) return null + + return ( +
+ {particles.map((p) => ( + + {p.emoji} + + ))} +
+ ) +} diff --git a/frontend/src/pages/HomePage.tsx b/frontend/src/pages/HomePage.tsx index 6f6cde0..5d87138 100644 --- a/frontend/src/pages/HomePage.tsx +++ b/frontend/src/pages/HomePage.tsx @@ -1,9 +1,14 @@ import { useState } from 'react' import { useQuery } from '@tanstack/react-query' +import { useNavigate } from 'react-router' +import { motion } from 'framer-motion' import Masonry from 'react-masonry-css' -import { fetchRecipes } from '../api/recipes' +import { Dices } from 'lucide-react' +import { fetchRecipes, fetchRandomRecipe } from '../api/recipes' import { fetchCategories } from '../api/categories' +import { fetchTags } from '../api/tags' import { RecipeCard } from '../components/recipe/RecipeCard' +import { RecipeCardSmall } from '../components/recipe/RecipeCardSmall' import type { Recipe } from '../api/types' import { Badge } from '../components/ui/Badge' import { RecipeCardSkeleton } from '../components/ui/Skeleton' @@ -11,57 +16,163 @@ import { EmptyState } from '../components/ui/EmptyState' export function HomePage() { const [activeCategory, setActiveCategory] = useState() + const [activeTag, setActiveTag] = useState() + const navigate = useNavigate() const { data: categories } = useQuery({ queryKey: ['categories'], queryFn: fetchCategories, }) + const { data: tags } = useQuery({ + queryKey: ['tags'], + queryFn: fetchTags, + }) + const { data: recipesData, isLoading } = useQuery({ queryKey: ['recipes', { category: activeCategory }], queryFn: () => fetchRecipes({ category: activeCategory, sort: 'newest' }), }) + const { data: favoritesData } = useQuery({ + queryKey: ['recipes', { favorite: true }], + queryFn: () => fetchRecipes({ favorite: true }), + }) + const recipes = recipesData?.data ?? [] + const totalCount = recipesData?.total ?? 0 + const favorites = favoritesData?.data ?? [] + + // Filter by tag client-side if active + const filteredRecipes = activeTag + ? recipes.filter(r => r.tags?.includes(activeTag)) + : recipes + + const handleRandomRecipe = async () => { + try { + const recipe = await fetchRandomRecipe() + if (recipe?.slug) navigate(`/recipe/${recipe.slug}`) + } catch { + // no recipes + } + } + + const activeTags = (tags || []).filter(t => t.recipe_count > 0) return (
{/* TopBar */}
-

🍰 Luna Recipes

+

🧁 Luna Recipes

👤
+ {/* Recipe Counter */} + {totalCount > 0 && ( + +

+ Du hast {totalCount} Rezept{totalCount !== 1 ? 'e' : ''} gesammelt! 🎉 +

+
+ )} + + {/* Random Recipe Button */} + {totalCount > 1 && ( +
+ +
+ )} + + {/* Favorites Section */} + {favorites.length > 0 && ( +
+

❤️ Favoriten

+
+ {favorites.map((recipe, i) => ( + + + + ))} +
+
+ )} + {/* Category Chips */} {categories && (Array.isArray(categories) ? categories : []).length > 0 && ( -
- setActiveCategory(undefined)}> +
+ { setActiveCategory(undefined); setActiveTag(undefined) }}> Alle {(Array.isArray(categories) ? categories : []).map((cat) => ( setActiveCategory(activeCategory === cat.slug ? undefined : cat.slug)} + onClick={() => { setActiveCategory(activeCategory === cat.slug ? undefined : cat.slug); setActiveTag(undefined) }} > - {cat.icon || ''} {cat.name} + {cat.icon ? `${cat.icon} ` : ''}{cat.name} ))}
)} + {/* Tag Chips */} + {activeTags.length > 0 && ( +
+ {activeTags.map((tag) => ( + setActiveTag(activeTag === tag.name ? undefined : tag.name)} + className={`inline-block px-3 py-1 rounded-full text-xs font-medium cursor-pointer transition-colors whitespace-nowrap ${ + activeTag === tag.name + ? 'bg-secondary text-white' + : 'bg-sand/50 text-warm-grey' + }`} + > + #{tag.name} + + ))} +
+ )} + {/* Recipe Grid */}
{isLoading ? ( {Array.from({ length: 6 }).map((_, i) => )} - ) : recipes.length === 0 ? ( - + ) : filteredRecipes.length === 0 ? ( + ) : ( - {recipes.map((recipe: Recipe) => ( - + {filteredRecipes.map((recipe: Recipe, i: number) => ( + + + ))} )} diff --git a/frontend/src/pages/RecipeFormPage.tsx b/frontend/src/pages/RecipeFormPage.tsx index d85aad0..de3e937 100644 --- a/frontend/src/pages/RecipeFormPage.tsx +++ b/frontend/src/pages/RecipeFormPage.tsx @@ -5,6 +5,7 @@ import toast from 'react-hot-toast' import { ArrowLeft, Plus, Trash2, Camera, X, GripVertical, Link, Loader2 } from 'lucide-react' import { fetchRecipe, createRecipe, updateRecipe, deleteRecipe, uploadRecipeImage, fetchOgPreview } from '../api/recipes' import { fetchCategories } from '../api/categories' +import { Confetti } from '../components/ui/Confetti' import type { RecipeFormData } from '../api/recipes' import type { Ingredient, Step } from '../api/types' @@ -60,8 +61,10 @@ export function RecipeFormPage() { const [steps, setSteps] = useState([ { key: nextKey(), instruction: '' }, ]) + const [tagsInput, setTagsInput] = useState('') const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) const [fetchingOg, setFetchingOg] = useState(false) + const [showConfetti, setShowConfetti] = useState(false) // Populate form when editing useEffect(() => { @@ -76,6 +79,9 @@ export function RecipeFormPage() { setSourceUrl((recipe as any).source_url || '') setImageUrl(recipe.image_url || '') if (recipe.image_url) setImagePreview(recipe.image_url) + if (recipe.tags && recipe.tags.length > 0) { + setTagsInput(recipe.tags.join(', ')) + } if (recipe.ingredients && recipe.ingredients.length > 0) { setIngredients(recipe.ingredients.map(ing => ({ key: nextKey(), @@ -112,8 +118,14 @@ export function RecipeFormPage() { } qc.invalidateQueries({ queryKey: ['recipes'] }) qc.invalidateQueries({ queryKey: ['recipe', slug] }) - toast.success(isEdit ? 'Rezept aktualisiert!' : 'Rezept erstellt!') - navigate(saved?.slug ? `/recipe/${saved.slug}` : '/') + if (!isEdit) { + setShowConfetti(true) + toast.success('Rezept erstellt! 🎉') + setTimeout(() => navigate(saved?.slug ? `/recipe/${saved.slug}` : '/'), 1500) + } else { + toast.success('Rezept aktualisiert!') + navigate(saved?.slug ? `/recipe/${saved.slug}` : '/') + } }, onError: () => toast.error('Fehler beim Speichern'), }) @@ -159,6 +171,7 @@ export function RecipeFormPage() { step_number: idx + 1, instruction: s.instruction.trim(), })), + tags: tagsInput.split(',').map(t => t.trim()).filter(Boolean), } saveMutation.mutate(data) } @@ -204,6 +217,7 @@ export function RecipeFormPage() { return (
+ {/* Header */}
+ {/* Tags */} +
+ + setTagsInput(e.target.value)} + placeholder="z.B. Sommer, Schnell, Omas Rezept" + className={inputClass} + /> +

Komma-getrennt eingeben

+ {tagsInput && ( +
+ {tagsInput.split(',').map(t => t.trim()).filter(Boolean).map((tag, i) => ( + #{tag} + ))} +
+ )} +
+ {/* Ingredients */}
diff --git a/frontend/src/pages/RecipePage.tsx b/frontend/src/pages/RecipePage.tsx index 75fde13..3f277b7 100644 --- a/frontend/src/pages/RecipePage.tsx +++ b/frontend/src/pages/RecipePage.tsx @@ -1,9 +1,12 @@ +import { useState } from 'react' import { useParams, useNavigate, Link } from 'react-router' import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { motion } from 'framer-motion' import toast from 'react-hot-toast' -import { ArrowLeft, Heart, Clock, Users, ChefHat, ShoppingCart, Pencil } from 'lucide-react' +import { ArrowLeft, Heart, Clock, Users, ChefHat, ShoppingCart, Pencil, Minus, Plus, Send, Trash2 } from 'lucide-react' import { fetchRecipe, toggleFavorite } from '../api/recipes' import { addFromRecipe } from '../api/shopping' +import { createNote, deleteNote } from '../api/notes' import { Badge } from '../components/ui/Badge' import { Skeleton } from '../components/ui/Skeleton' @@ -13,6 +16,8 @@ export function RecipePage() { const { slug } = useParams<{ slug: string }>() const navigate = useNavigate() const qc = useQueryClient() + const [servingScale, setServingScale] = useState(null) + const [noteText, setNoteText] = useState('') const { data: recipe, isLoading } = useQuery({ queryKey: ['recipe', slug], @@ -38,6 +43,24 @@ export function RecipePage() { onError: () => toast.error('Fehler beim Hinzufügen zur Einkaufsliste'), }) + const noteMutation = useMutation({ + mutationFn: (content: string) => createNote(recipe!.id, content), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['recipe', slug] }) + setNoteText('') + toast.success('Notiz gespeichert! 📝') + }, + onError: () => toast.error('Fehler beim Speichern der Notiz'), + }) + + const deleteNoteMutation = useMutation({ + mutationFn: (noteId: string) => deleteNote(noteId), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['recipe', slug] }) + toast.success('Notiz gelöscht') + }, + }) + if (isLoading) { return (
@@ -50,9 +73,20 @@ export function RecipePage() { if (!recipe) return
Rezept nicht gefunden.
+ const originalServings = recipe.servings || 4 + const currentServings = servingScale ?? originalServings + const scaleFactor = currentServings / originalServings const totalTime = recipe.total_time_min || ((recipe.prep_time_min || 0) + (recipe.cook_time_min || 0)) const gradient = gradients[recipe.title.length % gradients.length] + const scaleAmount = (amount?: number) => { + if (!amount) return '' + const scaled = amount * scaleFactor + // Nice formatting + if (scaled === Math.round(scaled)) return String(Math.round(scaled)) + return scaled.toFixed(1).replace(/\.0$/, '') + } + // Group ingredients const ingredientGroups = (recipe.ingredients || []).reduce>((acc, ing) => { const group = ing.group_name || 'Zutaten' @@ -62,7 +96,7 @@ export function RecipePage() { }, {}) return ( -
+ {/* Hero */}
{recipe.image_url ? ( @@ -72,14 +106,14 @@ export function RecipePage() { 🍰
)} -
- + -
@@ -90,13 +124,14 @@ export function RecipePage() {

{recipe.title}

- {recipe.category_name && {recipe.category_name}} + {recipe.category_name ? ( + {recipe.category_name} + ) : ( + 🍽️ Allgemein + )} {totalTime > 0 && ( {totalTime} min )} - {recipe.servings && ( - {recipe.servings} Portionen - )} {recipe.difficulty && ( {recipe.difficulty} )} @@ -113,6 +148,38 @@ export function RecipePage() {
)} + {/* Serving Calculator */} +
+
+ + Portionen + +
+ + {currentServings} + +
+
+ {servingScale !== null && servingScale !== originalServings && ( + + )} +
+ {/* Ingredients */} {Object.keys(ingredientGroups).length > 0 && (
@@ -124,12 +191,18 @@ export function RecipePage() { )}
    {items!.map((ing, i) => ( -
  • + - {ing.amount ? `${ing.amount} ${ing.unit || ''}`.trim() : ''} + {ing.amount ? `${scaleAmount(ing.amount)} ${ing.unit || ''}`.trim() : ''} {ing.name} -
  • + ))}
@@ -155,7 +228,13 @@ export function RecipePage() {

Zubereitung

    {recipe.steps.map((step, i) => ( -
  1. + {step.step_number || i + 1} @@ -167,27 +246,58 @@ export function RecipePage() { )}
- + ))}
)} {/* Notes */} - {recipe.notes && recipe.notes.length > 0 && ( -
-

Notizen

-
+
+

Notizen

+ + {/* Existing notes */} + {recipe.notes && recipe.notes.length > 0 && ( +
{recipe.notes.map((note) => ( -
- 📝 {note.content} -
+ + 📝 {note.content} + + ))}
-
- )} -
+ )} -
+ {/* Add note */} +
+ setNoteText(e.target.value)} + onKeyDown={(e) => { if (e.key === 'Enter' && noteText.trim()) noteMutation.mutate(noteText.trim()) }} + placeholder="Notiz hinzufügen..." + className="flex-1 bg-surface border border-sand rounded-xl px-4 py-3 text-sm text-espresso placeholder:text-warm-grey/50 focus:outline-none focus:ring-2 focus:ring-primary/30 min-h-[44px]" + /> + +
+
+
+ ) } diff --git a/frontend/src/styles/globals.css b/frontend/src/styles/globals.css index a8523cc..46f3cf6 100644 --- a/frontend/src/styles/globals.css +++ b/frontend/src/styles/globals.css @@ -12,6 +12,7 @@ --color-sage: #7BAE7F; --color-berry: #C94C4C; --color-sand: #E8E0D8; + --color-berry-red: #C94C4C; --font-display: 'Playfair Display', serif; --font-sans: 'Inter', system-ui, sans-serif; } @@ -30,3 +31,12 @@ body { -ms-overflow-style: none; scrollbar-width: none; } + +@keyframes confetti-fall { + 0% { transform: translateY(0) rotate(0deg); opacity: 1; } + 100% { transform: translateY(100vh) rotate(720deg); opacity: 0; } +} + +.animate-confetti-fall { + animation: confetti-fall 2s ease-in forwards; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index 058274a..9aac067 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -19,8 +19,8 @@ export default defineConfig({ orientation: 'portrait', start_url: '/', icons: [ - { src: '/icon-192.png', sizes: '192x192', type: 'image/png' }, - { src: '/icon-512.png', sizes: '512x512', type: 'image/png' }, + { src: '/icon-192.svg', sizes: '192x192', type: 'image/svg+xml' }, + { src: '/icon-512.svg', sizes: '512x512', type: 'image/svg+xml', purpose: 'any maskable' }, ], }, }),