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,182 +1,148 @@
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");
};
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."
// });
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")
} catch (err) {
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">
<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="mb-6"
className="cursor-pointer flex items-center gap-2"
onClick={() => navigate("/recipes")}
>
<ArrowLeft className="mr-2 h-4 w-4" />
<ArrowLeft className="h-4 w-4" />
Retour aux recettes
</Button>
</div>
<Card>
<div className="w-full space-y-6 mt-4">
{/* Illustrations */}
<div className="flex justify-center mb-8">
<KitchenIllustration />
</div>
<Card className="border-none shadow-lg">
<CardHeader>
<CardTitle>Créer une nouvelle recette</CardTitle>
<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.
Enregistrez ou téléchargez un fichier audio listant les ingrédients disponibles, et nous générerons une
recette pour vous.
</CardDescription>
</CardHeader>
<form onSubmit={handleSubmit}>
<CardContent className="space-y-6">
<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" && (
<div className="flex items-center justify-center p-4">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Traitement de l'enregistrement...
</div>
)}
{recordingStatus !== "processing" && !loading && (
<div className="space-y-4">
{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="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>
<div className="mt-2">
<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.
@ -185,25 +151,66 @@ export default function RecipeForm() {
</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.
<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>
);
{/* 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>
)}
{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 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 ? (
<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"
<motion.div
className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ staggerChildren: 0.1 }}
>
<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>
{filteredRecipes.map((recipe, index) => (
<RecipeCard key={recipe.id} recipe={recipe} index={index} />
))}
</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>
))}
</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>
)
}