TypeScript 61.9%
HTML 37.2%
SQL 0.7%
1'use client';23import { SmartImage, ImagePlaceholder, type PlaceholderGlyph } from '@/components/ui/smart-image';4import { useCallback, useEffect, useRef, useState } from 'react';5import { ChevronLeft, ChevronRight, Maximize2, X } from 'lucide-react';6import { cn } from '@/lib/format';78export interface GalleryImage {9 url: string;10 caption?: string | null;11 /** cached /img sources (added by the server component) */12 src?: string | null;13 srcSet?: string | null;14 full?: string | null;15}1617/**18 * Hero image with a full-screen, swipeable gallery (scroll-snap + keyboard). Images are shown19 * `object-contain` on a neutral surface so cards, watches and boxes keep their proportions.20 */21export function AssetGallery({ images, alt, className, priority = true, glyph = 'box' }: { images: GalleryImage[]; alt: string; className?: string; priority?: boolean; glyph?: PlaceholderGlyph }) {22 const [open, setOpen] = useState(false);23 const [index, setIndex] = useState(0);24 const track = useRef<HTMLDivElement>(null);25 const hero = images[0];2627 const go = useCallback(28 (i: number) => {29 const n = images.length;30 const next = ((i % n) + n) % n;31 setIndex(next);32 track.current?.children[next]?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });33 },34 [images.length],35 );3637 useEffect(() => {38 if (!open) return;39 const onKey = (e: KeyboardEvent) => {40 if (e.key === 'Escape') setOpen(false);41 if (e.key === 'ArrowRight') go(index + 1);42 if (e.key === 'ArrowLeft') go(index - 1);43 };44 document.addEventListener('keydown', onKey);45 document.body.style.overflow = 'hidden';46 return () => {47 document.removeEventListener('keydown', onKey);48 document.body.style.overflow = '';49 };50 }, [open, index, go]);5152 if (!hero) {53 return (54 <div className={cn('relative flex items-center justify-center overflow-hidden rounded-lg border border-border bg-inset', className)}>55 <ImagePlaceholder label="No image" glyph={glyph} />56 </div>57 );58 }5960 return (61 <>62 <button type="button" onClick={() => setOpen(true)} className={cn('group relative block overflow-hidden rounded-lg border border-border bg-inset text-left', className)} aria-label={`Open image gallery for ${alt}`}>63 <SmartImage src={hero.src ?? null} srcSet={hero.srcSet ?? null} sizes="(max-width: 768px) 100vw, 360px" alt={alt} priority={priority} imgClassName="p-2 transition-transform duration-300 group-hover:scale-[1.02]" placeholder={{ label: 'No image', glyph }} />64 <span className="absolute bottom-2 right-2 inline-flex items-center gap-1 rounded-md bg-black/55 px-1.5 py-1 text-[10px] font-medium text-white backdrop-blur-sm">65 <Maximize2 className="h-3 w-3" />66 {images.length > 1 ? `${images.length} photos` : 'View'}67 </span>68 </button>69 {open ? (70 <div className="fixed inset-0 z-[60] flex flex-col bg-black/95 text-white" role="dialog" aria-modal="true" aria-label={`${alt} gallery`}>71 <div className="flex items-center justify-between px-3 pt-[max(env(safe-area-inset-top),0.5rem)] pb-2 text-xs">72 <span className="num text-white/80">73 {index + 1} / {images.length}74 </span>75 <button type="button" onClick={() => setOpen(false)} aria-label="Close gallery" className="inline-flex h-9 w-9 items-center justify-center rounded-md hover:bg-white/10">76 <X className="h-5 w-5" />77 </button>78 </div>79 <div80 ref={track}81 className="scrollbar-none flex flex-1 snap-x snap-mandatory overflow-x-auto"82 onScroll={(e) => {83 const el = e.currentTarget;84 const i = Math.round(el.scrollLeft / el.clientWidth);85 if (i !== index) setIndex(i);86 }}87 >88 {images.map((im, i) => (89 <figure key={`${im.url}-${i}`} className="relative flex h-full w-full shrink-0 snap-center items-center justify-center p-2">90 <SmartImage src={im.full ?? im.src ?? null} alt={`${alt} — image ${i + 1}`} priority={i === index} placeholder={{ glyph }} />91 {im.caption ? <figcaption className="absolute bottom-3 left-3 right-3 truncate text-center text-[11px] text-white/70">{im.caption}</figcaption> : null}92 </figure>93 ))}94 </div>95 {images.length > 1 ? (96 <div className="flex items-center justify-center gap-6 px-3 pb-[max(env(safe-area-inset-bottom),0.75rem)] pt-2">97 <button type="button" onClick={() => go(index - 1)} aria-label="Previous image" className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/10 hover:bg-white/20">98 <ChevronLeft className="h-5 w-5" />99 </button>100 <div className="flex gap-1.5">101 {images.slice(0, 12).map((_, i) => (102 <span key={i} className={cn('h-1.5 w-1.5 rounded-full', i === index ? 'bg-white' : 'bg-white/30')} />103 ))}104 </div>105 <button type="button" onClick={() => go(index + 1)} aria-label="Next image" className="inline-flex h-10 w-10 items-center justify-center rounded-full bg-white/10 hover:bg-white/20">106 <ChevronRight className="h-5 w-5" />107 </button>108 </div>109 ) : (110 <div className="pb-[max(env(safe-area-inset-bottom),0.75rem)]" />111 )}112 </div>113 ) : null}114 </>115 );116}117