diff --git a/frontend/package.json b/frontend/package.json index 65af04d..11a6e40 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-slot": "^1.1.2", "@radix-ui/react-tabs": "^1.1.3", "@radix-ui/react-toast": "^1.2.6", + "@radix-ui/react-tooltip": "^1.1.8", "@react-oauth/google": "^0.12.1", "@tailwindcss/vite": "^4.0.12", "axios": "^1.8.2", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 985bebf..cbcf44e 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -35,6 +35,9 @@ importers: '@radix-ui/react-toast': specifier: ^1.2.6 version: 1.2.6(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-tooltip': + specifier: ^1.1.8 + version: 1.1.8(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@react-oauth/google': specifier: ^0.12.1 version: 0.12.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) @@ -1163,6 +1166,19 @@ packages: '@types/react-dom': optional: true + '@radix-ui/react-tooltip@1.1.8': + resolution: {integrity: sha512-YAA2cu48EkJZdAMHC0dqo9kialOcRStbtiY4nJPaht7Ptrhcvpo+eDChaM6BIs8kL6a8Z5l5poiqLnXcNduOkA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + react-dom: ^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + '@radix-ui/react-use-callback-ref@1.1.0': resolution: {integrity: sha512-CasTfvsy+frcFkbXtSJ2Zu9JHpN8TYKxkgJGWbjiZhFivxaeW7rMeZt7QELGVLaYVfFMsKHjb7Ak0nMEe+2Vfw==} peerDependencies: @@ -4200,6 +4216,26 @@ snapshots: '@types/react': 19.0.10 '@types/react-dom': 19.0.4(@types/react@19.0.10) + '@radix-ui/react-tooltip@1.1.8(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + '@radix-ui/primitive': 1.1.1 + '@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-context': 1.1.1(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-dismissable-layer': 1.1.5(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-id': 1.1.0(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-popper': 1.2.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-portal': 1.1.4(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-presence': 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-primitive': 2.0.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@radix-ui/react-slot': 1.1.2(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-use-controllable-state': 1.1.0(@types/react@19.0.10)(react@19.0.0) + '@radix-ui/react-visually-hidden': 1.1.2(@types/react-dom@19.0.4(@types/react@19.0.10))(@types/react@19.0.10)(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + optionalDependencies: + '@types/react': 19.0.10 + '@types/react-dom': 19.0.4(@types/react@19.0.10) + '@radix-ui/react-use-callback-ref@1.1.0(@types/react@19.0.10)(react@19.0.0)': dependencies: react: 19.0.0 diff --git a/frontend/src/components/ui/sheet.tsx b/frontend/src/components/ui/sheet.tsx new file mode 100644 index 0000000..6906f5b --- /dev/null +++ b/frontend/src/components/ui/sheet.tsx @@ -0,0 +1,137 @@ +import * as React from "react" +import * as SheetPrimitive from "@radix-ui/react-dialog" +import { XIcon } from "lucide-react" + +import { cn } from "@/lib/utils" + +function Sheet({ ...props }: React.ComponentProps) { + return +} + +function SheetTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function SheetClose({ + ...props +}: React.ComponentProps) { + return +} + +function SheetPortal({ + ...props +}: React.ComponentProps) { + return +} + +function SheetOverlay({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetContent({ + className, + children, + side = "right", + ...props +}: React.ComponentProps & { + side?: "top" | "right" | "bottom" | "left" +}) { + return ( + + + + {children} + + + Close + + + + ) +} + +function SheetHeader({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetFooter({ className, ...props }: React.ComponentProps<"div">) { + return ( +
+ ) +} + +function SheetTitle({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function SheetDescription({ + className, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +export { + Sheet, + SheetTrigger, + SheetClose, + SheetContent, + SheetHeader, + SheetFooter, + SheetTitle, + SheetDescription, +} diff --git a/frontend/src/components/ui/tooltip.tsx b/frontend/src/components/ui/tooltip.tsx new file mode 100644 index 0000000..ee7ae86 --- /dev/null +++ b/frontend/src/components/ui/tooltip.tsx @@ -0,0 +1,59 @@ +import * as React from "react" +import * as TooltipPrimitive from "@radix-ui/react-tooltip" + +import { cn } from "@/lib/utils" + +function TooltipProvider({ + delayDuration = 0, + ...props +}: React.ComponentProps) { + return ( + + ) +} + +function Tooltip({ + ...props +}: React.ComponentProps) { + return ( + + + + ) +} + +function TooltipTrigger({ + ...props +}: React.ComponentProps) { + return +} + +function TooltipContent({ + className, + sideOffset = 0, + children, + ...props +}: React.ComponentProps) { + return ( + + + {children} + + + + ) +} + +export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider } diff --git a/frontend/src/hooks/useMobile.ts b/frontend/src/hooks/useMobile.ts new file mode 100644 index 0000000..c195b4b --- /dev/null +++ b/frontend/src/hooks/useMobile.ts @@ -0,0 +1,33 @@ +import { useState, useEffect } from 'react'; + +/** + * Hook qui détecte si l'appareil est un mobile en fonction de la largeur de l'écran + * @param breakpoint Largeur en pixels en dessous de laquelle on considère qu'il s'agit d'un mobile (défaut: 768px) + * @returns boolean indiquant si l'appareil est considéré comme mobile + */ +export function useMobile(breakpoint: number = 768): boolean { + // État initial basé sur la largeur de la fenêtre (si disponible côté client) + const [isMobile, setIsMobile] = useState( + typeof window !== 'undefined' ? window.innerWidth < breakpoint : false + ); + + useEffect(() => { + // Fonction pour mettre à jour l'état en fonction de la largeur de la fenêtre + const checkMobile = () => { + setIsMobile(window.innerWidth < breakpoint); + }; + + // Vérifier immédiatement au montage du composant + checkMobile(); + + // Ajouter un écouteur d'événement pour le redimensionnement de la fenêtre + window.addEventListener('resize', checkMobile); + + // Nettoyer l'écouteur d'événement lors du démontage du composant + return () => { + window.removeEventListener('resize', checkMobile); + }; + }, [breakpoint]); // Recalculer si le breakpoint change + + return isMobile; +} \ No newline at end of file diff --git a/frontend/src/pages/Recipes/RecipeForm.tsx b/frontend/src/pages/Recipes/RecipeForm.tsx index 4d4c86e..2d43202 100644 --- a/frontend/src/pages/Recipes/RecipeForm.tsx +++ b/frontend/src/pages/Recipes/RecipeForm.tsx @@ -6,35 +6,41 @@ import { useState, useEffect } from "react" import { useNavigate } from "react-router-dom" import { recipeService } from "@/api/recipe" import { Button } from "@/components/ui/button" -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 { Card } from "@/components/ui/card" +import { Mic, ArrowLeft, Trash2, ChefHat, Info, X, Check } from "lucide-react" +import { motion, AnimatePresence } from "framer-motion" import { KitchenIllustration } from "@/components/illustrations/KitchenIllustration" import { CookingLoader } from "@/components/illustrations/CookingLoader" import { useAudioRecorder } from "@/hooks/useAudioRecorder" +import { Sheet, SheetContent, SheetDescription, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet" +import { cn } from "@/lib/utils" +import { useMobile } from "@/hooks/useMobile" export default function RecipeForm() { const navigate = useNavigate() + const isMobile = useMobile() const { isLoading: isRecorderLoading, isRecording, currentRecording, startRecording, - stopRecording + stopRecording, } = useAudioRecorder() const [audioFile, setAudioFile] = useState(null) const [recordingStatus, setRecordingStatus] = useState<"idle" | "recording" | "processing">("idle") const [loading, setLoading] = useState(false) const [error, setError] = useState("") + const [recordingTime, setRecordingTime] = useState(0) + const [showTips, setShowTips] = useState(false) // Update audioFile when recording is available useEffect(() => { if (currentRecording) { fetch(currentRecording) - .then(res => res.blob()) - .then(blob => { + .then((res) => res.blob()) + .then((blob) => { const file = new File([blob], "recording.mp3", { type: "audio/mp3" }) setAudioFile(file) setRecordingStatus("idle") @@ -43,6 +49,21 @@ export default function RecipeForm() { } }, [currentRecording]) + // Recording timer + useEffect(() => { + let interval: NodeJS.Timeout | null = null + if (isRecording) { + interval = setInterval(() => { + setRecordingTime((prev) => prev + 1) + }, 1000) + } else { + setRecordingTime(0) + } + return () => { + if (interval) clearInterval(interval) + } + }, [isRecording]) + // Handle recording start const handleStartRecording = async () => { try { @@ -63,6 +84,13 @@ export default function RecipeForm() { } } + // Fonction pour réinitialiser l'enregistrement + const handleResetRecording = () => { + setAudioFile(null) + setRecordingStatus("idle") + setError("") + } + // Soumettre le formulaire const handleSubmit = async (e: React.FormEvent) => { e.preventDefault() @@ -88,143 +116,273 @@ export default function RecipeForm() { } } + // Format recording time + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60) + const secs = seconds % 60 + return `${mins.toString().padStart(2, "0")}:${secs.toString().padStart(2, "0")}` + } + return ( -
-
- -
+

Nouvelle Recette

+ + + + + + + Comment ça marche ? + +
    +
  1. Appuyez sur le bouton d'enregistrement
  2. +
  3. Listez clairement les ingrédients que vous avez à disposition
  4. +
  5. Terminez l'enregistrement
  6. +
  7. Notre IA créera une recette adaptée à vos ingrédients
  8. +
+
+
+
+
+ -
- {/* Illustrations */} -
- -

- Notre intelligence artificielle transforme votre façon de cuisiner -

-
+
+ {/* Main content */} +
+ {/* Illustration */} + + {!isRecording && !loading && ( + + + + )} + - - - 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 message */} + {error && ( -
- {error} -
+ +
+
{error}
+ +
+
)} +
- {recordingStatus !== "processing" && !loading && ( -
- {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. -

-
- )} - - {(recordingStatus === "processing" || loading) && ( -
+ {/* Recording UI */} + + {recordingStatus === "processing" || loading ? ( + -

Préparation de votre recette...

-

+

Préparation en cours...

+

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

-
- )} -
- - - - - {!audioFile && !isRecording && recordingStatus !== "processing" && !loading && ( - - )} + + +
+
+ +
+ - {isRecording && ( -
+ +
+ +
+
+ +
+ + + + {showTips && ( + +
+

Conseils pour un bon enregistrement :

+
    +
  • Parlez clairement et lentement
  • +
  • Listez un ingrédient à la fois
  • +
  • Précisez les quantités si possible
  • +
  • Mentionnez vos préférences culinaires
  • +
+
+
+ )} +
+
+ + ) : audioFile ? ( + - +
+
+
+ +
+

Enregistrement prêt !

+
+ +
+ +
+ +

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

+
+ +
+ +
+
+ +
+ +
+ + ) : ( + +
+

Qu'avez-vous dans votre frigo ?

+

+ Enregistrez les ingrédients que vous avez à disposition +

+
+ + - - Arrêter l'enregistrement -
- - )} + +
+ +
+
- {audioFile && !isRecording && recordingStatus !== "processing" && !loading && ( - - )} +

Appuyez pour commencer

+

+ Listez les ingrédients que vous avez à disposition et notre IA créera une recette adaptée +

- {loading && ( - + + + +
+

+ + Comment ça marche ? +

+
    +
  1. Enregistrez les ingrédients que vous avez
  2. +
  3. Notre IA analyse votre enregistrement
  4. +
  5. Recevez une recette personnalisée
  6. +
  7. Cuisinez et régalez-vous !
  8. +
+
+ )} - - -
-
+ +
+ +
) }