Backend generates thumbnails on-demand via Celery, so the first request often 404s while the worker runs. Auto-retry with 1.5s/3.5s/6s backoff, manual retry fallback, and proper timer cleanup so fast-scrolling a virtualized timeline doesn't setState on unmounted components. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
197 lines
6.4 KiB
TypeScript
197 lines
6.4 KiB
TypeScript
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<number | null>(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 (
|
|
<div
|
|
className={clsx(
|
|
'group relative cursor-pointer overflow-hidden rounded-sm transition-all duration-200',
|
|
'hover:ring-2 hover:ring-primary/50',
|
|
isSelected && 'ring-2 ring-primary shadow-lg',
|
|
!imageLoaded && 'bg-surface animate-pulse'
|
|
)}
|
|
style={{
|
|
width: size,
|
|
height: displayHeight,
|
|
}}
|
|
onClick={onClick}
|
|
title="Click to select • Shift+Click for range • Ctrl+Click to add"
|
|
>
|
|
{/* Thumbnail Image */}
|
|
{!imageError ? (
|
|
<>
|
|
<img
|
|
src={thumbnailUrl}
|
|
alt={photo.filename}
|
|
className={clsx(
|
|
'h-full w-full object-cover transition-opacity duration-200',
|
|
imageLoaded ? 'opacity-100' : 'opacity-0'
|
|
)}
|
|
onLoad={handleImageLoad}
|
|
onError={handleImageError}
|
|
loading="lazy"
|
|
/>
|
|
{/* Loading indicator */}
|
|
{!imageLoaded && (
|
|
<div className="absolute inset-0 flex items-center justify-center bg-surface">
|
|
<div className="text-text-muted">
|
|
{isRetrying ? (
|
|
<div className="text-center">
|
|
<RefreshCw className="h-5 w-5 animate-spin mx-auto mb-1" />
|
|
<div className="text-xs">Retrying...</div>
|
|
</div>
|
|
) : (
|
|
<div className="h-8 w-8 border-2 border-primary/30 border-t-primary rounded-full animate-spin" />
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<div className="flex h-full w-full items-center justify-center bg-surface text-text-muted">
|
|
<div className="text-center text-xs">
|
|
<button
|
|
onClick={handleManualRetry}
|
|
className="p-2 hover:bg-surface-light rounded transition-colors"
|
|
title="Retry loading thumbnail"
|
|
>
|
|
<RefreshCw className="h-5 w-5 mb-1" />
|
|
</button>
|
|
<div>Unable to load</div>
|
|
<div className="mt-1 font-mono text-[10px] px-2 break-all">{photo.filename}</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Selection Indicator */}
|
|
{isSelected && (
|
|
<div className="absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-white">
|
|
<Check className="h-4 w-4" />
|
|
</div>
|
|
)}
|
|
|
|
{/* Rating Stars */}
|
|
{photo.rating > 0 && (
|
|
<div className="absolute bottom-1 left-1 flex gap-0.5">
|
|
{Array.from({ length: photo.rating }).map((_, i) => (
|
|
<Star
|
|
key={i}
|
|
className="h-3 w-3 fill-star text-star"
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Flag Indicators */}
|
|
<div className="absolute bottom-1 right-1">
|
|
{photo.is_picked && (
|
|
<Check className="h-4 w-4 text-pick" />
|
|
)}
|
|
{photo.is_rejected && (
|
|
<X className="h-4 w-4 text-reject" />
|
|
)}
|
|
</div>
|
|
|
|
{/* 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)) && (
|
|
<div className="absolute right-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[10px] font-medium text-white">
|
|
{photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i) ? 'VIDEO' : 'RAW'}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
} |