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:
ordinarthur 2026-04-08 13:31:49 +02:00
parent d926ad89c5
commit e7872df156
14 changed files with 644 additions and 216 deletions

View 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>
)
}

View File

@ -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">
<Logo size="md" showText={true} />
<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} />
</div>
</Link>
</div>
</motion.div>
{/* Navigation desktop */}
{
isAuthenticated && (
<nav className="hidden md:flex items-center gap-10">
{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 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(
"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={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>
)
}

View File

@ -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 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 inset-0 rounded-full bg-gradient-to-br from-orange-400/30 to-amber-400/30 blur-xl"
animate={{
scale: [1, 1.25, 1],
opacity: [0.5, 0.8, 0.5],
}}
transition={{
duration: 2.5,
repeat: Infinity,
ease: "easeInOut",
}}
/>
{/* Anneau rotatif */}
<motion.div
className="absolute inset-0 rounded-full border-2 border-dashed border-orange-400/40"
animate={{ rotate: 360 }}
transition={{
duration: 8,
repeat: Infinity,
ease: "linear",
}}
/>
{/* Chapeau de chef qui flotte */}
<motion.div
className="relative z-10"
animate={{
y: [-4, 4, -4],
rotate: [-3, 3, -3],
}}
transition={{
duration: 3,
repeat: Infinity,
ease: "easeInOut",
}}
>
<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>
{/* 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>
{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>
);
}

View File

@ -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>
<motion.div
className="max-w-4xl mx-auto px-4 pb-12"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
{/* 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>
{/* 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>
{/* É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>
<p className="mt-4 text-center font-medium">Préparation de votre recette...</p>
</div>
);
</motion.div>
)
}

View File

@ -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">

View 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>
)
}

View File

@ -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">

View 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}
/>
)
}

View File

@ -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); }
}

View File

@ -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">
&copy; {new Date().getFullYear()} Freedge. Tous droits réservés.
<p className="text-center text-xs sm:text-sm text-muted-foreground md:text-left">
&copy; {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>

View File

@ -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" />
Propulsé par l'IA
</span>
</div>
</motion.div>
Propulsé par l'IA
</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">
Commencer maintenant
<ArrowRight className="ml-2 h-4 w-4" />
<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 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">

View File

@ -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>
);
}

View File

@ -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">
{liveImageUrl ? (
<motion.img
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
src={liveImageUrl}
alt={liveTitle || "Recette"}
className="w-48 h-48 object-cover rounded-2xl shadow-lg"
/>
) : (
<CookingLoader />
)}
<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
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-56 h-56 object-cover rounded-3xl shadow-2xl shadow-orange-500/20 ring-1 ring-white/40"
/>
) : (
<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) */}

View File

@ -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>