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:
parent
cfb94900ef
commit
72d18ead8c
203
frontend/src/components/PhotoViewer.tsx
Normal file
203
frontend/src/components/PhotoViewer.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user