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:
|
dependencies:
|
||||||
'@fastify/cors':
|
'@fastify/cors':
|
||||||
specifier: ^11.0.0
|
specifier: ^8.5.0
|
||||||
version: 11.0.0
|
version: 8.5.0
|
||||||
'@fastify/jwt':
|
'@fastify/jwt':
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.2.4
|
version: 7.2.4
|
||||||
@ -54,8 +54,8 @@ packages:
|
|||||||
'@fastify/busboy@3.1.1':
|
'@fastify/busboy@3.1.1':
|
||||||
resolution: {integrity: sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==}
|
resolution: {integrity: sha512-5DGmA8FTdB2XbDeEwc/5ZXBl6UbBAyBOOLlPuBnZ/N1SwdH9Ii+cOX3tBROlDgcTXxjOYnLMVoKk9+FXAw0CJw==}
|
||||||
|
|
||||||
'@fastify/cors@11.0.0':
|
'@fastify/cors@8.5.0':
|
||||||
resolution: {integrity: sha512-41Bx0LVGr2a6DnnhDN/SgfDlTRNZtEs8niPxyoymV6Hw09AIdz/9Rn/0Fpu+pBOs6kviwS44JY2mB8NcU2qSAA==}
|
resolution: {integrity: sha512-/oZ1QSb02XjP0IK1U0IXktEsw/dUBTxJOW7IpIeO8c/tNalw/KjoNSJv1Sf6eqoBPO+TDGkifq6ynFK3v68HFQ==}
|
||||||
|
|
||||||
'@fastify/deepmerge@2.0.2':
|
'@fastify/deepmerge@2.0.2':
|
||||||
resolution: {integrity: sha512-3wuLdX5iiiYeZWP6bQrjqhrcvBIf0NHbQH1Ur1WbHvoiuTYUEItgygea3zs8aHpiitn0lOB8gX20u1qO+FDm7Q==}
|
resolution: {integrity: sha512-3wuLdX5iiiYeZWP6bQrjqhrcvBIf0NHbQH1Ur1WbHvoiuTYUEItgygea3zs8aHpiitn0lOB8gX20u1qO+FDm7Q==}
|
||||||
@ -330,9 +330,6 @@ packages:
|
|||||||
fastify-plugin@4.5.1:
|
fastify-plugin@4.5.1:
|
||||||
resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==}
|
resolution: {integrity: sha512-stRHYGeuqpEZTL1Ef0Ovr2ltazUT9g844X5z/zEBFLG8RYlpDiOCIG+ATvYEp+/zmc7sN29mcIMp8gvYplYPIQ==}
|
||||||
|
|
||||||
fastify-plugin@5.0.1:
|
|
||||||
resolution: {integrity: sha512-HCxs+YnRaWzCl+cWRYFnHmeRFyR5GVnJTAaCJQiYzQSDwK9MgJdyAsuL3nh0EWRCYMgQ5MeziymvmAhUHYHDUQ==}
|
|
||||||
|
|
||||||
fastify@4.29.0:
|
fastify@4.29.0:
|
||||||
resolution: {integrity: sha512-MaaUHUGcCgC8fXQDsDtioaCcag1fmPJ9j64vAKunqZF4aSub040ZGi/ag8NGE2714yREPOKZuHCfpPzuUD3UQQ==}
|
resolution: {integrity: sha512-MaaUHUGcCgC8fXQDsDtioaCcag1fmPJ9j64vAKunqZF4aSub040ZGi/ag8NGE2714yREPOKZuHCfpPzuUD3UQQ==}
|
||||||
|
|
||||||
@ -516,12 +513,12 @@ packages:
|
|||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
mnemonist@0.39.6:
|
||||||
|
resolution: {integrity: sha512-A/0v5Z59y63US00cRSLiloEIw3t5G+MiKz4BhX21FI+YBJXBOGW0ohFxTxO08dsOYlzxo87T7vGfZKYp2bcAWA==}
|
||||||
|
|
||||||
mnemonist@0.39.8:
|
mnemonist@0.39.8:
|
||||||
resolution: {integrity: sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==}
|
resolution: {integrity: sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==}
|
||||||
|
|
||||||
mnemonist@0.40.0:
|
|
||||||
resolution: {integrity: sha512-kdd8AFNig2AD5Rkih7EPCXhu/iMvwevQFX/uEiGhZyPZi7fHqOoF4V4kHLpCfysxXMgQ4B52kdPMCwARshKvEg==}
|
|
||||||
|
|
||||||
ms@2.1.3:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
@ -818,10 +815,10 @@ snapshots:
|
|||||||
|
|
||||||
'@fastify/busboy@3.1.1': {}
|
'@fastify/busboy@3.1.1': {}
|
||||||
|
|
||||||
'@fastify/cors@11.0.0':
|
'@fastify/cors@8.5.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
fastify-plugin: 5.0.1
|
fastify-plugin: 4.5.1
|
||||||
mnemonist: 0.40.0
|
mnemonist: 0.39.6
|
||||||
|
|
||||||
'@fastify/deepmerge@2.0.2': {}
|
'@fastify/deepmerge@2.0.2': {}
|
||||||
|
|
||||||
@ -1113,8 +1110,6 @@ snapshots:
|
|||||||
|
|
||||||
fastify-plugin@4.5.1: {}
|
fastify-plugin@4.5.1: {}
|
||||||
|
|
||||||
fastify-plugin@5.0.1: {}
|
|
||||||
|
|
||||||
fastify@4.29.0:
|
fastify@4.29.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@fastify/ajv-compiler': 3.6.0
|
'@fastify/ajv-compiler': 3.6.0
|
||||||
@ -1323,11 +1318,11 @@ snapshots:
|
|||||||
|
|
||||||
mkdirp@1.0.4: {}
|
mkdirp@1.0.4: {}
|
||||||
|
|
||||||
mnemonist@0.39.8:
|
mnemonist@0.39.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
obliterator: 2.0.5
|
obliterator: 2.0.5
|
||||||
|
|
||||||
mnemonist@0.40.0:
|
mnemonist@0.39.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
obliterator: 2.0.5
|
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",
|
"axios": "^1.8.2",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"framer-motion": "^12.4.11",
|
||||||
"ky": "^1.7.5",
|
"ky": "^1.7.5",
|
||||||
"lucide-react": "^0.478.0",
|
"lucide-react": "^0.478.0",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
|
|||||||
38
frontend/pnpm-lock.yaml
generated
38
frontend/pnpm-lock.yaml
generated
@ -44,6 +44,9 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 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:
|
ky:
|
||||||
specifier: ^1.7.5
|
specifier: ^1.7.5
|
||||||
version: 1.7.5
|
version: 1.7.5
|
||||||
@ -1307,6 +1310,20 @@ packages:
|
|||||||
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
|
resolution: {integrity: sha512-hGfm/slu0ZabnNt4oaRZ6uREyfCj6P4fT/n6A1rGV+Z0VdGXjfOhVUpkn6qVQONHGIFwmveGXyDs75+nr6FM8w==}
|
||||||
engines: {node: '>= 6'}
|
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:
|
fsevents@2.3.3:
|
||||||
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==}
|
||||||
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0}
|
||||||
@ -1551,6 +1568,12 @@ packages:
|
|||||||
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
|
resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==}
|
||||||
engines: {node: '>=16 || 14 >=14.17'}
|
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:
|
ms@2.1.3:
|
||||||
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
|
||||||
|
|
||||||
@ -3017,6 +3040,15 @@ snapshots:
|
|||||||
es-set-tostringtag: 2.1.0
|
es-set-tostringtag: 2.1.0
|
||||||
mime-types: 2.1.35
|
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:
|
fsevents@2.3.3:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@ -3206,6 +3238,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
brace-expansion: 2.0.1
|
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: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
nanoid@3.3.9: {}
|
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 (
|
return (
|
||||||
<div className="flex min-h-screen flex-col">
|
<div className="flex min-h-screen flex-col bg-amber-50">
|
||||||
<Header />
|
<Header />
|
||||||
<main className="flex-1">
|
<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}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@ -1,182 +1,148 @@
|
|||||||
import { useState } from "react";
|
"use client"
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import { recipeService } from "@/api/recipe";
|
import type React from "react"
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { useState, useRef } from "react"
|
||||||
import { Label } from "@/components/ui/label";
|
import { useNavigate } from "react-router-dom"
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { recipeService } from "@/api/recipe"
|
||||||
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card";
|
import { Button } from "@/components/ui/button"
|
||||||
import { Mic, Upload, ArrowLeft, Loader2 } from "lucide-react";
|
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() {
|
export default function RecipeForm() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate()
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
||||||
|
|
||||||
const [audioFile, setAudioFile] = useState<File | null>(null);
|
const [audioFile, setAudioFile] = useState<File | null>(null)
|
||||||
const [isRecording, setIsRecording] = useState(false);
|
const [isRecording, setIsRecording] = useState(false)
|
||||||
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null);
|
const [mediaRecorder, setMediaRecorder] = useState<MediaRecorder | null>(null)
|
||||||
const [recordingStatus, setRecordingStatus] = useState("idle");
|
const [recordingStatus, setRecordingStatus] = useState<"idle" | "recording" | "processing">("idle")
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("")
|
||||||
|
|
||||||
// Gérer l'upload de fichier audio
|
// Gérer l'upload de fichier audio
|
||||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
if (e.target.files && e.target.files.length > 0) {
|
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
|
// Démarrer l'enregistrement audio
|
||||||
const startRecording = async () => {
|
const startRecording = async () => {
|
||||||
try {
|
try {
|
||||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
|
||||||
const recorder = new MediaRecorder(stream);
|
const recorder = new MediaRecorder(stream)
|
||||||
setMediaRecorder(recorder);
|
setMediaRecorder(recorder)
|
||||||
|
|
||||||
const chunks: BlobPart[] = [];
|
const chunks: BlobPart[] = []
|
||||||
recorder.ondataavailable = (e) => {
|
recorder.ondataavailable = (e) => {
|
||||||
chunks.push(e.data);
|
chunks.push(e.data)
|
||||||
};
|
}
|
||||||
|
|
||||||
recorder.onstop = () => {
|
recorder.onstop = () => {
|
||||||
const blob = new Blob(chunks, { type: 'audio/webm' });
|
const blob = new Blob(chunks, { type: "audio/webm" })
|
||||||
const file = new File([blob], "recording.webm", { type: 'audio/webm' });
|
const file = new File([blob], "recording.webm", { type: "audio/webm" })
|
||||||
setAudioFile(file);
|
setAudioFile(file)
|
||||||
setRecordingStatus("idle");
|
setRecordingStatus("idle")
|
||||||
};
|
setError("")
|
||||||
|
}
|
||||||
recorder.start();
|
|
||||||
setIsRecording(true);
|
recorder.start()
|
||||||
setRecordingStatus("recording");
|
setIsRecording(true)
|
||||||
} catch (err) {
|
setRecordingStatus("recording")
|
||||||
console.error("Erreur lors de l'accès au microphone:", err);
|
} catch (err) {
|
||||||
// toast({
|
console.error("Erreur lors de l'accès au microphone:", err)
|
||||||
// variant: "destructive",
|
setError("Impossible d'accéder au microphone. Vérifiez les permissions.")
|
||||||
// title: "Erreur de microphone",
|
}
|
||||||
// description: "Impossible d'accéder au microphone. Vérifiez les permissions."
|
|
||||||
// });
|
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Arrêter l'enregistrement audio
|
// Arrêter l'enregistrement audio
|
||||||
const stopRecording = () => {
|
const stopRecording = () => {
|
||||||
if (mediaRecorder && isRecording) {
|
if (mediaRecorder && isRecording) {
|
||||||
mediaRecorder.stop();
|
mediaRecorder.stop()
|
||||||
setIsRecording(false);
|
setIsRecording(false)
|
||||||
setRecordingStatus("processing");
|
setRecordingStatus("processing")
|
||||||
|
|
||||||
// Arrêter toutes les pistes audio
|
// Arrêter toutes les pistes audio
|
||||||
mediaRecorder.stream.getTracks().forEach(track => track.stop());
|
mediaRecorder.stream.getTracks().forEach((track) => track.stop())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
// Soumettre le formulaire
|
// Soumettre le formulaire
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
|
|
||||||
if (!audioFile) {
|
if (!audioFile) {
|
||||||
setError("Veuillez fournir un enregistrement audio des ingrédients");
|
setError("Veuillez fournir un enregistrement audio des ingrédients")
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true)
|
||||||
setError("");
|
setError("")
|
||||||
|
setRecordingStatus("processing")
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const recipe = await recipeService.createRecipe(audioFile);
|
const recipe = await recipeService.createRecipe(audioFile)
|
||||||
|
navigate(`/recipes/${recipe.id}`)
|
||||||
// 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}`);
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Erreur lors de la création de la recette:", 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");
|
setError(err instanceof Error ? err.message : "Une erreur est survenue lors de la création de la recette")
|
||||||
|
setRecordingStatus("idle")
|
||||||
// toast({
|
|
||||||
// variant: "destructive",
|
|
||||||
// title: "Erreur",
|
|
||||||
// description: "Impossible de créer la recette. Veuillez réessayer."
|
|
||||||
// });
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
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
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="mb-6"
|
className="cursor-pointer flex items-center gap-2"
|
||||||
onClick={() => navigate("/recipes")}
|
onClick={() => navigate("/recipes")}
|
||||||
>
|
>
|
||||||
<ArrowLeft className="mr-2 h-4 w-4" />
|
<ArrowLeft className="h-4 w-4" />
|
||||||
Retour aux recettes
|
Retour aux recettes
|
||||||
</Button>
|
</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>
|
<CardHeader>
|
||||||
<CardTitle>Créer une nouvelle recette</CardTitle>
|
<CardTitle className="text-2xl">Créer une nouvelle recette</CardTitle>
|
||||||
<CardDescription>
|
<CardDescription>
|
||||||
Enregistrez ou téléchargez un fichier audio listant les ingrédients disponibles,
|
Enregistrez ou téléchargez un fichier audio listant les ingrédients disponibles, et nous générerons une
|
||||||
et nous générerons une recette pour vous.
|
recette pour vous.
|
||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<CardContent>
|
||||||
<CardContent className="space-y-6">
|
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-2">
|
{recordingStatus !== "processing" && !loading && (
|
||||||
<Label htmlFor="audio-file">Fichier audio</Label>
|
<div className="space-y-4">
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{audioFile && (
|
{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">
|
<div className="rounded-lg bg-green-50 dark:bg-green-900/20 p-4">
|
||||||
<p className="font-medium">Fichier audio prêt :</p>
|
<p className="font-medium text-green-800 dark:text-green-300">Fichier audio prêt !</p>
|
||||||
<p>{audioFile.name} ({(audioFile.size / 1024).toFixed(2)} KB)</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">
|
<audio controls className="w-full">
|
||||||
<source src={URL.createObjectURL(audioFile)} type={audioFile.type} />
|
<source src={URL.createObjectURL(audioFile)} type={audioFile.type} />
|
||||||
Votre navigateur ne supporte pas la lecture audio.
|
Votre navigateur ne supporte pas la lecture audio.
|
||||||
@ -185,25 +151,66 @@ export default function RecipeForm() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<p className="text-sm text-muted-foreground mt-2">
|
<div className="flex items-center gap-2">
|
||||||
Enregistrez-vous en listant les ingrédients que vous avez à disposition.
|
<Input
|
||||||
Notre IA générera une recette adaptée à ces ingrédients.
|
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>
|
</p>
|
||||||
</div>
|
</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>
|
</CardContent>
|
||||||
|
|
||||||
<CardFooter className="flex justify-between">
|
<CardFooter className="flex justify-between">
|
||||||
<Button
|
<Button
|
||||||
type="button"
|
|
||||||
variant="outline"
|
variant="outline"
|
||||||
|
className="cursor-pointer"
|
||||||
onClick={() => navigate("/recipes")}
|
onClick={() => navigate("/recipes")}
|
||||||
disabled={loading}
|
disabled={loading || recordingStatus === "processing"}
|
||||||
>
|
>
|
||||||
Annuler
|
Annuler
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
type="submit"
|
className="cursor-pointer"
|
||||||
disabled={loading || !audioFile}
|
onClick={handleSubmit}
|
||||||
|
disabled={!audioFile || loading || recordingStatus === "processing" || isRecording}
|
||||||
>
|
>
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<>
|
<>
|
||||||
@ -218,8 +225,39 @@ export default function RecipeForm() {
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</CardFooter>
|
</CardFooter>
|
||||||
</form>
|
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</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";
|
"use client"
|
||||||
import { Link, useNavigate } from "react-router-dom";
|
|
||||||
import { recipeService, Recipe } from "@/api/recipe";
|
import type React from "react"
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Input } from "@/components/ui/input";
|
import { useState, useEffect } from "react"
|
||||||
import { Search, Filter, Plus } from "lucide-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() {
|
export default function RecipeList() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate()
|
||||||
const [recipes, setRecipes] = useState<Recipe[]>([]);
|
const [recipes, setRecipes] = useState<Recipe[]>([])
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true)
|
||||||
const [error, setError] = useState("");
|
const [error, setError] = useState("")
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("")
|
||||||
|
const [activeFilter, setActiveFilter] = useState("all")
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const fetchRecipes = async () => {
|
const fetchRecipes = async () => {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true)
|
||||||
const data = await recipeService.getRecipes();
|
const data = await recipeService.getRecipes()
|
||||||
setRecipes(data);
|
setRecipes(data)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Impossible de charger les recettes");
|
setError("Impossible de charger les recettes")
|
||||||
console.error(err);
|
console.error(err)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
fetchRecipes();
|
fetchRecipes()
|
||||||
}, []);
|
}, [])
|
||||||
|
|
||||||
const handleSearch = async (e: React.FormEvent) => {
|
const handleSearch = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault()
|
||||||
|
|
||||||
if (!searchQuery.trim()) {
|
if (!searchQuery.trim()) {
|
||||||
const data = await recipeService.getRecipes();
|
const data = await recipeService.getRecipes()
|
||||||
setRecipes(data);
|
setRecipes(data)
|
||||||
return;
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true)
|
||||||
const results = await recipeService.getRecipes();
|
const results = await recipeService.getRecipes()
|
||||||
setRecipes(results);
|
setRecipes(results)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError("Erreur lors de la recherche");
|
setError("Erreur lors de la recherche")
|
||||||
console.error(err);
|
console.error(err)
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
|
||||||
|
|
||||||
const handleCreateRecipe = () => {
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="dark:from-slate-950 dark:to-slate-900">
|
||||||
<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>
|
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
{error && (
|
{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}
|
{error}
|
||||||
</div>
|
</motion.div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{loading ? (
|
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6 mt-6">
|
||||||
<div className="flex justify-center py-12">
|
<Tabs defaultValue="all" className="w-full sm:w-auto" onValueChange={setActiveFilter}>
|
||||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent"></div>
|
<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>
|
</div>
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<CookingLoader />
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{recipes.length === 0 ? (
|
{filteredRecipes.length === 0 ? (
|
||||||
<div className="flex flex-col items-center justify-center py-12 text-center">
|
<EmptyRecipes onCreateRecipe={handleCreateRecipe} />
|
||||||
<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>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
|
<motion.div
|
||||||
{recipes.map((recipe) => (
|
className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"
|
||||||
<Link
|
initial={{ opacity: 0 }}
|
||||||
key={recipe.id}
|
animate={{ opacity: 1 }}
|
||||||
to={`/recipes/${recipe.id}`}
|
transition={{ staggerChildren: 0.1 }}
|
||||||
className="group overflow-hidden rounded-lg border bg-card shadow-sm transition-all hover:shadow-md"
|
|
||||||
>
|
>
|
||||||
<div className="aspect-video w-full overflow-hidden">
|
{filteredRecipes.map((recipe, index) => (
|
||||||
<img
|
<RecipeCard key={recipe.id} recipe={recipe} index={index} />
|
||||||
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>
|
</motion.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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
);
|
</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