From 72d18ead8cc8ec46ba7e091a92a1068cb8b59433 Mon Sep 17 00:00:00 2001 From: tanyar09 Date: Thu, 13 Nov 2025 11:54:37 -0500 Subject: [PATCH] feat: Implement PhotoViewer component for enhanced photo browsing experience This commit introduces a new PhotoViewer component that allows users to view photos in a full-screen mode with navigation controls. The component supports preloading adjacent images for smoother transitions and includes keyboard navigation for improved accessibility. Additionally, the Search page has been updated to integrate the PhotoViewer, enabling users to load and display all photos seamlessly. Documentation has been updated to reflect these changes. --- frontend/src/components/PhotoViewer.tsx | 203 ++++++++++++++++++++++++ frontend/src/pages/Search.tsx | 94 +++++++++++ 2 files changed, 297 insertions(+) create mode 100644 frontend/src/components/PhotoViewer.tsx diff --git a/frontend/src/components/PhotoViewer.tsx b/frontend/src/components/PhotoViewer.tsx new file mode 100644 index 0000000..35e5c53 --- /dev/null +++ b/frontend/src/components/PhotoViewer.tsx @@ -0,0 +1,203 @@ +import { useEffect, useState, useRef } from 'react' +import { PhotoSearchResult } from '../api/photos' +import { apiClient } from '../api/client' + +interface PhotoViewerProps { + photos: PhotoSearchResult[] + initialIndex: number + onClose: () => void +} + +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()) + + const currentPhoto = photos[currentIndex] + const canGoPrev = currentIndex > 0 + const canGoNext = currentIndex < photos.length - 1 + + // Get photo URL + const getPhotoUrl = (photoId: number) => { + return `${apiClient.defaults.baseURL}/api/v1/photos/${photoId}/image` + } + + // Preload adjacent images + const preloadAdjacent = (index: number) => { + // Preload next photo + if (index + 1 < photos.length) { + const nextPhotoId = photos[index + 1].id + if (!preloadedImages.current.has(nextPhotoId)) { + const img = new Image() + img.src = getPhotoUrl(nextPhotoId) + preloadedImages.current.add(nextPhotoId) + } + } + // Preload previous photo + if (index - 1 >= 0) { + const prevPhotoId = photos[index - 1].id + if (!preloadedImages.current.has(prevPhotoId)) { + const img = new Image() + img.src = getPhotoUrl(prevPhotoId) + preloadedImages.current.add(prevPhotoId) + } + } + } + + // Handle navigation + const goPrev = () => { + if (currentIndex > 0) { + setCurrentIndex(currentIndex - 1) + } + } + + const goNext = () => { + if (currentIndex < photos.length - 1) { + setCurrentIndex(currentIndex + 1) + } + } + + // Handle image load + useEffect(() => { + if (!currentPhoto) return + + setImageLoading(true) + setImageError(false) + + // Preload adjacent images when current photo changes + preloadAdjacent(currentIndex) + }, [currentIndex, currentPhoto, photos.length]) + + // Keyboard navigation + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (e.key === 'Escape') { + onClose() + } else if (e.key === 'ArrowLeft') { + e.preventDefault() + if (currentIndex > 0) { + setCurrentIndex(currentIndex - 1) + } + } else if (e.key === 'ArrowRight') { + e.preventDefault() + if (currentIndex < photos.length - 1) { + setCurrentIndex(currentIndex + 1) + } + } + } + + window.addEventListener('keydown', handleKeyDown) + return () => window.removeEventListener('keydown', handleKeyDown) + }, [currentIndex, photos.length, onClose]) + + if (!currentPhoto) { + return null + } + + const photoUrl = getPhotoUrl(currentPhoto.id) + + return ( +
+ {/* Header */} +
+
+ +
+ Photo {currentIndex + 1} of {photos.length} +
+ {currentPhoto.filename && ( +
+ {currentPhoto.filename} +
+ )} +
+
+ {currentPhoto.date_taken && ( +
+ {currentPhoto.date_taken} +
+ )} + {currentPhoto.person_name && ( +
+ 👤 {currentPhoto.person_name} +
+ )} +
+
+ + {/* Main Image Area */} +
+ {imageLoading && ( +
+
Loading...
+
+ )} + {imageError ? ( +
+
Failed to load image
+
{currentPhoto.path}
+
+ ) : ( + {currentPhoto.filename setImageLoading(false)} + onError={() => { + setImageLoading(false) + setImageError(true) + }} + /> + )} + + {/* Navigation Buttons */} + + +
+ + {/* Footer Info */} +
+
+
+ {currentPhoto.tags.length > 0 && ( +
+ Tags: + {currentPhoto.tags.join(', ')} +
+ )} + {currentPhoto.path && ( +
+ {currentPhoto.path} +
+ )} +
+
+ Use ← → arrow keys to navigate, Esc to close +
+
+
+
+ ) +} + diff --git a/frontend/src/pages/Search.tsx b/frontend/src/pages/Search.tsx index 3fd1d64..8458a0d 100644 --- a/frontend/src/pages/Search.tsx +++ b/frontend/src/pages/Search.tsx @@ -2,6 +2,7 @@ import { useEffect, useState, useMemo } from 'react' import { photosApi, PhotoSearchResult } from '../api/photos' import tagsApi, { TagResponse, PhotoTagItem } from '../api/tags' import { apiClient } from '../api/client' +import PhotoViewer from '../components/PhotoViewer' type SearchType = 'name' | 'date' | 'tags' | 'no_faces' | 'no_tags' | 'processed' | 'unprocessed' @@ -57,6 +58,12 @@ export default function Search() { const [loadingTags, setLoadingTags] = useState(false) const [showDeleteConfirm, setShowDeleteConfirm] = useState(false) + // Photo viewer + const [showPhotoViewer, setShowPhotoViewer] = useState(false) + const [allPhotos, setAllPhotos] = useState([]) + const [loadingAllPhotos, setLoadingAllPhotos] = useState(false) + const [photoViewerIndex, setPhotoViewerIndex] = useState(0) + const loadTags = async () => { try { const res = await tagsApi.list() @@ -401,6 +408,76 @@ export default function Search() { } } + // Load all photos for photo viewer (all pages) + const loadAllPhotos = async () => { + if (total === 0) { + alert('No photos to display.') + return + } + + setLoadingAllPhotos(true) + try { + const allPhotosList: PhotoSearchResult[] = [] + const maxPageSize = 200 // Maximum allowed by API + const totalPages = Math.ceil(total / maxPageSize) + + // Build search params (same as current search) + const baseParams: any = { + search_type: searchType, + folder_path: folderPath || undefined, + page_size: maxPageSize, + } + + if (searchType === 'name') { + baseParams.person_name = personName.trim() + } else if (searchType === 'date') { + baseParams.date_from = dateFrom || undefined + baseParams.date_to = dateTo || undefined + } else if (searchType === 'tags') { + baseParams.tag_names = selectedTags.join(', ') + baseParams.match_all = matchAll + } + + // Fetch all pages sequentially + for (let pageNum = 1; pageNum <= totalPages; pageNum++) { + const res = await photosApi.searchPhotos({ + ...baseParams, + page: pageNum, + }) + allPhotosList.push(...res.items) + } + + setAllPhotos(allPhotosList) + + // Determine starting index + let startIndex = 0 + if (selectedPhotos.size > 0) { + // Start with first selected photo + const selectedArray = Array.from(selectedPhotos) + const firstSelectedId = selectedArray[0] + const foundIndex = allPhotosList.findIndex(p => p.id === firstSelectedId) + if (foundIndex !== -1) { + startIndex = foundIndex + } + } else if (results.length > 0) { + // Start with first photo in current results + const firstResultId = results[0].id + const foundIndex = allPhotosList.findIndex(p => p.id === firstResultId) + if (foundIndex !== -1) { + startIndex = foundIndex + } + } + + setPhotoViewerIndex(startIndex) + setShowPhotoViewer(true) + } catch (error) { + console.error('Error loading all photos:', error) + alert('Error loading photos. Please try again.') + } finally { + setLoadingAllPhotos(false) + } + } + return (

🔎 Search Photos

@@ -591,6 +668,14 @@ export default function Search() { ({total} items)
+
)} + + {/* Photo Viewer */} + {showPhotoViewer && allPhotos.length > 0 && ( + setShowPhotoViewer(false)} + /> + )} ) }