update UI
This commit is contained in:
parent
958a778f85
commit
9c773e8e64
29
backend/pnpm-lock.yaml
generated
29
backend/pnpm-lock.yaml
generated
@ -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
|
||||
|
||||
|
||||
BIN
backend/uploads/1741639296658-recording.webm
Normal file
BIN
backend/uploads/1741639296658-recording.webm
Normal file
Binary file not shown.
@ -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",
|
||||
|
||||
38
frontend/pnpm-lock.yaml
generated
38
frontend/pnpm-lock.yaml
generated
@ -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: {}
|
||||
|
||||
69
frontend/src/components/illustrations/CookingLoader.tsx
Normal file
69
frontend/src/components/illustrations/CookingLoader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
51
frontend/src/components/illustrations/EmptyRecipes.tsx
Normal file
51
frontend/src/components/illustrations/EmptyRecipes.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
26
frontend/src/components/ui/progress.tsx
Normal file
26
frontend/src/components/ui/progress.tsx
Normal 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 }
|
||||
@ -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>
|
||||
|
||||
@ -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 >
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user