import { useState, useEffect, useCallback, useRef } from 'react' import { Star, ShoppingBasket, Trash2, RefreshCw, Check, Copy, AlertTriangle, } from 'lucide-react' import clsx from 'clsx' import { photos as photosApi } from '../../services/api' import type { Photo } from '../../types/photo' import { usePhotoStore } from '../../store/photoStore' import { COLOR_LABEL_OPTIONS } from '../../constants/colorLabels' /** Custom MIME used by HeapsPanel to recognise our drag payload. */ export const PHOTO_DRAG_MIME = 'application/x-mulita-photos' // ── Thumbnail badge family ─────────────────────────────────────────────── // Every ornament that overlays a thumbnail — here and in other views that // wrap PhotoThumbnail (e.g. DuplicatesView) — must compose these classes so // the set reads as one coherent system. Shape, height, typography are // fixed; variants set the fill + chiseled-pixel frame: // PRIMARY → user-affirmed state (selection, rating, active-heap) // NEUTRAL → informational metadata (duplicate flag, discard, file type, dims) // PICK → auto-suggested "best" in duplicate groups // // Frame is a single box-shadow stack rather than a Tailwind `ring` so the // 1px outline, the subtle inset top highlight, and the drop shadow all // live in one paint, and variants can tint the inset highlight to suit // their fill. Corners are `rounded-sm` (2px) to echo the pixel-art theme // used in the TopBar and ActiveHeapCard. export const THUMB_BADGE_BASE = 'inline-flex h-5 items-center gap-1 rounded-sm px-1.5 text-[10px] font-semibold leading-none text-white' /** Square icon-only variant — compose alongside THUMB_BADGE_BASE. */ export const THUMB_BADGE_SQUARE = 'w-5 justify-center !px-0' /** Standard icon size for any lucide glyph inside a badge. */ export const THUMB_BADGE_ICON = 'h-3 w-3' /** Chiseled-pixel frame: 1px outline + inset top highlight + drop shadow. * Shared by every "user-affirmed" variant so any swatch (primary, pick, * color label) reads as part of the same badge family. */ const THUMB_BADGE_FRAME = 'shadow-[0_0_0_1px_rgba(0,0,0,0.55),inset_0_1px_0_rgba(255,255,255,0.28),0_1px_2px_rgba(0,0,0,0.5)]' export const THUMB_BADGE_PRIMARY = `bg-primary ${THUMB_BADGE_FRAME}` export const THUMB_BADGE_NEUTRAL = 'bg-black/75 backdrop-blur-sm shadow-[0_0_0_1px_rgba(255,255,255,0.12),inset_0_1px_0_rgba(255,255,255,0.08),0_1px_2px_rgba(0,0,0,0.55)]' export const THUMB_BADGE_PICK = `bg-pick ${THUMB_BADGE_FRAME}` /** Tailwind bg-class for each color label, looked up at render time so the * classnames are statically present in the source for the JIT to scan. */ const COLOR_LABEL_BG: Record = Object.fromEntries( COLOR_LABEL_OPTIONS.map((o) => [o.value, o.className]) ) // Auto-retry schedule (ms). Backend generates thumbs on-demand via Celery, so // the first hit on a freshly-scanned library returns 404 "not ready" until // the worker catches up. RAW postprocess can take several seconds per file // when the queue is deep, so the tail of the schedule is generous. const AUTO_RETRY_DELAYS = [1500, 3500, 7000, 12000, 20000] interface PhotoThumbnailProps { photo: Photo size: number /** When true, the cell stretches to fill its parent (100% width + * 100% height) and ignores `size` for the box dimensions. Used by * the Timeline grid where the parent is a CSS grid track of 1fr — * this is what guarantees the row fills the container without any * rounding gap on the right. The heap sidebar leaves this off so * thumbnails stay at the explicit `size`. */ fill?: boolean isSelected: boolean /** True when the photo belongs to the currently active heap. */ isInActiveHeap?: boolean /** Name of the active heap. When set + isInActiveHeap, the basket * badge expands into a name chip so the user knows which heap. */ activeHeapName?: string | null onClick: (e: React.MouseEvent) => void onDoubleClick?: (e: React.MouseEvent) => void } export function PhotoThumbnail({ photo, size, fill = false, isSelected, isInActiveHeap = false, activeHeapName = null, onClick, onDoubleClick, }: 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 // "Capture date probably wrong" — read straight from the stored // `has_date_warning` flag rather than recomputing the heuristic // client-side. The backend sets this column at scan time and // refreshes it on any taken_at edit, so the UI, the filter, and the // thumbnail badge all read from one source of truth. const dateWarning = photo.has_date_warning === true // Square cells (Lightroom-style grid). Variable-aspect cells previously // overflowed their row because TanStack Virtual estimates row height as a // single fixed value — portraits in a landscape row would overlap the row // below. With object-cover the image still fills the cell, just cropped. // // The cell stretches to whatever width the parent grid track gives it // (via width:100% + aspect-ratio:1) so the timeline's CSS grid can hand // out 1fr columns and we never leave horizontal space unused. `size` // remains the *minimum* track width and the fallback when there's no // parent grid (e.g. heap thumbnails). const displayHeight = size 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() }, []) // Build the drag payload at fire time so multi-selection drags carry the // current selection. If the dragged photo isn't part of the selection, // drag just that one photo (matches Finder semantics). const handleDragStart = (e: React.DragEvent) => { const state = usePhotoStore.getState() const ids = state.selectedPhotos.includes(photo.id) && state.selectedPhotos.length > 0 ? state.selectedPhotos : [photo.id] e.dataTransfer.effectAllowed = 'copy' e.dataTransfer.setData(PHOTO_DRAG_MIME, JSON.stringify(ids)) // A plain text fallback so the OS shows something sensible if the user // drops outside the app. e.dataTransfer.setData('text/plain', `${ids.length} photo${ids.length > 1 ? 's' : ''}`) } return (
{/* Thumbnail Image */} {!imageError ? ( <> {photo.filename} {/* Loading indicator */} {!imageLoaded && (
{isRetrying ? (
Retrying...
) : (
)}
)} ) : (
Unable to load
{photo.filename}
)} {/* ── Ornaments ──────────────────────────────────────────────────── * All overlays compose the THUMB_BADGE_* classes so they share one * shape/size/ring family. Colour signals semantics: * PRIMARY → user-affirmed state (selection, rating, heap) * NEUTRAL → informational metadata (duplicate, discard, file type) * Corner ownership is fixed: TL=selection, TR=file-type, * BL=rating, BR=flags. This keeps badges from stacking or colliding. */} {/* TL — selection */} {isSelected && (
)} {/* BL — color label + rating. Color comes first (left of rating) * so the swatch reads as a "category dot" prefixing the stars. */} {(photo.color_label || photo.rating > 0) && (
{photo.color_label && COLOR_LABEL_BG[photo.color_label] && (
)} {photo.rating > 0 && (
{Array.from({ length: photo.rating }).map((_, i) => ( ))}
)}
)} {/* BR — flags stack: heap (primary) · duplicate / discard (neutral). * Heap is the only user-state flag here so it gets primary; the * rest are metadata about the file, so they're neutral-dark. */}
{isInActiveHeap && (
{activeHeapName && {activeHeapName}}
)} {photo.is_duplicate && (
)} {photo.is_discarded && (
)} {dateWarning && (
)}
{/* TR — file-type metadata (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'}
)}
) }