'use client'; import { useEffect, useCallback, useRef, useState } from 'react'; import { XMarkIcon, ChevronLeftIcon, ChevronRightIcon, ArrowDownTrayIcon, } from '@heroicons/react/24/outline'; import Spinner from '@/components/ui/Spinner'; export interface LightboxItem { id: string; previewUrl: string; downloadUrl: string; filename?: string; /** Small thumbnail for the filmstrip; falls back to previewUrl. */ thumbUrl?: string; } interface LightboxProps { items: LightboxItem[]; index: number; onClose: () => void; onNavigate: (index: number) => void; /** Extra per-item action buttons rendered in the top bar (admin use). */ renderActions?: (item: LightboxItem, index: number) => React.ReactNode; /** * Handles the download in JS instead of navigating, so the button can show * progress. Without it the button stays a plain . */ onDownload?: (item: LightboxItem) => void; /** Id of the item currently downloading (pairs with onDownload). */ downloadingId?: string | null; downloadLabels?: { download: string; downloading: string }; } /** * Full-screen photo lightbox with keyboard and swipe navigation, in the * style of the admin gallery preview modal (fixed inset-0 bg-black/90). */ export default function Lightbox({ items, index, onClose, onNavigate, renderActions, onDownload, downloadingId, downloadLabels, }: LightboxProps) { const touchStart = useRef<{ x: number; y: number } | null>(null); const activeThumbRef = useRef(null); const item = items[index]; const hasMultiple = items.length > 1; const downloading = !!downloadingId && item?.id === downloadingId; const prev = useCallback(() => { onNavigate(index > 0 ? index - 1 : items.length - 1); }, [index, items.length, onNavigate]); const next = useCallback(() => { onNavigate(index < items.length - 1 ? index + 1 : 0); }, [index, items.length, onNavigate]); useEffect(() => { const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); if (e.key === 'ArrowLeft') prev(); if (e.key === 'ArrowRight') next(); }; window.addEventListener('keydown', onKey); document.body.style.overflow = 'hidden'; return () => { window.removeEventListener('keydown', onKey); document.body.style.overflow = ''; }; }, [onClose, prev, next]); // Keep the active thumbnail centred in the filmstrip as you navigate. useEffect(() => { activeThumbRef.current?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest', }); }, [index]); if (!item) return null; return (
{ touchStart.current = { x: e.touches[0].clientX, y: e.touches[0].clientY }; }} onTouchEnd={(e) => { if (touchStart.current === null) return; const dx = e.changedTouches[0].clientX - touchStart.current.x; const dy = e.changedTouches[0].clientY - touchStart.current.y; touchStart.current = null; // Only treat as a swipe when it's clearly horizontal. if (Math.abs(dx) < 50 || Math.abs(dx) < Math.abs(dy)) return; if (dx > 0) prev(); else next(); }} >
e.stopPropagation()} > {onDownload ? ( ) : ( )} {renderActions?.(item, index)}
{/* Main image area */}
{hasMultiple && ( <> )} {/* eslint-disable-next-line @next/next/no-img-element */} {/* Nothing is layered over the image (the navigation buttons sit at the edges), and neither -webkit-touch-callout nor -webkit-user- select is suppressed here, so iOS long-press → "Save to Photos" still works as a backup to the save sheet. Don't add select-none. */} e.stopPropagation()} draggable={false} />
{/* Counter + thumbnail filmstrip you can skip through. */}
e.stopPropagation()} onTouchStart={(e) => e.stopPropagation()} onTouchEnd={(e) => e.stopPropagation()} >
{index + 1} / {items.length}
{hasMultiple && (
{items.map((it, i) => ( ))}
)}
{/* Preload neighbours so prev/next feels instant. */}
{hasMultiple && ( <> {/* eslint-disable-next-line @next/next/no-img-element */} {/* eslint-disable-next-line @next/next/no-img-element */} )}
); }