diff --git a/frontend/src/components/PhotoViewer.tsx b/frontend/src/components/PhotoViewer.tsx index 35e5c53..df185f7 100644 --- a/frontend/src/components/PhotoViewer.tsx +++ b/frontend/src/components/PhotoViewer.tsx @@ -8,11 +8,35 @@ interface PhotoViewerProps { onClose: () => void } +const ZOOM_MIN = 0.5 +const ZOOM_MAX = 5 +const ZOOM_STEP = 0.25 +const SLIDESHOW_INTERVALS = [ + { value: 1, label: '1s' }, + { value: 2, label: '2s' }, + { value: 3, label: '3s' }, + { value: 5, label: '5s' }, + { value: 10, label: '10s' }, +] + export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoViewerProps) { const [currentIndex, setCurrentIndex] = useState(initialIndex) const [imageLoading, setImageLoading] = useState(true) const [imageError, setImageError] = useState(false) const preloadedImages = useRef>(new Set()) + + // Zoom state + const [zoom, setZoom] = useState(1) + const [panX, setPanX] = useState(0) + const [panY, setPanY] = useState(0) + const [isDragging, setIsDragging] = useState(false) + const [dragStart, setDragStart] = useState({ x: 0, y: 0 }) + const imageContainerRef = useRef(null) + + // Slideshow state + const [isPlaying, setIsPlaying] = useState(false) + const [slideshowInterval, setSlideshowInterval] = useState(3) // seconds + const slideshowTimerRef = useRef(null) const currentPhoto = photos[currentIndex] const canGoPrev = currentIndex > 0 @@ -49,21 +73,112 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView const goPrev = () => { if (currentIndex > 0) { setCurrentIndex(currentIndex - 1) + // Reset zoom when navigating + setZoom(1) + setPanX(0) + setPanY(0) } } const goNext = () => { if (currentIndex < photos.length - 1) { setCurrentIndex(currentIndex + 1) + // Reset zoom when navigating + setZoom(1) + setPanX(0) + setPanY(0) } } + // Zoom functions + const zoomIn = () => { + setZoom(prev => Math.min(prev + ZOOM_STEP, ZOOM_MAX)) + } + + const zoomOut = () => { + setZoom(prev => Math.max(prev - ZOOM_STEP, ZOOM_MIN)) + } + + const resetZoom = () => { + setZoom(1) + setPanX(0) + setPanY(0) + } + + const handleWheel = (e: React.WheelEvent) => { + if (e.ctrlKey || e.metaKey) { + // Zoom with Ctrl/Cmd + wheel + e.preventDefault() + const delta = e.deltaY > 0 ? -ZOOM_STEP : ZOOM_STEP + setZoom(prev => Math.max(ZOOM_MIN, Math.min(ZOOM_MAX, prev + delta))) + } + } + + // Pan (drag) functionality + const handleMouseDown = (e: React.MouseEvent) => { + if (zoom > 1) { + setIsDragging(true) + setDragStart({ x: e.clientX - panX, y: e.clientY - panY }) + } + } + + const handleMouseMove = (e: React.MouseEvent) => { + if (isDragging && zoom > 1) { + setPanX(e.clientX - dragStart.x) + setPanY(e.clientY - dragStart.y) + } + } + + const handleMouseUp = () => { + setIsDragging(false) + } + + // Slideshow functions + const toggleSlideshow = () => { + setIsPlaying(prev => !prev) + } + + useEffect(() => { + if (isPlaying) { + slideshowTimerRef.current = setInterval(() => { + setCurrentIndex(prev => { + if (prev < photos.length - 1) { + return prev + 1 + } else { + // Loop back to start or stop + setIsPlaying(false) + return prev + } + }) + // Reset zoom when slideshow advances + setZoom(1) + setPanX(0) + setPanY(0) + }, slideshowInterval * 1000) + } else { + if (slideshowTimerRef.current) { + clearInterval(slideshowTimerRef.current) + slideshowTimerRef.current = null + } + } + + return () => { + if (slideshowTimerRef.current) { + clearInterval(slideshowTimerRef.current) + } + } + }, [isPlaying, slideshowInterval, photos.length]) + // Handle image load useEffect(() => { if (!currentPhoto) return setImageLoading(true) setImageError(false) + // Reset zoom when photo changes + setZoom(1) + setPanX(0) + setPanY(0) // Preload adjacent images when current photo changes preloadAdjacent(currentIndex) @@ -74,22 +189,42 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView const handleKeyDown = (e: KeyboardEvent) => { if (e.key === 'Escape') { onClose() - } else if (e.key === 'ArrowLeft') { + } else if (e.key === 'ArrowLeft' && !isPlaying) { e.preventDefault() if (currentIndex > 0) { setCurrentIndex(currentIndex - 1) + setZoom(1) + setPanX(0) + setPanY(0) } - } else if (e.key === 'ArrowRight') { + } else if (e.key === 'ArrowRight' && !isPlaying) { e.preventDefault() if (currentIndex < photos.length - 1) { setCurrentIndex(currentIndex + 1) + setZoom(1) + setPanX(0) + setPanY(0) } + } else if (e.key === '+' || e.key === '=') { + e.preventDefault() + setZoom(prev => Math.min(prev + ZOOM_STEP, ZOOM_MAX)) + } else if (e.key === '-' || e.key === '_') { + e.preventDefault() + setZoom(prev => Math.max(prev - ZOOM_STEP, ZOOM_MIN)) + } else if (e.key === '0') { + e.preventDefault() + setZoom(1) + setPanX(0) + setPanY(0) + } else if (e.key === ' ') { + e.preventDefault() + setIsPlaying(prev => !prev) } } window.addEventListener('keydown', handleKeyDown) return () => window.removeEventListener('keydown', handleKeyDown) - }, [currentIndex, photos.length, onClose]) + }, [currentIndex, photos.length, onClose, isPlaying]) if (!currentPhoto) { return null @@ -99,41 +234,70 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView return (
- {/* Header */} -
-
+ {/* Top Left Info Corner */} +
+
-
- Photo {currentIndex + 1} of {photos.length} +
+ {currentIndex + 1} / {photos.length}
{currentPhoto.filename && ( -
+
{currentPhoto.filename}
)}
-
- {currentPhoto.date_taken && ( -
- {currentPhoto.date_taken} -
- )} - {currentPhoto.person_name && ( -
- 👤 {currentPhoto.person_name} -
- )} -
+
+ + {/* Top Right Play Button */} +
+
+ {isPlaying && ( + + )} + +
{/* Main Image Area */} -
+
1 ? (isDragging ? 'grabbing' : 'grab') : 'default' }} + > {imageLoading && (
Loading...
@@ -145,22 +309,63 @@ export default function PhotoViewer({ photos, initialIndex, onClose }: PhotoView
{currentPhoto.path}
) : ( - {currentPhoto.filename setImageLoading(false)} - onError={() => { - setImageLoading(false) - setImageError(true) +
+ > + {currentPhoto.filename setImageLoading(false)} + onError={() => { + setImageLoading(false) + setImageError(true) + }} + draggable={false} + /> +
)} + {/* Zoom Controls */} +
+ +
+ {Math.round(zoom * 100)}% +
+ + {zoom !== 1 && ( + + )} +
+ {/* Navigation Buttons */}
- {/* Footer Info */} -
-
-
- {currentPhoto.tags.length > 0 && ( -
- Tags: - {currentPhoto.tags.join(', ')} -
- )} - {currentPhoto.path && ( -
- {currentPhoto.path} -
- )} -
-
- Use ← → arrow keys to navigate, Esc to close -
-
-
) }