diff --git a/backend/pnpm-lock.yaml b/backend/pnpm-lock.yaml index 907134b..dd7c930 100644 --- a/backend/pnpm-lock.yaml +++ b/backend/pnpm-lock.yaml @@ -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 diff --git a/backend/uploads/1741639296658-recording.webm b/backend/uploads/1741639296658-recording.webm new file mode 100644 index 0000000..84faa3a Binary files /dev/null and b/backend/uploads/1741639296658-recording.webm differ diff --git a/frontend/package.json b/frontend/package.json index 28612a6..f026693 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index fbb1713..b4d175b 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -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: {} diff --git a/frontend/src/components/illustrations/CookingLoader.tsx b/frontend/src/components/illustrations/CookingLoader.tsx new file mode 100644 index 0000000..09ad374 --- /dev/null +++ b/frontend/src/components/illustrations/CookingLoader.tsx @@ -0,0 +1,69 @@ +import { motion } from "framer-motion"; + +export function CookingLoader() { + return ( +
+ {/* Pot */} +
+
+
+
+ + {/* Bubbling animation */} + + + + + + + {/* Spoon stirring animation */} + +
+
+
+ ); +} diff --git a/frontend/src/components/illustrations/EmptyRecipes.tsx b/frontend/src/components/illustrations/EmptyRecipes.tsx new file mode 100644 index 0000000..2ec0b81 --- /dev/null +++ b/frontend/src/components/illustrations/EmptyRecipes.tsx @@ -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 ( + +
+ + +
+ +

Aucune recette trouvée

+

+ Votre collection de recettes est vide. Créez votre première recette en enregistrant les ingrédients que vous + avez dans votre frigo. +

+ + +
+ ) +} + diff --git a/frontend/src/components/illustrations/KitchenIllustration.tsx b/frontend/src/components/illustrations/KitchenIllustration.tsx new file mode 100644 index 0000000..927a216 --- /dev/null +++ b/frontend/src/components/illustrations/KitchenIllustration.tsx @@ -0,0 +1,62 @@ +import { motion } from "framer-motion"; + +export function KitchenIllustration() { + return ( + + {/* Pot */} + + + + {/* Pot handles */} + + + + {/* Steam animation */} + + + + + + + {/* Stove */} + + + + + + {/* Spoon */} + + + + {/* Vegetables */} + {/* Lettuce */} + {/* Tomato */} + {/* Carrot */} + {/* Carrot top */} + + ); +} diff --git a/frontend/src/components/ui/progress.tsx b/frontend/src/components/ui/progress.tsx new file mode 100644 index 0000000..bd761c6 --- /dev/null +++ b/frontend/src/components/ui/progress.tsx @@ -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, + React.ComponentPropsWithoutRef +>(({ className, value, ...props }, ref) => ( + + + +)) +Progress.displayName = ProgressPrimitive.Root.displayName + +export { Progress } \ No newline at end of file diff --git a/frontend/src/layouts/MainLayout.tsx b/frontend/src/layouts/MainLayout.tsx index 46e82d7..1d9355b 100644 --- a/frontend/src/layouts/MainLayout.tsx +++ b/frontend/src/layouts/MainLayout.tsx @@ -17,10 +17,10 @@ export function MainLayout({ children }: MainLayoutProps) { } return ( -
+
-
+
{children}
diff --git a/frontend/src/pages/Recipes/RecipeForm.tsx b/frontend/src/pages/Recipes/RecipeForm.tsx index f57b18d..ae0674a 100644 --- a/frontend/src/pages/Recipes/RecipeForm.tsx +++ b/frontend/src/pages/Recipes/RecipeForm.tsx @@ -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(null) - const [audioFile, setAudioFile] = useState(null); - const [isRecording, setIsRecording] = useState(false); - const [mediaRecorder, setMediaRecorder] = useState(null); - const [recordingStatus, setRecordingStatus] = useState("idle"); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(""); + const [audioFile, setAudioFile] = useState(null) + const [isRecording, setIsRecording] = useState(false) + const [mediaRecorder, setMediaRecorder] = useState(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) => { 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 ( -
- +
+
+ +
- - - Créer une nouvelle recette - - Enregistrez ou téléchargez un fichier audio listant les ingrédients disponibles, - et nous générerons une recette pour vous. - - +
+ {/* Illustrations */} +
+ +
-
- + + + Créer une nouvelle recette + + Enregistrez ou téléchargez un fichier audio listant les ingrédients disponibles, et nous générerons une + recette pour vous. + + + + {error && ( -
+
{error}
)} -
- -
- - -
+ {recordingStatus !== "processing" && !loading && ( +
+ {audioFile && ( +
+

Fichier audio prêt !

+

+ {audioFile.name} ({(audioFile.size / 1024).toFixed(2)} KB) +

- {recordingStatus === "processing" && ( -
- - Traitement de l'enregistrement... -
- )} - - {audioFile && ( -
-

Fichier audio prêt :

-

{audioFile.name} ({(audioFile.size / 1024).toFixed(2)} KB)

- -
- +
+ +
-
- )} + )} -

- Enregistrez-vous en listant les ingrédients que vous avez à disposition. - Notre IA générera une recette adaptée à ces ingrédients. -

-
+
+ + + + +
+ +

+ Enregistrez-vous en listant les ingrédients que vous avez à disposition. Notre IA générera une + recette adaptée à ces ingrédients. +

+
+ )} + + {(recordingStatus === "processing" || loading) && ( +
+ +

Préparation de votre recette...

+

+ Notre chef IA mijote quelque chose de délicieux avec vos ingrédients +

+
+ )} + - - -
- ); -} \ No newline at end of file + +
+ + {/* Recording button at bottom */} + {recordingStatus !== "processing" && !loading && !audioFile && ( +
+ + + +
+ )} + +
+ ) +} + diff --git a/frontend/src/pages/Recipes/RecipeList.tsx b/frontend/src/pages/Recipes/RecipeList.tsx index 26ea4cd..e98ae44 100644 --- a/frontend/src/pages/Recipes/RecipeList.tsx +++ b/frontend/src/pages/Recipes/RecipeList.tsx @@ -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([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(""); - const [searchQuery, setSearchQuery] = useState(""); + const navigate = useNavigate() + const [recipes, setRecipes] = useState([]) + 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 ( -
-
-
-

Recettes

-

- Découvrez notre collection de recettes délicieuses -

-
-
-
- setSearchQuery(e.target.value)} - /> - -
- -
-
+
+ {/* Main content */} {error && ( -
+ {error} -
+ )} +
+ + + Toutes + Faciles + Rapides + Végé + + + + +
+ {loading ? ( -
-
-
+ ) : ( <> - {recipes.length === 0 ? ( -
-

Aucune recette trouvée

-

- Essayez de modifier vos critères de recherche ou - -

-
+ {filteredRecipes.length === 0 ? ( + ) : ( -
- {recipes.map((recipe) => ( - -
- {recipe.title} -
-
-

{recipe.title}

-

- {recipe.description || "Aucune description disponible"} -

-
- {recipe.tags?.slice(0, 3).map((tag) => ( - - {tag} - - ))} -
-
- - {recipe.preparationTime ? `${recipe.preparationTime} min` : ""} - - {recipe.difficulty || ""} -
-
- + + {filteredRecipes.map((recipe, index) => ( + ))} -
+ )} )} + + {/* Floating action button (mobile only) */} +
+ + + +
- ); -} \ No newline at end of file + ) +} + +interface RecipeCardProps { + recipe: Recipe + index: number +} + +function RecipeCard({ recipe, index }: RecipeCardProps) { + const navigate = useNavigate() + + return ( + + +
navigate(`/recipes/${recipe.id}`)} + > + {recipe.title} +
+
+

{recipe.title}

+

{recipe.description || "Aucune description disponible"}

+
+
+ +
+ {recipe.difficulty === "Facile" && ( + + Facile + + )} + {recipe.difficulty === "Moyen" && ( + + Moyen + + )} + {recipe.difficulty === "Difficile" && ( + + Difficile + + )} + {(recipe.preparationTime || 0) <= 30 && ( + + Rapide + + )} +
+
+ + +
+ {recipe.tags?.slice(0, 3).map((tag) => ( + + {tag} + + ))} +
+ +
+ {recipe.preparationTime && ( +
+ + {recipe.preparationTime} min +
+ )} + + {recipe.servings && ( +
+ + {recipe.servings} pers. +
+ )} +
+
+ + + + +
+ + +
+
+
+
+ ) +} +