feat(ui): polish across the app with new loaders, skeletons & animations
- ui/shimmer.tsx: reusable shimmer placeholder - recipe-card-skeleton.tsx: skeleton grid for loading states - CookingLoader: rebuilt as floating chef hat with orbiting sparkles - RecipeDetailLoader: now a proper skeleton of the detail page - PageTransition: smooth fade+lift on route change - index.css: custom keyframes (shimmer, float, glow-pulse), thin scrollbars, :focus-visible, safe-area utilities - RecipeList: skeleton grid, header with count, polished tabs, hover lift on cards, spring FAB on mobile - Header: scroll-aware blur/shadow, animated active underline, auto-close mobile menu on navigation - MainLayout: ambient blurred blobs in background, warm gradient - Home hero: gradient pill badge with wobbling Sparkles, CTA with sliding sheen - Login/Register buttons: brand gradient, inline spinners - Profile: skeleton loading state instead of plain spinner - RecipeForm streaming: glow halo behind image, blur-to-sharp reveal Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
d926ad89c5
commit
e7872df156
26
frontend/src/components/PageTransition.tsx
Normal file
26
frontend/src/components/PageTransition.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { motion } from "framer-motion"
|
||||
import { useLocation } from "react-router-dom"
|
||||
import { type ReactNode } from "react"
|
||||
|
||||
interface PageTransitionProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* Enveloppe chaque page dans une transition d'entrée subtile :
|
||||
* légère remontée + fade-in. Utilise la clé `location.pathname`
|
||||
* pour que framer-motion rejoue l'animation à chaque navigation.
|
||||
*/
|
||||
export function PageTransition({ children }: PageTransitionProps) {
|
||||
const location = useLocation()
|
||||
return (
|
||||
<motion.div
|
||||
key={location.pathname}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.35, ease: [0.22, 1, 0.36, 1] }}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
@ -12,8 +12,20 @@ import useAuth from "@/hooks/useAuth"
|
||||
export function Header() {
|
||||
const { isAuthenticated } = useAuth()
|
||||
const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false)
|
||||
const [scrolled, setScrolled] = useState(false)
|
||||
const location = useLocation()
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => setScrolled(window.scrollY > 8)
|
||||
onScroll()
|
||||
window.addEventListener("scroll", onScroll, { passive: true })
|
||||
return () => window.removeEventListener("scroll", onScroll)
|
||||
}, [])
|
||||
|
||||
// Ferme le menu mobile quand on change de route
|
||||
useEffect(() => {
|
||||
setIsMobileMenuOpen(false)
|
||||
}, [location.pathname])
|
||||
|
||||
const toggleMobileMenu = () => {
|
||||
setIsMobileMenuOpen(!isMobileMenuOpen)
|
||||
@ -36,32 +48,62 @@ export function Header() {
|
||||
const filteredNavItems = navItems.filter((item) => item.public || isAuthenticated)
|
||||
|
||||
return (
|
||||
<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">
|
||||
<header
|
||||
className={cn(
|
||||
"sticky top-0 z-50 w-full transition-all duration-300 safe-pt",
|
||||
scrolled
|
||||
? "bg-white/85 dark:bg-slate-950/85 backdrop-blur-xl shadow-[0_1px_0_0_rgba(0,0,0,0.04)] dark:shadow-[0_1px_0_0_rgba(255,255,255,0.06)]"
|
||||
: "bg-white/60 dark:bg-slate-950/40 backdrop-blur-md"
|
||||
)}
|
||||
>
|
||||
<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">
|
||||
<motion.div
|
||||
className="flex items-center gap-2"
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<Link to="/" className="flex items-center gap-2 group">
|
||||
<div className="transition-transform duration-500 group-hover:rotate-[-6deg]">
|
||||
<Logo size="md" showText={true} />
|
||||
</Link>
|
||||
</div>
|
||||
</Link>
|
||||
</motion.div>
|
||||
|
||||
{/* Navigation desktop */}
|
||||
{
|
||||
isAuthenticated && (
|
||||
<nav className="hidden md:flex items-center gap-10">
|
||||
{filteredNavItems.map((item) => (
|
||||
<nav className="hidden md:flex items-center gap-8">
|
||||
{filteredNavItems.map((item) => {
|
||||
const isActive = location.pathname === item.path ||
|
||||
(item.path !== "/" && location.pathname.startsWith(item.path))
|
||||
return (
|
||||
<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",
|
||||
"relative text-sm font-medium transition-colors flex items-center gap-1.5 py-5 group",
|
||||
isActive
|
||||
? "text-orange-600 dark:text-orange-400"
|
||||
: "text-muted-foreground hover:text-foreground"
|
||||
)}
|
||||
>
|
||||
<item.icon className="h-4 w-4" />
|
||||
<item.icon className={cn(
|
||||
"h-4 w-4 transition-transform",
|
||||
isActive && "scale-110"
|
||||
)} />
|
||||
{item.name}
|
||||
{/* Underline animé */}
|
||||
<span
|
||||
className={cn(
|
||||
"absolute inset-x-0 -bottom-px h-[2px] rounded-full bg-gradient-to-r from-orange-500 to-amber-500 transition-all duration-300",
|
||||
isActive ? "opacity-100 scale-x-100" : "opacity-0 scale-x-50 group-hover:opacity-40"
|
||||
)}
|
||||
/>
|
||||
</Link>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
|
||||
@ -1,69 +1,125 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { ChefHat, Sparkles } from "lucide-react";
|
||||
|
||||
interface CookingLoaderProps {
|
||||
/** Message affiché sous l'animation */
|
||||
label?: string;
|
||||
/** Taille (rayon) : 'sm' = 64px, 'md' = 96px, 'lg' = 128px */
|
||||
size?: "sm" | "md" | "lg";
|
||||
}
|
||||
|
||||
/**
|
||||
* Un loader moderne avec un chapeau de chef qui flotte et tourne doucement,
|
||||
* entouré d'étincelles orbitales. Remplace l'ancien "pot qui bouillonne"
|
||||
* dessiné en CSS.
|
||||
*/
|
||||
export function CookingLoader({ label, size = "md" }: CookingLoaderProps) {
|
||||
const sizePx = size === "sm" ? 64 : size === "lg" ? 128 : 96;
|
||||
const iconSize = size === "sm" ? 28 : size === "lg" ? 56 : 42;
|
||||
|
||||
export function CookingLoader() {
|
||||
return (
|
||||
<div className="relative w-24 h-24">
|
||||
{/* Pot */}
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-20 h-16 bg-gray-600 rounded-b-xl"></div>
|
||||
<div className="absolute top-0 w-24 h-4 bg-gray-500 rounded-t-xl"></div>
|
||||
</div>
|
||||
|
||||
{/* Bubbling animation */}
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-8">
|
||||
<div
|
||||
className="relative flex items-center justify-center"
|
||||
style={{ width: sizePx, height: sizePx }}
|
||||
>
|
||||
{/* Halo pulsant */}
|
||||
<motion.div
|
||||
className="absolute top-6 left-8 w-3 h-3 bg-blue-300 rounded-full"
|
||||
className="absolute inset-0 rounded-full bg-gradient-to-br from-orange-400/30 to-amber-400/30 blur-xl"
|
||||
animate={{
|
||||
y: [0, -15, -5],
|
||||
opacity: [0.7, 1, 0]
|
||||
scale: [1, 1.25, 1],
|
||||
opacity: [0.5, 0.8, 0.5],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.5,
|
||||
duration: 2.5,
|
||||
repeat: Infinity,
|
||||
repeatType: "loop"
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Anneau rotatif */}
|
||||
<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]
|
||||
}}
|
||||
className="absolute inset-0 rounded-full border-2 border-dashed border-orange-400/40"
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{
|
||||
duration: 1.2,
|
||||
duration: 8,
|
||||
repeat: Infinity,
|
||||
repeatType: "loop",
|
||||
delay: 0.3
|
||||
ease: "linear",
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Chapeau de chef qui flotte */}
|
||||
<motion.div
|
||||
className="absolute top-6 left-11 w-4 h-4 bg-blue-300 rounded-full"
|
||||
className="relative z-10"
|
||||
animate={{
|
||||
y: [0, -20, -5],
|
||||
opacity: [0.7, 1, 0]
|
||||
y: [-4, 4, -4],
|
||||
rotate: [-3, 3, -3],
|
||||
}}
|
||||
transition={{
|
||||
duration: 1.8,
|
||||
duration: 3,
|
||||
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"
|
||||
ease: "easeInOut",
|
||||
}}
|
||||
>
|
||||
<div className="absolute -top-3 -left-2 w-6 h-3 bg-gray-300 rounded-full"></div>
|
||||
<ChefHat
|
||||
className="text-orange-600 drop-shadow-lg"
|
||||
style={{ width: iconSize, height: iconSize }}
|
||||
strokeWidth={1.5}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/* Étincelles orbitales */}
|
||||
{[0, 1, 2].map((i) => (
|
||||
<motion.div
|
||||
key={i}
|
||||
className="absolute"
|
||||
style={{
|
||||
width: sizePx,
|
||||
height: sizePx,
|
||||
}}
|
||||
animate={{ rotate: 360 }}
|
||||
transition={{
|
||||
duration: 4 + i,
|
||||
repeat: Infinity,
|
||||
ease: "linear",
|
||||
delay: i * 0.4,
|
||||
}}
|
||||
>
|
||||
<motion.div
|
||||
className="absolute"
|
||||
style={{
|
||||
top: i === 0 ? "10%" : i === 1 ? "50%" : "80%",
|
||||
left: i === 0 ? "85%" : i === 1 ? "10%" : "60%",
|
||||
}}
|
||||
animate={{
|
||||
scale: [0.6, 1.1, 0.6],
|
||||
opacity: [0.4, 1, 0.4],
|
||||
}}
|
||||
transition={{
|
||||
duration: 2,
|
||||
repeat: Infinity,
|
||||
delay: i * 0.6,
|
||||
}}
|
||||
>
|
||||
<Sparkles
|
||||
className="text-amber-400"
|
||||
style={{ width: 14, height: 14 }}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{label && (
|
||||
<motion.p
|
||||
className="text-sm text-muted-foreground font-medium animate-pulse"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.2 }}
|
||||
>
|
||||
{label}
|
||||
</motion.p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,72 +1,61 @@
|
||||
import { motion } from "framer-motion";
|
||||
import { Shimmer } from "@/components/ui/shimmer"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
/**
|
||||
* Skeleton de la page détail recette. Mime la structure de la vraie page :
|
||||
* hero image, titre, meta (temps/portions/difficulté), ingrédients, étapes.
|
||||
*/
|
||||
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"
|
||||
}}
|
||||
className="max-w-4xl mx-auto px-4 pb-12"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
>
|
||||
<div className="absolute -top-3 -left-2 w-6 h-3 bg-gray-300 rounded-full"></div>
|
||||
{/* Image hero */}
|
||||
<Shimmer className="aspect-[16/9] w-full rounded-2xl mb-6" />
|
||||
|
||||
{/* Titre + description */}
|
||||
<div className="space-y-3 mb-8">
|
||||
<Shimmer className="h-8 w-3/4" />
|
||||
<Shimmer className="h-5 w-full max-w-lg" />
|
||||
<Shimmer className="h-5 w-1/2" />
|
||||
</div>
|
||||
|
||||
{/* Meta (temps, portions, difficulté) */}
|
||||
<div className="flex flex-wrap gap-3 mb-8">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Shimmer key={i} className="h-10 w-24 rounded-full" />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Ingrédients */}
|
||||
<div className="grid gap-6 md:grid-cols-[1fr_2fr]">
|
||||
<div className="space-y-3">
|
||||
<Shimmer className="h-6 w-32 mb-4" />
|
||||
{[1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<div key={i} className="flex gap-3 items-center">
|
||||
<Shimmer className="h-4 w-4 rounded-full" />
|
||||
<Shimmer className="h-4 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Étapes */}
|
||||
<div className="space-y-3">
|
||||
<Shimmer className="h-6 w-32 mb-4" />
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<div key={i} className="flex gap-3">
|
||||
<Shimmer className="h-8 w-8 rounded-full shrink-0" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<Shimmer className="h-4 w-full" />
|
||||
<Shimmer className="h-4 w-5/6" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
<p className="mt-4 text-center font-medium">Préparation de votre recette...</p>
|
||||
</div>
|
||||
);
|
||||
)
|
||||
}
|
||||
|
||||
@ -168,8 +168,19 @@ export function LoginForm({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Connexion en cours..." : "Se connecter"}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white shadow-md shadow-orange-500/20 hover:shadow-lg hover:shadow-orange-500/30 transition-all"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
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-amber-50 text-muted-foreground relative z-10 px-2">
|
||||
|
||||
56
frontend/src/components/recipe-card-skeleton.tsx
Normal file
56
frontend/src/components/recipe-card-skeleton.tsx
Normal file
@ -0,0 +1,56 @@
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Shimmer } from "@/components/ui/shimmer"
|
||||
import { motion } from "framer-motion"
|
||||
|
||||
interface RecipeCardSkeletonProps {
|
||||
index?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Skeleton qui mime la structure d'une RecipeCard, avec un shimmer
|
||||
* animé. Utilisé pendant le chargement initial de la liste des recettes.
|
||||
*/
|
||||
export function RecipeCardSkeleton({ index = 0 }: RecipeCardSkeletonProps) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 16 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.06, duration: 0.4 }}
|
||||
>
|
||||
<Card className="overflow-hidden h-full border-none shadow-md">
|
||||
{/* Image placeholder */}
|
||||
<Shimmer className="aspect-video w-full rounded-none" />
|
||||
|
||||
{/* Contenu */}
|
||||
<div className="p-4 space-y-3">
|
||||
<div className="flex gap-2">
|
||||
<Shimmer className="h-5 w-16 rounded-full" />
|
||||
<Shimmer className="h-5 w-20 rounded-full" />
|
||||
</div>
|
||||
<Shimmer className="h-4 w-3/4" />
|
||||
<Shimmer className="h-3 w-1/2" />
|
||||
<div className="flex items-center justify-between pt-2">
|
||||
<Shimmer className="h-8 w-28 rounded-full" />
|
||||
<div className="flex gap-2">
|
||||
<Shimmer className="h-8 w-8 rounded-full" />
|
||||
<Shimmer className="h-8 w-8 rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Grille de skeletons pour remplir l'écran pendant le chargement.
|
||||
*/
|
||||
export function RecipeListSkeleton({ count = 6 }: { count?: number }) {
|
||||
return (
|
||||
<div className="grid gap-6 my-6 mx-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: count }).map((_, i) => (
|
||||
<RecipeCardSkeleton key={i} index={i} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@ -119,8 +119,19 @@ export function RegisterForm({
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Button type="submit" className="w-full" disabled={loading}>
|
||||
{loading ? "Création en cours..." : "Créer un compte"}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white shadow-md shadow-orange-500/20 hover:shadow-lg hover:shadow-orange-500/30 transition-all"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<div className="mr-2 h-4 w-4 animate-spin rounded-full border-2 border-white border-t-transparent" />
|
||||
Création en cours…
|
||||
</>
|
||||
) : (
|
||||
"Créer un compte"
|
||||
)}
|
||||
</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-amber-50 text-muted-foreground relative z-10 px-2">
|
||||
|
||||
26
frontend/src/components/ui/shimmer.tsx
Normal file
26
frontend/src/components/ui/shimmer.tsx
Normal file
@ -0,0 +1,26 @@
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
/**
|
||||
* Shimmer — un placeholder animé avec un dégradé qui balaie le contenu,
|
||||
* plus élégant qu'un simple pulse. À utiliser à la place de Skeleton
|
||||
* pour les zones plus grandes (cards, images).
|
||||
*/
|
||||
export function Shimmer({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<"div">) {
|
||||
return (
|
||||
<div
|
||||
data-slot="shimmer"
|
||||
className={cn(
|
||||
"relative overflow-hidden rounded-md bg-muted/60",
|
||||
"before:absolute before:inset-0 before:-translate-x-full",
|
||||
"before:animate-[shimmer_1.8s_ease-in-out_infinite]",
|
||||
"before:bg-gradient-to-r before:from-transparent before:via-white/40 before:to-transparent",
|
||||
"dark:before:via-white/10",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@ -120,5 +120,96 @@
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
font-feature-settings: "rlig" 1, "calt" 1, "ss01" 1;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
|
||||
/* Scroll behavior et scrollbar discret */
|
||||
html {
|
||||
scroll-behavior: smooth;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
/* Scrollbars fines (desktop uniquement) */
|
||||
@media (min-width: 768px) {
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: color-mix(in oklch, var(--muted-foreground) 30%, transparent);
|
||||
border-radius: 9999px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: padding-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: color-mix(in oklch, var(--muted-foreground) 50%, transparent);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
}
|
||||
|
||||
/* Focus visible élégant */
|
||||
:focus-visible {
|
||||
outline: 2px solid oklch(0.75 0.18 55);
|
||||
outline-offset: 2px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* Keyframes personnalisés */
|
||||
@keyframes shimmer {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(100%); }
|
||||
}
|
||||
|
||||
@keyframes float {
|
||||
0%, 100% { transform: translateY(0) rotate(0deg); }
|
||||
50% { transform: translateY(-8px) rotate(2deg); }
|
||||
}
|
||||
|
||||
@keyframes glow-pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 20px -5px oklch(0.75 0.18 55 / 0.3),
|
||||
0 0 40px -10px oklch(0.75 0.18 55 / 0.2);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 30px -5px oklch(0.75 0.18 55 / 0.5),
|
||||
0 0 60px -10px oklch(0.75 0.18 55 / 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(12px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.animate-float { animation: float 4s ease-in-out infinite; }
|
||||
.animate-glow-pulse { animation: glow-pulse 3s ease-in-out infinite; }
|
||||
.animate-fade-in-up { animation: fade-in-up 0.5s ease-out forwards; }
|
||||
|
||||
/* Gradient warm Freedge */
|
||||
.bg-warm-gradient {
|
||||
background: linear-gradient(135deg, oklch(0.85 0.15 55) 0%, oklch(0.8 0.18 45) 100%);
|
||||
}
|
||||
.text-warm-gradient {
|
||||
background: linear-gradient(135deg, oklch(0.7 0.2 55) 0%, oklch(0.65 0.22 30) 100%);
|
||||
-webkit-background-clip: text;
|
||||
background-clip: text;
|
||||
color: transparent;
|
||||
}
|
||||
|
||||
/* Safe-area iOS / Android */
|
||||
.safe-pt { padding-top: env(safe-area-inset-top); }
|
||||
.safe-pb { padding-bottom: env(safe-area-inset-bottom); }
|
||||
}
|
||||
@ -1,6 +1,7 @@
|
||||
import { ReactNode } from "react";
|
||||
import { useLocation } from "react-router-dom";
|
||||
import { Header } from "@/components/header";
|
||||
import { PageTransition } from "@/components/PageTransition";
|
||||
|
||||
interface MainLayoutProps {
|
||||
children: ReactNode;
|
||||
@ -17,23 +18,32 @@ export function MainLayout({ children }: MainLayoutProps) {
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col bg-amber-50">
|
||||
<div className="relative flex min-h-screen flex-col bg-gradient-to-b from-amber-50/60 via-orange-50/30 to-background dark:from-slate-950 dark:via-slate-950 dark:to-slate-900">
|
||||
{/* Texture ambiante discrète — taches colorées floues en arrière-plan */}
|
||||
<div className="pointer-events-none fixed inset-0 -z-10 overflow-hidden">
|
||||
<div className="absolute -top-32 -left-32 h-96 w-96 rounded-full bg-orange-300/20 blur-3xl dark:bg-orange-900/10" />
|
||||
<div className="absolute top-1/3 -right-32 h-96 w-96 rounded-full bg-amber-300/20 blur-3xl dark:bg-amber-900/10" />
|
||||
<div className="absolute bottom-0 left-1/3 h-[500px] w-[500px] rounded-full bg-yellow-200/15 blur-3xl dark:bg-yellow-900/5" />
|
||||
</div>
|
||||
|
||||
<Header />
|
||||
|
||||
<main className="flex-1">
|
||||
<div className="mx-auto max-w-7xl">
|
||||
{children}
|
||||
<PageTransition>{children}</PageTransition>
|
||||
</div>
|
||||
</main>
|
||||
<footer className="border-t">
|
||||
|
||||
<footer className="border-t border-border/40 backdrop-blur-sm bg-white/30 dark:bg-slate-950/30 safe-pb">
|
||||
<div className="mx-auto max-w-7xl px-4 sm:px-6 lg:px-8 py-6 md:h-16 md:py-0">
|
||||
<div className="flex flex-col items-center justify-between gap-4 md:flex-row">
|
||||
<p className="text-center text-sm text-muted-foreground md:text-left">
|
||||
© {new Date().getFullYear()} Freedge. Tous droits réservés.
|
||||
<p className="text-center text-xs sm:text-sm text-muted-foreground md:text-left">
|
||||
© {new Date().getFullYear()} Freedge. Fait avec amour pour les gourmands.
|
||||
</p>
|
||||
<div className="flex items-center gap-4 text-sm text-muted-foreground pt-4">
|
||||
<a href="#" className="hover:underline">Mentions légales</a>
|
||||
<a href="#" className="hover:underline">Confidentialité</a>
|
||||
<a href="#" className="hover:underline">Contact</a>
|
||||
<div className="flex items-center gap-5 text-xs sm:text-sm text-muted-foreground">
|
||||
<a href="#" className="hover:text-orange-600 transition-colors">Mentions légales</a>
|
||||
<a href="#" className="hover:text-orange-600 transition-colors">Confidentialité</a>
|
||||
<a href="#" className="hover:text-orange-600 transition-colors">Contact</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -22,12 +22,20 @@ export default function Home() {
|
||||
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">
|
||||
<motion.div
|
||||
className="inline-flex items-center gap-1.5 rounded-full bg-gradient-to-r from-orange-100 to-amber-100 dark:from-orange-900/40 dark:to-amber-900/40 px-3 py-1.5 text-sm font-medium text-orange-700 dark:text-orange-300 mb-2 border border-orange-200/60 dark:border-orange-800/40 backdrop-blur-sm"
|
||||
initial={{ opacity: 0, scale: 0.9 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
transition={{ duration: 0.5, delay: 0.1 }}
|
||||
>
|
||||
<motion.div
|
||||
animate={{ rotate: [0, 15, -10, 0] }}
|
||||
transition={{ duration: 2.5, repeat: Infinity, repeatDelay: 3 }}
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
</motion.div>
|
||||
Propulsé par l'IA
|
||||
</span>
|
||||
</div>
|
||||
</motion.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>
|
||||
@ -37,9 +45,16 @@ export default function Home() {
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 min-[400px]:flex-row">
|
||||
<Link to="/auth/register">
|
||||
<Button className="bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white">
|
||||
<Button
|
||||
size="lg"
|
||||
className="group relative overflow-hidden bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white shadow-lg shadow-orange-500/25 hover:shadow-xl hover:shadow-orange-500/30 transition-all duration-300"
|
||||
>
|
||||
<span className="relative z-10 flex items-center">
|
||||
Commencer maintenant
|
||||
<ArrowRight className="ml-2 h-4 w-4" />
|
||||
<ArrowRight className="ml-2 h-4 w-4 transition-transform duration-300 group-hover:translate-x-1" />
|
||||
</span>
|
||||
{/* Brillance qui traverse le bouton */}
|
||||
<span className="absolute inset-0 -translate-x-full bg-gradient-to-r from-transparent via-white/30 to-transparent transition-transform duration-1000 group-hover:translate-x-full" />
|
||||
</Button>
|
||||
</Link>
|
||||
{/* <Link to="/auth/register">
|
||||
|
||||
@ -301,8 +301,19 @@ export default function Profile() {
|
||||
|
||||
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 className="mx-auto max-w-4xl px-4 py-8 space-y-6">
|
||||
{/* Hero profil skeleton */}
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="h-20 w-20 rounded-full bg-muted/60 animate-pulse" />
|
||||
<div className="flex-1 space-y-2">
|
||||
<div className="h-5 w-48 bg-muted/60 rounded animate-pulse" />
|
||||
<div className="h-4 w-32 bg-muted/60 rounded animate-pulse" />
|
||||
</div>
|
||||
</div>
|
||||
{/* Tabs skeleton */}
|
||||
<div className="h-10 w-full bg-muted/60 rounded-md animate-pulse" />
|
||||
{/* Card skeleton */}
|
||||
<div className="h-64 w-full bg-muted/60 rounded-xl animate-pulse" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -253,18 +253,32 @@ export default function RecipeForm() {
|
||||
className="flex-1 flex flex-col items-center justify-center px-4"
|
||||
>
|
||||
{/* Image en cours de génération ou finale */}
|
||||
<div className="relative w-48 h-48 mb-6">
|
||||
<div className="relative w-56 h-56 mb-8 flex items-center justify-center">
|
||||
{/* Halo derrière l'image */}
|
||||
<div className="absolute inset-0 -z-10 bg-gradient-to-br from-orange-200/50 via-amber-200/30 to-transparent rounded-full blur-3xl animate-pulse" />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{liveImageUrl ? (
|
||||
<motion.img
|
||||
initial={{ opacity: 0, scale: 0.8 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
key="image"
|
||||
initial={{ opacity: 0, scale: 0.85, filter: "blur(20px)" }}
|
||||
animate={{ opacity: 1, scale: 1, filter: "blur(0px)" }}
|
||||
transition={{ duration: 0.7, ease: [0.22, 1, 0.36, 1] }}
|
||||
src={liveImageUrl}
|
||||
alt={liveTitle || "Recette"}
|
||||
className="w-48 h-48 object-cover rounded-2xl shadow-lg"
|
||||
className="w-56 h-56 object-cover rounded-3xl shadow-2xl shadow-orange-500/20 ring-1 ring-white/40"
|
||||
/>
|
||||
) : (
|
||||
<CookingLoader />
|
||||
<motion.div
|
||||
key="loader"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
>
|
||||
<CookingLoader size="lg" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
|
||||
{/* Titre live (apparaît dès que le stream le révèle) */}
|
||||
|
||||
@ -8,11 +8,11 @@ import { recipeService, type Recipe } from "@/api/recipe"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Plus, Clock, Utensils, Heart, Share2, ArrowUpRight } from "lucide-react"
|
||||
import { motion } from "framer-motion"
|
||||
import { CookingLoader } from "@/components/illustrations/CookingLoader"
|
||||
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"
|
||||
import { RecipeListSkeleton } from "@/components/recipe-card-skeleton"
|
||||
|
||||
export default function RecipeList() {
|
||||
const navigate = useNavigate()
|
||||
@ -91,12 +91,31 @@ export default function RecipeList() {
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="dark:from-slate-950 dark:to-slate-900">
|
||||
<div className="min-h-[calc(100vh-4rem)]">
|
||||
{/* Header de page avec titre */}
|
||||
<div className="mx-4 md:mx-8 mt-8 mb-6">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 12 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.4 }}
|
||||
>
|
||||
<h1 className="text-3xl md:text-4xl font-bold tracking-tight">
|
||||
Mes recettes
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
{loading
|
||||
? "Chargement de votre collection…"
|
||||
: recipes.length > 0
|
||||
? `${recipes.length} recette${recipes.length > 1 ? "s" : ""} dans votre carnet`
|
||||
: "Commencez votre collection dès maintenant"}
|
||||
</p>
|
||||
</motion.div>
|
||||
</div>
|
||||
|
||||
{/* Main content */}
|
||||
{/* Erreur */}
|
||||
{error && (
|
||||
<motion.div
|
||||
className="rounded-md bg-red-50 p-4 text-red-700 dark:bg-red-900/50 dark:text-red-200 mb-6"
|
||||
className="mx-4 md:mx-8 rounded-xl bg-red-50 p-4 text-red-700 dark:bg-red-950/40 dark:text-red-200 mb-6 border border-red-100 dark:border-red-900/40"
|
||||
initial={{ opacity: 0, y: -10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
>
|
||||
@ -104,29 +123,37 @@ export default function RecipeList() {
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
<div className="flex flex-col mx-4 md:mx-4 sm:flex-row sm:items-center sm:justify-between mb-6 mt-6">
|
||||
{/* Barre filtres + CTA */}
|
||||
<div className="flex flex-col mx-4 md:mx-8 sm:flex-row sm:items-center sm:justify-between mb-6 gap-3">
|
||||
<Tabs defaultValue="all" className="w-full sm:w-auto" onValueChange={setActiveFilter}>
|
||||
<TabsList className="grid grid-cols-4 w-full sm:w-auto">
|
||||
<TabsTrigger value="all">Toutes</TabsTrigger>
|
||||
<TabsTrigger value="easy">Faciles</TabsTrigger>
|
||||
<TabsTrigger value="quick">Rapides</TabsTrigger>
|
||||
<TabsTrigger value="vegetarian">Végé</TabsTrigger>
|
||||
<TabsList className="grid grid-cols-4 w-full sm:w-auto bg-muted/60 backdrop-blur-sm">
|
||||
<TabsTrigger value="all" className="data-[state=active]:bg-white dark:data-[state=active]:bg-slate-800 data-[state=active]:shadow-sm transition-all">
|
||||
Toutes
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="easy" className="data-[state=active]:bg-white dark:data-[state=active]:bg-slate-800 data-[state=active]:shadow-sm transition-all">
|
||||
Faciles
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="quick" className="data-[state=active]:bg-white dark:data-[state=active]:bg-slate-800 data-[state=active]:shadow-sm transition-all">
|
||||
Rapides
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="vegetarian" className="data-[state=active]:bg-white dark:data-[state=active]:bg-slate-800 data-[state=active]:shadow-sm transition-all">
|
||||
Végé
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
{
|
||||
filteredRecipes.length !== 0 &&
|
||||
{filteredRecipes.length !== 0 && (
|
||||
<Button
|
||||
onClick={handleCreateRecipe}
|
||||
className="mt-4 sm:mt-0 bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white cursor-pointer"
|
||||
className="hidden sm:inline-flex bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 text-white shadow-md hover:shadow-lg hover:shadow-orange-500/25 transition-all cursor-pointer"
|
||||
>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
Nouvelle recette
|
||||
</Button>
|
||||
}
|
||||
)}
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<CookingLoader />
|
||||
<RecipeListSkeleton count={6} />
|
||||
) : (
|
||||
<>
|
||||
{filteredRecipes.length === 0 ? (
|
||||
@ -147,14 +174,20 @@ export default function RecipeList() {
|
||||
)}
|
||||
|
||||
{/* Floating action button (mobile only) */}
|
||||
<div className="fixed bottom-8 right-8 sm:hidden">
|
||||
<motion.div initial={{ scale: 0.8, opacity: 0 }} animate={{ scale: 1, opacity: 1 }} transition={{ delay: 0.2 }}>
|
||||
<div className="fixed bottom-6 right-6 sm:hidden z-30 safe-pb">
|
||||
<motion.div
|
||||
initial={{ scale: 0, opacity: 0 }}
|
||||
animate={{ scale: 1, opacity: 1 }}
|
||||
transition={{ delay: 0.3, type: "spring", stiffness: 260, damping: 20 }}
|
||||
whileTap={{ scale: 0.9 }}
|
||||
>
|
||||
<Button
|
||||
onClick={handleCreateRecipe}
|
||||
className="h-14 w-14 rounded-full shadow-lg bg-gradient-to-r from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600"
|
||||
className="h-14 w-14 rounded-full shadow-xl shadow-orange-500/30 bg-gradient-to-br from-orange-500 to-amber-500 hover:from-orange-600 hover:to-amber-600 animate-glow-pulse"
|
||||
size="icon"
|
||||
aria-label="Créer une nouvelle recette"
|
||||
>
|
||||
<Plus className="h-6 w-6" />
|
||||
<Plus className="h-6 w-6" strokeWidth={2.5} />
|
||||
</Button>
|
||||
</motion.div>
|
||||
</div>
|
||||
@ -181,32 +214,57 @@ function RecipeCard({ recipe, index }: RecipeCardProps) {
|
||||
}[recipe.difficulty || ""] || "bg-gray-500/80 text-white hover:bg-gray-600/80";
|
||||
|
||||
return (
|
||||
<motion.div initial={{ opacity: 0, y: 20 }} animate={{ opacity: 1, y: 0 }} transition={{ delay: index * 0.05 }}>
|
||||
<Card className="overflow-hidden h-full border-none shadow-md hover:shadow-lg transition-all duration-300">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 24 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: index * 0.06, duration: 0.4, ease: [0.22, 1, 0.36, 1] }}
|
||||
whileHover={{ y: -4 }}
|
||||
className="group"
|
||||
>
|
||||
<Card className="overflow-hidden h-full border border-transparent bg-card/60 backdrop-blur-sm shadow-sm hover:shadow-2xl hover:border-orange-200/60 dark:hover:border-orange-800/40 transition-all duration-500 rounded-2xl">
|
||||
<div
|
||||
className="relative aspect-video w-full overflow-hidden cursor-pointer"
|
||||
onClick={() => navigate(`/recipes/${recipe.id}`)}
|
||||
>
|
||||
{/* Image avec zoom subtil */}
|
||||
<img
|
||||
src={recipe.imageUrl || "/placeholder.svg?height=200&width=400"}
|
||||
alt={recipe.title}
|
||||
className="h-full w-full object-cover transition-transform hover:scale-105 duration-500"
|
||||
loading="lazy"
|
||||
className="h-full w-full object-cover transition-transform duration-[800ms] ease-out group-hover:scale-110"
|
||||
/>
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent flex items-end">
|
||||
<div className="p-4 text-white">
|
||||
<h3 className="font-bold text-lg">{recipe.title}</h3>
|
||||
<p className="text-sm opacity-90 line-clamp-1">{recipe.description || "Aucune description disponible"}</p>
|
||||
</div>
|
||||
|
||||
{/* Dégradé plus prononcé pour la lisibilité */}
|
||||
<div className="absolute inset-0 bg-gradient-to-t from-black/80 via-black/20 to-transparent" />
|
||||
|
||||
{/* Brillance au hover */}
|
||||
<div className="absolute inset-0 bg-gradient-to-tr from-transparent via-white/0 to-white/0 group-hover:to-white/10 transition-all duration-700" />
|
||||
|
||||
{/* Titre + description en bas */}
|
||||
<div className="absolute inset-x-0 bottom-0 p-4 text-white">
|
||||
<h3 className="font-semibold text-lg leading-tight line-clamp-1 drop-shadow-sm">
|
||||
{recipe.title}
|
||||
</h3>
|
||||
<p className="text-sm opacity-90 line-clamp-1 mt-0.5">
|
||||
{recipe.description || "Aucune description disponible"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="absolute top-2 right-2 flex gap-1">
|
||||
{/* Badges flottants en haut */}
|
||||
<div className="absolute top-3 right-3 flex gap-1.5">
|
||||
{recipe.difficulty && (
|
||||
<Badge variant="secondary" className={difficultyClass}>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`${difficultyClass} shadow-sm backdrop-blur-md border-0`}
|
||||
>
|
||||
{recipe.difficulty.charAt(0).toUpperCase() + recipe.difficulty.slice(1)}
|
||||
</Badge>
|
||||
)}
|
||||
{(recipe.preparationTime || 0) <= 30 && (
|
||||
<Badge variant="secondary" className="bg-blue-500/80 text-white hover:bg-blue-600/80">
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="bg-blue-500/85 text-white hover:bg-blue-600/85 shadow-sm backdrop-blur-md border-0"
|
||||
>
|
||||
Rapide
|
||||
</Badge>
|
||||
)}
|
||||
@ -241,22 +299,34 @@ function RecipeCard({ recipe, index }: RecipeCardProps) {
|
||||
</div>
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className="p-4 pt-0 flex justify-between">
|
||||
<CardFooter className="p-4 pt-0 flex justify-between items-center">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="rounded-full"
|
||||
className="rounded-full border-orange-200/70 dark:border-orange-800/50 hover:bg-orange-50 hover:text-orange-700 hover:border-orange-300 dark:hover:bg-orange-950/30 dark:hover:text-orange-300 transition-all group/btn"
|
||||
onClick={() => navigate(`/recipes/${recipe.id}`)}
|
||||
>
|
||||
Voir la recette
|
||||
<ArrowUpRight className="ml-1 h-3 w-3" />
|
||||
<ArrowUpRight className="ml-1 h-3 w-3 transition-transform group-hover/btn:translate-x-0.5 group-hover/btn:-translate-y-0.5" />
|
||||
</Button>
|
||||
|
||||
<div className="flex gap-1">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full hover:bg-red-50 hover:text-red-500 dark:hover:bg-red-950/30 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Ajouter aux favoris"
|
||||
>
|
||||
<Heart className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8 rounded-full">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 rounded-full hover:bg-orange-50 hover:text-orange-500 dark:hover:bg-orange-950/30 transition-colors"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
aria-label="Partager"
|
||||
>
|
||||
<Share2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user