upgrade UI de fou

This commit is contained in:
Arthur Barre 2025-03-10 22:57:18 +01:00
parent 9c773e8e64
commit d5be543421
18 changed files with 1349 additions and 501 deletions

View File

@ -11,6 +11,7 @@
},
"dependencies": {
"@radix-ui/react-avatar": "^1.1.3",
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.6",
"@radix-ui/react-label": "^2.1.2",
"@radix-ui/react-select": "^2.1.6",

View File

@ -11,6 +11,9 @@ importers:
'@radix-ui/react-avatar':
specifier: ^1.1.3
version: 1.1.3(@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-checkbox':
specifier: ^1.1.4
version: 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-dialog':
specifier: ^1.1.6
version: 1.1.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)
@ -475,6 +478,19 @@ packages:
'@types/react-dom':
optional: true
'@radix-ui/react-checkbox@1.1.4':
resolution: {integrity: sha512-wP0CPAHq+P5I4INKe3hJrIa1WoNqqrejzW+zoU0rOvo1b9gDEJJFl2rYfO1PYJUQCc2H1WZxIJmyv9BS8i5fLw==}
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-collection@1.1.2':
resolution: {integrity: sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==}
peerDependencies:
@ -2201,6 +2217,22 @@ snapshots:
'@types/react': 19.0.10
'@types/react-dom': 19.0.4(@types/react@19.0.10)
'@radix-ui/react-checkbox@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)':
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-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-use-controllable-state': 1.1.0(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-use-previous': 1.1.0(@types/react@19.0.10)(react@19.0.0)
'@radix-ui/react-use-size': 1.1.0(@types/react@19.0.10)(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-collection@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)':
dependencies:
'@radix-ui/react-compose-refs': 1.1.1(@types/react@19.0.10)(react@19.0.0)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

View File

@ -0,0 +1,36 @@
"use client"
import { motion } from "framer-motion"
import type { LucideIcon } from "lucide-react"
interface HowItWorksStepProps {
icon: LucideIcon
title: string
description: string
step: number
delay?: number
}
export function HowItWorksStep({ icon: Icon, title, description, step, delay = 0 }: HowItWorksStepProps) {
return (
<motion.div
className="flex gap-4 items-start"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay }}
viewport={{ once: true }}
>
<div className="flex-shrink-0 w-12 h-12 rounded-full bg-gradient-to-br from-orange-500 to-amber-500 flex items-center justify-center text-white font-bold text-lg">
{step}
</div>
<div className="space-y-2">
<div className="flex items-center gap-2">
<Icon className="h-5 w-5 text-orange-500" />
<h3 className="text-xl font-bold">{title}</h3>
</div>
<p className="text-muted-foreground">{description}</p>
</div>
</motion.div>
)
}

View File

@ -0,0 +1,85 @@
import { Check } from "lucide-react"
import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
import { Badge } from "@/components/ui/badge"
import { Link } from "react-router-dom"
interface PricingFeature {
text: string
included: boolean
}
interface PricingCardProps {
name: string
description: string
price: string | null
duration?: string
features: PricingFeature[]
popular?: boolean
buttonText: string
buttonVariant?: "default" | "outline"
className?: string
}
export function PricingCard({
name,
description,
price,
duration = "mois",
features,
popular = false,
buttonText,
buttonVariant = "default",
className,
}: PricingCardProps) {
return (
<Card
className={`flex flex-col ${popular ? "border-orange-500 dark:border-orange-500 shadow-lg relative" : ""} ${className}`}
>
{popular && (
<Badge className="absolute -top-2 right-4 bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600">
Populaire
</Badge>
)}
<CardHeader>
<CardTitle className="text-xl">{name}</CardTitle>
<CardDescription>{description}</CardDescription>
</CardHeader>
<CardContent className="grid gap-4 flex-1">
<div className="flex items-baseline gap-1">
{price ? (
<>
<span className="text-3xl font-bold">{price}</span>
<span className="text-sm text-muted-foreground">/{duration}</span>
</>
) : (
<span className="text-3xl font-bold">Gratuit</span>
)}
</div>
<ul className="grid gap-2 text-sm">
{features.map((feature, index) => (
<li key={index} className="flex items-center gap-2">
<Check className={`h-4 w-4 ${feature.included ? "text-green-500" : "text-muted-foreground"}`} />
<span className={feature.included ? "" : "text-muted-foreground line-through"}>{feature.text}</span>
</li>
))}
</ul>
</CardContent>
<CardFooter>
<Link to="/auth/register" className="w-full">
<Button
variant={buttonVariant}
className={`w-full ${popular && buttonVariant === "default" ? "bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600" : ""}`}
>
{buttonText}
</Button>
</Link>
</CardFooter>
</Card>
)
}

View File

@ -0,0 +1,44 @@
import { Card, CardContent } from "@/components/ui/card"
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"
import { Star } from "lucide-react"
interface TestimonialCardProps {
quote: string
author: string
role: string
avatarSrc?: string
rating: number
}
export function TestimonialCard({ quote, author, role, avatarSrc, rating }: TestimonialCardProps) {
return (
<Card className="h-full">
<CardContent className="p-6 flex flex-col h-full">
<div className="flex mb-4">
{Array.from({ length: 5 }).map((_, i) => (
<Star key={i} className={`h-4 w-4 ${i < rating ? "fill-amber-400 text-amber-400" : "text-gray-300"}`} />
))}
</div>
<blockquote className="text-lg mb-6 flex-1">"{quote}"</blockquote>
<div className="flex items-center gap-3 mt-auto">
<Avatar>
<AvatarImage src={avatarSrc} alt={author} />
<AvatarFallback>
{author
.split(" ")
.map((n) => n[0])
.join("")}
</AvatarFallback>
</Avatar>
<div>
<p className="font-medium">{author}</p>
<p className="text-sm text-muted-foreground">{role}</p>
</div>
</div>
</CardContent>
</Card>
)
}

View File

@ -1,63 +1,73 @@
import { useState, useEffect } from "react";
import { Link, useLocation } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { GalleryVerticalEnd, Menu, X } from "lucide-react";
import { cn } from "@/lib/utils";
"use client"
import { useState, useEffect } from "react"
import { Link, useLocation } from "react-router-dom"
import { Button } from "@/components/ui/button"
import { Menu, X, LogOut, User, Heart, Home, BookOpen } from "lucide-react"
import { cn } from "@/lib/utils"
import { motion, AnimatePresence } from "framer-motion"
import { Logo } from "@/components/illustrations/Logo"
export function Header() {
const [isAuthenticated, setIsAuthenticated] = useState(false);
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
const location = useLocation();
const [isAuthenticated, setIsAuthenticated] = useState(false)
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
const location = useLocation()
useEffect(() => {
// Vérifier si l'utilisateur est authentifié
const token = localStorage.getItem("token");
setIsAuthenticated(!!token);
}, [location]); // Re-vérifier à chaque changement de route
const token = localStorage.getItem("token")
setIsAuthenticated(!!token)
// Fermer le menu mobile lors d'un changement de route
setIsMobileMenuOpen(false)
}, [location])
const toggleMobileMenu = () => {
setIsMobileMenuOpen(!isMobileMenuOpen);
};
setIsMobileMenuOpen(!isMobileMenuOpen)
}
const handleLogout = () => {
localStorage.removeItem("token")
setIsAuthenticated(false)
setIsMobileMenuOpen(false)
window.location.href = "/auth/login"
}
const navItems = [
{ name: "Accueil", path: "/", public: true },
{ name: "Recettes", path: "/recipes", public: true },
{ name: "Mes recettes", path: "/my-recipes", public: false },
{ name: "Favoris", path: "/favorites", public: false },
{ name: "Profil", path: "/profile", public: false },
];
{ name: "Accueil", path: "/", icon: Home, public: true },
{ name: "Recettes", path: "/recipes", icon: BookOpen, public: true },
// { name: "Mes recettes", path: "/my-recipes", icon: BookOpen, public: false },
{ name: "Favoris", path: "/favorites", icon: Heart, public: false },
{ name: "Profil", path: "/profile", icon: User, public: false },
]
const filteredNavItems = navItems.filter((item) => item.public || isAuthenticated)
return (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<header className="sticky top-0 z-50 w-full border-b bg-white/95 dark:bg-slate-900/95 backdrop-blur supports-[backdrop-filter]:bg-white/60 dark:supports-[backdrop-filter]:bg-slate-900/60">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8">
<div className="flex h-16 items-center justify-between">
<div className="flex items-center gap-2">
<Link to="/" className="flex items-center gap-2 font-medium">
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
<GalleryVerticalEnd className="size-4" />
</div>
<span className="hidden sm:inline-block">Freedge</span>
<Link to="/" className="flex items-center gap-2">
<Logo size="md" showText={true} />
</Link>
</div>
{/* Navigation desktop */}
<nav className="hidden md:flex items-center gap-6">
{navItems
.filter(item => item.public || isAuthenticated)
.map(item => (
<Link
key={item.path}
to={item.path}
className={cn(
"text-sm font-medium transition-colors hover:text-primary",
location.pathname === item.path
? "text-primary"
: "text-muted-foreground"
)}
>
{item.name}
</Link>
))}
{filteredNavItems.map((item) => (
<Link
key={item.path}
to={item.path}
className={cn(
"text-sm font-medium transition-colors hover:text-orange-500 flex items-center gap-1.5",
location.pathname === item.path ? "text-orange-500" : "text-muted-foreground",
)}
>
<item.icon className="h-4 w-4" />
{item.name}
</Link>
))}
</nav>
{/* Boutons d'authentification */}
@ -65,91 +75,104 @@ export function Header() {
{isAuthenticated ? (
<Button
variant="outline"
onClick={() => {
localStorage.removeItem("token");
setIsAuthenticated(false);
window.location.href = "/auth/login";
}}
onClick={handleLogout}
className="border-orange-200 hover:bg-orange-50 hover:text-orange-600 dark:border-orange-800 dark:hover:bg-orange-900/20 dark:hover:text-orange-400"
>
<LogOut className="mr-2 h-4 w-4" />
Déconnexion
</Button>
) : (
<>
<Link to="/auth/login">
<Button variant="ghost">Connexion</Button>
<Button variant="ghost" className="hover:text-orange-500">
Connexion
</Button>
</Link>
<Link to="/auth/register">
<Button>S'inscrire</Button>
<Button className="bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white">
S'inscrire
</Button>
</Link>
</>
)}
</div>
{/* Bouton menu mobile */}
<Button
variant="ghost"
size="icon"
className="md:hidden"
onClick={toggleMobileMenu}
>
{isMobileMenuOpen ? (
<X className="h-6 w-6" />
) : (
<Menu className="h-6 w-6" />
)}
<Button variant="ghost" size="icon" className="md:hidden rounded-full" onClick={toggleMobileMenu}>
{isMobileMenuOpen ? <X className="h-6 w-6" /> : <Menu className="h-6 w-6" />}
</Button>
</div>
</div>
{/* Menu mobile */}
{isMobileMenuOpen && (
<div className="md:hidden border-b">
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-4 pb-6">
<nav className="flex flex-col space-y-4">
{navItems
.filter(item => item.public || isAuthenticated)
.map(item => (
<Link
<AnimatePresence>
{isMobileMenuOpen && (
<motion.div
className="md:hidden border-b"
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: "auto" }}
exit={{ opacity: 0, height: 0 }}
transition={{ duration: 0.2 }}
>
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-4 pb-6">
<nav className="flex flex-col space-y-4">
{filteredNavItems.map((item, index) => (
<motion.div
key={item.path}
to={item.path}
className={cn(
"text-sm font-medium transition-colors hover:text-primary",
location.pathname === item.path
? "text-primary"
: "text-muted-foreground"
)}
onClick={() => setIsMobileMenuOpen(false)}
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: index * 0.05 }}
>
{item.name}
</Link>
<Link
to={item.path}
className={cn(
"flex items-center gap-3 py-2 text-sm font-medium transition-colors hover:text-orange-500",
location.pathname === item.path ? "text-orange-500" : "text-muted-foreground",
)}
onClick={() => setIsMobileMenuOpen(false)}
>
<item.icon className="h-5 w-5" />
{item.name}
</Link>
</motion.div>
))}
{isAuthenticated ? (
<Button
variant="outline"
onClick={() => {
localStorage.removeItem("token");
setIsAuthenticated(false);
setIsMobileMenuOpen(false);
window.location.href = "/auth/login";
}}
<motion.div
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: filteredNavItems.length * 0.05 }}
className="pt-2"
>
Déconnexion
</Button>
) : (
<div className="flex flex-col space-y-2">
<Link to="/auth/login" onClick={() => setIsMobileMenuOpen(false)}>
<Button variant="ghost" className="w-full">Connexion</Button>
</Link>
<Link to="/auth/register" onClick={() => setIsMobileMenuOpen(false)}>
<Button className="w-full">S'inscrire</Button>
</Link>
</div>
)}
</nav>
</div>
</div>
)}
{isAuthenticated ? (
<Button
variant="outline"
onClick={handleLogout}
className="w-full border-orange-200 hover:bg-orange-50 hover:text-orange-600 dark:border-orange-800 dark:hover:bg-orange-900/20 dark:hover:text-orange-400"
>
<LogOut className="mr-2 h-4 w-4" />
Déconnexion
</Button>
) : (
<div className="flex flex-col space-y-2">
<Link to="/auth/login" onClick={() => setIsMobileMenuOpen(false)}>
<Button variant="ghost" className="w-full hover:text-orange-500">
Connexion
</Button>
</Link>
<Link to="/auth/register" onClick={() => setIsMobileMenuOpen(false)}>
<Button className="w-full bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white">
S'inscrire
</Button>
</Link>
</div>
)}
</motion.div>
</nav>
</div>
</motion.div>
)}
</AnimatePresence>
</header>
);
}
)
}

View File

@ -1,8 +1,23 @@
import { motion } from "framer-motion";
export function KitchenIllustration() {
interface KitchenIllustrationProps {
height?: number;
}
export function KitchenIllustration({ height = 200 }: KitchenIllustrationProps) {
// Calculate width based on original ratio (280:200)
const ratio = 280 / 200;
const width = height * ratio;
return (
<svg width="280" height="200" viewBox="0 0 280 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<svg
width={width}
height={height}
viewBox="0 0 280 200"
fill="none"
xmlns="http://www.w3.org/2000/svg"
style={{ maxWidth: '100%' }}
>
{/* 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" />

View File

@ -0,0 +1,61 @@
import { motion } from "framer-motion";
interface LogoProps {
size?: "sm" | "md" | "lg";
showText?: boolean;
}
export function Logo({ size = "md", showText = true }: LogoProps) {
const sizes = {
sm: { container: 24, icon: 18 },
md: { container: 32, icon: 24 },
lg: { container: 40, icon: 30 }
};
const containerSize = sizes[size].container;
const iconSize = sizes[size].icon;
return (
<div className="flex items-center gap-2">
<div
className="relative flex items-center justify-center rounded-lg bg-gradient-to-br from-orange-500 to-amber-500"
style={{ width: containerSize, height: containerSize }}
>
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.3 }}
>
{/* Fridge SVG Icon */}
<svg
width={iconSize}
height={iconSize}
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="text-white"
>
<rect x="4" y="2" width="16" height="20" rx="2" stroke="currentColor" strokeWidth="2" />
<line x1="4" y1="10" x2="20" y2="10" stroke="currentColor" strokeWidth="2" />
<circle cx="7" cy="7" r="1" fill="currentColor" />
<circle cx="7" cy="14" r="1" fill="currentColor" />
<line x1="10" y1="6" x2="16" y2="6" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<line x1="10" y1="14" x2="16" y2="14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
<line x1="10" y1="17" x2="14" y2="17" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</motion.div>
</div>
{showText && (
<motion.span
className="text-lg font-bold bg-gradient-to-r from-orange-600 to-amber-600 bg-clip-text text-transparent dark:from-orange-400 dark:to-amber-400"
initial={{ opacity: 0, x: -5 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.3, delay: 0.1 }}
>
Freedge
</motion.span>
)}
</div>
);
}

View File

@ -0,0 +1,72 @@
import { motion } from "framer-motion";
export function RecipeDetailLoader() {
return (
<div className="flex flex-col items-center justify-center py-12">
<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>
<p className="mt-4 text-center font-medium">Préparation de votre recette...</p>
</div>
);
}

View File

@ -5,7 +5,6 @@ import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input"
import { Label } from "@/components/ui/label"
import { login } from "@/api/auth"
export function LoginForm({
className,
...props
@ -40,9 +39,9 @@ export function LoginForm({
return (
<form className={cn("flex flex-col gap-6", className)} {...props} onSubmit={handleSubmit}>
<div className="flex flex-col items-center gap-2 text-center">
<h1 className="text-2xl font-bold">Login to your account</h1>
<h1 className="text-2xl font-bold">Connexion à votre compte</h1>
<p className="text-muted-foreground text-sm text-balance">
Enter your email below to login to your account
Entrez votre email ci-dessous pour vous connecter à votre compte
</p>
</div>
{error && (
@ -56,7 +55,7 @@ export function LoginForm({
<Input
id="email"
type="email"
placeholder="m@example.com"
placeholder="m@exemple.com"
value={email}
onChange={(e) => setEmail(e.target.value)}
required
@ -64,12 +63,12 @@ export function LoginForm({
</div>
<div className="grid gap-3">
<div className="flex items-center">
<Label htmlFor="password">Password</Label>
<Label htmlFor="password">Mot de passe</Label>
<a
href="#"
className="ml-auto text-sm underline-offset-4 hover:underline"
>
Forgot your password?
Mot de passe oublié ?
</a>
</div>
<Input
@ -81,11 +80,11 @@ export function LoginForm({
/>
</div>
<Button type="submit" className="w-full" disabled={loading}>
{loading ? "Connexion en cours..." : "Login"}
{loading ? "Connexion en cours..." : "Se connecter"}
</Button>
<div className="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t">
<span className="bg-background text-muted-foreground relative z-10 px-2">
Or continue with
<span className="text-muted-foreground relative z-10 px-2">
Ou continuer avec
</span>
</div>
<Button variant="outline" className="w-full">
@ -95,13 +94,13 @@ export function LoginForm({
fill="currentColor"
/>
</svg>
Login with Google
Se connecter avec Google
</Button>
</div>
<div className="text-center text-sm">
Don&apos;t have an account?{" "}
Vous n'avez pas de compte ?{" "}
<a href="/register" className="underline underline-offset-4">
Sign up
S'inscrire
</a>
</div>
</form>

View File

@ -5,7 +5,7 @@ import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-[color,box-shadow] disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive cursor-pointer",
{
variants: {
variant: {
@ -14,7 +14,7 @@ const buttonVariants = cva(
destructive:
"bg-destructive text-white shadow-xs hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40",
outline:
"border border-input bg-background shadow-xs hover:bg-accent hover:text-accent-foreground",
"border border-input bg-transparent shadow-xs hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground shadow-xs hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",

View File

@ -0,0 +1,30 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { CheckIcon } from "lucide-react"
import { cn } from "@/lib/utils"
function Checkbox({
className,
...props
}: React.ComponentProps<typeof CheckboxPrimitive.Root>) {
return (
<CheckboxPrimitive.Root
data-slot="checkbox"
className={cn(
"peer border-input data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=checked]:border-primary focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive size-4 shrink-0 rounded-[4px] border shadow-xs transition-shadow outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
data-slot="checkbox-indicator"
className="flex items-center justify-center text-current transition-none"
>
<CheckIcon className="size-3.5" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
}
export { Checkbox }

View File

@ -1,10 +1,11 @@
import { GalleryVerticalEnd } from "lucide-react"
import { LoginForm } from "@/components/login-form"
import PastaVongole from "@/assets/pasta-alla-vongole-home.jpeg"
export default function Login() {
return (
<div className="grid min-h-svh lg:grid-cols-2">
<div className="flex flex-col gap-4 p-6 md:p-10">
<div className="flex flex-col gap-4 p-6 md:p-10 bg-gradient-to-b from-amber-50 to-orange-50 dark:from-slate-950 dark:to-slate-900">
<div className="flex justify-center gap-2 md:justify-start">
<a href="#" className="flex items-center gap-2 font-medium">
<div className="flex h-6 w-6 items-center justify-center rounded-md bg-primary text-primary-foreground">
@ -21,7 +22,7 @@ export default function Login() {
</div>
<div className="relative hidden bg-muted lg:block">
<img
src="/placeholder.svg"
src={PastaVongole}
alt="Image"
className="absolute inset-0 h-full w-full object-cover dark:brightness-[0.2] dark:grayscale"
/>

View File

@ -1,90 +1,444 @@
import { Link } from "react-router-dom";
import { Button } from "@/components/ui/button";
import { ArrowRight, ChefHat, Heart, Search } from "lucide-react";
"use client"
import { Link } from "react-router-dom"
import { Button } from "@/components/ui/button"
import { ArrowRight, Heart, Sparkles, Refrigerator, Camera, Mic, Clock, Utensils, Zap } from "lucide-react"
import { motion } from "framer-motion"
import { PricingCard } from "@/components/PricingCard"
import { TestimonialCard } from "@/components/TestimonialCard"
import { HowItWorksStep } from "@/components/HowItWorksStep"
import { KitchenIllustration } from "@/components/illustrations/KitchenIllustration"
import PastaVongole from "@/assets/pasta-alla-vongole-home.jpeg"
export default function Home() {
return (
<div className="space-y-16">
{/* Hero section */}
<section className="py-12 md:py-20">
<div className="md:space-y-24 space-y-4 pb-16">
<section className="py-12 md:py-20 flex justify-center">
<div className="container px-4 md:px-6">
<div className="grid gap-6 lg:grid-cols-2 lg:gap-12">
<div className="space-y-4">
<h1 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
Découvrez et partagez des recettes délicieuses
<div className="grid gap-6 lg:grid-cols-2 lg:gap-12 items-center">
<motion.div
className="space-y-4"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
>
<div className="inline-block rounded-lg bg-orange-100 dark:bg-orange-900/30 px-3 py-1 text-sm text-orange-700 dark:text-orange-300 mb-2">
<span className="flex items-center gap-1">
<Sparkles className="h-3.5 w-3.5" />
Propulsé par l'IA
</span>
</div>
<h1 className="text-4xl font-bold tracking-tighter sm:text-5xl md:text-6xl bg-gradient-to-r from-orange-600 to-amber-600 bg-clip-text text-transparent dark:from-orange-400 dark:to-amber-400">
Des recettes délicieuses avec ce que vous avez déjà
</h1>
<p className="max-w-[600px] text-gray-500 md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed dark:text-gray-400">
Freedge vous aide à trouver des recettes adaptées à vos ingrédients disponibles et à partager vos créations culinaires avec la communauté.
<p className="max-w-[600px] text-lg text-muted-foreground md:text-xl">
Freedge analyse les ingrédients de votre frigo et vous propose des recettes personnalisées en quelques
secondes. Fini le gaspillage alimentaire !
</p>
<div className="flex flex-col gap-2 min-[400px]:flex-row">
<div className="flex flex-col gap-3 min-[400px]:flex-row">
<Link to="/recipes">
<Button className="flex gap-1">
Explorer les recettes
<ArrowRight className="h-4 w-4" />
<Button className="bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white">
Commencer maintenant
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
<Link to="/auth/register">
<Button variant="outline">Créer un compte</Button>
<Button
variant="outline"
className="border-orange-200 hover:bg-orange-50 hover:text-orange-600 dark:border-orange-800 dark:hover:bg-orange-900/20 dark:hover:text-orange-400"
>
Créer un compte
</Button>
</Link>
</div>
</div>
<div className="flex items-center justify-center">
<img
alt="Hero Image"
className="mx-auto aspect-video overflow-hidden rounded-xl object-cover object-center sm:w-full"
height="310"
src="/images/hero-image.jpg"
width="550"
/>
</div>
</motion.div>
<motion.div
className="flex items-center justify-center relative"
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.5, delay: 0.2 }}
>
<div className="absolute -z-10 h-full w-full bg-gradient-to-br from-orange-100 to-amber-100 dark:from-orange-900/20 dark:to-amber-900/20 rounded-3xl blur-2xl" />
<div className="bg-white dark:bg-slate-800 rounded-3xl shadow-xl overflow-hidden border border-orange-100 dark:border-orange-900/30">
<img
alt="Recettes personnalisées"
className="w-full aspect-[4/3] object-cover"
src={PastaVongole}
/>
<div className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="font-bold text-lg">Pasta Primavera</h3>
<div className="flex items-center gap-1 text-sm">
<Clock className="h-4 w-4 text-orange-500" />
<span>25 min</span>
</div>
</div>
<p className="text-sm text-muted-foreground mb-4">
Créé avec les ingrédients de votre frigo : pâtes, tomates, courgettes, basilic
</p>
<div className="flex justify-between items-center">
<div className="flex gap-1">
<span className="inline-flex items-center rounded-full bg-green-100 dark:bg-green-900/30 px-2.5 py-0.5 text-xs font-medium text-green-700 dark:text-green-300">
Facile
</span>
<span className="inline-flex items-center rounded-full bg-blue-100 dark:bg-blue-900/30 px-2.5 py-0.5 text-xs font-medium text-blue-700 dark:text-blue-300">
Végétarien
</span>
</div>
<Button variant="ghost" size="sm" className="rounded-full">
<Heart className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</motion.div>
</div>
</div>
</section>
{/* Features section */}
<section className="py-12 md:py-20">
{/* AI Features section */}
<section className="py-12 md:py-20 flex justify-center bg-gradient-to-b from-orange-50 to-amber-50 dark:from-slate-900 dark:to-slate-900/80">
<div className="container px-4 md:px-6">
<div className="flex flex-col items-center justify-center space-y-4 text-center">
<motion.div
className="flex flex-col items-center justify-center space-y-4 text-center mb-12"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
<div className="space-y-2">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
Fonctionnalités principales
L'IA au service de votre cuisine
</h2>
<p className="max-w-[900px] text-gray-500 md:text-xl/relaxed lg:text-base/relaxed xl:text-xl/relaxed dark:text-gray-400">
Découvrez tout ce que Freedge peut faire pour vous
<p className="max-w-[900px] text-muted-foreground md:text-xl/relaxed">
Notre intelligence artificielle transforme votre façon de cuisiner
</p>
</div>
</div>
<div className="mx-auto grid max-w-5xl grid-cols-1 gap-6 py-12 md:grid-cols-3 lg:gap-12">
<div className="flex flex-col items-center space-y-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<Search className="h-8 w-8 text-primary" />
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-12 items-center">
<motion.div
className="order-2 md:order-1"
initial={{ opacity: 0, x: -20 }}
whileInView={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
<div className="space-y-8">
<div className="space-y-2">
<h3 className="text-2xl font-bold">Dites adieu au gaspillage alimentaire</h3>
<p className="text-muted-foreground">
Notre IA analyse les ingrédients que vous avez déjà et vous propose des recettes adaptées, vous
permettant d'utiliser ce que vous avez sous la main.
</p>
</div>
<div className="space-y-4">
<div className="flex items-start gap-3">
<div className="bg-orange-100 dark:bg-orange-900/30 p-2 rounded-lg">
<Mic className="h-5 w-5 text-orange-600 dark:text-orange-400" />
</div>
<div>
<h4 className="font-medium">Dictez vos ingrédients</h4>
<p className="text-sm text-muted-foreground">
Énumérez simplement ce que vous avez dans votre frigo
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="bg-orange-100 dark:bg-orange-900/30 p-2 rounded-lg">
<Camera className="h-5 w-5 text-orange-600 dark:text-orange-400" />
</div>
<div>
<h4 className="font-medium">Scannez votre frigo</h4>
<p className="text-sm text-muted-foreground">
Prenez une photo et notre IA identifie vos ingrédients
</p>
</div>
</div>
<div className="flex items-start gap-3">
<div className="bg-orange-100 dark:bg-orange-900/30 p-2 rounded-lg">
<Sparkles className="h-5 w-5 text-orange-600 dark:text-orange-400" />
</div>
<div>
<h4 className="font-medium">Recettes personnalisées</h4>
<p className="text-sm text-muted-foreground">
Obtenez des suggestions adaptées à vos préférences et restrictions
</p>
</div>
</div>
</div>
<Link to="/recipes/new">
<Button className="bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white">
Essayer maintenant
<Zap className="ml-2 h-4 w-4" />
</Button>
</Link>
</div>
<h3 className="text-xl font-bold">Recherche intelligente</h3>
<p className="text-gray-500 dark:text-gray-400">
Trouvez des recettes en fonction des ingrédients que vous avez déjà chez vous.
</p>
</div>
<div className="flex flex-col items-center space-y-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<ChefHat className="h-8 w-8 text-primary" />
</motion.div>
<motion.div
className="order-1 md:order-2 flex justify-center"
initial={{ opacity: 0, x: 20 }}
whileInView={{ opacity: 1, x: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
<div className="relative w-full max-w-md">
<div className="absolute -z-10 h-full w-full bg-gradient-to-br from-orange-100 to-amber-100 dark:from-orange-900/20 dark:to-amber-900/20 rounded-3xl blur-2xl" />
<KitchenIllustration />
</div>
<h3 className="text-xl font-bold">Créez et partagez</h3>
<p className="text-gray-500 dark:text-gray-400">
Ajoutez vos propres recettes et partagez-les avec la communauté.
</p>
</div>
<div className="flex flex-col items-center space-y-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10">
<Heart className="h-8 w-8 text-primary" />
</div>
<h3 className="text-xl font-bold">Favoris personnalisés</h3>
<p className="text-gray-500 dark:text-gray-400">
Enregistrez vos recettes préférées pour y accéder rapidement.
</p>
</div>
</motion.div>
</div>
</div>
</section>
{/* How it works section */}
<section className="py-12 md:py-20 flex justify-center">
<div className="container px-4 md:px-6">
<motion.div
className="flex flex-col items-center justify-center space-y-4 text-center mb-12"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
<div className="space-y-2">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">Comment ça marche</h2>
<p className="max-w-[900px] text-muted-foreground md:text-xl/relaxed">
En trois étapes simples, transformez les ingrédients de votre frigo en délicieux repas
</p>
</div>
</motion.div>
<div className="max-w-3xl mx-auto space-y-12">
<HowItWorksStep
icon={Refrigerator}
title="Listez vos ingrédients"
description="Dictez ou scannez les ingrédients disponibles dans votre réfrigérateur. Notre IA les identifie automatiquement."
step={1}
delay={0.1}
/>
<HowItWorksStep
icon={Sparkles}
title="L'IA génère des recettes"
description="Notre intelligence artificielle analyse vos ingrédients et crée des recettes personnalisées adaptées à ce que vous avez sous la main."
step={2}
delay={0.2}
/>
<HowItWorksStep
icon={Utensils}
title="Cuisinez et savourez"
description="Suivez les instructions étape par étape et préparez un délicieux repas sans avoir à faire de courses supplémentaires."
step={3}
delay={0.3}
/>
</div>
</div>
</section>
{/* Pricing section */}
<section className="py-12 md:py-20 flex justify-center bg-gradient-to-b from-orange-50 to-amber-50 dark:from-slate-900 dark:to-slate-900/80">
<div className="container px-4 md:px-6">
<motion.div
className="flex flex-col items-center justify-center space-y-4 text-center mb-12"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
<div className="space-y-2">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">Plans d'abonnement</h2>
<p className="max-w-[900px] text-muted-foreground md:text-xl/relaxed">
Choisissez le plan qui correspond à vos besoins culinaires
</p>
</div>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
viewport={{ once: true }}
>
<PricingCard
name="Gratuit"
description="Parfait pour découvrir l'application"
price={null}
features={[
{ text: "3 recettes par mois", included: true },
{ text: "Reconnaissance vocale des ingrédients", included: true },
{ text: "Recettes personnalisées", included: true },
{ text: "Sauvegarde des recettes", included: true },
{ text: "Reconnaissance par photo", included: false },
{ text: "Recettes premium", included: false },
{ text: "Support prioritaire", included: false },
]}
buttonText="S'inscrire gratuitement"
buttonVariant="outline"
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
viewport={{ once: true }}
>
<PricingCard
name="Essentiel"
description="Pour les cuisiniers réguliers"
price="3"
features={[
{ text: "15 recettes par mois", included: true },
{ text: "Reconnaissance vocale des ingrédients", included: true },
{ text: "Recettes personnalisées", included: true },
{ text: "Sauvegarde des recettes", included: true },
{ text: "Reconnaissance par photo", included: true },
{ text: "Recettes premium", included: false },
{ text: "Support prioritaire", included: false },
]}
buttonText="Choisir Essentiel"
popular={true}
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
viewport={{ once: true }}
>
<PricingCard
name="Premium"
description="Pour les passionnés de cuisine"
price="5"
features={[
{ text: "Recettes illimitées", included: true },
{ text: "Reconnaissance vocale des ingrédients", included: true },
{ text: "Recettes personnalisées", included: true },
{ text: "Sauvegarde des recettes", included: true },
{ text: "Reconnaissance par photo", included: true },
{ text: "Recettes premium", included: true },
{ text: "Support prioritaire", included: true },
]}
buttonText="Choisir Premium"
/>
</motion.div>
</div>
</div>
</section>
{/* Testimonials section */}
<section className="py-12 md:py-20 flex justify-center">
<div className="container px-4 md:px-6">
<motion.div
className="flex flex-col items-center justify-center space-y-4 text-center mb-12"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
<div className="space-y-2">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
Ce que disent nos utilisateurs
</h2>
<p className="max-w-[900px] text-muted-foreground md:text-xl/relaxed">
Découvrez comment Freedge transforme la façon dont les gens cuisinent
</p>
</div>
</motion.div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.1 }}
viewport={{ once: true }}
>
<TestimonialCard
quote="Freedge a complètement changé ma façon de cuisiner. Je ne jette plus rien et je découvre de nouvelles recettes chaque semaine !"
author="Sophie Martin"
role="Utilisatrice depuis 6 mois"
rating={5}
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.2 }}
viewport={{ once: true }}
>
<TestimonialCard
quote="L'IA est impressionnante ! J'ai dicté les 5 ingrédients qui me restaient et j'ai obtenu une recette délicieuse en quelques secondes."
author="Thomas Dubois"
role="Abonné Premium"
rating={5}
/>
</motion.div>
<motion.div
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5, delay: 0.3 }}
viewport={{ once: true }}
>
<TestimonialCard
quote="Je fais des économies considérables sur mes courses depuis que j'utilise Freedge. Plus besoin d'acheter des ingrédients spécifiques pour chaque recette."
author="Julie Moreau"
role="Utilisatrice depuis 3 mois"
rating={4}
/>
</motion.div>
</div>
</div>
</section>
{/* CTA section */}
<section className="py-12 md:py-20 flex justify-center">
<div className="container px-4 md:px-6">
<motion.div
className="rounded-3xl bg-gradient-to-r from-orange-500 to-amber-500 p-8 md:p-12 lg:p-16 text-white relative overflow-hidden"
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
transition={{ duration: 0.5 }}
viewport={{ once: true }}
>
<div className="absolute inset-0 bg-[url('/placeholder.svg?height=600&width=800')] opacity-10 bg-cover bg-center mix-blend-overlay" />
<div className="relative z-10 max-w-3xl mx-auto text-center space-y-6">
<h2 className="text-3xl font-bold tracking-tighter sm:text-4xl md:text-5xl">
Prêt à transformer votre façon de cuisiner ?
</h2>
<p className="text-white/80 md:text-xl/relaxed">
Rejoignez des milliers d'utilisateurs qui économisent du temps, de l'argent et réduisent leur gaspillage
alimentaire grâce à Freedge.
</p>
<div className="flex flex-col sm:flex-row gap-4 justify-center">
<Link to="/auth/register">
<Button className="bg-white text-orange-600 hover:bg-white/90 hover:text-orange-700">
Commencer gratuitement
<ArrowRight className="ml-2 h-4 w-4" />
</Button>
</Link>
<Link to="/recipes">
<Button variant="outline" className="border-white text-white hover:bg-white/20">
Explorer les recettes
</Button>
</Link>
</div>
</div>
</motion.div>
</div>
</section>
</div>
);
}
)
}

View File

@ -1,7 +1,9 @@
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import { recipeService, Recipe } from "@/api/recipe";
import { Button } from "@/components/ui/button";
"use client"
import { useState, useEffect, useRef } from "react"
import { useParams, useNavigate } from "react-router-dom"
import { recipeService, type Recipe } from "@/api/recipe"
import { Button } from "@/components/ui/button"
import {
Clock,
Users,
@ -11,132 +13,175 @@ import {
ArrowLeft,
Trash2,
Edit,
HeartOff
} from "lucide-react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
Printer,
CheckCircle2,
Timer,
ChevronUp,
} from "lucide-react"
import { motion, AnimatePresence } from "framer-motion"
import { RecipeDetailLoader } from "@/components/illustrations/RecipeDetailLoader"
import { Badge } from "@/components/ui/badge"
import { Card, CardContent } from "@/components/ui/card"
import { Checkbox } from "@/components/ui/checkbox"
export default function RecipeDetail() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { id } = useParams<{ id: string }>()
const navigate = useNavigate()
const contentRef = useRef<HTMLDivElement>(null)
const [recipe, setRecipe] = useState<Recipe | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [isFavorite, setIsFavorite] = useState(false);
const [addingToFavorites, setAddingToFavorites] = useState(false);
const [recipe, setRecipe] = useState<Recipe | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState("")
const [isFavorite, setIsFavorite] = useState(false)
const [addingToFavorites, setAddingToFavorites] = useState(false)
const [checkedIngredients, setCheckedIngredients] = useState<Set<number>>(new Set())
const [checkedSteps, setCheckedSteps] = useState<Set<number>>(new Set())
const [showScrollTop, setShowScrollTop] = useState(false)
useEffect(() => {
const fetchRecipeDetails = async () => {
if (!id) return;
if (!id) return
try {
setLoading(true);
setLoading(true)
// ✅ GET RECIPE DETAILS
const recipeData = await recipeService.getRecipeById(id);
const recipeData = await recipeService.getRecipeById(id)
// Optionnel : conversion ingrédients / instructions si nécessaire
let ingredients = recipeData.ingredients;
let ingredients = recipeData.ingredients
if (typeof ingredients === "string") {
ingredients = ingredients.split("\n").filter(item => item.trim() !== "");
ingredients = ingredients.split("\n").filter((item) => item.trim() !== "")
}
// Correction: déclarer la variable instructions
let instructions: string[] = [];
let instructions: string[] = []
if (recipeData.generatedRecipe) {
instructions = recipeData.generatedRecipe
.split("\n")
.filter(item => item.trim() !== "");
instructions = recipeData.generatedRecipe.split("\n").filter((item) => item.trim() !== "")
}
setRecipe({
...recipeData,
ingredients,
instructions, // Ajouter instructions au recipe
});
})
// ✅ GET FAVORITE RECIPES & CHECK IF FAVORITE
try {
const favorites = await recipeService.getFavoriteRecipes();
setIsFavorite(favorites.some(fav => fav.id === id));
const favorites = await recipeService.getFavoriteRecipes()
setIsFavorite(favorites.some((fav) => fav.id === id))
} catch (favError) {
// Ignorer les erreurs de favoris pour ne pas bloquer l'affichage
console.log("Impossible de vérifier les favoris:", favError);
console.log("Impossible de vérifier les favoris:", favError)
}
} catch (err) {
console.error(err);
setError("Impossible de charger les détails de la recette");
console.error(err)
setError("Impossible de charger les détails de la recette")
} finally {
setLoading(false);
setLoading(false)
}
};
}
fetchRecipeDetails();
}, [id]);
fetchRecipeDetails()
// Setup scroll listener
const handleScroll = () => {
if (window.scrollY > 300) {
setShowScrollTop(true)
} else {
setShowScrollTop(false)
}
}
window.addEventListener("scroll", handleScroll)
return () => window.removeEventListener("scroll", handleScroll)
}, [id])
const handleToggleFavorite = async () => {
if (!id || !recipe) return;
if (!id || !recipe) return
try {
setAddingToFavorites(true);
setAddingToFavorites(true)
if (isFavorite) {
// ✅ REMOVE FROM FAVORITES
await recipeService.removeFromFavorites(id);
setIsFavorite(false);
await recipeService.removeFromFavorites(id)
setIsFavorite(false)
} else {
// ✅ ADD TO FAVORITES
await recipeService.addToFavorites(id);
setIsFavorite(true);
await recipeService.addToFavorites(id)
setIsFavorite(true)
}
} catch (err) {
console.error("Erreur lors de la modification des favoris:", err);
console.error("Erreur lors de la modification des favoris:", err)
// Ne pas afficher d'erreur à l'utilisateur pour cette fonctionnalité
} finally {
setAddingToFavorites(false);
setAddingToFavorites(false)
}
};
}
const handleDeleteRecipe = async () => {
if (!id) return;
if (!window.confirm("Êtes-vous sûr de vouloir supprimer cette recette ?")) return;
if (!id) return
if (!window.confirm("Êtes-vous sûr de vouloir supprimer cette recette ?")) return
try {
// ✅ DELETE RECIPE
await recipeService.deleteRecipe(id);
navigate("/recipes");
await recipeService.deleteRecipe(id)
navigate("/recipes")
} catch (err) {
console.error("Erreur lors de la suppression:", err);
console.error("Erreur lors de la suppression:", err)
// Optionnel: afficher un message d'erreur à l'utilisateur
}
};
}
const handleShare = () => {
if (!recipe) return;
if (!recipe) return
if (navigator.share) {
navigator.share({
title: recipe.title,
text: recipe.description || "Découvrez cette recette !",
url: window.location.href,
});
})
} else {
navigator.clipboard.writeText(window.location.href);
alert("Lien copié dans le presse-papier !");
navigator.clipboard.writeText(window.location.href)
alert("Lien copié dans le presse-papier !")
}
};
}
const handlePrint = () => {
window.print()
}
const toggleIngredient = (index: number) => {
const newChecked = new Set(checkedIngredients)
if (newChecked.has(index)) {
newChecked.delete(index)
} else {
newChecked.add(index)
}
setCheckedIngredients(newChecked)
}
const toggleStep = (index: number) => {
const newChecked = new Set(checkedSteps)
if (newChecked.has(index)) {
newChecked.delete(index)
} else {
newChecked.add(index)
}
setCheckedSteps(newChecked)
}
const scrollToTop = () => {
window.scrollTo({ top: 0, behavior: "smooth" })
}
// ========================================
// === LOADING & ERROR STATES ============
// ========================================
if (loading) {
return (
<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>
);
return <RecipeDetailLoader />
}
if (error || !recipe) {
@ -144,195 +189,268 @@ export default function RecipeDetail() {
<div className="rounded-md bg-red-50 p-6 text-center text-red-700 dark:bg-red-900/50 dark:text-red-200">
<h2 className="text-xl font-bold">Erreur</h2>
<p>{error || "Recette introuvable"}</p>
<Button
variant="outline"
className="mt-4"
onClick={() => navigate("/recipes")}
>
<Button variant="outline" className="mt-4" onClick={() => navigate("/recipes")}>
<ArrowLeft className="mr-2 h-4 w-4" />
Retour aux recettes
</Button>
</div>
);
)
}
// Calculate total time
const totalTime = (recipe.preparationTime || 0) + (recipe.cookingTime || 0)
// ========================================
// === MAIN COMPONENT =====================
// ========================================
return (
<div className="space-y-8">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<Button
variant="ghost"
className="w-fit"
onClick={() => navigate("/recipes")}
>
<ArrowLeft className="mr-2 h-4 w-4" />
Retour aux recettes
</Button>
<div className="min-h-screen bg-gradient-to-b from-amber-50 to-orange-50 dark:from-slate-950 dark:to-slate-900 pb-16">
{/* Header */}
<header className="sticky top-0 z-10 bg-white/80 dark:bg-slate-900/80 backdrop-blur-md border-b border-slate-200 dark:border-slate-800 print:hidden">
<div className="container px-4 py-3">
<div className="flex items-center justify-between">
<Button variant="ghost" className="flex items-center gap-2" onClick={() => navigate("/recipes")}>
<ArrowLeft className="h-4 w-4" />
Retour aux recettes
</Button>
<div className="flex gap-2">
<Button
variant="outline"
onClick={handleShare}
>
<Share2 className="mr-2 h-4 w-4" />
Partager
</Button>
<div className="flex gap-2">
<Button variant="ghost" size="icon" className="rounded-full" onClick={handlePrint}>
<Printer className="h-4 w-4" />
</Button>
<Button
variant={isFavorite ? "default" : "outline"}
onClick={handleToggleFavorite}
disabled={addingToFavorites}
>
{isFavorite ? (
<>
<HeartOff className="mr-2 h-4 w-4" />
Retirer des favoris
</>
) : (
<>
<Heart className="mr-2 h-4 w-4" />
Ajouter aux favoris
</>
)}
</Button>
<Button variant="ghost" size="icon" className="rounded-full" onClick={handleShare}>
<Share2 className="h-4 w-4" />
</Button>
<Button
variant="outline"
onClick={() => navigate(`/recipes/edit/${id}`)}
>
<Edit className="mr-2 h-4 w-4" />
Modifier
</Button>
<Button
variant="destructive"
onClick={handleDeleteRecipe}
>
<Trash2 className="mr-2 h-4 w-4" />
Supprimer
</Button>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-5">
<div className="lg:col-span-3">
<div className="overflow-hidden rounded-lg">
<img
src={recipe.imageUrl || "/images/recipe-placeholder.jpg"}
alt={recipe.title}
className="h-[300px] w-full object-cover sm:h-[400px]"
/>
</div>
</div>
<div className="space-y-4 lg:col-span-2">
<h1 className="text-3xl font-bold">{recipe.title}</h1>
<p className="text-muted-foreground">
{recipe.description || "Aucune description disponible"}
</p>
<div className="flex flex-wrap gap-2">
{recipe.tags?.map((tag) => (
<span
key={tag}
className="inline-flex items-center rounded-full bg-primary/10 px-3 py-1 text-sm font-medium text-primary"
<Button
variant={isFavorite ? "default" : "ghost"}
size="icon"
className={`rounded-full ${isFavorite ? "bg-red-500 hover:bg-red-600 text-white" : ""}`}
onClick={handleToggleFavorite}
disabled={addingToFavorites}
>
{tag}
</span>
))}
</div>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
{recipe.preparationTime && (
<div className="flex flex-col items-center rounded-lg border p-3">
<Clock className="mb-1 h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">Préparation</span>
<span className="text-lg font-bold">{recipe.preparationTime} min</span>
</div>
)}
{recipe.cookingTime && (
<div className="flex flex-col items-center rounded-lg border p-3">
<Clock className="mb-1 h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">Cuisson</span>
<span className="text-lg font-bold">{recipe.cookingTime} min</span>
</div>
)}
{recipe.servings && (
<div className="flex flex-col items-center rounded-lg border p-3">
<Users className="mb-1 h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">Portions</span>
<span className="text-lg font-bold">{recipe.servings}</span>
</div>
)}
{recipe.difficulty && (
<div className="flex flex-col items-center rounded-lg border p-3">
<ChefHat className="mb-1 h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">Difficulté</span>
<span className="text-lg font-bold capitalize">{recipe.difficulty}</span>
</div>
)}
<Heart className="h-4 w-4" />
</Button>
</div>
</div>
</div>
</div>
</header>
<Tabs defaultValue="ingredients" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="ingredients">Ingrédients</TabsTrigger>
<TabsTrigger value="instructions">Instructions</TabsTrigger>
</TabsList>
<TabsContent value="ingredients" className="mt-6">
<div className="rounded-lg border p-6">
<h2 className="mb-4 text-xl font-bold">Ingrédients</h2>
<ul className="space-y-2">
{Array.isArray(recipe.ingredients) ? (
recipe.ingredients.map((ingredient, index) => (
<li key={index} className="flex items-start">
<span className="mr-2 mt-1 h-2 w-2 rounded-full bg-primary"></span>
<span>{ingredient}</span>
</li>
))
) : (
<li className="flex items-start">
<span className="mr-2 mt-1 h-2 w-2 rounded-full bg-primary"></span>
<span>{recipe.ingredients}</span>
</li>
)}
</ul>
{/* Main content */}
<main className="container px-4 py-6" ref={contentRef}>
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ duration: 0.5 }}>
{/* Recipe Hero */}
<div className="relative rounded-xl overflow-hidden mb-8">
<img
src={recipe.imageUrl || "/placeholder.svg?height=400&width=800"}
alt={recipe.title}
className="w-full h-[300px] md:h-[400px] object-cover"
/>
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/30 to-transparent flex flex-col justify-end p-6">
<div className="flex flex-wrap gap-2 mb-3">
{recipe.tags?.map((tag) => (
<Badge key={tag} variant="secondary" className="bg-white/20 text-white border-none">
{tag}
</Badge>
))}
</div>
<h1 className="text-3xl md:text-4xl font-bold text-white mb-2">{recipe.title}</h1>
<p className="text-white/80 max-w-2xl">{recipe.description || "Aucune description disponible"}</p>
</div>
</div>
</TabsContent>
<TabsContent value="instructions" className="mt-6">
<div className="rounded-lg border p-6">
<h2 className="mb-4 text-xl font-bold">Instructions</h2>
<ol className="space-y-4">
{recipe.instructions && recipe.instructions.length > 0 ? (
recipe.instructions.map((instruction, index) => (
<li key={index} className="flex">
<span className="mr-4 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground">
{index + 1}
{/* Recipe Info Cards */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<Card className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-none shadow-sm">
<CardContent className="flex flex-col items-center justify-center p-4">
<Clock className="h-6 w-6 text-orange-500 mb-2" />
<span className="text-sm text-muted-foreground">Préparation</span>
<span className="text-lg font-bold">{recipe.preparationTime || 0} min</span>
</CardContent>
</Card>
<Card className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-none shadow-sm">
<CardContent className="flex flex-col items-center justify-center p-4">
<Timer className="h-6 w-6 text-orange-500 mb-2" />
<span className="text-sm text-muted-foreground">Cuisson</span>
<span className="text-lg font-bold">{recipe.cookingTime || 0} min</span>
</CardContent>
</Card>
<Card className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-none shadow-sm">
<CardContent className="flex flex-col items-center justify-center p-4">
<Users className="h-6 w-6 text-orange-500 mb-2" />
<span className="text-sm text-muted-foreground">Portions</span>
<span className="text-lg font-bold">{recipe.servings || "-"}</span>
</CardContent>
</Card>
<Card className="bg-white/80 dark:bg-slate-800/80 backdrop-blur-sm border-none shadow-sm">
<CardContent className="flex flex-col items-center justify-center p-4">
<ChefHat className="h-6 w-6 text-orange-500 mb-2" />
<span className="text-sm text-muted-foreground">Difficulté</span>
<span className="text-lg font-bold capitalize">{recipe.difficulty || "-"}</span>
</CardContent>
</Card>
</div>
{/* Recipe Content */}
<div className="grid md:grid-cols-3 gap-8">
{/* Ingredients */}
<div className="md:col-span-1">
<Card className="bg-white/90 dark:bg-slate-800/90 backdrop-blur-sm border-none shadow-sm sticky top-[85px]">
<CardContent className="p-6">
<h2 className="text-xl font-bold mb-4 flex items-center">
<span className="bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 w-8 h-8 rounded-full flex items-center justify-center mr-2">
1
</span>
<p className="pt-1">{instruction}</p>
</li>
))
) : (
<li className="flex">
<span className="mr-4 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-primary text-sm font-bold text-primary-foreground">
1
</span>
<p className="pt-1">{recipe.generatedRecipe}</p>
</li>
)}
</ol>
Ingrédients
</h2>
{totalTime > 0 && (
<div className="mb-4 p-3 bg-orange-50 dark:bg-orange-900/20 rounded-lg">
<p className="text-sm flex items-center">
<Clock className="h-4 w-4 mr-2 text-orange-500" />
Temps total: <span className="font-bold ml-1">{totalTime} min</span>
</p>
</div>
)}
<ul className="space-y-2">
{Array.isArray(recipe.ingredients) ? (
recipe.ingredients.map((ingredient, index) => (
<li key={index} className="flex items-center">
<Checkbox
id={`ingredient-${index}`}
checked={checkedIngredients.has(index)}
onCheckedChange={() => toggleIngredient(index)}
className="mr-2 data-[state=checked]:bg-orange-500 data-[state=checked]:border-orange-500"
/>
<label
htmlFor={`ingredient-${index}`}
className={`flex-1 cursor-pointer ${checkedIngredients.has(index) ? "line-through text-muted-foreground" : ""}`}
>
{ingredient}
</label>
</li>
))
) : (
<li className="flex items-center">
<Checkbox
id="ingredient-single"
checked={checkedIngredients.has(0)}
onCheckedChange={() => toggleIngredient(0)}
className="mr-2 data-[state=checked]:bg-orange-500 data-[state=checked]:border-orange-500"
/>
<label
htmlFor="ingredient-single"
className={`flex-1 cursor-pointer ${checkedIngredients.has(0) ? "line-through text-muted-foreground" : ""}`}
>
{recipe.ingredients}
</label>
</li>
)}
</ul>
</CardContent>
</Card>
</div>
{/* Instructions */}
<div className="md:col-span-2">
<Card className="bg-white/90 dark:bg-slate-800/90 backdrop-blur-sm border-none shadow-sm">
<CardContent className="p-6">
<h2 className="text-xl font-bold mb-6 flex items-center">
<span className="bg-orange-100 dark:bg-orange-900/30 text-orange-800 dark:text-orange-300 w-8 h-8 rounded-full flex items-center justify-center mr-2">
2
</span>
Instructions
</h2>
<ol className="space-y-6">
{recipe.instructions && recipe.instructions.length > 0 ? (
recipe.instructions.map((instruction, index) => (
<li key={index} className="flex">
<Checkbox
id={`step-${index}`}
checked={checkedSteps.has(index)}
onCheckedChange={() => toggleStep(index)}
className="mr-3 mt-1 data-[state=checked]:bg-orange-500 data-[state=checked]:border-orange-500"
/>
<div className="flex-1">
<label
htmlFor={`step-${index}`}
className={`flex items-center font-medium mb-2 cursor-pointer ${checkedSteps.has(index) ? "line-through text-muted-foreground" : ""}`}
>
Étape {index + 1}
{checkedSteps.has(index) && <CheckCircle2 className="ml-2 h-4 w-4 text-green-500" />}
</label>
<p className={checkedSteps.has(index) ? "text-muted-foreground" : ""}>{instruction}</p>
</div>
</li>
))
) : (
<li className="flex">
<Checkbox
id="step-single"
checked={checkedSteps.has(0)}
onCheckedChange={() => toggleStep(0)}
className="mr-3 mt-1 data-[state=checked]:bg-orange-500 data-[state=checked]:border-orange-500"
/>
<div className="flex-1">
<label
htmlFor="step-single"
className={`flex items-center font-medium mb-2 cursor-pointer ${checkedSteps.has(0) ? "line-through text-muted-foreground" : ""}`}
>
Instructions
{checkedSteps.has(0) && <CheckCircle2 className="ml-2 h-4 w-4 text-green-500" />}
</label>
<p className={checkedSteps.has(0) ? "text-muted-foreground" : ""}>{recipe.generatedRecipe}</p>
</div>
</li>
)}
</ol>
</CardContent>
</Card>
</div>
</div>
</TabsContent>
</Tabs>
{/* Admin Actions */}
<div className="mt-8 flex justify-end gap-2 print:hidden">
<Button variant="outline" onClick={() => navigate(`/recipes/edit/${id}`)}>
<Edit className="mr-2 h-4 w-4" />
Modifier
</Button>
<Button variant="destructive" onClick={handleDeleteRecipe}>
<Trash2 className="mr-2 h-4 w-4" />
Supprimer
</Button>
</div>
</motion.div>
</main>
{/* Scroll to top button */}
<AnimatePresence>
{showScrollTop && (
<motion.div
className="fixed bottom-8 right-8 print:hidden"
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.8 }}
transition={{ duration: 0.2 }}
>
<Button
onClick={scrollToTop}
className="h-12 w-12 rounded-full shadow-lg bg-white dark:bg-slate-800"
size="icon"
>
<ChevronUp className="h-6 w-6" />
</Button>
</motion.div>
)}
</AnimatePresence>
</div>
);
)
}

View File

@ -99,8 +99,8 @@ export default function RecipeForm() {
}
return (
<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">
<div className="h-full flex flex-col bg-gradient-to-b from-amber-50 to-orange-50 dark:from-slate-950 dark:to-slate-900">
<div className="p-2 sm:p-4">
<Button
variant="ghost"
className="cursor-pointer flex items-center gap-2"
@ -111,22 +111,27 @@ export default function RecipeForm() {
</Button>
</div>
<div className="w-full space-y-6 mt-4">
<div className="w-full space-y-4 sm:space-y-6 mt-2 sm:mt-4 px-2 sm:px-4 md:px-6 lg:px-8 max-w-3xl mx-auto">
{/* Illustrations */}
<div className="flex justify-center mb-8">
<KitchenIllustration />
<div className="flex flex-col items-center text-center mb-4 sm:mb-8">
<KitchenIllustration
height={150}
/>
<p className="max-w-[900px] text-muted-foreground text-sm md:text-xl/relaxed mt-2 sm:mt-4">
Notre intelligence artificielle transforme votre façon de cuisiner
</p>
</div>
<Card className="border-none shadow-lg">
<CardHeader>
<CardTitle className="text-2xl">Créer une nouvelle recette</CardTitle>
<CardDescription>
<CardHeader className="p-4 sm:p-6">
<CardTitle className="text-xl sm:text-2xl">Créer une nouvelle recette</CardTitle>
<CardDescription className="text-sm">
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>
<CardContent className="p-4 sm:p-6">
{error && (
<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}
@ -151,34 +156,6 @@ export default function RecipeForm() {
</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.
@ -197,7 +174,7 @@ export default function RecipeForm() {
)}
</CardContent>
<CardFooter className="flex justify-between">
<CardFooter className="p-4 sm:p-6 flex justify-between">
<Button
variant="outline"
className="cursor-pointer"
@ -207,56 +184,56 @@ export default function RecipeForm() {
Annuler
</Button>
<Button
className="cursor-pointer"
onClick={handleSubmit}
disabled={!audioFile || loading || recordingStatus === "processing" || isRecording}
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Création en cours...
</>
) : (
<>
<Upload className="mr-2 h-4 w-4" />
Créer la recette
</>
)}
</Button>
{!audioFile && !isRecording && recordingStatus !== "processing" && !loading && (
<Button
variant="default"
className="cursor-pointer"
onClick={startRecording}
>
<Mic className="mr-2 h-4 w-4" />
Commencer l'enregistrement
</Button>
)}
{isRecording && (
<Button
variant="destructive"
className="cursor-pointer"
onClick={stopRecording}
>
<motion.div
className="flex items-center"
animate={{ scale: [1, 1.05, 1] }}
transition={{ repeat: Number.POSITIVE_INFINITY, duration: 1.5 }}
>
<Mic className="mr-2 h-4 w-4" />
Arrêter l'enregistrement
</motion.div>
</Button>
)}
{audioFile && !isRecording && recordingStatus !== "processing" && !loading && (
<Button
className="cursor-pointer"
onClick={handleSubmit}
>
<Upload className="mr-2 h-4 w-4" />
Créer la recette
</Button>
)}
{loading && (
<Button
className="cursor-pointer"
disabled
>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Création en cours...
</Button>
)}
</CardFooter>
</Card>
</div>
{/* Recording button at bottom */}
{recordingStatus !== "processing" && !loading && !audioFile && (
<div className="md:hidden fixed bottom-8 left-0 right-0 flex justify-center">
<motion.div
initial={{ scale: 0.8, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
transition={{ duration: 0.3 }}
>
<Button
className={`h-16 w-16 rounded-full shadow-lg cursor-pointer ${isRecording ? "bg-red-500 hover:bg-red-600" : "bg-orange-500 hover:bg-orange-600"
}`}
size="icon"
onClick={isRecording ? stopRecording : startRecording}
>
{isRecording ? (
<motion.div
animate={{ scale: [1, 1.2, 1] }}
transition={{ repeat: Number.POSITIVE_INFINITY, duration: 1.5 }}
>
<Mic className="h-6 w-6" />
</motion.div>
) : (
<Mic className="h-6 w-6" />
)}
</Button>
</motion.div>
</div>
)}
</div >
)
}

View File

@ -89,7 +89,7 @@ export default function RecipeList() {
</motion.div>
)}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between mb-6 mt-6">
<div className="flex flex-col mx-4 md:mx-4 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>
@ -116,7 +116,7 @@ export default function RecipeList() {
<EmptyRecipes onCreateRecipe={handleCreateRecipe} />
) : (
<motion.div
className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3"
className="grid gap-6 my-6 mx-4 sm:grid-cols-2 lg:grid-cols-3"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ staggerChildren: 0.1 }}