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>
367 lines
15 KiB
TypeScript
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>
|
|
)
|
|
} |