Files
luna-recipes/frontend/src/pages/RecipePage.tsx

329 lines
14 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useState, useCallback } from 'react'
import { useParams, useNavigate, useSearchParams, 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, Minus, Plus, Send, Trash2 } from 'lucide-react'
import { Dices } from 'lucide-react'
import { fetchRecipe, toggleFavorite, fetchRandomRecipe } 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'
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 [searchParams] = useSearchParams()
const fromRandom = searchParams.get('from') === 'random'
const qc = useQueryClient()
const [servingScale, setServingScale] = useState<number | null>(null)
const [noteText, setNoteText] = useState('')
const [rerolling, setRerolling] = useState(false)
const handleReroll = useCallback(async () => {
setRerolling(true)
try {
const r = await fetchRandomRecipe()
if (r?.slug) navigate(`/recipe/${r.slug}?from=random`, { replace: true })
} catch { /* ignore */ }
setRerolling(false)
}, [navigate])
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'),
})
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">
<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 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'
if (!acc[group]) acc[group] = []
acc[group]!.push(ing)
return acc
}, {})
return (
<motion.div initial={{ opacity: 0 }} animate={{ opacity: 1 }} transition={{ duration: 0.3 }}>
{/* 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 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 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 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>
</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>
) : (
<Badge>🍽 Allgemein</Badge>
)}
{totalTime > 0 && (
<span className="flex items-center gap-1 text-warm-grey text-sm"><Clock size={14} /> {totalTime} min</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>
)}
{/* 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>
<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) => (
<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 ? `${scaleAmount(ing.amount)} ${ing.unit || ''}`.trim() : ''}
</span>
<span>{ing.name}</span>
</motion.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) => (
<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>
<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>
</motion.li>
))}
</ol>
</div>
)}
{/* Notes */}
<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) => (
<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>
)}
{/* 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>
{/* Floating Re-Roll Button */}
{fromRandom && (
<button
onClick={handleReroll}
disabled={rerolling}
className="fixed bottom-20 right-4 z-50 w-12 h-12 rounded-full bg-gradient-to-r from-primary to-secondary text-white shadow-lg flex items-center justify-center active:scale-95 transition-transform disabled:opacity-50"
title="Nochmal würfeln"
>
<Dices size={20} className={rerolling ? 'animate-spin' : ''} />
</button>
)}
</motion.div>
)
}