resto-demo/src/components/blocks/Gallery.tsx
thewebmasterpro c5165a407a feat: create La Maison Doree restaurant website
Elegant gastronomic restaurant site inspired by depeerdestal.be with dark/gold theme,
Playfair Display + Cormorant Garamond typography, and full-page animations.

Pages: homepage, carte, histoire, galerie, contact with reservation form.
Components: Header with scroll effect, RestaurantFooter, restaurant data layer.
Stack: Next.js 16, React 19, Tailwind CSS v4, Shadcn/UI, Framer Motion 12.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 23:34:21 +01:00

344 lines
9.7 KiB
TypeScript

"use client";
import { useState } from "react";
import Image from "next/image";
import { motion, AnimatePresence } from "framer-motion";
import { X, ChevronLeft, ChevronRight, ZoomIn } from "lucide-react";
import { cn } from "@/lib/utils";
// =============================================================================
// GALLERY
// Image gallery with lightbox
// =============================================================================
interface GalleryImage {
src: string;
alt: string;
width?: number;
height?: number;
caption?: string;
category?: string;
}
interface GalleryProps {
images: GalleryImage[];
columns?: 2 | 3 | 4;
gap?: "sm" | "md" | "lg";
variant?: "grid" | "masonry";
lightbox?: boolean;
className?: string;
}
export function Gallery({
images,
columns = 3,
gap = "md",
variant = "grid",
lightbox = true,
className,
}: GalleryProps) {
const [selectedIndex, setSelectedIndex] = useState<number | null>(null);
const colStyles = {
2: "columns-1 md:columns-2",
3: "columns-1 md:columns-2 lg:columns-3",
4: "columns-1 md:columns-2 lg:columns-4",
};
const gapStyles = {
sm: "gap-2",
md: "gap-4",
lg: "gap-6",
};
const gridColStyles = {
2: "grid-cols-1 md:grid-cols-2",
3: "grid-cols-1 md:grid-cols-2 lg:grid-cols-3",
4: "grid-cols-1 md:grid-cols-2 lg:grid-cols-4",
};
const openLightbox = (index: number) => {
if (lightbox) {
setSelectedIndex(index);
document.body.style.overflow = "hidden";
}
};
const closeLightbox = () => {
setSelectedIndex(null);
document.body.style.overflow = "";
};
const goNext = () => {
if (selectedIndex !== null) {
setSelectedIndex((selectedIndex + 1) % images.length);
}
};
const goPrev = () => {
if (selectedIndex !== null) {
setSelectedIndex((selectedIndex - 1 + images.length) % images.length);
}
};
if (variant === "masonry") {
return (
<>
<div className={cn(colStyles[columns], gapStyles[gap], className)}>
{images.map((image, index) => (
<motion.div
key={index}
initial={{ opacity: 0, y: 20 }}
whileInView={{ opacity: 1, y: 0 }}
viewport={{ once: true }}
transition={{ delay: index * 0.05 }}
className="mb-4 break-inside-avoid"
>
<div
className="relative group cursor-pointer overflow-hidden rounded-lg"
onClick={() => openLightbox(index)}
>
<Image
src={image.src}
alt={image.alt}
width={image.width || 800}
height={image.height || 600}
className="w-full h-auto transition-transform group-hover:scale-105"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center">
<ZoomIn className="w-8 h-8 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</div>
{image.caption && (
<p className="mt-2 text-sm text-muted-foreground">
{image.caption}
</p>
)}
</motion.div>
))}
</div>
{lightbox && (
<Lightbox
images={images}
selectedIndex={selectedIndex}
onClose={closeLightbox}
onNext={goNext}
onPrev={goPrev}
/>
)}
</>
);
}
// Grid variant
return (
<>
<div
className={cn(
"grid",
gridColStyles[columns],
gapStyles[gap],
className
)}
>
{images.map((image, index) => (
<motion.div
key={index}
initial={{ opacity: 0, scale: 0.95 }}
whileInView={{ opacity: 1, scale: 1 }}
viewport={{ once: true }}
transition={{ delay: index * 0.05 }}
>
<div
className="relative aspect-square group cursor-pointer overflow-hidden rounded-lg"
onClick={() => openLightbox(index)}
>
<Image
src={image.src}
alt={image.alt}
fill
className="object-cover transition-transform group-hover:scale-105"
/>
<div className="absolute inset-0 bg-black/0 group-hover:bg-black/30 transition-colors flex items-center justify-center">
<ZoomIn className="w-8 h-8 text-white opacity-0 group-hover:opacity-100 transition-opacity" />
</div>
</div>
</motion.div>
))}
</div>
{lightbox && (
<Lightbox
images={images}
selectedIndex={selectedIndex}
onClose={closeLightbox}
onNext={goNext}
onPrev={goPrev}
/>
)}
</>
);
}
// =============================================================================
// LIGHTBOX
// Fullscreen image viewer
// =============================================================================
interface LightboxProps {
images: GalleryImage[];
selectedIndex: number | null;
onClose: () => void;
onNext: () => void;
onPrev: () => void;
}
function Lightbox({
images,
selectedIndex,
onClose,
onNext,
onPrev,
}: LightboxProps) {
if (selectedIndex === null) return null;
const image = images[selectedIndex];
return (
<AnimatePresence>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 z-50 bg-black/90 flex items-center justify-center"
onClick={onClose}
>
{/* Close button */}
<button
onClick={onClose}
className="absolute top-4 right-4 w-10 h-10 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white transition-colors z-10"
>
<X className="w-6 h-6" />
</button>
{/* Navigation */}
{images.length > 1 && (
<>
<button
onClick={(e) => {
e.stopPropagation();
onPrev();
}}
className="absolute left-4 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white transition-colors"
>
<ChevronLeft className="w-8 h-8" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onNext();
}}
className="absolute right-4 top-1/2 -translate-y-1/2 w-12 h-12 rounded-full bg-white/10 hover:bg-white/20 flex items-center justify-center text-white transition-colors"
>
<ChevronRight className="w-8 h-8" />
</button>
</>
)}
{/* Image */}
<motion.div
key={selectedIndex}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
className="relative max-w-[90vw] max-h-[85vh]"
onClick={(e) => e.stopPropagation()}
>
<Image
src={image.src}
alt={image.alt}
width={image.width || 1200}
height={image.height || 800}
className="max-w-full max-h-[85vh] object-contain"
/>
{image.caption && (
<p className="absolute bottom-0 left-0 right-0 p-4 bg-gradient-to-t from-black/60 to-transparent text-white text-center">
{image.caption}
</p>
)}
</motion.div>
{/* Counter */}
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 text-white/70 text-sm">
{selectedIndex + 1} / {images.length}
</div>
</motion.div>
</AnimatePresence>
);
}
// =============================================================================
// FILTERED GALLERY
// Gallery with category filters
// =============================================================================
interface FilteredGalleryProps {
images: GalleryImage[];
columns?: 2 | 3 | 4;
className?: string;
}
export function FilteredGallery({
images,
columns = 3,
className,
}: FilteredGalleryProps) {
const [activeCategory, setActiveCategory] = useState<string | null>(null);
const categories = Array.from(
new Set(images.map((img) => img.category).filter(Boolean))
) as string[];
const filteredImages = activeCategory
? images.filter((img) => img.category === activeCategory)
: images;
return (
<div className={className}>
{/* Filters */}
{categories.length > 0 && (
<div className="flex flex-wrap gap-2 mb-6">
<button
onClick={() => setActiveCategory(null)}
className={cn(
"px-4 py-2 rounded-full text-sm font-medium transition-colors",
activeCategory === null
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80"
)}
>
Tous
</button>
{categories.map((category) => (
<button
key={category}
onClick={() => setActiveCategory(category)}
className={cn(
"px-4 py-2 rounded-full text-sm font-medium transition-colors",
activeCategory === category
? "bg-primary text-primary-foreground"
: "bg-muted hover:bg-muted/80"
)}
>
{category}
</button>
))}
</div>
)}
{/* Gallery */}
<Gallery images={filteredImages} columns={columns} />
</div>
);
}