- Implement file scanner with SHA256 hash-based duplicate detection - Add Sharp-based thumbnail generation with object-contain display - Create comprehensive photo grid with EXIF metadata overlay - Add SQLite thumbnail blob caching for improved performance - Support full image preview with proper aspect ratio preservation - Include background directory scanning with progress tracking 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
190 lines
6.6 KiB
TypeScript
190 lines
6.6 KiB
TypeScript
'use client'
|
||
|
||
import { useState } from 'react'
|
||
import { IconCamera, IconMapPin, IconCalendar, IconEye, IconHeart, IconStar } from '@tabler/icons-react'
|
||
import { Photo } from '@/types/photo'
|
||
|
||
interface PhotoThumbnailProps {
|
||
photo: Photo
|
||
size?: 'small' | 'medium' | 'large'
|
||
showMetadata?: boolean
|
||
onPhotoClick?: (photo: Photo) => void
|
||
}
|
||
|
||
export default function PhotoThumbnail({
|
||
photo,
|
||
size = 'medium',
|
||
showMetadata = false,
|
||
onPhotoClick
|
||
}: PhotoThumbnailProps) {
|
||
const [imageError, setImageError] = useState(false)
|
||
const [showDetails, setShowDetails] = useState(false)
|
||
|
||
// Parse metadata
|
||
let metadata: any = {}
|
||
try {
|
||
metadata = photo.metadata ? JSON.parse(photo.metadata) : {}
|
||
} catch (error) {
|
||
console.warn('Failed to parse photo metadata:', error)
|
||
}
|
||
|
||
const exif = metadata.exif || {}
|
||
|
||
// Size configurations - keep square containers for grid layout
|
||
const sizeConfig = {
|
||
small: {
|
||
container: 'aspect-square',
|
||
thumbnail: 150
|
||
},
|
||
medium: {
|
||
container: 'aspect-square',
|
||
thumbnail: 200
|
||
},
|
||
large: {
|
||
container: 'aspect-square',
|
||
thumbnail: 300
|
||
}
|
||
}
|
||
|
||
const config = sizeConfig[size]
|
||
|
||
// Format metadata for display
|
||
const formatExposureTime = (time: number) => {
|
||
if (time >= 1) return `${time}s`
|
||
return `1/${Math.round(1 / time)}s`
|
||
}
|
||
|
||
const formatFocalLength = (length: number) => `${Math.round(length)}mm`
|
||
|
||
const formatISO = (iso: number | number[]) => {
|
||
const isoValue = Array.isArray(iso) ? iso[0] : iso
|
||
return `ISO ${isoValue}`
|
||
}
|
||
|
||
const formatDate = (dateString: string) => {
|
||
try {
|
||
return new Date(dateString).toLocaleDateString()
|
||
} catch {
|
||
return 'Unknown'
|
||
}
|
||
}
|
||
|
||
const formatFileSize = (bytes: number) => {
|
||
if (bytes === 0) return '0 B'
|
||
const k = 1024
|
||
const sizes = ['B', 'KB', 'MB', 'GB']
|
||
const i = Math.floor(Math.log(bytes) / Math.log(k))
|
||
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
|
||
}
|
||
|
||
return (
|
||
<div
|
||
className={`relative group cursor-pointer overflow-hidden rounded-lg shadow-md hover:shadow-lg transition-all duration-200 bg-gray-100 dark:bg-gray-800 ${config.container} w-full`}
|
||
onClick={() => onPhotoClick?.(photo)}
|
||
onMouseEnter={() => setShowDetails(true)}
|
||
onMouseLeave={() => setShowDetails(false)}
|
||
>
|
||
{/* Thumbnail Image */}
|
||
{!imageError ? (
|
||
<img
|
||
src={`/api/photos/${photo.id}/thumbnail?size=${config.thumbnail}`}
|
||
alt={photo.filename}
|
||
className="absolute inset-0 w-full h-full object-contain transition-transform duration-200 group-hover:scale-105"
|
||
onError={() => setImageError(true)}
|
||
loading="lazy"
|
||
/>
|
||
) : (
|
||
<div className="w-full h-full flex items-center justify-center text-gray-400 dark:text-gray-600">
|
||
<IconCamera size={32} />
|
||
</div>
|
||
)}
|
||
|
||
{/* Favorite indicator */}
|
||
{photo.favorite && (
|
||
<div className="absolute top-2 right-2 z-10">
|
||
<IconHeart className="w-5 h-5 text-red-500 fill-current" />
|
||
</div>
|
||
)}
|
||
|
||
{/* Rating indicator */}
|
||
{photo.rating && photo.rating > 0 && (
|
||
<div className="absolute top-2 left-2 z-10 flex">
|
||
{[...Array(photo.rating)].map((_, i) => (
|
||
<IconStar key={i} className="w-4 h-4 text-yellow-400 fill-current" />
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{/* Metadata overlay - appears at bottom, leaving image visible */}
|
||
{showMetadata && (showDetails || size === 'large') && (
|
||
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black via-black/80 to-transparent text-white p-3 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||
<div className="space-y-1 text-xs">
|
||
{/* Filename */}
|
||
<div className="font-medium truncate" title={photo.filename}>
|
||
{photo.filename}
|
||
</div>
|
||
|
||
{/* Camera info and settings in a compact row */}
|
||
<div className="flex items-center justify-between text-gray-300">
|
||
{(exif.camera_make || exif.camera_model) && (
|
||
<div className="flex items-center gap-1 truncate flex-1 mr-2">
|
||
<IconCamera className="w-3 h-3 flex-shrink-0" />
|
||
<span className="truncate">
|
||
{[exif.camera_make, exif.camera_model].filter(Boolean).join(' ')}
|
||
</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Photo settings - compact display */}
|
||
{(exif.f_number || exif.exposure_time || exif.iso_speed) && (
|
||
<div className="flex items-center gap-2 text-xs">
|
||
{exif.f_number && <span>f/{exif.f_number}</span>}
|
||
{exif.exposure_time && <span>{formatExposureTime(exif.exposure_time)}</span>}
|
||
{exif.iso_speed && <span>{formatISO(exif.iso_speed)}</span>}
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Second row: location, date, dimensions */}
|
||
<div className="flex items-center justify-between text-gray-400 text-xs">
|
||
<div className="flex items-center gap-3">
|
||
{/* GPS location */}
|
||
{exif.gps && (exif.gps.latitude || exif.gps.longitude) && (
|
||
<div className="flex items-center gap-1">
|
||
<IconMapPin className="w-3 h-3" />
|
||
<span>{exif.gps.latitude?.toFixed(2)}, {exif.gps.longitude?.toFixed(2)}</span>
|
||
</div>
|
||
)}
|
||
|
||
{/* Date taken */}
|
||
{exif.date_time_original && (
|
||
<div className="flex items-center gap-1">
|
||
<IconCalendar className="w-3 h-3" />
|
||
<span>{formatDate(exif.date_time_original)}</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Image dimensions and file size */}
|
||
<div className="text-right">
|
||
{photo.width && photo.height && (
|
||
<div>{photo.width} × {photo.height}</div>
|
||
)}
|
||
<div>{formatFileSize(photo.filesize)}</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* Simple overlay for small sizes */}
|
||
{size === 'small' && showDetails && (
|
||
<div className="absolute inset-0 bg-black bg-opacity-50 text-white p-2 flex items-end opacity-0 group-hover:opacity-100 transition-opacity duration-200">
|
||
<div className="text-xs font-medium truncate" title={photo.filename}>
|
||
{photo.filename}
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)
|
||
} |