- 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>
227 lines
7.8 KiB
TypeScript
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>
|
|
)
|
|
} |