freedge/frontend/src/pages/Recipes/RecipeForm.tsx

716 lines
28 KiB
TypeScript

"use client"
import type React from "react"
import { useState, useEffect, useMemo } from "react"
import { useNavigate, Link } from "react-router-dom"
import { recipeService } from "@/api/recipe"
import userService from "@/api/user"
import type { User } from "@/api/auth"
import { Button } from "@/components/ui/button"
import { Badge } from "@/components/ui/badge"
import {
Mic,
ArrowLeft,
Trash2,
ChefHat,
X,
Check,
Play,
Pause,
Settings2,
Lightbulb,
Square,
Wand2,
} from "lucide-react"
import { motion, AnimatePresence } from "framer-motion"
import { CookingLoader } from "@/components/illustrations/CookingLoader"
import { useAudioRecorder } from "@/hooks/useAudioRecorder"
type PageState = "idle" | "recording" | "review" | "processing"
// Messages d'état pendant la génération
const stepLabels: Record<string, string> = {
saving_audio: "Envoi de ton enregistrement…",
transcribing: "Je t'écoute attentivement…",
generating_recipe: "Antoine invente ta recette…",
generating_image: "Préparation de la photo du plat…",
finalizing: "Derniers préparatifs…",
}
// Astuces affichées en rotation sur la page d'accueil
const tips = [
"Parle naturellement, comme à un ami. Pas besoin d'être exhaustif.",
"Précise les quantités quand tu les connais (\"environ 200g de riz\").",
"Mentionne les herbes et épices que tu as — ça change tout.",
"Tu peux ajouter une envie : \"quelque chose de rapide\" ou \"plutôt épicé\".",
]
export default function RecipeForm() {
const navigate = useNavigate()
const {
isRecording,
currentRecording,
startRecording,
stopRecording,
} = useAudioRecorder()
const [audioFile, setAudioFile] = useState<File | null>(null)
const [pageState, setPageState] = useState<PageState>("idle")
const [error, setError] = useState("")
const [recordingTime, setRecordingTime] = useState(0)
const [user, setUser] = useState<User | null>(null)
const [tipIndex, setTipIndex] = useState(0)
const [isPlaying, setIsPlaying] = useState(false)
// Live streaming state
const [progressStep, setProgressStep] = useState<string>("")
const [liveTranscription, setLiveTranscription] = useState("")
const [liveTitle, setLiveTitle] = useState("")
const [liveDescription, setLiveDescription] = useState("")
const [liveImageUrl, setLiveImageUrl] = useState<string | null>(null)
// --- Effects ---
// Charge les préférences utilisateur pour les afficher en chips
useEffect(() => {
userService.getCurrentUser().then(setUser).catch(() => {/* ignore */})
}, [])
// Rotation des astuces
useEffect(() => {
if (pageState !== "idle") return
const id = setInterval(() => setTipIndex((i) => (i + 1) % tips.length), 4500)
return () => clearInterval(id)
}, [pageState])
// Convertit l'enregistrement en File
useEffect(() => {
if (!currentRecording) return
fetch(currentRecording)
.then((res) => res.blob())
.then((blob) => {
const file = new File([blob], "recording.mp3", { type: "audio/mp3" })
setAudioFile(file)
setPageState("review")
setError("")
})
}, [currentRecording])
// Timer
useEffect(() => {
if (!isRecording) {
setRecordingTime(0)
return
}
const id = setInterval(() => setRecordingTime((t) => t + 1), 1000)
return () => clearInterval(id)
}, [isRecording])
// Raccourci clavier : espace pour démarrer/arrêter (quand pas dans un input)
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.code !== "Space") return
const target = e.target as HTMLElement
if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") return
if (pageState === "idle") {
e.preventDefault()
handleStartRecording()
} else if (pageState === "recording") {
e.preventDefault()
handleStopRecording()
}
}
window.addEventListener("keydown", onKey)
return () => window.removeEventListener("keydown", onKey)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [pageState])
// --- Handlers ---
const handleStartRecording = async () => {
try {
await startRecording()
setPageState("recording")
setError("")
} catch {
setError("Impossible d'accéder au microphone. Vérifiez les permissions.")
}
}
const handleStopRecording = async () => {
if (!isRecording) return
await stopRecording()
// Le useEffect sur currentRecording bascule vers 'review'
}
const handleResetRecording = () => {
setAudioFile(null)
setPageState("idle")
setError("")
setIsPlaying(false)
}
const handleSubmit = async () => {
if (!audioFile) return
setPageState("processing")
setError("")
setProgressStep("")
setLiveTranscription("")
setLiveTitle("")
setLiveDescription("")
setLiveImageUrl(null)
try {
let savedId: string | null = null
for await (const event of recipeService.createRecipeStream(audioFile)) {
switch (event.type) {
case "progress":
setProgressStep(event.step)
break
case "transcription":
setLiveTranscription(event.text)
break
case "title":
setLiveTitle(event.title)
break
case "description":
setLiveDescription(event.description)
break
case "image":
setLiveImageUrl(event.url)
break
case "saved":
savedId = event.id
break
case "error":
throw new Error(event.message)
}
}
if (savedId) {
setTimeout(() => navigate(`/recipes/${savedId}`), 800)
}
} catch (err) {
setError(err instanceof Error ? err.message : "Une erreur est survenue")
setPageState("review")
}
}
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60)
const secs = seconds % 60
return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}`
}
// URL audio pour lecture (stable sur la durée du review)
const audioUrl = useMemo(
() => (audioFile ? URL.createObjectURL(audioFile) : null),
[audioFile]
)
// Chips des préférences utilisateur
const prefChips = useMemo(() => {
if (!user) return []
const chips: string[] = []
if (user.dietaryPreference && user.dietaryPreference !== "none") {
const labels: Record<string, string> = {
vegetarian: "Végétarien",
vegan: "Végan",
pescatarian: "Pescétarien",
}
chips.push(labels[user.dietaryPreference] ?? user.dietaryPreference)
}
if (user.allergies && user.allergies.trim()) {
chips.push(`Sans ${user.allergies.split(",")[0].trim()}`)
}
if (user.maxCookingTime) {
chips.push(`${user.maxCookingTime} min`)
}
if (user.cuisinePreference) {
chips.push(`Cuisine ${user.cuisinePreference}`)
}
return chips
}, [user])
// -------------------------------------------------------------------------
// Render
// -------------------------------------------------------------------------
return (
<div className="relative min-h-[calc(100vh-4rem)] px-4 py-6 md:py-12 md:px-8">
{/* Top bar avec bouton retour */}
<div className="max-w-3xl mx-auto mb-8 flex items-center justify-between">
<Button
variant="ghost"
size="sm"
className="rounded-full -ml-2 gap-1.5 hover:bg-white/60 dark:hover:bg-slate-800/60"
onClick={() => navigate("/recipes")}
>
<ArrowLeft className="h-4 w-4" />
Mes recettes
</Button>
{pageState === "idle" && (
<Link to="/profile">
<Button
variant="ghost"
size="sm"
className="rounded-full gap-1.5 hover:bg-white/60 dark:hover:bg-slate-800/60"
>
<Settings2 className="h-4 w-4" />
Préférences
</Button>
</Link>
)}
</div>
{/* Erreur */}
<AnimatePresence>
{error && (
<motion.div
initial={{ opacity: 0, y: -8 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -8 }}
className="max-w-3xl mx-auto mb-6"
>
<div className="flex items-start gap-3 rounded-xl bg-red-50 dark:bg-red-950/40 border border-red-200 dark:border-red-900/50 p-4 text-sm text-red-800 dark:text-red-200">
<div className="flex-1">{error}</div>
<button
onClick={() => setError("")}
className="text-red-600 hover:text-red-800"
aria-label="Fermer"
>
<X className="h-4 w-4" />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
<div className="max-w-3xl mx-auto">
<AnimatePresence mode="wait">
{/* ================================================================= */}
{/* IDLE — accueil de la page */}
{/* ================================================================= */}
{pageState === "idle" && (
<motion.div
key="idle"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.4 }}
className="flex flex-col items-center text-center"
>
{/* Titre */}
<motion.div
className="mb-2 flex items-center gap-1.5 rounded-full bg-gradient-to-r from-orange-100 to-amber-100 dark:from-orange-900/40 dark:to-amber-900/40 px-3 py-1 text-xs font-medium text-orange-700 dark:text-orange-300 border border-orange-200/60 dark:border-orange-800/40"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: 0.1 }}
>
<Wand2 className="h-3 w-3" />
Nouvelle recette
</motion.div>
<h1 className="text-3xl md:text-5xl font-bold tracking-tight mt-4 max-w-xl">
Qu'est-ce qu'il y a dans{" "}
<span className="text-warm-gradient">ton frigo</span> ?
</h1>
<p className="text-muted-foreground mt-3 max-w-md text-sm md:text-base">
Dicte tes ingrédients et le chef Antoine s'occupe du reste. Une recette
originale, personnalisée, en moins d'une minute.
</p>
{/* Chips préférences actives */}
{prefChips.length > 0 && (
<motion.div
className="mt-5 flex flex-wrap gap-2 justify-center"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.3 }}
>
{prefChips.map((chip) => (
<Badge
key={chip}
variant="secondary"
className="bg-white/70 dark:bg-slate-800/70 backdrop-blur-sm border border-border/60 text-foreground/80 rounded-full px-3"
>
{chip}
</Badge>
))}
<Link to="/profile">
<Badge
variant="outline"
className="rounded-full px-3 cursor-pointer hover:bg-muted transition-colors"
>
Modifier
</Badge>
</Link>
</motion.div>
)}
{/* Gros bouton microphone */}
<div className="relative mt-12 mb-6 flex items-center justify-center">
{/* Ondes concentriques qui pulsent */}
{[0, 1, 2].map((i) => (
<motion.div
key={i}
className="absolute rounded-full border-2 border-orange-300/40 dark:border-orange-700/40"
style={{ width: 140 + i * 40, height: 140 + i * 40 }}
animate={{
scale: [1, 1.15, 1],
opacity: [0.6, 0, 0.6],
}}
transition={{
duration: 2.5,
repeat: Infinity,
delay: i * 0.6,
ease: "easeOut",
}}
/>
))}
{/* Le bouton lui-même */}
<motion.button
type="button"
onClick={handleStartRecording}
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.94 }}
className="relative z-10 h-28 w-28 md:h-32 md:w-32 rounded-full bg-gradient-to-br from-orange-500 via-orange-500 to-amber-500 text-white shadow-2xl shadow-orange-500/40 ring-4 ring-white/80 dark:ring-slate-900/80 flex items-center justify-center transition-shadow hover:shadow-orange-500/60 focus-visible:outline-none focus-visible:ring-offset-4 focus-visible:ring-orange-500"
aria-label="Commencer l'enregistrement"
>
<Mic className="h-12 w-12 md:h-14 md:w-14" strokeWidth={1.8} />
</motion.button>
</div>
<p className="text-sm text-muted-foreground">
Appuie pour parler
<kbd className="ml-2 hidden md:inline-flex items-center rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
Espace
</kbd>
</p>
{/* Astuces en rotation */}
<motion.div
className="mt-12 max-w-md w-full rounded-2xl bg-white/60 dark:bg-slate-900/60 backdrop-blur-sm border border-border/50 p-5"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4 }}
>
<div className="flex items-start gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-900/50 text-amber-700 dark:text-amber-300">
<Lightbulb className="h-4 w-4" />
</div>
<div className="flex-1 min-h-[2.5rem]">
<p className="text-xs font-medium text-amber-700 dark:text-amber-300 mb-1">
Astuce du chef
</p>
<AnimatePresence mode="wait">
<motion.p
key={tipIndex}
initial={{ opacity: 0, y: 6 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -6 }}
transition={{ duration: 0.3 }}
className="text-sm text-foreground/80 leading-relaxed"
>
{tips[tipIndex]}
</motion.p>
</AnimatePresence>
</div>
</div>
</motion.div>
</motion.div>
)}
{/* ================================================================= */}
{/* RECORDING — en train d'enregistrer */}
{/* ================================================================= */}
{pageState === "recording" && (
<motion.div
key="recording"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.3 }}
className="flex flex-col items-center text-center pt-8"
>
<div className="flex items-center gap-2 mb-2">
<motion.div
className="h-2.5 w-2.5 rounded-full bg-red-500"
animate={{ opacity: [1, 0.3, 1] }}
transition={{ duration: 1.2, repeat: Infinity }}
/>
<span className="text-sm font-medium text-red-600 dark:text-red-400 uppercase tracking-wider">
Enregistrement
</span>
</div>
<div className="text-6xl md:text-7xl font-mono font-bold tabular-nums my-6">
{formatTime(recordingTime)}
</div>
{/* Waveform animée (synthétique) */}
<div className="flex items-end justify-center gap-1.5 h-20 mb-10 mt-2">
{Array.from({ length: 20 }).map((_, i) => (
<motion.div
key={i}
className="w-1.5 rounded-full bg-gradient-to-t from-orange-400 to-amber-400"
animate={{
height: [
`${15 + Math.random() * 20}%`,
`${40 + Math.random() * 60}%`,
`${15 + Math.random() * 20}%`,
],
}}
transition={{
duration: 0.8 + Math.random() * 0.6,
repeat: Infinity,
ease: "easeInOut",
delay: i * 0.04,
}}
/>
))}
</div>
{/* Bouton stop */}
<motion.button
type="button"
onClick={handleStopRecording}
whileHover={{ scale: 1.04 }}
whileTap={{ scale: 0.94 }}
className="h-20 w-20 rounded-full bg-red-500 hover:bg-red-600 text-white shadow-xl shadow-red-500/40 flex items-center justify-center transition-colors ring-4 ring-white/80 dark:ring-slate-900/80 focus-visible:outline-none focus-visible:ring-offset-4 focus-visible:ring-red-500"
aria-label="Arrêter l'enregistrement"
>
<Square className="h-8 w-8 fill-current" />
</motion.button>
<p className="mt-4 text-sm text-muted-foreground">
Appuie pour arrêter
<kbd className="ml-2 hidden md:inline-flex items-center rounded border border-border bg-muted px-1.5 py-0.5 text-[10px] font-medium">
Espace
</kbd>
</p>
</motion.div>
)}
{/* ================================================================= */}
{/* REVIEW — écoute + validation */}
{/* ================================================================= */}
{pageState === "review" && audioFile && audioUrl && (
<motion.div
key="review"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -12 }}
transition={{ duration: 0.35 }}
className="flex flex-col items-center pt-4"
>
<motion.div
className="flex items-center gap-2 mb-3 text-emerald-600 dark:text-emerald-400"
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ type: "spring", stiffness: 260, damping: 20 }}
>
<div className="h-8 w-8 rounded-full bg-emerald-100 dark:bg-emerald-950/60 flex items-center justify-center">
<Check className="h-5 w-5" strokeWidth={3} />
</div>
<span className="text-sm font-medium">Enregistrement terminé</span>
</motion.div>
<h2 className="text-2xl md:text-3xl font-semibold tracking-tight mb-1">
Tout est bon ?
</h2>
<p className="text-sm text-muted-foreground mb-8 text-center max-w-md">
Écoute ton enregistrement avant de lancer la génération.
</p>
{/* Card lecteur audio */}
<div className="w-full max-w-md rounded-2xl bg-white/80 dark:bg-slate-900/80 backdrop-blur-sm border border-border/60 p-5 shadow-sm">
<div className="flex items-center gap-4">
<button
type="button"
onClick={() => {
const audio = document.getElementById("review-audio") as HTMLAudioElement | null
if (!audio) return
if (isPlaying) {
audio.pause()
} else {
audio.play()
}
}}
className="h-12 w-12 shrink-0 rounded-full bg-gradient-to-br from-orange-500 to-amber-500 text-white shadow-md flex items-center justify-center hover:shadow-lg hover:scale-105 transition-all"
aria-label={isPlaying ? "Mettre en pause" : "Lire"}
>
{isPlaying ? (
<Pause className="h-5 w-5" />
) : (
<Play className="h-5 w-5 ml-0.5" />
)}
</button>
{/* Mini-waveform statique */}
<div className="flex-1 flex items-center justify-between gap-0.5 h-8">
{Array.from({ length: 32 }).map((_, i) => {
const h = 20 + Math.sin(i * 0.7) * 30 + Math.cos(i * 0.3) * 15
return (
<div
key={i}
className="w-1 rounded-full bg-orange-300/60 dark:bg-orange-700/60"
style={{ height: `${Math.max(15, Math.min(100, h))}%` }}
/>
)
})}
</div>
<button
type="button"
onClick={handleResetRecording}
className="h-9 w-9 shrink-0 rounded-full text-muted-foreground hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-950/30 transition-colors flex items-center justify-center"
aria-label="Supprimer l'enregistrement"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
<audio
id="review-audio"
src={audioUrl}
onPlay={() => setIsPlaying(true)}
onPause={() => setIsPlaying(false)}
onEnded={() => setIsPlaying(false)}
className="hidden"
/>
<div className="mt-3 text-[11px] text-muted-foreground text-right">
{(audioFile.size / 1024).toFixed(0)} KB
</div>
</div>
{/* Actions */}
<div className="w-full max-w-md mt-6 flex flex-col sm:flex-row gap-3">
<Button
variant="outline"
className="flex-1 h-14 rounded-full text-base font-medium"
onClick={handleResetRecording}
>
Recommencer
</Button>
<Button
onClick={handleSubmit}
className="flex-1 h-14 rounded-full text-base font-semibold bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white shadow-lg shadow-orange-500/25 hover:shadow-xl hover:shadow-orange-500/40 transition-all group"
>
<ChefHat className="mr-2 h-5 w-5 transition-transform group-hover:rotate-[-8deg]" />
Créer ma recette
</Button>
</div>
</motion.div>
)}
{/* ================================================================= */}
{/* PROCESSING — génération en cours (streaming) */}
{/* ================================================================= */}
{pageState === "processing" && (
<motion.div
key="processing"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="flex flex-col items-center text-center pt-4"
>
{/* Image en cours de génération ou finale */}
<div className="relative w-60 h-60 md:w-72 md:h-72 mb-8 flex items-center justify-center">
{/* Halo derrière l'image */}
<div className="absolute inset-0 -z-10 bg-gradient-to-br from-orange-300/50 via-amber-300/40 to-yellow-200/30 rounded-full blur-3xl animate-pulse" />
<AnimatePresence mode="wait">
{liveImageUrl ? (
<motion.img
key="image"
initial={{ opacity: 0, scale: 0.85, filter: "blur(20px)" }}
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
src={liveImageUrl}
alt={liveTitle || "Recette"}
className="w-60 h-60 md:w-72 md:h-72 object-cover rounded-3xl shadow-2xl shadow-orange-500/30 ring-1 ring-white/60"
/>
) : (
<motion.div
key="loader"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
>
<CookingLoader size="lg" />
</motion.div>
)}
</AnimatePresence>
</div>
{/* Titre live */}
<AnimatePresence mode="wait">
{liveTitle ? (
<motion.h2
key="title"
initial={{ opacity: 0, y: 12 }}
animate={{ opacity: 1, y: 0 }}
className="text-2xl md:text-3xl font-semibold text-center max-w-md tracking-tight"
>
{liveTitle}
</motion.h2>
) : (
<motion.h2
key="default"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="text-xl md:text-2xl font-medium text-center text-muted-foreground"
>
{stepLabels[progressStep] || "Préparation en cours…"}
</motion.h2>
)}
</AnimatePresence>
{/* Description live */}
<AnimatePresence>
{liveDescription && (
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="text-sm md:text-base text-muted-foreground text-center mt-4 max-w-md italic"
>
« {liveDescription} »
</motion.p>
)}
</AnimatePresence>
{/* Transcription live (avant le titre) */}
<AnimatePresence>
{liveTranscription && !liveTitle && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="text-xs text-muted-foreground text-center mt-6 max-w-md px-4 py-2 rounded-full bg-white/60 dark:bg-slate-800/60 backdrop-blur-sm border border-border/40"
>
<span className="font-medium text-foreground/70">J'ai compris : </span>
{liveTranscription}
</motion.div>
)}
</AnimatePresence>
{/* Label étape quand titre déjà présent */}
{liveTitle && progressStep && (
<p className="text-xs text-muted-foreground mt-6 animate-pulse">
{stepLabels[progressStep] || "En cours…"}
</p>
)}
</motion.div>
)}
</AnimatePresence>
</div>
</div>
)
}