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>
344 lines
9.7 KiB
TypeScript
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>
|
|
);
|
|
}
|