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.
This commit is contained in:
tanyar09 2025-11-13 11:54:37 -05:00
parent cfb94900ef
commit 72d18ead8c
2 changed files with 297 additions and 0 deletions

View File

@ -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<Set<number>>(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 (
<div className="fixed inset-0 bg-black z-[100] flex flex-col">
{/* Header */}
<div className="absolute top-0 left-0 right-0 z-10 bg-black bg-opacity-70 text-white p-4 flex items-center justify-between">
<div className="flex items-center gap-4">
<button
onClick={onClose}
className="px-3 py-1 bg-gray-700 hover:bg-gray-600 rounded text-sm"
title="Close (Esc)"
>
Close
</button>
<div className="text-sm">
Photo {currentIndex + 1} of {photos.length}
</div>
{currentPhoto.filename && (
<div className="text-sm text-gray-300 truncate max-w-md">
{currentPhoto.filename}
</div>
)}
</div>
<div className="flex items-center gap-2">
{currentPhoto.date_taken && (
<div className="text-sm text-gray-300">
{currentPhoto.date_taken}
</div>
)}
{currentPhoto.person_name && (
<div className="text-sm text-gray-300">
👤 {currentPhoto.person_name}
</div>
)}
</div>
</div>
{/* Main Image Area */}
<div className="flex-1 flex items-center justify-center relative overflow-hidden">
{imageLoading && (
<div className="absolute inset-0 flex items-center justify-center bg-black z-20">
<div className="text-white text-lg">Loading...</div>
</div>
)}
{imageError ? (
<div className="text-white text-center">
<div className="text-lg mb-2">Failed to load image</div>
<div className="text-sm text-gray-400">{currentPhoto.path}</div>
</div>
) : (
<img
src={photoUrl}
alt={currentPhoto.filename || `Photo ${currentIndex + 1}`}
className="max-w-full max-h-full object-contain"
onLoad={() => setImageLoading(false)}
onError={() => {
setImageLoading(false)
setImageError(true)
}}
/>
)}
{/* Navigation Buttons */}
<button
onClick={goPrev}
disabled={!canGoPrev}
className="absolute left-4 top-1/2 -translate-y-1/2 px-4 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed z-30"
title="Previous (←)"
>
Prev
</button>
<button
onClick={goNext}
disabled={!canGoNext}
className="absolute right-4 top-1/2 -translate-y-1/2 px-4 py-2 bg-black bg-opacity-70 hover:bg-opacity-90 text-white rounded disabled:opacity-30 disabled:cursor-not-allowed z-30"
title="Next (→)"
>
Next
</button>
</div>
{/* Footer Info */}
<div className="absolute bottom-0 left-0 right-0 z-10 bg-black bg-opacity-70 text-white p-4 text-sm">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
{currentPhoto.tags.length > 0 && (
<div>
<span className="text-gray-400">Tags: </span>
{currentPhoto.tags.join(', ')}
</div>
)}
{currentPhoto.path && (
<div className="text-gray-400 truncate max-w-2xl">
{currentPhoto.path}
</div>
)}
</div>
<div className="text-gray-400">
Use arrow keys to navigate, Esc to close
</div>
</div>
</div>
</div>
)
}

View File

@ -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<PhotoSearchResult[]>([])
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 (
<div>
<h1 className="text-2xl font-bold text-gray-900 mb-4">🔎 Search Photos</h1>
@ -591,6 +668,14 @@ export default function Search() {
<span className="text-sm text-gray-500 ml-2">({total} items)</span>
</div>
<div className="flex gap-2">
<button
onClick={loadAllPhotos}
disabled={loadingAllPhotos || total === 0}
className="px-3 py-1 text-sm border rounded hover:bg-gray-50 disabled:bg-gray-100 disabled:text-gray-400"
title="Open all photos in full-screen viewer"
>
{loadingAllPhotos ? 'Loading...' : '▶ Play photos'}
</button>
<button
onClick={() => setShowTagModal(true)}
disabled={selectedPhotos.size === 0}
@ -915,6 +1000,15 @@ export default function Search() {
</div>
</div>
)}
{/* Photo Viewer */}
{showPhotoViewer && allPhotos.length > 0 && (
<PhotoViewer
photos={allPhotos}
initialIndex={photoViewerIndex}
onClose={() => setShowPhotoViewer(false)}
/>
)}
</div>
)
}