## Features Added: - **Dual Model Classification**: ViT (objects) + CLIP (style/artistic concepts) - **Image Captioning**: BLIP model for detailed photo descriptions - **Auto-tagging**: Process all photos with configurable confidence thresholds - **Tag Management**: Clear all tags functionality with safety confirmations - **Comprehensive Analysis**: 15-25+ tags per image covering objects, style, mood, lighting ## New API Endpoints: - `/api/classify/batch` - Batch classification with comprehensive mode - `/api/classify/comprehensive` - Dual-model analysis for maximum tags - `/api/classify/config` - Tunable classifier parameters - `/api/caption/batch` - Batch image captioning - `/api/tags/clear` - Clear all tags with safety checks ## UI Enhancements: - Auto-tag All button (processes 5 photos at a time) - Caption All button (processes 3 photos at a time) - Clear All Tags button with confirmation dialogs - Real-time progress bars for batch operations - Tag pills displayed on thumbnails and image modal - AI-generated captions shown in image modal ## Performance Optimizations: - Uses cached thumbnails for 10-100x faster processing - Parallel model initialization and processing - Graceful fallback to original files when thumbnails fail - Configurable batch sizes to prevent memory issues ## Technical Implementation: - Vision Transformer (ViT) for ImageNet object classification (1000+ classes) - CLIP for zero-shot artistic/style classification (photography, lighting, mood) - BLIP for natural language image descriptions - Comprehensive safety checks and error handling - Database integration for persistent tag and caption storage 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
270 lines
9.8 KiB
TypeScript
270 lines
9.8 KiB
TypeScript
'use client'
|
||
|
||
import { useState, useEffect } 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)
|
||
const [tags, setTags] = useState<Array<{id: string, name: string, color: string}>>([])
|
||
|
||
// Fetch tags for this photo
|
||
useEffect(() => {
|
||
const fetchTags = async () => {
|
||
try {
|
||
const response = await fetch(`/api/photos/${photo.id}/tags`)
|
||
if (response.ok) {
|
||
const photoTags = await response.json()
|
||
setTags(photoTags)
|
||
}
|
||
} catch (error) {
|
||
console.warn('Failed to fetch tags for photo:', photo.id)
|
||
}
|
||
}
|
||
fetchTags()
|
||
}, [photo.id])
|
||
|
||
// 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 formatDateTime = (dateString: string) => {
|
||
try {
|
||
return new Date(dateString).toLocaleDateString(undefined, {
|
||
year: 'numeric',
|
||
month: 'short',
|
||
day: 'numeric',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
})
|
||
} catch {
|
||
return 'Unknown'
|
||
}
|
||
}
|
||
|
||
// Get the best available date from EXIF data
|
||
const getBestDate = () => {
|
||
if (exif.date_time_original) return exif.date_time_original
|
||
if (exif.date_time_digitized) return exif.date_time_digitized
|
||
if (exif.date_time) return exif.date_time
|
||
if (photo.created_at) return photo.created_at
|
||
return null
|
||
}
|
||
|
||
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>
|
||
)}
|
||
|
||
{/* Tag pills */}
|
||
{tags.length > 0 && (
|
||
<div className="absolute bottom-2 left-2 right-2 z-10">
|
||
<div className="flex flex-wrap gap-1">
|
||
{tags.slice(0, size === 'large' ? 6 : size === 'medium' ? 4 : 2).map((tag) => (
|
||
<span
|
||
key={tag.id}
|
||
className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium text-white bg-black bg-opacity-70 backdrop-blur-sm"
|
||
style={{ backgroundColor: `${tag.color}cc` }} // Add some transparency
|
||
title={tag.name}
|
||
>
|
||
{tag.name}
|
||
</span>
|
||
))}
|
||
{tags.length > (size === 'large' ? 6 : size === 'medium' ? 4 : 2) && (
|
||
<span className="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium text-white bg-gray-700 bg-opacity-80">
|
||
+{tags.length - (size === 'large' ? 6 : size === 'medium' ? 4 : 2)}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</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 with time */}
|
||
{getBestDate() && (
|
||
<div className="flex items-center gap-1">
|
||
<IconCalendar className="w-3 h-3" />
|
||
<span title={`Original: ${exif.date_time_original || 'N/A'}\nDigitized: ${exif.date_time_digitized || 'N/A'}\nModified: ${exif.date_time || 'N/A'}`}>
|
||
{formatDateTime(getBestDate()!)}
|
||
</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>
|
||
|
||
{/* Third row: Additional EXIF date info for large thumbnails */}
|
||
{size === 'large' && (exif.date_time_original || exif.date_time_digitized || exif.date_time) && (
|
||
<div className="text-gray-500 text-xs mt-1 space-y-0.5">
|
||
{exif.date_time_original && exif.date_time_original !== getBestDate() && (
|
||
<div>📷 Taken: {formatDateTime(exif.date_time_original)}</div>
|
||
)}
|
||
{exif.date_time_digitized && exif.date_time_digitized !== getBestDate() && (
|
||
<div>💾 Digitized: {formatDateTime(exif.date_time_digitized)}</div>
|
||
)}
|
||
{exif.date_time && exif.date_time !== getBestDate() && (
|
||
<div>📝 Modified: {formatDateTime(exif.date_time)}</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>
|
||
)
|
||
} |