import { useState, useEffect } from 'react'; import { X, ChevronLeft, ChevronRight, Download, Trash2 } from 'lucide-react'; import { api } from '../api/client'; import { Button } from './Button'; import { ConfirmModal } from './ConfirmModal'; // Archive photos resolve through `archiveId`; any other owner (a library // file, #3077) passes `getPhotoUrl` instead. Exactly one of the two is // required so a caller cannot silently end up requesting archive 0. type PhotoSource = | { archiveId: number; getPhotoUrl?: undefined } | { archiveId?: undefined; getPhotoUrl: (filename: string) => string }; type PhotoGalleryModalProps = PhotoSource & { archiveName: string; photos: string[]; // Which photo to open on. A caller that opens the gallery from a per-photo // grid passes the clicked index (#3077); one with a single "view photos" // button leaves it at the first. initialIndex?: number; onClose: () => void; onDelete?: (filename: string) => void; }; export function PhotoGalleryModal(props: PhotoGalleryModalProps) { const { archiveName, photos, initialIndex, onClose, onDelete } = props; const [currentIndex, setCurrentIndex] = useState(() => Math.min(Math.max(initialIndex ?? 0, 0), Math.max(photos.length - 1, 0)) ); const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); // Keyboard navigation. Stands down while the delete confirmation is up: // that has its own Escape handler, so one press would cancel the prompt and // close the gallery underneath it, and arrow keys would move the selection // out from under a confirmation already naming a photo. useEffect(() => { if (showDeleteConfirm) return; const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') onClose(); if (e.key === 'ArrowLeft') setCurrentIndex((i) => Math.max(0, i - 1)); if (e.key === 'ArrowRight') setCurrentIndex((i) => Math.min(photos.length - 1, i + 1)); }; window.addEventListener('keydown', handleKeyDown); return () => window.removeEventListener('keydown', handleKeyDown); }, [onClose, photos.length, showDeleteConfirm]); // Reset index if photos change useEffect(() => { if (currentIndex >= photos.length) { setCurrentIndex(Math.max(0, photos.length - 1)); } }, [photos.length, currentIndex]); if (photos.length === 0) { onClose(); return null; } const resolvePhotoUrl = (filename: string) => props.getPhotoUrl ? props.getPhotoUrl(filename) : api.getArchivePhotoUrl(props.archiveId, filename); const currentPhoto = photos[currentIndex]; const photoUrl = resolvePhotoUrl(currentPhoto); const handleDownload = () => { const link = document.createElement('a'); link.href = photoUrl; link.download = `${archiveName}_photo_${currentIndex + 1}.jpg`; link.click(); }; const handleDelete = () => { if (onDelete) { setShowDeleteConfirm(true); } }; // The gallery can be rendered inside another modal's overlay, and that // overlay closes on a backdrop click (#3077). Dismissing the gallery must // not bubble up and take the parent — and its unsaved edits — with it. const handleBackdropClick = (e: React.MouseEvent) => { e.stopPropagation(); onClose(); }; return (
Photo {currentIndex + 1} of {photos.length}