photos/src/components/PhotoGrid.tsx
Michael Mainguy f6b651eeda Add full-resolution image modal with zoom and navigation capabilities
- Create ImageModal component with professional photo viewing experience
- Support zoom in/out, pan, rotate, and full-screen display
- Add photo navigation with arrow keys and previous/next buttons
- Include comprehensive keyboard shortcuts and visual controls
- Implement proper image centering and aspect ratio preservation
- Create full-resolution image API endpoint with range request support
- Display rich EXIF metadata in modal header with camera settings

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-27 08:58:33 -05:00

227 lines
7.8 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import PhotoThumbnail from './PhotoThumbnail'
import ImageModal from './ImageModal'
import { Photo } from '@/types/photo'
import { IconPhoto, IconFilter, IconSearch, IconSortAscending, IconSortDescending } from '@tabler/icons-react'
interface PhotoGridProps {
directoryPath?: string
showMetadata?: boolean
thumbnailSize?: 'small' | 'medium' | 'large'
}
type SortOption = 'created_at' | 'modified_at' | 'filename' | 'filesize'
type SortOrder = 'ASC' | 'DESC'
export default function PhotoGrid({
directoryPath,
showMetadata = true,
thumbnailSize = 'medium'
}: PhotoGridProps) {
const [photos, setPhotos] = useState<Photo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [sortBy, setSortBy] = useState<SortOption>('created_at')
const [sortOrder, setSortOrder] = useState<SortOrder>('DESC')
const [searchTerm, setSearchTerm] = useState('')
const [selectedPhoto, setSelectedPhoto] = useState<Photo | null>(null)
const fetchPhotos = async () => {
try {
setLoading(true)
setError(null)
const params = new URLSearchParams({
sortBy,
sortOrder,
limit: '100', // Load first 100 photos
})
if (directoryPath) {
params.append('directory', directoryPath)
}
const response = await fetch(`/api/photos?${params}`)
if (!response.ok) {
throw new Error('Failed to fetch photos')
}
const data = await response.json()
setPhotos(data.photos || [])
} catch (error) {
console.error('Error fetching photos:', error)
setError('Failed to load photos')
} finally {
setLoading(false)
}
}
useEffect(() => {
fetchPhotos()
}, [directoryPath, sortBy, sortOrder])
// Filter photos based on search term
const filteredPhotos = photos.filter(photo => {
if (!searchTerm) return true
const searchLower = searchTerm.toLowerCase()
// Search in filename
if (photo.filename.toLowerCase().includes(searchLower)) return true
// Search in metadata
try {
const metadata = photo.metadata ? JSON.parse(photo.metadata) : {}
const exif = metadata.exif || {}
// Search in camera info
if (exif.camera_make?.toLowerCase().includes(searchLower)) return true
if (exif.camera_model?.toLowerCase().includes(searchLower)) return true
if (exif.lens_model?.toLowerCase().includes(searchLower)) return true
} catch (error) {
// Ignore metadata parsing errors for search
}
return false
})
const handlePhotoClick = (photo: Photo) => {
setSelectedPhoto(photo)
}
const handleSortChange = (newSortBy: SortOption) => {
if (newSortBy === sortBy) {
// Toggle sort order if same field
setSortOrder(sortOrder === 'ASC' ? 'DESC' : 'ASC')
} else {
setSortBy(newSortBy)
setSortOrder('DESC') // Default to descending for new field
}
}
// Grid classes based on thumbnail size - made thumbnails larger
const gridClasses = {
small: 'grid-cols-4 sm:grid-cols-6 md:grid-cols-8 lg:grid-cols-10 xl:grid-cols-12 gap-2',
medium: 'grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4',
large: 'grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-6'
}
if (loading) {
return (
<div className="flex flex-col items-center justify-center py-12">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-blue-600 border-t-transparent mb-4"></div>
<p className="text-gray-600 dark:text-gray-400">Loading photos...</p>
</div>
)
}
if (error) {
return (
<div className="flex flex-col items-center justify-center py-12 text-red-600 dark:text-red-400">
<IconPhoto className="w-12 h-12 mb-4 opacity-50" />
<p className="text-lg font-medium mb-2">Error Loading Photos</p>
<p className="text-sm opacity-75">{error}</p>
<button
onClick={fetchPhotos}
className="mt-4 px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 transition-colors"
>
Try Again
</button>
</div>
)
}
if (photos.length === 0) {
return (
<div className="flex flex-col items-center justify-center py-12 text-gray-500 dark:text-gray-400">
<IconPhoto className="w-16 h-16 mb-4 opacity-50" />
<p className="text-xl font-medium mb-2">No Photos Found</p>
<p className="text-sm opacity-75">
{directoryPath
? `No photos found in ${directoryPath}`
: 'No photos have been scanned yet. Select a directory and click scan to get started.'
}
</p>
</div>
)
}
return (
<div className="p-6 space-y-6">
{/* Controls */}
<div className="flex flex-col sm:flex-row gap-4 items-start sm:items-center justify-between">
<div className="flex items-center gap-4">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">
Photos {directoryPath && `in ${directoryPath.split('/').pop()}`}
</h2>
<span className="text-sm text-gray-500 dark:text-gray-400">
{filteredPhotos.length} photo{filteredPhotos.length !== 1 ? 's' : ''}
</span>
</div>
<div className="flex items-center gap-3">
{/* Search */}
<div className="relative">
<IconSearch className="absolute left-3 top-1/2 transform -translate-y-1/2 w-4 h-4 text-gray-400" />
<input
type="text"
placeholder="Search photos..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10 pr-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
{/* Sort controls */}
<div className="flex items-center gap-2">
<IconFilter className="w-4 h-4 text-gray-400" />
<select
value={sortBy}
onChange={(e) => handleSortChange(e.target.value as SortOption)}
className="border border-gray-300 dark:border-gray-600 rounded bg-white dark:bg-gray-800 text-gray-900 dark:text-white text-sm px-3 py-2 focus:ring-2 focus:ring-blue-500"
>
<option value="created_at">Date Created</option>
<option value="modified_at">Date Modified</option>
<option value="filename">Filename</option>
<option value="filesize">File Size</option>
</select>
<button
onClick={() => setSortOrder(sortOrder === 'ASC' ? 'DESC' : 'ASC')}
className="p-2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
title={`Sort ${sortOrder === 'ASC' ? 'Descending' : 'Ascending'}`}
>
{sortOrder === 'ASC' ? <IconSortAscending className="w-4 h-4" /> : <IconSortDescending className="w-4 h-4" />}
</button>
</div>
</div>
</div>
{/* Photo Grid */}
<div className={`grid ${gridClasses[thumbnailSize]}`}>
{filteredPhotos.map((photo) => (
<PhotoThumbnail
key={photo.id}
photo={photo}
size={thumbnailSize}
showMetadata={showMetadata}
onPhotoClick={handlePhotoClick}
/>
))}
</div>
{/* Full Resolution Image Modal */}
<ImageModal
photo={selectedPhoto}
isOpen={!!selectedPhoto}
onClose={() => setSelectedPhoto(null)}
photos={filteredPhotos}
onNavigate={setSelectedPhoto}
/>
</div>
)
}