feat(ui): redesign /recipes/new from scratch
The page had 2 stacked headers (its own + MainLayout), was capped at max-w-md which wasted desktop space, and had cheap-looking dashed amber borders. Full rewrite with 4 clean states. State machine: idle -> recording -> review -> processing IDLE state: - Brand pill 'Nouvelle recette' with Wand2 icon - Big H1 with 'ton frigo' in warm-gradient - Subtitle explaining the chef Antoine - User preferences rendered as chips (vegan, allergies, max time, cuisine) loaded from /users/profile. Each chip is a Badge with backdrop-blur, + a 'Modifier' chip linking to /profile. Chips only appear if the user actually set preferences. - Giant centered mic button (112-128px) with: - 3 concentric pulsing rings (border-2, staggered 0.6s delays) - Gradient from-orange-500 to-amber-500 - shadow-2xl + orange glow - ring-4 ring-white for separation from background - Scale 1.04/0.94 on hover/tap - Keyboard shortcut hint (Space) shown on md+ - Rotating tip card below with Lightbulb icon (4 tips cycling every 4.5s) RECORDING state: - Pulsing red dot + 'ENREGISTREMENT' label - 7xl monospaced tabular timer - Synthetic 20-bar waveform animating (each bar randomized height/duration for organic feel) - Large red stop button (80px) with Square icon - Keyboard shortcut hint REVIEW state: - Spring-animated green checkmark badge - H2 'Tout est bon ?' + subtitle - Audio player card with: - Circular play/pause button (brand gradient) - 32-bar static waveform (deterministic sin/cos pattern) - Trash button - Hidden <audio> controlled via id - File size in KB - Two buttons: 'Recommencer' (outline) + 'Créer ma recette' (brand gradient, ChefHat icon that rotates -8deg on hover) PROCESSING state: (kept from previous version, slightly enlarged to 60-72 and using the new gradient halo) Bonus: - Space bar shortcut: starts/stops recording when not focused on input - Remove the duplicate sticky header - 'Mes recettes' back button + 'Préférences' link in top bar - max-w-3xl centered layout, works from mobile to desktop - All imports cleaned up (no more KitchenIllustration, Sheet, Card, useMobile, cn, Info, showTips, isRecorderLoading) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e7872df156
commit
f4b3339fe4
@ -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<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 isMobile = useMobile()
|
||||
|
||||
const {
|
||||
isLoading: isRecorderLoading,
|
||||
isRecording,
|
||||
currentRecording,
|
||||
startRecording,
|
||||
@ -29,11 +56,12 @@ export default function RecipeForm() {
|
||||
} = useAudioRecorder()
|
||||
|
||||
const [audioFile, setAudioFile] = useState<File | null>(null)
|
||||
const [recordingStatus, setRecordingStatus] = useState<"idle" | "recording" | "processing">("idle")
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [pageState, setPageState] = useState<PageState>("idle")
|
||||
const [error, setError] = useState("")
|
||||
const [recordingTime, setRecordingTime] = useState(0)
|
||||
const [showTips, setShowTips] = useState(false)
|
||||
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>("")
|
||||
@ -42,74 +70,92 @@ export default function RecipeForm() {
|
||||
const [liveDescription, setLiveDescription] = useState("")
|
||||
const [liveImageUrl, setLiveImageUrl] = useState<string | null>(null)
|
||||
|
||||
// Update audioFile when recording is available
|
||||
// --- Effects ---
|
||||
|
||||
// Charge les préférences utilisateur pour les afficher en chips
|
||||
useEffect(() => {
|
||||
if (currentRecording) {
|
||||
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)
|
||||
setRecordingStatus("idle")
|
||||
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) {
|
||||
if (!isRecording) return
|
||||
await stopRecording()
|
||||
setRecordingStatus("processing")
|
||||
// Processing will be updated to "idle" when the audioFile is set
|
||||
}
|
||||
// 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,121 +187,445 @@ 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<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…",
|
||||
}
|
||||
|
||||
// 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<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="min-h-screen flex flex-col bg-gradient-to-b from-amber-50 to-orange-50 dark:from-slate-950 dark:to-slate-900">
|
||||
{/* Header */}
|
||||
<header className="sticky top-0 z-10 backdrop-blur-sm bg-amber-50/80 dark:bg-slate-950/80 border-b border-amber-100 dark:border-slate-800 p-4 flex justify-between items-center">
|
||||
<Button variant="ghost" size="icon" className="rounded-full" onClick={() => navigate("/recipes")}>
|
||||
<ArrowLeft className="h-5 w-5" />
|
||||
<span className="sr-only">Retour</span>
|
||||
</Button>
|
||||
<h1 className="text-lg font-medium">Nouvelle Recette</h1>
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="rounded-full">
|
||||
<Info className="h-5 w-5" />
|
||||
<span className="sr-only">Aide</span>
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="bottom" className="rounded-t-xl">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Comment ça marche ?</SheetTitle>
|
||||
<SheetDescription>
|
||||
<ol className="mt-4 space-y-3 list-decimal list-inside text-sm">
|
||||
<li>Appuyez sur le bouton d'enregistrement</li>
|
||||
<li>Listez clairement les ingrédients que vous avez à disposition</li>
|
||||
<li>Terminez l'enregistrement</li>
|
||||
<li>Notre IA créera une recette adaptée à vos ingrédients</li>
|
||||
</ol>
|
||||
</SheetDescription>
|
||||
</SheetHeader>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</header>
|
||||
|
||||
<main className="flex-1 flex flex-col">
|
||||
{/* Main content */}
|
||||
<div className="flex-1 flex flex-col p-4 max-w-md mx-auto w-full">
|
||||
{/* Illustration */}
|
||||
<AnimatePresence>
|
||||
{!isRecording && !loading && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="flex justify-center mb-6"
|
||||
<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")}
|
||||
>
|
||||
<KitchenIllustration height={120} />
|
||||
</motion.div>
|
||||
<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>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Error message */}
|
||||
{/* Erreur */}
|
||||
<AnimatePresence>
|
||||
{error && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="mb-4"
|
||||
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="rounded-lg bg-red-50 p-3 text-sm text-red-700 dark:bg-red-900/30 dark:text-red-200 flex items-start">
|
||||
<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 variant="ghost" size="sm" className="h-6 w-6 p-0 rounded-full" onClick={() => setError("")}>
|
||||
<button
|
||||
onClick={() => setError("")}
|
||||
className="text-red-600 hover:text-red-800"
|
||||
aria-label="Fermer"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</button>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Recording UI */}
|
||||
<div className="max-w-3xl mx-auto">
|
||||
<AnimatePresence mode="wait">
|
||||
{recordingStatus === "processing" || loading ? (
|
||||
{/* ================================================================= */}
|
||||
{/* 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"
|
||||
size="lg"
|
||||
className="flex-1 rounded-full"
|
||||
onClick={handleResetRecording}
|
||||
>
|
||||
Recommencer
|
||||
</Button>
|
||||
<Button
|
||||
size="lg"
|
||||
onClick={handleSubmit}
|
||||
className="flex-1 rounded-full 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-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-1 flex flex-col items-center justify-center px-4"
|
||||
className="flex flex-col items-center text-center pt-4"
|
||||
>
|
||||
{/* Image en cours de génération ou finale */}
|
||||
<div className="relative w-56 h-56 mb-8 flex items-center justify-center">
|
||||
<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-200/50 via-amber-200/30 to-transparent rounded-full blur-3xl animate-pulse" />
|
||||
<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 ? (
|
||||
@ -266,7 +636,7 @@ export default function RecipeForm() {
|
||||
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
|
||||
src={liveImageUrl}
|
||||
alt={liveTitle || "Recette"}
|
||||
className="w-56 h-56 object-cover rounded-3xl shadow-2xl shadow-orange-500/20 ring-1 ring-white/40"
|
||||
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
|
||||
@ -281,14 +651,14 @@ export default function RecipeForm() {
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Titre live (apparaît dès que le stream le révèle) */}
|
||||
{/* Titre live */}
|
||||
<AnimatePresence mode="wait">
|
||||
{liveTitle ? (
|
||||
<motion.h2
|
||||
key="title"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-2xl font-semibold text-center max-w-xs"
|
||||
className="text-2xl md:text-3xl font-semibold text-center max-w-md tracking-tight"
|
||||
>
|
||||
{liveTitle}
|
||||
</motion.h2>
|
||||
@ -298,7 +668,7 @@ export default function RecipeForm() {
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="text-xl font-medium text-center"
|
||||
className="text-xl md:text-2xl font-medium text-center text-muted-foreground"
|
||||
>
|
||||
{stepLabels[progressStep] || "Préparation en cours…"}
|
||||
</motion.h2>
|
||||
@ -311,210 +681,37 @@ export default function RecipeForm() {
|
||||
<motion.p
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
className="text-sm text-muted-foreground text-center mt-3 max-w-sm italic"
|
||||
className="text-sm md:text-base text-muted-foreground text-center mt-4 max-w-md italic"
|
||||
>
|
||||
« {liveDescription} »
|
||||
</motion.p>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Transcription live (quand pas encore de titre) */}
|
||||
{/* Transcription live (avant le titre) */}
|
||||
<AnimatePresence>
|
||||
{liveTranscription && !liveTitle && (
|
||||
<motion.p
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
className="text-xs text-muted-foreground text-center mt-4 max-w-xs"
|
||||
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">Ingrédients détectés : </span>
|
||||
<span className="font-medium text-foreground/70">J'ai compris : </span>
|
||||
{liveTranscription}
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Libellé d'étape quand un titre est déjà affiché */}
|
||||
{/* Label étape quand titre déjà présent */}
|
||||
{liveTitle && progressStep && (
|
||||
<p className="text-xs text-muted-foreground mt-4 animate-pulse">
|
||||
<p className="text-xs text-muted-foreground mt-6 animate-pulse">
|
||||
{stepLabels[progressStep] || "En cours…"}
|
||||
</p>
|
||||
)}
|
||||
</motion.div>
|
||||
) : isRecording ? (
|
||||
<motion.div
|
||||
key="recording"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex-1 flex flex-col"
|
||||
>
|
||||
<Card className="flex-1 flex flex-col items-center justify-center p-6 border-2 border-red-400 dark:border-red-700 bg-gradient-to-b from-red-50 to-red-100 dark:from-red-950/30 dark:to-red-900/20">
|
||||
<motion.div
|
||||
animate={{ scale: [1, 1.1, 1] }}
|
||||
transition={{ repeat: Number.POSITIVE_INFINITY, duration: 1.5 }}
|
||||
className="relative"
|
||||
>
|
||||
<div className="absolute inset-0 bg-red-400/20 dark:bg-red-700/30 rounded-full animate-ping" />
|
||||
<div className="relative bg-red-500 dark:bg-red-600 text-white p-6 rounded-full">
|
||||
<Mic className="h-8 w-8" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<div className="mt-8 text-center">
|
||||
<div className="text-3xl font-mono font-bold text-red-600 dark:text-red-400">
|
||||
{formatTime(recordingTime)}
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-red-700 dark:text-red-300 animate-pulse">
|
||||
Enregistrement en cours...
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-8 w-full max-w-xs">
|
||||
<Button variant="destructive" className="w-full" size="lg" onClick={handleStopRecording}>
|
||||
Arrêter l'enregistrement
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mt-4">
|
||||
<Button variant="outline" className="w-full text-sm" onClick={() => setShowTips(!showTips)}>
|
||||
{showTips ? "Masquer les conseils" : "Afficher les conseils"}
|
||||
</Button>
|
||||
|
||||
<AnimatePresence>
|
||||
{showTips && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="mt-3 p-3 bg-amber-100/50 dark:bg-amber-900/20 rounded-lg text-sm">
|
||||
<h3 className="font-medium mb-2">Conseils pour un bon enregistrement :</h3>
|
||||
<ul className="space-y-2 list-disc list-inside text-muted-foreground">
|
||||
<li>Parlez clairement et lentement</li>
|
||||
<li>Listez un ingrédient à la fois</li>
|
||||
<li>Précisez les quantités si possible</li>
|
||||
<li>Mentionnez vos préférences culinaires</li>
|
||||
</ul>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : audioFile ? (
|
||||
<motion.div
|
||||
key="review"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex-1 flex flex-col"
|
||||
>
|
||||
<Card className="flex-1 p-5 border-2 border-green-200 dark:border-green-800 bg-gradient-to-b from-green-50 to-green-100/50 dark:from-green-950/30 dark:to-green-900/20">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="bg-green-100 dark:bg-green-800 p-2 rounded-full">
|
||||
<Check className="h-5 w-5 text-green-600 dark:text-green-300" />
|
||||
</div>
|
||||
<h3 className="font-medium text-green-700 dark:text-green-300">Enregistrement prêt !</h3>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:text-red-700 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-full"
|
||||
onClick={handleResetRecording}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">Supprimer</span>
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="bg-white/80 dark:bg-slate-800/50 rounded-lg p-3 mb-4">
|
||||
<audio controls className="w-full h-10">
|
||||
<source src={URL.createObjectURL(audioFile)} type={audioFile.type} />
|
||||
Votre navigateur ne supporte pas la lecture audio.
|
||||
</audio>
|
||||
<p className="text-xs text-muted-foreground mt-2">
|
||||
{audioFile.name} ({(audioFile.size / 1024).toFixed(1)} KB)
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto">
|
||||
<Button className="w-full" size="lg" onClick={handleSubmit}>
|
||||
<ChefHat className="h-5 w-5 mr-2" />
|
||||
Créer ma recette
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mt-4 text-center">
|
||||
<Button variant="outline" onClick={handleResetRecording}>
|
||||
Nouvel enregistrement
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
) : (
|
||||
<motion.div
|
||||
key="start"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
className="flex-1 flex flex-col"
|
||||
>
|
||||
<div className="text-center mb-6">
|
||||
<h2 className="text-xl font-medium">Qu'avez-vous dans votre frigo ?</h2>
|
||||
<p className="text-sm text-muted-foreground mt-2">
|
||||
Enregistrez les ingrédients que vous avez à disposition
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card
|
||||
className={cn(
|
||||
"flex-1 flex flex-col items-center justify-center p-6",
|
||||
"bg-gradient-to-b from-amber-100/50 to-orange-100/30",
|
||||
"dark:from-amber-900/20 dark:to-orange-900/10",
|
||||
"border-2 border-dashed border-amber-200 dark:border-amber-800",
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
whileHover={{ scale: 1.05 }}
|
||||
whileTap={{ scale: 0.95 }}
|
||||
className="cursor-pointer"
|
||||
onClick={handleStartRecording}
|
||||
>
|
||||
<div className="bg-gradient-to-br from-amber-400 to-orange-500 dark:from-amber-500 dark:to-orange-600 p-6 rounded-full text-white shadow-lg">
|
||||
<Mic className="h-10 w-10" />
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
<h3 className="mt-6 text-lg font-medium">Appuyez pour commencer</h3>
|
||||
<p className="text-sm text-muted-foreground text-center mt-2 max-w-xs">
|
||||
Listez les ingrédients que vous avez à disposition et notre IA créera une recette adaptée
|
||||
</p>
|
||||
|
||||
<Button className="mt-8" size="lg" onClick={handleStartRecording}>
|
||||
Commencer l'enregistrement
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<div className="mt-6 bg-amber-100/50 dark:bg-amber-900/20 rounded-lg p-4">
|
||||
<h3 className="font-medium mb-2 flex items-center gap-2">
|
||||
<Info className="h-4 w-4" />
|
||||
Comment ça marche ?
|
||||
</h3>
|
||||
<ol className="space-y-2 list-decimal list-inside text-sm text-muted-foreground">
|
||||
<li>Enregistrez les ingrédients que vous avez</li>
|
||||
<li>Notre IA analyse votre enregistrement</li>
|
||||
<li>Recevez une recette personnalisée</li>
|
||||
<li>Cuisinez et régalez-vous !</li>
|
||||
</ol>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user