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)} + /> + )} ) }