'use client'; import { useEffect, useRef, useState } from 'react'; import { createPortal } from 'react-dom'; import { IconX, IconChevronLeft, IconChevronRight } from '@tabler/icons-react'; import { usePanZoom } from '@/hooks/usePanZoom'; import type { Screenshot } from '@/constants/projects'; type ImageViewerProps = { items: Screenshot[]; index: number; onIndexChange: (i: number) => void; onClose: () => void; }; /** Caption to display — falls back to the required `alt` text. */ const captionOf = (s: Screenshot) => s.caption ?? s.alt; /** * Full-bleed fullscreen image viewer with pan / pinch / wheel zoom, keyboard * navigation, and a thumbnail rail (vertical on desktop, horizontal at the * bottom on mobile). Borrowed from imagelab-gpu's FullscreenImage, trimmed to * the parts this portfolio needs — no slideshow. */ export default function ImageViewer({ items, index, onIndexChange, onClose }: ImageViewerProps) { // Natural size is tagged with the index it was measured for, so it reads as // null until the *current* image's onLoad fires — no reset effect needed. const [sized, setSized] = useState<{ i: number; w: number; h: number } | null>(null); const stageRef = useRef(null); const activeThumbRef = useRef(null); const len = items.length; const item = items[index]; const prev = () => onIndexChange((index - 1 + len) % len); const next = () => onIndexChange((index + 1) % len); const { zoom, offset, reframing, handlers } = usePanZoom(stageRef, { resetKey: index, onSwipe: (dir) => onIndexChange((index + dir + len) % len), onTap: onClose, }); // Lock body scroll while the viewer is open. useEffect(() => { const original = document.body.style.overflow; document.body.style.overflow = 'hidden'; return () => { document.body.style.overflow = original; }; }, []); // Keyboard navigation: arrows to move, Esc to close. useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); else if (e.key === 'ArrowLeft') onIndexChange((index - 1 + len) % len); else if (e.key === 'ArrowRight') onIndexChange((index + 1) % len); }; document.addEventListener('keydown', onKey); return () => document.removeEventListener('keydown', onKey); }, [index, len, onClose, onIndexChange]); // Keep the active thumbnail scrolled into view. useEffect(() => { activeThumbRef.current?.scrollIntoView({ block: 'nearest', inline: 'nearest' }); }, [index]); if (!item) return null; const url = item.src; return createPortal(
{/* Ambient backdrop sampled from the current image via heavy blur. */}
{/* eslint-disable-next-line @next/next/no-img-element */}
{/* Stage — owns the pan/zoom gestures. */}
{/* eslint-disable-next-line @next/next/no-img-element */} {item.alt} setSized({ i: index, w: e.currentTarget.naturalWidth, h: e.currentTarget.naturalHeight })} className="max-h-full max-w-full select-none rounded-md shadow-[0_20px_70px_rgba(0,0,0,0.7)]" style={{ transform: `translate(${offset.x}px, ${offset.y}px) scale(${zoom})`, transition: reframing ? 'transform 250ms cubic-bezier(0.22, 1, 0.36, 1)' : undefined, }} />
{/* Prev / next */} {len > 1 && ( <> )} {/* Close */} {/* Page counter + dimensions */}
{index + 1} / {len}
{/* Caption */}

{captionOf(item)}

{/* Thumbnail rail — horizontal at the bottom on mobile, vertical on the right on desktop. */} {len > 1 && (
{items.map((shot, i) => { const isActive = i === index; return ( ); })}
)}
, document.body, ); }