feat: stabilization + recipe edit/create UI

This commit is contained in:
clawd
2026-02-18 09:55:39 +00:00
commit ee452efa6a
75 changed files with 15160 additions and 0 deletions

View File

@@ -0,0 +1,193 @@
import { useParams, useNavigate, Link } from 'react-router'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import toast from 'react-hot-toast'
import { ArrowLeft, Heart, Clock, Users, ChefHat, ShoppingCart, Pencil } from 'lucide-react'
import { fetchRecipe, toggleFavorite } from '../api/recipes'
import { addFromRecipe } from '../api/shopping'
import { Badge } from '../components/ui/Badge'
import { Skeleton } from '../components/ui/Skeleton'
const gradients = ['from-primary/40 to-secondary/40', 'from-secondary/40 to-sage/40']
export function RecipePage() {
const { slug } = useParams<{ slug: string }>()
const navigate = useNavigate()
const qc = useQueryClient()
const { data: recipe, isLoading } = useQuery({
queryKey: ['recipe', slug],
queryFn: () => fetchRecipe(slug!),
enabled: !!slug,
})
const favMutation = useMutation({
mutationFn: () => toggleFavorite(recipe!.id),
onSuccess: () => {
qc.invalidateQueries({ queryKey: ['recipe', slug] })
qc.invalidateQueries({ queryKey: ['recipes'] })
},
onError: () => toast.error('Fehler beim Ändern des Favoriten-Status'),
})
const shoppingMutation = useMutation({
mutationFn: () => addFromRecipe(recipe!.id),
onSuccess: (data) => {
qc.invalidateQueries({ queryKey: ['shopping'] })
toast.success(`${data.added} Zutaten zur Einkaufsliste hinzugefügt!`)
},
onError: () => toast.error('Fehler beim Hinzufügen zur Einkaufsliste'),
})
if (isLoading) {
return (
<div className="p-4 space-y-4">
<Skeleton className="w-full h-64" />
<Skeleton className="h-8 w-3/4" />
<Skeleton className="h-4 w-1/2" />
</div>
)
}
if (!recipe) return <div className="p-4">Rezept nicht gefunden.</div>
const totalTime = recipe.total_time_min || ((recipe.prep_time_min || 0) + (recipe.cook_time_min || 0))
const gradient = gradients[recipe.title.length % gradients.length]
// Group ingredients
const ingredientGroups = (recipe.ingredients || []).reduce<Record<string, typeof recipe.ingredients>>((acc, ing) => {
const group = ing.group_name || 'Zutaten'
if (!acc[group]) acc[group] = []
acc[group]!.push(ing)
return acc
}, {})
return (
<div>
{/* Hero */}
<div className="relative">
{recipe.image_url ? (
<img src={recipe.image_url} alt={recipe.title} className="w-full h-64 object-cover" />
) : (
<div className={`w-full h-64 bg-gradient-to-br ${gradient} flex items-center justify-center`}>
<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">
<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">
<Pencil size={20} className="text-espresso" />
</Link>
<button onClick={() => favMutation.mutate()} className="bg-surface/80 backdrop-blur-sm rounded-full p-2">
<Heart size={20} className={recipe.is_favorite ? 'fill-primary text-primary' : 'text-espresso'} />
</button>
</div>
</div>
<div className="p-4 space-y-6">
{/* Title & Meta */}
<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>}
{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>
)}
</div>
{recipe.description && <p className="text-warm-grey mt-2 text-sm">{recipe.description}</p>}
</div>
{/* Tags */}
{recipe.tags && recipe.tags.length > 0 && (
<div className="flex flex-wrap gap-1">
{recipe.tags.map((tag) => (
<span key={tag} className="text-xs bg-sand/50 text-warm-grey px-2 py-0.5 rounded-full">#{tag}</span>
))}
</div>
)}
{/* Ingredients */}
{Object.keys(ingredientGroups).length > 0 && (
<div>
<h2 className="font-display text-lg text-espresso mb-3">Zutaten</h2>
{Object.entries(ingredientGroups).map(([group, items]) => (
<div key={group} className="mb-3">
{Object.keys(ingredientGroups).length > 1 && (
<h3 className="font-semibold text-sm text-warm-grey mb-1">{group}</h3>
)}
<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">
<span className="text-warm-grey min-w-[60px]">
{ing.amount ? `${ing.amount} ${ing.unit || ''}`.trim() : ''}
</span>
<span>{ing.name}</span>
</li>
))}
</ul>
</div>
))}
</div>
)}
{/* Add to shopping list */}
{recipe.ingredients && recipe.ingredients.length > 0 && (
<button
onClick={() => shoppingMutation.mutate()}
disabled={shoppingMutation.isPending}
className="w-full flex items-center justify-center gap-2 bg-secondary text-white px-4 py-3 rounded-xl font-medium transition-colors hover:bg-secondary/90 disabled:opacity-50 min-h-[44px]"
>
<ShoppingCart size={18} />
{shoppingMutation.isPending ? 'Wird hinzugefügt...' : '🛒 Zur Einkaufsliste'}
</button>
)}
{/* Steps */}
{recipe.steps && recipe.steps.length > 0 && (
<div>
<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">
<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>
<div className="text-sm text-espresso pt-1">
<p>{step.instruction}</p>
{step.timer_minutes && (
<span className="inline-flex items-center gap-1 mt-1 text-xs text-primary">
<Clock size={12} /> {step.timer_label || 'Timer'}: {step.timer_minutes} min
</span>
)}
</div>
</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">
{recipe.notes.map((note) => (
<div key={note.id} className="bg-primary-light/30 rounded-xl p-3 text-sm text-espresso">
📝 {note.content}
</div>
))}
</div>
</div>
)}
</div>
</div>
)
}