Files
mule-image/frontend/src/components/timeline/PhotoThumbnail.tsx
dtoro 30d03d8d4d feat: editable taken_at + folder-based date repair and filter
Lets operators fix corrupted capture dates at scale. Adds an editable
Date Taken field with a folder/filename-derived suggestion hint, a bulk
Date Taken section in the multi-select sidebar that either applies one
date to the whole selection or infers a per-photo date from each path,
a warning badge on thumbnails whose stored date disagrees with the
path, and a "Date issues" filter pill so suspicious photos can be
surfaced and fixed as a group. Edits are written back to EXIF on disk
so rescans don't clobber the fix.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-11 11:48:55 +02:00

367 lines
15 KiB
TypeScript

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<string, string> = 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<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
// "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<HTMLDivElement>) => {
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 (
<div
className={clsx(
'group relative cursor-pointer overflow-hidden rounded-sm transition-all duration-200',
// Two-tone hover ring: bright primary inner + dark offset so it
// pops on light AND dark photos.
'hover:ring-2 hover:ring-primary/60 hover:ring-offset-1 hover:ring-offset-bg',
isSelected &&
'ring-2 ring-primary ring-offset-2 ring-offset-bg shadow-lg',
!imageLoaded && 'bg-surface animate-pulse'
)}
style={
fill
? { width: '100%', height: '100%' }
: { width: size, height: displayHeight }
}
onClick={onClick}
onDoubleClick={onDoubleClick}
draggable
onDragStart={handleDragStart}
title="Click to select • Double-click to open • Shift+Click for range • Ctrl+Click to add • Drag onto a heap 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',
// Discarded photos fade out + desaturate so the trash section
// reads as a trash section, not just another grid view.
photo.is_discarded && 'opacity-50 grayscale'
)}
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>
)}
{/* ── 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 && (
<div
className={clsx(
'absolute left-1 top-1',
THUMB_BADGE_BASE,
THUMB_BADGE_SQUARE,
THUMB_BADGE_PRIMARY
)}
>
<Check className={THUMB_BADGE_ICON} strokeWidth={3} />
</div>
)}
{/* 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) && (
<div className="absolute bottom-1 left-1 flex items-center gap-1">
{photo.color_label && COLOR_LABEL_BG[photo.color_label] && (
<div
className={clsx(
THUMB_BADGE_BASE,
THUMB_BADGE_SQUARE,
COLOR_LABEL_BG[photo.color_label],
THUMB_BADGE_FRAME
)}
title={`Color label: ${photo.color_label}`}
/>
)}
{photo.rating > 0 && (
<div
className={clsx('gap-0.5', THUMB_BADGE_BASE, THUMB_BADGE_PRIMARY)}
>
{Array.from({ length: photo.rating }).map((_, i) => (
<Star key={i} className={clsx(THUMB_BADGE_ICON, 'fill-white')} />
))}
</div>
)}
</div>
)}
{/* 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. */}
<div className="absolute bottom-1 right-1 flex items-center gap-1">
{isInActiveHeap && (
<div
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_PRIMARY, 'max-w-[120px]')}
title={activeHeapName ? `In heap: ${activeHeapName}` : 'In active heap'}
>
<ShoppingBasket
className={clsx(THUMB_BADGE_ICON, 'flex-shrink-0')}
strokeWidth={2.5}
/>
{activeHeapName && <span className="truncate">{activeHeapName}</span>}
</div>
)}
{photo.is_duplicate && (
<div
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)}
title="Duplicate (matches another photo's hash)"
>
<Copy className={THUMB_BADGE_ICON} strokeWidth={2.5} />
</div>
)}
{photo.is_discarded && (
<div
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)}
title="Discarded"
>
<Trash2 className={THUMB_BADGE_ICON} strokeWidth={2.5} />
</div>
)}
{dateWarning && (
<div
className={clsx(
THUMB_BADGE_BASE,
THUMB_BADGE_SQUARE,
// Amber fill with the same chiseled frame as other affirmative
// badges so it reads as a first-class warning rather than a
// neutral info chip.
'bg-amber-500 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)]'
)}
title="Capture date may be wrong — folder/filename suggests a different date"
>
<AlertTriangle className={THUMB_BADGE_ICON} strokeWidth={2.5} />
</div>
)}
</div>
{/* 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)) && (
<div className={clsx('absolute right-1 top-1', THUMB_BADGE_BASE, THUMB_BADGE_NEUTRAL)}>
{photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i) ? 'VIDEO' : 'RAW'}
</div>
)}
</div>
)
}