PhotoGalleryModal.tsx 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171
  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. interface PhotoGalleryModalProps {
  7. archiveId: number;
  8. archiveName: string;
  9. photos: string[];
  10. onClose: () => void;
  11. onDelete?: (filename: string) => void;
  12. }
  13. export function PhotoGalleryModal({
  14. archiveId,
  15. archiveName,
  16. photos,
  17. onClose,
  18. onDelete,
  19. }: PhotoGalleryModalProps) {
  20. const [currentIndex, setCurrentIndex] = useState(0);
  21. const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
  22. // Keyboard navigation
  23. useEffect(() => {
  24. const handleKeyDown = (e: KeyboardEvent) => {
  25. if (e.key === 'Escape') onClose();
  26. if (e.key === 'ArrowLeft') setCurrentIndex((i) => Math.max(0, i - 1));
  27. if (e.key === 'ArrowRight') setCurrentIndex((i) => Math.min(photos.length - 1, i + 1));
  28. };
  29. window.addEventListener('keydown', handleKeyDown);
  30. return () => window.removeEventListener('keydown', handleKeyDown);
  31. }, [onClose, photos.length]);
  32. // Reset index if photos change
  33. useEffect(() => {
  34. if (currentIndex >= photos.length) {
  35. setCurrentIndex(Math.max(0, photos.length - 1));
  36. }
  37. }, [photos.length, currentIndex]);
  38. if (photos.length === 0) {
  39. onClose();
  40. return null;
  41. }
  42. const currentPhoto = photos[currentIndex];
  43. const photoUrl = api.getArchivePhotoUrl(archiveId, currentPhoto);
  44. const handleDownload = () => {
  45. const link = document.createElement('a');
  46. link.href = photoUrl;
  47. link.download = `${archiveName}_photo_${currentIndex + 1}.jpg`;
  48. link.click();
  49. };
  50. const handleDelete = () => {
  51. if (onDelete) {
  52. setShowDeleteConfirm(true);
  53. }
  54. };
  55. return (
  56. <div
  57. className="fixed inset-0 bg-black/90 flex items-center justify-center z-50"
  58. onClick={onClose}
  59. >
  60. <div
  61. className="relative w-full h-full flex flex-col"
  62. onClick={(e) => e.stopPropagation()}
  63. >
  64. {/* Header */}
  65. <div className="flex items-center justify-between px-6 py-4 bg-black/50">
  66. <div>
  67. <h2 className="text-lg font-semibold text-white">{archiveName}</h2>
  68. <p className="text-sm text-bambu-gray">
  69. Photo {currentIndex + 1} of {photos.length}
  70. </p>
  71. </div>
  72. <div className="flex items-center gap-2">
  73. <Button variant="secondary" size="sm" onClick={handleDownload}>
  74. <Download className="w-4 h-4" />
  75. Download
  76. </Button>
  77. {onDelete && (
  78. <Button variant="secondary" size="sm" onClick={handleDelete} className="text-red-400 hover:text-red-300">
  79. <Trash2 className="w-4 h-4" />
  80. </Button>
  81. )}
  82. <button
  83. onClick={onClose}
  84. className="p-2 text-bambu-gray hover:text-white transition-colors"
  85. >
  86. <X className="w-6 h-6" />
  87. </button>
  88. </div>
  89. </div>
  90. {/* Image */}
  91. <div className="flex-1 min-h-0 flex items-center justify-center p-4 relative overflow-hidden">
  92. {/* Previous button */}
  93. {currentIndex > 0 && (
  94. <button
  95. onClick={() => setCurrentIndex((i) => i - 1)}
  96. className="absolute left-4 z-10 p-3 bg-black/50 hover:bg-black/70 rounded-full transition-colors"
  97. >
  98. <ChevronLeft className="w-8 h-8 text-white" />
  99. </button>
  100. )}
  101. {/* Image */}
  102. <img
  103. src={photoUrl}
  104. alt={`Photo ${currentIndex + 1}`}
  105. className="max-w-full max-h-full object-contain rounded-lg"
  106. style={{ maxHeight: 'calc(100vh - 200px)' }}
  107. />
  108. {/* Next button */}
  109. {currentIndex < photos.length - 1 && (
  110. <button
  111. onClick={() => setCurrentIndex((i) => i + 1)}
  112. className="absolute right-4 z-10 p-3 bg-black/50 hover:bg-black/70 rounded-full transition-colors"
  113. >
  114. <ChevronRight className="w-8 h-8 text-white" />
  115. </button>
  116. )}
  117. </div>
  118. {/* Thumbnails */}
  119. {photos.length > 1 && (
  120. <div className="flex justify-center gap-2 p-4 bg-black/50">
  121. {photos.map((photo, index) => (
  122. <button
  123. key={photo}
  124. onClick={() => setCurrentIndex(index)}
  125. className={`w-16 h-16 rounded-lg overflow-hidden border-2 transition-colors ${
  126. index === currentIndex
  127. ? 'border-bambu-green'
  128. : 'border-transparent hover:border-bambu-gray'
  129. }`}
  130. >
  131. <img
  132. src={api.getArchivePhotoUrl(archiveId, photo)}
  133. alt={`Thumbnail ${index + 1}`}
  134. className="w-full h-full object-cover"
  135. />
  136. </button>
  137. ))}
  138. </div>
  139. )}
  140. </div>
  141. {/* Delete Confirmation Modal */}
  142. {showDeleteConfirm && (
  143. <ConfirmModal
  144. title="Delete Photo"
  145. message="Delete this photo? This cannot be undone."
  146. confirmText="Delete"
  147. variant="danger"
  148. onConfirm={() => {
  149. onDelete?.(currentPhoto);
  150. setShowDeleteConfirm(false);
  151. }}
  152. onCancel={() => setShowDeleteConfirm(false)}
  153. />
  154. )}
  155. </div>
  156. );
  157. }