diff --git a/frontend/src/pages/Recipes/RecipeForm.tsx b/frontend/src/pages/Recipes/RecipeForm.tsx index 7e0249b..c3c66c6 100644 --- a/frontend/src/pages/Recipes/RecipeForm.tsx +++ b/frontend/src/pages/Recipes/RecipeForm.tsx @@ -1,27 +1,54 @@ "use client" import type React from "react" - -import { useState, useEffect } from "react" -import { useNavigate } from "react-router-dom" +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 { Card } from "@/components/ui/card" -import { Mic, ArrowLeft, Trash2, ChefHat, Info, X, Check } from "lucide-react" +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 { KitchenIllustration } from "@/components/illustrations/KitchenIllustration" import { CookingLoader } from "@/components/illustrations/CookingLoader" import { useAudioRecorder } from "@/hooks/useAudioRecorder" -import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet" -import { cn } from "@/lib/utils" -import { useMobile } from "@/hooks/useMobile" + +type PageState = "idle" | "recording" | "review" | "processing" + +// Messages d'état pendant la génération +const stepLabels: Record = { + 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 isMobile = useMobile() const { - isLoading: isRecorderLoading, isRecording, currentRecording, startRecording, @@ -29,11 +56,12 @@ export default function RecipeForm() { } = useAudioRecorder() const [audioFile, setAudioFile] = useState(null) - const [recordingStatus, setRecordingStatus] = useState<"idle" | "recording" | "processing">("idle") - const [loading, setLoading] = useState(false) + const [pageState, setPageState] = useState("idle") const [error, setError] = useState("") const [recordingTime, setRecordingTime] = useState(0) - const [showTips, setShowTips] = useState(false) + const [user, setUser] = useState(null) + const [tipIndex, setTipIndex] = useState(0) + const [isPlaying, setIsPlaying] = useState(false) // Live streaming state const [progressStep, setProgressStep] = useState("") @@ -42,74 +70,92 @@ export default function RecipeForm() { const [liveDescription, setLiveDescription] = useState("") const [liveImageUrl, setLiveImageUrl] = useState(null) - // Update audioFile when recording is available + // --- Effects --- + + // Charge les préférences utilisateur pour les afficher en chips useEffect(() => { - if (currentRecording) { - fetch(currentRecording) - .then((res) => res.blob()) - .then((blob) => { - const file = new File([blob], "recording.mp3", { type: "audio/mp3" }) - setAudioFile(file) - setRecordingStatus("idle") - setError("") - }) - } + 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]) - // Recording timer + // Timer useEffect(() => { - let interval: NodeJS.Timeout | null = null - if (isRecording) { - interval = setInterval(() => { - setRecordingTime((prev) => prev + 1) - }, 1000) - } else { + if (!isRecording) { setRecordingTime(0) + return } - return () => { - if (interval) clearInterval(interval) - } + const id = setInterval(() => setRecordingTime((t) => t + 1), 1000) + return () => clearInterval(id) }, [isRecording]) - // Handle recording start + // 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() - setRecordingStatus("recording") - } catch (err) { - console.error("Erreur lors de l'accès au microphone:", err) + setPageState("recording") + setError("") + } catch { setError("Impossible d'accéder au microphone. Vérifiez les permissions.") } } - // Handle recording stop const handleStopRecording = async () => { - if (isRecording) { - await stopRecording() - setRecordingStatus("processing") - // Processing will be updated to "idle" when the audioFile is set - } + if (!isRecording) return + await stopRecording() + // Le useEffect sur currentRecording bascule vers 'review' } - // Fonction pour réinitialiser l'enregistrement const handleResetRecording = () => { setAudioFile(null) - setRecordingStatus("idle") + setPageState("idle") setError("") + setIsPlaying(false) } - // Soumettre le formulaire — utilise le streaming SSE - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault() + const handleSubmit = async () => { + if (!audioFile) return - if (!audioFile) { - setError("Veuillez fournir un enregistrement audio des ingrédients") - return - } - - setLoading(true) + setPageState("processing") setError("") - setRecordingStatus("processing") setProgressStep("") setLiveTranscription("") setLiveTitle("") @@ -141,380 +187,531 @@ export default function RecipeForm() { break case "error": throw new Error(event.message) - case "done": - break } } if (savedId) { - // Petit délai pour que l'utilisateur voie l'image finale avant de partir setTimeout(() => navigate(`/recipes/${savedId}`), 800) } } catch (err) { - console.error("Erreur lors de la création de la recette:", err) - setError(err instanceof Error ? err.message : "Une erreur est survenue lors de la création de la recette") - setRecordingStatus("idle") - setLoading(false) + setError(err instanceof Error ? err.message : "Une erreur est survenue") + setPageState("review") } } - // Libellés des étapes - const stepLabels: Record = { - 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…", - } - - // Format recording time 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 = { + 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 ( -
- {/* Header */} -
- -

Nouvelle Recette

- - - - - - - Comment ça marche ? - -
    -
  1. Appuyez sur le bouton d'enregistrement
  2. -
  3. Listez clairement les ingrédients que vous avez à disposition
  4. -
  5. Terminez l'enregistrement
  6. -
  7. Notre IA créera une recette adaptée à vos ingrédients
  8. -
-
-
-
-
-
+ + )} +
-
- {/* Main content */} -
- {/* Illustration */} - - {!isRecording && !loading && ( - + {error && ( + +
+
{error}
+ +
+
+ )} +
+ +
+ + {/* ================================================================= */} + {/* IDLE — accueil de la page */} + {/* ================================================================= */} + {pageState === "idle" && ( + + {/* Titre */} + + + Nouvelle recette - )} - - {/* Error message */} - - {error && ( - -
-
{error}
- -
-
- )} -
+

+ Qu'est-ce qu'il y a dans{" "} + ton frigo ? +

+

+ Dicte tes ingrédients et le chef Antoine s'occupe du reste. Une recette + originale, personnalisée, en moins d'une minute. +

- {/* Recording UI */} - - {recordingStatus === "processing" || loading ? ( - - {/* Image en cours de génération ou finale */} -
- {/* Halo derrière l'image */} -
- - - {liveImageUrl ? ( - - ) : ( - - - - )} - -
- - {/* Titre live (apparaît dès que le stream le révèle) */} - - {liveTitle ? ( - 0 && ( + + {prefChips.map((chip) => ( + - {liveTitle} - + {chip} + + ))} + + + Modifier + + + + )} + + {/* Gros bouton microphone */} +
+ {/* Ondes concentriques qui pulsent */} + {[0, 1, 2].map((i) => ( + + ))} + + {/* Le bouton lui-même */} + + + +
+ +

+ Appuie pour parler + + Espace + +

+ + {/* Astuces en rotation */} + +
+
+ +
+
+

+ Astuce du chef +

+ + + {tips[tipIndex]} + + +
+
+
+ + )} + + {/* ================================================================= */} + {/* RECORDING — en train d'enregistrer */} + {/* ================================================================= */} + {pageState === "recording" && ( + +
+ + + Enregistrement + +
+ +
+ {formatTime(recordingTime)} +
+ + {/* Waveform animée (synthétique) */} +
+ {Array.from({ length: 20 }).map((_, i) => ( + + ))} +
+ + {/* Bouton stop */} + + + + +

+ Appuie pour arrêter + + Espace + +

+
+ )} + + {/* ================================================================= */} + {/* REVIEW — écoute + validation */} + {/* ================================================================= */} + {pageState === "review" && audioFile && audioUrl && ( + + +
+ +
+ Enregistrement terminé +
+ +

+ Tout est bon ? +

+

+ Écoute ton enregistrement avant de lancer la génération. +

+ + {/* Card lecteur audio */} +
+
+ + + {/* Mini-waveform statique */} +
+ {Array.from({ length: 32 }).map((_, i) => { + const h = 20 + Math.sin(i * 0.7) * 30 + Math.cos(i * 0.3) * 15 + return ( +
+ ) + })} +
+ + +
+ +
+ + {/* Actions */} +
+ + +
+ + )} + + {/* ================================================================= */} + {/* PROCESSING — génération en cours (streaming) */} + {/* ================================================================= */} + {pageState === "processing" && ( + + {/* Image en cours de génération ou finale */} +
+ {/* Halo derrière l'image */} +
+ + + {liveImageUrl ? ( + ) : ( - - {stepLabels[progressStep] || "Préparation en cours…"} - + + )} +
- {/* Description live */} - - {liveDescription && ( - - « {liveDescription} » - - )} - - - {/* Transcription live (quand pas encore de titre) */} - - {liveTranscription && !liveTitle && ( - - Ingrédients détectés : - {liveTranscription} - - )} - - - {/* Libellé d'étape quand un titre est déjà affiché */} - {liveTitle && progressStep && ( -

- {stepLabels[progressStep] || "En cours…"} -

+ {/* Titre live */} + + {liveTitle ? ( + + {liveTitle} + + ) : ( + + {stepLabels[progressStep] || "Préparation en cours…"} + )} - - ) : isRecording ? ( - - - + + {/* Description live */} + + {liveDescription && ( + -
-
- -
- + « {liveDescription} » + + )} + -
-
- {formatTime(recordingTime)} -
-

- Enregistrement en cours... -

-
- -
- -
- - -
- - - - {showTips && ( - -
-

Conseils pour un bon enregistrement :

-
    -
  • Parlez clairement et lentement
  • -
  • Listez un ingrédient à la fois
  • -
  • Précisez les quantités si possible
  • -
  • Mentionnez vos préférences culinaires
  • -
-
-
- )} -
-
- - ) : audioFile ? ( - - -
-
-
- -
-

Enregistrement prêt !

-
- -
- -
- -

- {audioFile.name} ({(audioFile.size / 1024).toFixed(1)} KB) -

-
- -
- -
-
- -
- -
-
- ) : ( - -
-

Qu'avez-vous dans votre frigo ?

-

- Enregistrez les ingrédients que vous avez à disposition -

-
- - + {/* Transcription live (avant le titre) */} + + {liveTranscription && !liveTitle && ( -
- -
+ J'ai compris : + {liveTranscription}
+ )} +
-

Appuyez pour commencer

-

- Listez les ingrédients que vous avez à disposition et notre IA créera une recette adaptée -

- - -
- -
-

- - Comment ça marche ? -

-
    -
  1. Enregistrez les ingrédients que vous avez
  2. -
  3. Notre IA analyse votre enregistrement
  4. -
  5. Recevez une recette personnalisée
  6. -
  7. Cuisinez et régalez-vous !
  8. -
-
-
- )} - -
-
+ {/* Label étape quand titre déjà présent */} + {liveTitle && progressStep && ( +

+ {stepLabels[progressStep] || "En cours…"} +

+ )} + + )} + + ) } -