SPB Git forge

spb/rareindex

Public
54commits 1branches 0releases
7.1 MBsize
maindefault branch
10 days agolast push
TypeScript 61.9% HTML 37.2% SQL 0.7%
5.4 KB · 117 lines tsx
Raw Blame History
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