update UI

This commit is contained in:
Arthur Barre 2025-03-10 21:52:09 +01:00
parent 958a778f85
commit 9c773e8e64
11 changed files with 665 additions and 291 deletions

29
backend/pnpm-lock.yaml generated
View File

@ -9,8 +9,8 @@ importers:
.:
dependencies:
'@fastify/cors':
specifier: ^11.0.0
version: 11.0.0
specifier: ^8.5.0
version: 8.5.0
'@fastify/jwt':
specifier: ^7.0.0
version: 7.2.4
@ -54,8 +54,8 @@ packages:
'@fastify/busboy@3.1.1':
resolution: {integrity: sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==}
'@fastify/cors@11.0.0':
resolution: {integrity: sha512-41Bx0LVGr2a6DnnhDN/SgfDlTRNZtEs8niPxyoymV6Hw09AIdz/9Rn/0Fpu+pBOs6kviwS44JY2mB8NcU2qSAA==}
'@fastify/cors@8.5.0':
resolution: {integrity: sha512-/oZ1QSb02XjP0IK1U0IXktEsw/dUBTxJOW7IpIeO8c/tNalw/KjoNSJv1Sf6eqoBPO+TDGkifq6ynFK3v68HFQ==}
'@fastify/deepmerge@2.0.2':
resolution: {integrity: sha512-3wuLdX5iiiYeZWP6bQrjqhrcvBIf0NHbQH1Ur1WbHvoiuTYUEItgygea3zs8aHpiitn0lOB8gX20u1qO+FDm7Q==}
@ -330,9 +330,6 @@ packages:
fastify-plugin@4.5.1:
resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==}
fastify-plugin@5.0.1:
resolution: {integrity: sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==}
fastify@4.29.0:
resolution: {integrity: sha512-MaaUHUGcCgC8fXQDsDtioaCcag1fmPJ9j64vAKunqZF4aSub040ZGi/ag8NGE2714yREPOKZuHCfpPzuUD3UQQ==}
@ -516,12 +513,12 @@ packages:
engines: {node: '>=10'}
hasBin: true
mnemonist@0.39.6:
resolution: {integrity: sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==}
mnemonist@0.39.8:
resolution: {integrity: sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==}
mnemonist@0.40.0:
resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@ -818,10 +815,10 @@ snapshots:
'@fastify/busboy@3.1.1': {}
'@fastify/cors@11.0.0':
'@fastify/cors@8.5.0':
dependencies:
fastify-plugin: 5.0.1
mnemonist: 0.40.0
fastify-plugin: 4.5.1
mnemonist: 0.39.6
'@fastify/deepmerge@2.0.2': {}
@ -1113,8 +1110,6 @@ snapshots:
fastify-plugin@4.5.1: {}
fastify-plugin@5.0.1: {}
fastify@4.29.0:
dependencies:
'@fastify/ajv-compiler': 3.6.0
@ -1323,11 +1318,11 @@ snapshots:
mkdirp@1.0.4: {}
mnemonist@0.39.8:
mnemonist@0.39.6:
dependencies:
obliterator: 2.0.5
mnemonist@0.40.0:
mnemonist@0.39.8:
dependencies:
obliterator: 2.0.5

Binary file not shown.

View File

@ -22,6 +22,7 @@
"axios": "^1.8.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"framer-motion": "^12.4.11",
"ky": "^1.7.5",
"lucide-react": "^0.478.0",
"react": "^19.0.0",

View File

@ -44,6 +44,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
framer-motion:
specifier: ^12.4.11
version: 12.4.11(react-dom@19.0.0(react@19.0.0))(react@19.0.0)
ky:
specifier: ^1.7.5
version: 1.7.5
@ -1307,6 +1310,20 @@ packages:
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
engines: {node: '>= 6'}
framer-motion@12.4.11:
resolution: {integrity: sha512-MHeZlgzo9DnQ6+TFgRqJiOk4vWwsDcXFtxeXlVawVs1nwgcZW3966foGIgkIiIrBSPHB9RlbqspAxiYWosFT9g==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fsevents@2.3.3:
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
@ -1551,6 +1568,12 @@ packages:
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
engines: {node: '>=16 || 14 >=14.17'}
motion-dom@12.4.11:
resolution: {integrity: sha512-wstlyV3pktgFjqsjbXMo1NX9hQD9XTVqxQNvfc+FREAgxr3GVzgWIEKvbyyNlki3J1jmmh+et9X3aCKeqFPcxA==}
motion-utils@12.4.10:
resolution: {integrity: sha512-NPwZd94V013SwRf++jMrk2+HEBgPkeIE2RiOzhAuuQlqxMJPkKt/LXVh6Upl+iN8oarSGD2dlY5/bqgsYXDABA==}
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@ -3017,6 +3040,15 @@ snapshots:
es-set-tostringtag: 2.1.0
mime-types: 2.1.35
framer-motion@12.4.11(react-dom@19.0.0(react@19.0.0))(react@19.0.0):
dependencies:
motion-dom: 12.4.11
motion-utils: 12.4.10
tslib: 2.8.1
optionalDependencies:
react: 19.0.0
react-dom: 19.0.0(react@19.0.0)
fsevents@2.3.3:
optional: true
@ -3206,6 +3238,12 @@ snapshots:
dependencies:
brace-expansion: 2.0.1
motion-dom@12.4.11:
dependencies:
motion-utils: 12.4.10
motion-utils@12.4.10: {}
ms@2.1.3: {}
nanoid@3.3.9: {}

View File

@ -0,0 +1,69 @@
import { motion } from "framer-motion";
export function CookingLoader() {
return (
<div className="relative w-24 h-24">
{/* Pot */}
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-20 h-16 bg-gray-600 rounded-b-xl"></div>
<div className="absolute top-0 w-24 h-4 bg-gray-500 rounded-t-xl"></div>
</div>
{/* Bubbling animation */}
<motion.div
className="absolute top-6 left-8 w-3 h-3 bg-blue-300 rounded-full"
animate={{
y: [0, -15, -5],
opacity: [0.7, 1, 0]
}}
transition={{
duration: 1.5,
repeat: Infinity,
repeatType: "loop"
}}
/>
<motion.div
className="absolute top-6 left-14 w-2 h-2 bg-blue-300 rounded-full"
animate={{
y: [0, -10, -2],
opacity: [0.7, 1, 0]
}}
transition={{
duration: 1.2,
repeat: Infinity,
repeatType: "loop",
delay: 0.3
}}
/>
<motion.div
className="absolute top-6 left-11 w-4 h-4 bg-blue-300 rounded-full"
animate={{
y: [0, -20, -5],
opacity: [0.7, 1, 0]
}}
transition={{
duration: 1.8,
repeat: Infinity,
repeatType: "loop",
delay: 0.5
}}
/>
{/* Spoon stirring animation */}
<motion.div
className="absolute top-2 left-10 w-2 h-14 bg-gray-300 origin-bottom"
animate={{ rotate: [-20, 20, -20] }}
transition={{
duration: 1.5,
repeat: Infinity,
repeatType: "reverse",
ease: "easeInOut"
}}
>
<div className="absolute -top-3 -left-2 w-6 h-3 bg-gray-300 rounded-full"></div>
</motion.div>
</div>
);
}

View File

@ -0,0 +1,51 @@
"use client"
import { motion } from "framer-motion"
import { Button } from "@/components/ui/button"
import { ChefHat, Plus } from "lucide-react"
interface EmptyRecipesProps {
onCreateRecipe: () => void
}
export function EmptyRecipes({ onCreateRecipe }: EmptyRecipesProps) {
return (
<motion.div
className="flex flex-col items-center justify-center py-12 text-center px-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<div className="mb-6 relative">
<ChefHat className="h-20 w-20 text-muted-foreground opacity-20" />
<motion.div
className="absolute -top-2 -right-2 w-6 h-6 rounded-full bg-orange-100 dark:bg-orange-900"
animate={{
scale: [1, 1.2, 1],
opacity: [0.7, 1, 0.7],
}}
transition={{
duration: 2,
repeat: Number.POSITIVE_INFINITY,
repeatType: "reverse",
}}
/>
</div>
<h3 className="text-xl font-semibold mb-2">Aucune recette trouvée</h3>
<p className="text-muted-foreground max-w-md mb-6">
Votre collection de recettes est vide. Créez votre première recette en enregistrant les ingrédients que vous
avez dans votre frigo.
</p>
<Button
onClick={onCreateRecipe}
className="bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white"
>
<Plus className="mr-2 h-4 w-4" />
Créer ma première recette
</Button>
</motion.div>
)
}

View File

@ -0,0 +1,62 @@
import { motion } from "framer-motion";
export function KitchenIllustration() {
return (
<svg width="280" height="200" viewBox="0 0 280 200" fill="none" xmlns="http://www.w3.org/2000/svg">
{/* Pot */}
<rect x="100" y="120" width="80" height="60" rx="5" fill="#6B7280" />
<rect x="90" y="110" width="100" height="15" rx="5" fill="#9CA3AF" />
{/* Pot handles */}
<rect x="85" y="130" width="10" height="25" rx="5" fill="#9CA3AF" />
<rect x="185" y="130" width="10" height="25" rx="5" fill="#9CA3AF" />
{/* Steam animation */}
<motion.path
d="M120 100C120 100 115 90 125 85C135 80 130 70 120 70"
stroke="#E5E7EB"
strokeWidth="3"
strokeLinecap="round"
initial={{ opacity: 0.3 }}
animate={{ opacity: 1 }}
transition={{ duration: 1.5, repeat: Infinity, repeatType: "reverse" }}
/>
<motion.path
d="M140 100C140 100 135 85 145 80C155 75 150 65 140 60"
stroke="#E5E7EB"
strokeWidth="3"
strokeLinecap="round"
initial={{ opacity: 0.5 }}
animate={{ opacity: 1 }}
transition={{ duration: 2, repeat: Infinity, repeatType: "reverse", delay: 0.5 }}
/>
<motion.path
d="M160 100C160 100 155 90 165 85C175 80 170 70 160 70"
stroke="#E5E7EB"
strokeWidth="3"
strokeLinecap="round"
initial={{ opacity: 0.3 }}
animate={{ opacity: 1 }}
transition={{ duration: 1.8, repeat: Infinity, repeatType: "reverse", delay: 0.3 }}
/>
{/* Stove */}
<rect x="70" y="180" width="140" height="10" rx="2" fill="#4B5563" />
<circle cx="140" cy="185" r="3" fill="#EF4444" />
<circle cx="120" cy="185" r="3" fill="#EF4444" />
<circle cx="160" cy="185" r="3" fill="#EF4444" />
{/* Spoon */}
<rect x="190" y="100" width="5" height="70" rx="2" fill="#D1D5DB" />
<ellipse cx="192.5" cy="95" rx="10" ry="5" fill="#D1D5DB" />
{/* Vegetables */}
<circle cx="60" cy="150" r="10" fill="#10B981" /> {/* Lettuce */}
<circle cx="40" cy="160" r="8" fill="#EF4444" /> {/* Tomato */}
<rect x="210" y="150" width="20" height="8" rx="2" fill="#F59E0B" /> {/* Carrot */}
<rect x="220" y="140" width="15" height="10" rx="2" fill="#F59E0B" transform="rotate(45 220 140)" /> {/* Carrot top */}
</svg>
);
}

View File

@ -0,0 +1,26 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef<
React.ElementRef<typeof ProgressPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof ProgressPrimitive.Root>
>(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-4 w-full overflow-hidden rounded-full bg-secondary",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }

View File

@ -17,10 +17,10 @@ export function MainLayout({ children }: MainLayoutProps) {
}
return (
<div className="flex min-h-screen flex-col">
<div className="flex min-h-screen flex-col bg-amber-50">
<Header />
<main className="flex-1">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-8">
<div className="mx-auto max-w-7xl">
{children}
</div>
</main>

View File

@ -1,209 +1,216 @@
import { useState } from "react";
import { useNavigate } from "react-router-dom";
import { recipeService } from "@/api/recipe";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
import { Mic, Upload, ArrowLeft, Loader2 } from "lucide-react";
"use client"
import type React from "react"
import { useState, useRef } from "react"
import { useNavigate } from "react-router-dom"
import { recipeService } from "@/api/recipe"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Mic, Upload, ArrowLeft, Loader2 } from "lucide-react"
import { motion } from "framer-motion"
import { KitchenIllustration } from "@/components/illustrations/KitchenIllustration"
import { CookingLoader } from "@/components/illustrations/CookingLoader"
export default function RecipeForm() {
const navigate = useNavigate();
const navigate = useNavigate()
const fileInputRef = useRef<HTMLInputElement>(null)
const [audioFile, setAudioFile] = useState<File | null>(null);
const [isRecording, setIsRecording] = useState(false);
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);
const [recordingStatus, setRecordingStatus] = useState("idle");
const [loading, setLoading] = useState(false);
const [error, setError] = useState("");
const [audioFile, setAudioFile] = useState<File | null>(null)
const [isRecording, setIsRecording] = useState(false)
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null)
const [recordingStatus, setRecordingStatus] = useState<"idle" | "recording" | "processing">("idle")
const [loading, setLoading] = useState(false)
const [error, setError] = useState("")
// Gérer l'upload de fichier audio
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files && e.target.files.length > 0) {
setAudioFile(e.target.files[0]);
setAudioFile(e.target.files[0])
setError("")
}
};
}
// Démarrer l'enregistrement audio
const startRecording = async () => {
try {
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
const recorder = new MediaRecorder(stream);
setMediaRecorder(recorder);
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
const recorder = new MediaRecorder(stream)
setMediaRecorder(recorder)
const chunks: BlobPart[] = [];
const chunks: BlobPart[] = []
recorder.ondataavailable = (e) => {
chunks.push(e.data);
};
chunks.push(e.data)
}
recorder.onstop = () => {
const blob = new Blob(chunks, { type: 'audio/webm' });
const file = new File([blob], "recording.webm", { type: 'audio/webm' });
setAudioFile(file);
setRecordingStatus("idle");
};
const blob = new Blob(chunks, { type: "audio/webm" })
const file = new File([blob], "recording.webm", { type: "audio/webm" })
setAudioFile(file)
setRecordingStatus("idle")
setError("")
}
recorder.start();
setIsRecording(true);
setRecordingStatus("recording");
recorder.start()
setIsRecording(true)
setRecordingStatus("recording")
} catch (err) {
console.error("Erreur lors de l'accès au microphone:", err);
// toast({
// variant: "destructive",
// title: "Erreur de microphone",
// description: "Impossible d'accéder au microphone. Vérifiez les permissions."
// });
console.error("Erreur lors de l'accès au microphone:", err)
setError("Impossible d'accéder au microphone. Vérifiez les permissions.")
}
};
}
// Arrêter l'enregistrement audio
const stopRecording = () => {
if (mediaRecorder && isRecording) {
mediaRecorder.stop();
setIsRecording(false);
setRecordingStatus("processing");
mediaRecorder.stop()
setIsRecording(false)
setRecordingStatus("processing")
// Arrêter toutes les pistes audio
mediaRecorder.stream.getTracks().forEach(track => track.stop());
mediaRecorder.stream.getTracks().forEach((track) => track.stop())
}
};
}
// Soumettre le formulaire
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
e.preventDefault()
if (!audioFile) {
setError("Veuillez fournir un enregistrement audio des ingrédients");
return;
setError("Veuillez fournir un enregistrement audio des ingrédients")
return
}
setLoading(true);
setError("");
setLoading(true)
setError("")
setRecordingStatus("processing")
try {
const recipe = await recipeService.createRecipe(audioFile);
// toast({
// title: "Recette créée !",
// description: "Votre recette a été générée avec succès."
// });
// Rediriger vers la page de détails de la recette
navigate(`/recipes/${recipe.id}`);
const recipe = await recipeService.createRecipe(audioFile)
navigate(`/recipes/${recipe.id}`)
} 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");
// toast({
// variant: "destructive",
// title: "Erreur",
// description: "Impossible de créer la recette. Veuillez réessayer."
// });
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")
} finally {
setLoading(false);
setLoading(false)
}
};
}
return (
<div className="container max-w-3xl py-8">
<Button
variant="ghost"
className="mb-6"
onClick={() => navigate("/recipes")}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Retour aux recettes
</Button>
<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">
<div className="p-4">
<Button
variant="ghost"
className="cursor-pointer flex items-center gap-2"
onClick={() => navigate("/recipes")}
>
<ArrowLeft className="h-4 w-4" />
Retour aux recettes
</Button>
</div>
<Card>
<CardHeader>
<CardTitle>Créer une nouvelle recette</CardTitle>
<CardDescription>
Enregistrez ou téléchargez un fichier audio listant les ingrédients disponibles,
et nous générerons une recette pour vous.
</CardDescription>
</CardHeader>
<div className="w-full space-y-6 mt-4">
{/* Illustrations */}
<div className="flex justify-center mb-8">
<KitchenIllustration />
</div>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-6">
<Card className="border-none shadow-lg">
<CardHeader>
<CardTitle className="text-2xl">Créer une nouvelle recette</CardTitle>
<CardDescription>
Enregistrez ou téléchargez un fichier audio listant les ingrédients disponibles, et nous générerons une
recette pour vous.
</CardDescription>
</CardHeader>
<CardContent>
{error && (
<div className="rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/50 dark:text-red-200">
<div className="rounded-md bg-red-50 p-4 text-sm text-red-700 dark:bg-red-900/50 dark:text-red-200 mb-4">
{error}
</div>
)}
<div className="space-y-2">
<Label htmlFor="audio-file">Fichier audio</Label>
<div className="flex items-center gap-2">
<Input
id="audio-file"
type="file"
accept="audio/*"
onChange={handleFileChange}
disabled={loading || isRecording}
className="flex-1"
/>
<Button
type="button"
variant={isRecording ? "destructive" : "outline"}
onClick={isRecording ? stopRecording : startRecording}
disabled={loading}
>
{isRecording ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Arrêter
</>
) : (
<>
<Mic className="mr-2 h-4 w-4" />
Enregistrer
</>
)}
</Button>
</div>
{recordingStatus !== "processing" && !loading && (
<div className="space-y-4">
{audioFile && (
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-4">
<p className="font-medium text-green-800 dark:text-green-300">Fichier audio prêt !</p>
<p className="text-sm text-green-700 dark:text-green-400 mt-1">
{audioFile.name} ({(audioFile.size / 1024).toFixed(2)} KB)
</p>
{recordingStatus === "processing" && (
<div className="flex items-center justify-center p-4">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Traitement de l'enregistrement...
</div>
)}
{audioFile && (
<div className="mt-4 rounded-md bg-green-50 p-3 text-sm text-green-700 dark:bg-green-900/50 dark:text-green-200">
<p className="font-medium">Fichier audio prêt :</p>
<p>{audioFile.name} ({(audioFile.size / 1024).toFixed(2)} KB)</p>
<div className="mt-2">
<audio controls className="w-full">
<source src={URL.createObjectURL(audioFile)} type={audioFile.type} />
Votre navigateur ne supporte pas la lecture audio.
</audio>
<div className="mt-3">
<audio controls className="w-full">
<source src={URL.createObjectURL(audioFile)} type={audioFile.type} />
Votre navigateur ne supporte pas la lecture audio.
</audio>
</div>
</div>
</div>
)}
)}
<p className="text-sm text-muted-foreground mt-2">
Enregistrez-vous en listant les ingrédients que vous avez à disposition.
Notre IA générera une recette adaptée à ces ingrédients.
</p>
</div>
<div className="flex items-center gap-2">
<Input
ref={fileInputRef}
type="file"
accept="audio/*"
onChange={handleFileChange}
className="hidden"
/>
<Button
variant="outline"
className="flex-1 cursor-pointer"
onClick={() => fileInputRef.current?.click()}
disabled={isRecording}
>
<Upload className="mr-2 h-4 w-4" />
Choisir un fichier
</Button>
<Button
variant={isRecording ? "destructive" : "outline"}
className="flex-1 cursor-pointer"
onClick={isRecording ? stopRecording : startRecording}
>
<Mic className="mr-2 h-4 w-4" />
{isRecording ? "Arrêter" : "Enregistrer"}
</Button>
</div>
<p className="text-sm text-muted-foreground">
Enregistrez-vous en listant les ingrédients que vous avez à disposition. Notre IA générera une
recette adaptée à ces ingrédients.
</p>
</div>
)}
{(recordingStatus === "processing" || loading) && (
<div className="py-8 flex flex-col items-center justify-center">
<CookingLoader />
<p className="mt-4 text-center font-medium">Préparation de votre recette...</p>
<p className="text-sm text-muted-foreground text-center mt-2">
Notre chef IA mijote quelque chose de délicieux avec vos ingrédients
</p>
</div>
)}
</CardContent>
<CardFooter className="flex justify-between">
<Button
type="button"
variant="outline"
className="cursor-pointer"
onClick={() => navigate("/recipes")}
disabled={loading}
disabled={loading || recordingStatus === "processing"}
>
Annuler
</Button>
<Button
type="submit"
disabled={loading || !audioFile}
className="cursor-pointer"
onClick={handleSubmit}
disabled={!audioFile || loading || recordingStatus === "processing" || isRecording}
>
{loading ? (
<>
@ -218,8 +225,39 @@ export default function RecipeForm() {
)}
</Button>
</CardFooter>
</form>
</Card>
</div>
);
</Card>
</div>
{/* Recording button at bottom */}
{recordingStatus !== "processing" && !loading && !audioFile && (
<div className="md:hidden fixed bottom-8 left-0 right-0 flex justify-center">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.3 }}
>
<Button
className={`h-16 w-16 rounded-full shadow-lg cursor-pointer ${isRecording ? "bg-red-500 hover:bg-red-600" : "bg-orange-500 hover:bg-orange-600"
}`}
size="icon"
onClick={isRecording ? stopRecording : startRecording}
>
{isRecording ? (
<motion.div
animate={{ scale: [1, 1.2, 1] }}
transition={{ repeat: Number.POSITIVE_INFINITY, duration: 1.5 }}
>
<Mic className="h-6 w-6" />
</motion.div>
) : (
<Mic className="h-6 w-6" />
)}
</Button>
</motion.div>
</div>
)}
</div >
)
}

View File

@ -1,159 +1,253 @@
import { useState, useEffect } from "react";
import { Link, useNavigate } from "react-router-dom";
import { recipeService, Recipe } from "@/api/recipe";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Search, Filter, Plus } from "lucide-react";
"use client"
import type React from "react"
import { useState, useEffect } from "react"
import { useNavigate } from "react-router-dom"
import { recipeService, type Recipe } from "@/api/recipe"
import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Search, Plus, Clock, Utensils, Heart, Share2, ArrowUpRight } from "lucide-react"
import { motion } from "framer-motion"
import { CookingLoader } from "@/components/illustrations/CookingLoader"
import { KitchenIllustration } from "@/components/illustrations/KitchenIllustration"
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent, CardFooter } from "@/components/ui/card"
import { EmptyRecipes } from "@/components/illustrations/EmptyRecipes"
export default function RecipeList() {
const navigate = useNavigate();
const [recipes, setRecipes] = useState<Recipe[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [searchQuery, setSearchQuery] = useState("");
const navigate = useNavigate()
const [recipes, setRecipes] = useState<Recipe[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
const [searchQuery, setSearchQuery] = useState("")
const [activeFilter, setActiveFilter] = useState("all")
useEffect(() => {
const fetchRecipes = async () => {
try {
setLoading(true);
const data = await recipeService.getRecipes();
setRecipes(data);
setLoading(true)
const data = await recipeService.getRecipes()
setRecipes(data)
} catch (err) {
setError("Impossible de charger les recettes");
console.error(err);
setError("Impossible de charger les recettes")
console.error(err)
} finally {
setLoading(false);
setLoading(false)
}
};
}
fetchRecipes();
}, []);
fetchRecipes()
}, [])
const handleSearch = async (e: React.FormEvent) => {
e.preventDefault();
e.preventDefault()
if (!searchQuery.trim()) {
const data = await recipeService.getRecipes();
setRecipes(data);
return;
const data = await recipeService.getRecipes()
setRecipes(data)
return
}
try {
setLoading(true);
const results = await recipeService.getRecipes();
setRecipes(results);
setLoading(true)
const results = await recipeService.getRecipes()
setRecipes(results)
} catch (err) {
setError("Erreur lors de la recherche");
console.error(err);
setError("Erreur lors de la recherche")
console.error(err)
} finally {
setLoading(false);
setLoading(false)
}
};
}
const handleCreateRecipe = () => {
navigate("/recipes/new");
};
navigate("/recipes/new")
}
// Filter recipes based on the active filter
const filteredRecipes = recipes.filter((recipe) => {
if (activeFilter === "all") return true
if (activeFilter === "easy" && recipe.difficulty === "Facile") return true
if (activeFilter === "quick" && (recipe.preparationTime || 0) <= 30) return true
if (activeFilter === "vegetarian" && recipe.tags?.includes("Végétarien")) return true
return false
})
return (
<div className="space-y-8">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Recettes</h1>
<p className="text-muted-foreground">
Découvrez notre collection de recettes délicieuses
</p>
</div>
<div className="flex flex-col gap-2 sm:flex-row">
<form onSubmit={handleSearch} className="flex w-full max-w-sm items-center space-x-2">
<Input
type="search"
placeholder="Rechercher des recettes..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
<Button type="submit" size="icon">
<Search className="h-4 w-4" />
</Button>
</form>
<Button
onClick={handleCreateRecipe}
className="flex items-center gap-2 cursor-pointer"
>
<Plus className="h-4 w-4" />
Créer une recette
</Button>
</div>
</div>
<div className="dark:from-slate-950 dark:to-slate-900">
{/* Main content */}
{error && (
<div className="rounded-md bg-red-50 p-4 text-red-700 dark:bg-red-900/50 dark:text-red-200">
<motion.div
className="rounded-md bg-red-50 p-4 text-red-700 dark:bg-red-900/50 dark:text-red-200 mb-6"
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
>
{error}
</div>
</motion.div>
)}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6 mt-6">
<Tabs defaultValue="all" className="w-full sm:w-auto" onValueChange={setActiveFilter}>
<TabsList className="grid grid-cols-4 w-full sm:w-auto">
<TabsTrigger value="all">Toutes</TabsTrigger>
<TabsTrigger value="easy">Faciles</TabsTrigger>
<TabsTrigger value="quick">Rapides</TabsTrigger>
<TabsTrigger value="vegetarian">Végé</TabsTrigger>
</TabsList>
</Tabs>
<Button
onClick={handleCreateRecipe}
className="mt-4 sm:mt-0 bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white cursor-pointer"
>
<Plus className="mr-2 h-4 w-4" />
Nouvelle recette
</Button>
</div>
{loading ? (
<div className="flex justify-center py-12">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
</div>
<CookingLoader />
) : (
<>
{recipes.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<p className="text-lg font-medium">Aucune recette trouvée</p>
<p className="text-muted-foreground">
Essayez de modifier vos critères de recherche ou
<Button
variant="link"
onClick={handleCreateRecipe}
className="px-1 py-0 h-auto"
>
créez une nouvelle recette
</Button>
</p>
</div>
{filteredRecipes.length === 0 ? (
<EmptyRecipes onCreateRecipe={handleCreateRecipe} />
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{recipes.map((recipe) => (
<Link
key={recipe.id}
to={`/recipes/${recipe.id}`}
className="group overflow-hidden rounded-lg border bg-card shadow-sm transition-all hover:shadow-md"
>
<div className="aspect-video w-full overflow-hidden">
<img
src={recipe.imageUrl || "/images/recipe-placeholder.jpg"}
alt={recipe.title}
className="h-full w-full object-cover transition-transform group-hover:scale-105"
/>
</div>
<div className="p-4">
<h3 className="font-semibold">{recipe.title}</h3>
<p className="mt-1 line-clamp-2 text-sm text-muted-foreground">
{recipe.description || "Aucune description disponible"}
</p>
<div className="mt-2 flex flex-wrap gap-1">
{recipe.tags?.slice(0, 3).map((tag) => (
<span
key={tag}
className="inline-flex items-center rounded-full bg-primary/10 px-2 py-1 text-xs font-medium text-primary"
>
{tag}
</span>
))}
</div>
<div className="mt-4 flex items-center justify-between text-sm text-muted-foreground">
<span>
{recipe.preparationTime ? `${recipe.preparationTime} min` : ""}
</span>
<span>{recipe.difficulty || ""}</span>
</div>
</div>
</Link>
<motion.div
className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ staggerChildren: 0.1 }}
>
{filteredRecipes.map((recipe, index) => (
<RecipeCard key={recipe.id} recipe={recipe} index={index} />
))}
</div>
</motion.div>
)}
</>
)}
{/* Floating action button (mobile only) */}
<div className="fixed bottom-8 right-8 sm:hidden">
<motion.div initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={{ delay: 0.2 }}>
<Button
onClick={handleCreateRecipe}
className="h-14 w-14 rounded-full shadow-lg bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600"
size="icon"
>
<Plus className="h-6 w-6" />
</Button>
</motion.div>
</div>
</div>
);
)
}
interface RecipeCardProps {
recipe: Recipe
index: number
}
function RecipeCard({ recipe, index }: RecipeCardProps) {
const navigate = useNavigate()
return (
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.05 }}>
<Card className="overflow-hidden h-full border-none shadow-md hover:shadow-lg transition-all duration-300">
<div
className="relative aspect-video w-full overflow-hidden cursor-pointer"
onClick={() => navigate(`/recipes/${recipe.id}`)}
>
<img
src={recipe.imageUrl || "/placeholder.svg?height=200&width=400"}
alt={recipe.title}
className="h-full w-full object-cover transition-transform hover:scale-105 duration-500"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent flex items-end">
<div className="p-4 text-white">
<h3 className="font-bold text-lg">{recipe.title}</h3>
<p className="text-sm opacity-90 line-clamp-1">{recipe.description || "Aucune description disponible"}</p>
</div>
</div>
<div className="absolute top-2 right-2 flex gap-1">
{recipe.difficulty === "Facile" && (
<Badge variant="secondary" className="bg-green-500/80 text-white hover:bg-green-600/80">
Facile
</Badge>
)}
{recipe.difficulty === "Moyen" && (
<Badge variant="secondary" className="bg-yellow-500/80 text-white hover:bg-yellow-600/80">
Moyen
</Badge>
)}
{recipe.difficulty === "Difficile" && (
<Badge variant="secondary" className="bg-red-500/80 text-white hover:bg-red-600/80">
Difficile
</Badge>
)}
{(recipe.preparationTime || 0) <= 30 && (
<Badge variant="secondary" className="bg-blue-500/80 text-white hover:bg-blue-600/80">
Rapide
</Badge>
)}
</div>
</div>
<CardContent className="p-4">
<div className="flex flex-wrap gap-1 mb-3">
{recipe.tags?.slice(0, 3).map((tag) => (
<Badge
key={tag}
variant="outline"
className="bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 border-orange-200 dark:border-orange-800"
>
{tag}
</Badge>
))}
</div>
<div className="flex items-center justify-between text-sm text-muted-foreground">
{recipe.preparationTime && (
<div className="flex items-center">
<Clock className="h-3 w-3 mr-1" />
<span>{recipe.preparationTime} min</span>
</div>
)}
{recipe.servings && (
<div className="flex items-center">
<Utensils className="h-3 w-3 mr-1" />
<span>{recipe.servings} pers.</span>
</div>
)}
</div>
</CardContent>
<CardFooter className="p-4 pt-0 flex justify-between">
<Button
variant="outline"
size="sm"
className="rounded-full"
onClick={() => navigate(`/recipes/${recipe.id}`)}
>
Voir la recette
<ArrowUpRight className="ml-1 h-3 w-3" />
</Button>
<div className="flex gap-1">
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
<Heart className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
<Share2 className="h-4 w-4" />
</Button>
</div>
</CardFooter>
</Card>
</motion.div>
)
}