import { useState, useEffect, useCallback, useRef } from 'react' import { Star, Check, X, RefreshCw } from 'lucide-react' import clsx from 'clsx' import { photos as photosApi } from '../../services/api' // Auto-retry schedule (ms). Backend generates thumbs on-demand via Celery, so // first hit often 404s. Try a few times with backoff before giving up. const AUTO_RETRY_DELAYS = [1500, 3500, 6000] interface Photo { id: string filepath: string filename: string width: number | null height: number | null taken_at: string | null rating: number is_picked: boolean is_rejected: boolean file_hash: string media_type: string } interface PhotoThumbnailProps { photo: Photo size: number isSelected: boolean onClick: (e: React.MouseEvent) => void } export function PhotoThumbnail({ photo, size, isSelected, onClick }: PhotoThumbnailProps) { const [imageError, setImageError] = useState(false) const [imageLoaded, setImageLoaded] = useState(false) const [retryCount, setRetryCount] = useState(0) const [isRetrying, setIsRetrying] = useState(false) const retryTimerRef = useRef(null) // Cache-bust on retry so the browser actually re-requests instead of // serving the cached 404. const baseUrl = photosApi.getThumbnailUrl(photo.id, 'medium') const thumbnailUrl = retryCount > 0 ? `${baseUrl}?retry=${retryCount}` : baseUrl // Calculate aspect ratio for proper sizing (default to 1:1 if dimensions unknown) const aspectRatio = (photo.height && photo.width) ? photo.height / photo.width : 1 const displayHeight = size * Math.min(aspectRatio, 1.5) // Cap height at 1.5x width const clearRetryTimer = () => { if (retryTimerRef.current !== null) { window.clearTimeout(retryTimerRef.current) retryTimerRef.current = null } } const handleImageLoad = () => { setImageLoaded(true) setIsRetrying(false) } const handleImageError = () => { // Schedule next auto-retry if attempts remain. const nextDelay = AUTO_RETRY_DELAYS[retryCount] if (nextDelay !== undefined) { setIsRetrying(true) clearRetryTimer() retryTimerRef.current = window.setTimeout(() => { retryTimerRef.current = null setRetryCount(prev => prev + 1) }, nextDelay) } else { setImageError(true) setIsRetrying(false) } } const handleManualRetry = useCallback((e: React.MouseEvent) => { e.stopPropagation() // Prevent selection when clicking retry clearRetryTimer() setRetryCount(prev => prev + 1) setImageError(false) setImageLoaded(false) setIsRetrying(true) }, []) // Reset state when photo changes (component is reused across rows when virtualized) useEffect(() => { clearRetryTimer() setImageError(false) setImageLoaded(false) setRetryCount(0) setIsRetrying(false) }, [photo.id]) // Clear pending timer on unmount to avoid setState-after-unmount. useEffect(() => { return () => clearRetryTimer() }, []) return (
{/* Thumbnail Image */} {!imageError ? ( <> {photo.filename} {/* Loading indicator */} {!imageLoaded && (
{isRetrying ? (
Retrying...
) : (
)}
)} ) : (
Unable to load
{photo.filename}
)} {/* Selection Indicator */} {isSelected && (
)} {/* Rating Stars */} {photo.rating > 0 && (
{Array.from({ length: photo.rating }).map((_, i) => ( ))}
)} {/* Flag Indicators */}
{photo.is_picked && ( )} {photo.is_rejected && ( )}
{/* File Type Badge for RAW/Video */} {(photo.filepath.toLowerCase().match(/\.(raw|arw|cr2|cr3|nef|orf|rw2|dng)$/i) || photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i)) && (
{photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i) ? 'VIDEO' : 'RAW'}
)}
) }