photos/src/components/PhotoThumbnail.tsx
Michael Mainguy 868ef2eeaa Add photo scanning with EXIF metadata extraction and thumbnail caching
- 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>
2025-08-27 08:35:07 -05:00

190 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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>
)
}