PhotoGalleryModal.tsx 7.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198
  1. import { useState, useEffect } from 'react';
  2. import { X, ChevronLeft, ChevronRight, Download, Trash2 } from 'lucide-react';
  3. import { api } from '../api/client';
  4. import { Button } from './Button';
  5. import { ConfirmModal } from './ConfirmModal';
  6. // Archive photos resolve through `archiveId`; any other owner (a library
  7. // file, #3077) passes `getPhotoUrl` instead. Exactly one of the two is
  8. // required so a caller cannot silently end up requesting archive 0.
  9. type PhotoSource =
  10. | { archiveId: number; getPhotoUrl?: undefined }
  11. | { archiveId?: undefined; getPhotoUrl: (filename: string) => string };
  12. type PhotoGalleryModalProps = PhotoSource & {
  13. archiveName: string;
  14. photos: string[];
  15. // Which photo to open on. A caller that opens the gallery from a per-photo
  16. // grid passes the clicked index (#3077); one with a single "view photos"
  17. // button leaves it at the first.
  18. initialIndex?: number;
  19. onClose: () => void;
  20. onDelete?: (filename: string) => void;
  21. };
  22. export function PhotoGalleryModal(props: PhotoGalleryModalProps) {
  23. const { archiveName, photos, initialIndex, onClose, onDelete } = props;
  24. const [currentIndex, setCurrentIndex] = useState(() =>
  25. Math.min(Math.max(initialIndex ?? 0, 0), Math.max(photos.length - 1, 0))
  26. );
  27. const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
  28. // Keyboard navigation. Stands down while the delete confirmation is up:
  29. // that has its own Escape handler, so one press would cancel the prompt and
  30. // close the gallery underneath it, and arrow keys would move the selection
  31. // out from under a confirmation already naming a photo.
  32. useEffect(() => {
  33. if (showDeleteConfirm) return;
  34. const handleKeyDown = (e: KeyboardEvent) => {
  35. if (e.key === 'Escape') onClose();
  36. if (e.key === 'ArrowLeft') setCurrentIndex((i) => Math.max(0, i - 1));
  37. if (e.key === 'ArrowRight') setCurrentIndex((i) => Math.min(photos.length - 1, i + 1));
  38. };
  39. window.addEventListener('keydown', handleKeyDown);
  40. return () => window.removeEventListener('keydown', handleKeyDown);
  41. }, [onClose, photos.length, showDeleteConfirm]);
  42. // Reset index if photos change
  43. useEffect(() => {
  44. if (currentIndex >= photos.length) {
  45. setCurrentIndex(Math.max(0, photos.length - 1));
  46. }
  47. }, [photos.length, currentIndex]);
  48. if (photos.length === 0) {
  49. onClose();
  50. return null;
  51. }
  52. const resolvePhotoUrl = (filename: string) =>
  53. props.getPhotoUrl ? props.getPhotoUrl(filename) : api.getArchivePhotoUrl(props.archiveId, filename);
  54. const currentPhoto = photos[currentIndex];
  55. const photoUrl = resolvePhotoUrl(currentPhoto);
  56. const handleDownload = () => {
  57. const link = document.createElement('a');
  58. link.href = photoUrl;
  59. link.download = `${archiveName}_photo_${currentIndex + 1}.jpg`;
  60. link.click();
  61. };
  62. const handleDelete = () => {
  63. if (onDelete) {
  64. setShowDeleteConfirm(true);
  65. }
  66. };
  67. // The gallery can be rendered inside another modal's overlay, and that
  68. // overlay closes on a backdrop click (#3077). Dismissing the gallery must
  69. // not bubble up and take the parent — and its unsaved edits — with it.
  70. const handleBackdropClick = (e: React.MouseEvent) => {
  71. e.stopPropagation();
  72. onClose();
  73. };
  74. return (
  75. <div
  76. className="fixed inset-0 bg-black/90 flex items-center justify-center z-50"
  77. onClick={handleBackdropClick}
  78. >
  79. <div
  80. className="relative w-full h-full flex flex-col"
  81. onClick={(e) => e.stopPropagation()}
  82. >
  83. {/* Header */}
  84. <div className="flex items-center justify-between px-6 py-4 bg-black/50">
  85. <div>
  86. <h2 className="text-lg font-semibold text-white">{archiveName}</h2>
  87. <p className="text-sm text-bambu-gray">
  88. Photo {currentIndex + 1} of {photos.length}
  89. </p>
  90. </div>
  91. <div className="flex items-center gap-2">
  92. <Button variant="secondary" size="sm" onClick={handleDownload}>
  93. <Download className="w-4 h-4" />
  94. Download
  95. </Button>
  96. {onDelete && (
  97. <Button variant="secondary" size="sm" onClick={handleDelete} className="text-red-400 hover:text-red-300">
  98. <Trash2 className="w-4 h-4" />
  99. </Button>
  100. )}
  101. <button
  102. onClick={onClose}
  103. className="p-2 text-bambu-gray hover:text-white transition-colors"
  104. >
  105. <X className="w-6 h-6" />
  106. </button>
  107. </div>
  108. </div>
  109. {/* Image */}
  110. <div className="flex-1 min-h-0 flex items-center justify-center p-4 relative overflow-hidden">
  111. {/* Previous button */}
  112. {currentIndex > 0 && (
  113. <button
  114. onClick={() => setCurrentIndex((i) => i - 1)}
  115. className="absolute left-4 z-10 p-3 bg-black/50 hover:bg-black/70 rounded-full transition-colors"
  116. >
  117. <ChevronLeft className="w-8 h-8 text-white" />
  118. </button>
  119. )}
  120. {/* Image */}
  121. <img
  122. src={photoUrl}
  123. alt={`Photo ${currentIndex + 1}`}
  124. className="max-w-full max-h-full object-contain rounded-lg"
  125. style={{ maxHeight: 'calc(100vh - 200px)' }}
  126. />
  127. {/* Next button */}
  128. {currentIndex < photos.length - 1 && (
  129. <button
  130. onClick={() => setCurrentIndex((i) => i + 1)}
  131. className="absolute right-4 z-10 p-3 bg-black/50 hover:bg-black/70 rounded-full transition-colors"
  132. >
  133. <ChevronRight className="w-8 h-8 text-white" />
  134. </button>
  135. )}
  136. </div>
  137. {/* Thumbnails */}
  138. {photos.length > 1 && (
  139. <div className="flex justify-center gap-2 p-4 bg-black/50">
  140. {photos.map((photo, index) => (
  141. <button
  142. key={photo}
  143. onClick={() => setCurrentIndex(index)}
  144. className={`w-16 h-16 rounded-lg overflow-hidden border-2 transition-colors ${
  145. index === currentIndex
  146. ? 'border-bambu-green'
  147. : 'border-transparent hover:border-bambu-gray'
  148. }`}
  149. >
  150. <img
  151. src={resolvePhotoUrl(photo)}
  152. alt={`Thumbnail ${index + 1}`}
  153. className="w-full h-full object-cover"
  154. />
  155. </button>
  156. ))}
  157. </div>
  158. )}
  159. </div>
  160. {/* Delete Confirmation Modal. Wrapped so that cancelling it by clicking
  161. its backdrop cancels only the confirmation — the click would
  162. otherwise reach the gallery backdrop below it and close the gallery
  163. as well. */}
  164. {showDeleteConfirm && (
  165. <div onClick={(e) => e.stopPropagation()}>
  166. <ConfirmModal
  167. title="Delete Photo"
  168. message="Delete this photo? This cannot be undone."
  169. confirmText="Delete"
  170. variant="danger"
  171. onConfirm={() => {
  172. onDelete?.(currentPhoto);
  173. setShowDeleteConfirm(false);
  174. }}
  175. onCancel={() => setShowDeleteConfirm(false)}
  176. />
  177. </div>
  178. )}
  179. </div>
  180. );
  181. }