feat: v1 release - serving calculator, notes UI, tags, random recipe, confetti, favorites section, PWA icons, category icons, animations

This commit is contained in:
clawd
2026-02-18 10:23:22 +00:00
parent 60ca01fb94
commit de567f93db
17 changed files with 476 additions and 39 deletions

View File

@@ -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<number | null>(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 (
<div className="p-4 space-y-4">
@@ -50,9 +73,20 @@ export function RecipePage() {
if (!recipe) return <div className="p-4">Rezept nicht gefunden.</div>
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<Record<string, typeof recipe.ingredients>>((acc, ing) => {
const group = ing.group_name || 'Zutaten'
@@ -62,7 +96,7 @@ export function RecipePage() {
}, {})
return (
<div>
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }}>
{/* Hero */}
<div className="relative">
{recipe.image_url ? (
@@ -72,14 +106,14 @@ export function RecipePage() {
<span className="text-6xl">🍰</span>
</div>
)}
<button onClick={() => navigate(-1)} className="absolute top-4 left-4 bg-surface/80 backdrop-blur-sm rounded-full p-2">
<button onClick={() => navigate(-1)} className="absolute top-4 left-4 bg-surface/80 backdrop-blur-sm rounded-full p-2 min-w-[44px] min-h-[44px] flex items-center justify-center">
<ArrowLeft size={20} className="text-espresso" />
</button>
<div className="absolute top-4 right-4 flex gap-2">
<Link to={`/recipe/${recipe.slug}/edit`} className="bg-surface/80 backdrop-blur-sm rounded-full p-2">
<Link to={`/recipe/${recipe.slug}/edit`} className="bg-surface/80 backdrop-blur-sm rounded-full p-2 min-w-[44px] min-h-[44px] flex items-center justify-center">
<Pencil size={20} className="text-espresso" />
</Link>
<button onClick={() => favMutation.mutate()} className="bg-surface/80 backdrop-blur-sm rounded-full p-2">
<button onClick={() => favMutation.mutate()} className="bg-surface/80 backdrop-blur-sm rounded-full p-2 min-w-[44px] min-h-[44px] flex items-center justify-center">
<Heart size={20} className={recipe.is_favorite ? 'fill-primary text-primary' : 'text-espresso'} />
</button>
</div>
@@ -90,13 +124,14 @@ export function RecipePage() {
<div>
<h1 className="font-display text-2xl text-espresso">{recipe.title}</h1>
<div className="flex flex-wrap items-center gap-2 mt-2">
{recipe.category_name && <Badge>{recipe.category_name}</Badge>}
{recipe.category_name ? (
<Badge>{recipe.category_name}</Badge>
) : (
<Badge>🍽 Allgemein</Badge>
)}
{totalTime > 0 && (
<span className="flex items-center gap-1 text-warm-grey text-sm"><Clock size={14} /> {totalTime} min</span>
)}
{recipe.servings && (
<span className="flex items-center gap-1 text-warm-grey text-sm"><Users size={14} /> {recipe.servings} Portionen</span>
)}
{recipe.difficulty && (
<span className="flex items-center gap-1 text-warm-grey text-sm"><ChefHat size={14} /> {recipe.difficulty}</span>
)}
@@ -113,6 +148,38 @@ export function RecipePage() {
</div>
)}
{/* Serving Calculator */}
<div className="bg-primary-light/30 rounded-2xl p-4">
<div className="flex items-center justify-between">
<span className="flex items-center gap-2 text-sm font-medium text-espresso">
<Users size={16} /> Portionen
</span>
<div className="flex items-center gap-3">
<button
onClick={() => setServingScale(Math.max(1, currentServings - 1))}
className="w-9 h-9 rounded-full bg-surface border border-sand flex items-center justify-center text-espresso active:bg-sand transition-colors"
>
<Minus size={16} />
</button>
<span className="font-display text-xl text-espresso min-w-[2ch] text-center">{currentServings}</span>
<button
onClick={() => setServingScale(currentServings + 1)}
className="w-9 h-9 rounded-full bg-surface border border-sand flex items-center justify-center text-espresso active:bg-sand transition-colors"
>
<Plus size={16} />
</button>
</div>
</div>
{servingScale !== null && servingScale !== originalServings && (
<button
onClick={() => setServingScale(null)}
className="text-xs text-primary mt-2 underline"
>
Original ({originalServings}) zurücksetzen
</button>
)}
</div>
{/* Ingredients */}
{Object.keys(ingredientGroups).length > 0 && (
<div>
@@ -124,12 +191,18 @@ export function RecipePage() {
)}
<ul className="space-y-1">
{items!.map((ing, i) => (
<li key={i} className="flex gap-2 text-sm text-espresso py-1 border-b border-sand/50">
<motion.li
key={i}
className="flex gap-2 text-sm text-espresso py-1 border-b border-sand/50"
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: i * 0.03 }}
>
<span className="text-warm-grey min-w-[60px]">
{ing.amount ? `${ing.amount} ${ing.unit || ''}`.trim() : ''}
{ing.amount ? `${scaleAmount(ing.amount)} ${ing.unit || ''}`.trim() : ''}
</span>
<span>{ing.name}</span>
</li>
</motion.li>
))}
</ul>
</div>
@@ -155,7 +228,13 @@ export function RecipePage() {
<h2 className="font-display text-lg text-espresso mb-3">Zubereitung</h2>
<ol className="space-y-4">
{recipe.steps.map((step, i) => (
<li key={i} className="flex gap-3">
<motion.li
key={i}
className="flex gap-3"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: i * 0.05 }}
>
<span className="flex-shrink-0 w-7 h-7 rounded-full bg-primary text-white text-sm flex items-center justify-center font-medium">
{step.step_number || i + 1}
</span>
@@ -167,27 +246,58 @@ export function RecipePage() {
</span>
)}
</div>
</li>
</motion.li>
))}
</ol>
</div>
)}
{/* Notes */}
{recipe.notes && recipe.notes.length > 0 && (
<div>
<h2 className="font-display text-lg text-espresso mb-3">Notizen</h2>
<div className="space-y-2">
<div>
<h2 className="font-display text-lg text-espresso mb-3">Notizen</h2>
{/* Existing notes */}
{recipe.notes && recipe.notes.length > 0 && (
<div className="space-y-2 mb-3">
{recipe.notes.map((note) => (
<div key={note.id} className="bg-primary-light/30 rounded-xl p-3 text-sm text-espresso">
📝 {note.content}
</div>
<motion.div
key={note.id}
className="bg-primary-light/30 rounded-xl p-3 text-sm text-espresso flex items-start gap-2"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
>
<span className="flex-1">📝 {note.content}</span>
<button
onClick={() => deleteNoteMutation.mutate(note.id)}
className="text-warm-grey hover:text-berry p-1 flex-shrink-0"
>
<Trash2 size={14} />
</button>
</motion.div>
))}
</div>
</div>
)}
</div>
)}
</div>
{/* Add note */}
<div className="flex gap-2">
<input
type="text"
value={noteText}
onChange={(e) => 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]"
/>
<button
onClick={() => noteText.trim() && noteMutation.mutate(noteText.trim())}
disabled={!noteText.trim() || noteMutation.isPending}
className="bg-primary text-white rounded-xl px-4 min-h-[44px] min-w-[44px] flex items-center justify-center disabled:opacity-50 transition-colors"
>
<Send size={18} />
</button>
</div>
</div>
</div>
</motion.div>
)
}