refactor: unify thumbnail ornaments into one chip family
Every overlay on a thumbnail now composes the shared THUMB_BADGE_* classes (one shape, one height, one ring, three semantic colour variants: primary for user state, neutral for metadata, pick for auto-suggested best). RAW badges, BEST pill, Keep-this button and the dimensions chip — previously three different styles — join the family, is_duplicate moves to neutral since it's file metadata not a user decision, and the selection check shrinks to match the rest. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,13 @@ import {
|
||||
type DuplicateGroup,
|
||||
type DuplicateGroupMember,
|
||||
} from '../../services/api'
|
||||
import { PhotoThumbnail } from '../timeline/PhotoThumbnail'
|
||||
import {
|
||||
PhotoThumbnail,
|
||||
THUMB_BADGE_BASE,
|
||||
THUMB_BADGE_ICON,
|
||||
THUMB_BADGE_NEUTRAL,
|
||||
THUMB_BADGE_PICK,
|
||||
} from '../timeline/PhotoThumbnail'
|
||||
import { usePhotoStore } from '../../store/photoStore'
|
||||
import { registerUndoable } from '../../store/undoStore'
|
||||
import { LIBRARY_STATS_QUERY_KEY } from '../../hooks/useLibraryStatsQuery'
|
||||
@@ -308,38 +314,57 @@ function DuplicateGroupSection({
|
||||
onClick={() => onSelectMember(member.id)}
|
||||
onDoubleClick={() => onPreviewMember(member.id)}
|
||||
/>
|
||||
{/* "BEST" pill marks the photo that will be KEPT when the
|
||||
* user clicks the discard button. Lives at top-right where
|
||||
* it doesn't collide with the cyan selection ring (which
|
||||
* draws around the cell perimeter); kept inset by 6px so
|
||||
* the ring's outer edge has clearance on either side. */}
|
||||
{/* BEST pill — top-right, pick-coloured. Composes the same
|
||||
* THUMB_BADGE_* family used by PhotoThumbnail so the full
|
||||
* set of ornaments (selection, rating, flags, BEST) reads
|
||||
* as one consistent chip system. Inset 1.5 (6px) rather
|
||||
* than 1 (4px) because these are SIBLINGS of the thumbnail,
|
||||
* not inside its overflow-hidden box, so they need
|
||||
* clearance from PhotoThumbnail's outer selection ring. */}
|
||||
{isBest && (
|
||||
<span className="pointer-events-none absolute right-1.5 top-1.5 z-10 flex items-center gap-1 rounded bg-pick px-1.5 py-0.5 text-[10px] font-bold uppercase text-white shadow ring-1 ring-black/30">
|
||||
<Crown className="h-3 w-3" />
|
||||
<span
|
||||
className={clsx(
|
||||
'pointer-events-none absolute right-1.5 top-1.5 z-10 uppercase',
|
||||
THUMB_BADGE_BASE,
|
||||
THUMB_BADGE_PICK
|
||||
)}
|
||||
>
|
||||
<Crown className={THUMB_BADGE_ICON} />
|
||||
Best
|
||||
</span>
|
||||
)}
|
||||
{/* "Make this best" affordance — shown on hover for non-best
|
||||
* thumbnails. Mirrors the BEST pill's top-right placement
|
||||
* so the eye doesn't have to retarget when the user is
|
||||
* scanning a row of thumbnails. Stops propagation so it
|
||||
* doesn't double-fire as a selection click. */}
|
||||
{/* "Keep this" — shown on hover for non-best thumbnails.
|
||||
* Mirrors BEST's placement so the eye doesn't retarget
|
||||
* while scanning. Starts neutral and shifts to pick on
|
||||
* hover as a preview of the state it'll set. */}
|
||||
{!isBest && (
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
setManualBestId(member.id)
|
||||
}}
|
||||
className="absolute right-1.5 top-1.5 z-10 hidden items-center gap-1 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-medium text-white shadow ring-1 ring-white/20 transition hover:bg-pick hover:text-white group-hover/dup:flex"
|
||||
className={clsx(
|
||||
'absolute right-1.5 top-1.5 z-10 hidden uppercase transition hover:bg-pick group-hover/dup:inline-flex',
|
||||
THUMB_BADGE_BASE,
|
||||
THUMB_BADGE_NEUTRAL
|
||||
)}
|
||||
title="Keep this one instead"
|
||||
>
|
||||
<Crown className="h-3 w-3" />
|
||||
<Crown className={THUMB_BADGE_ICON} />
|
||||
Keep this
|
||||
</button>
|
||||
)}
|
||||
{/* Dimensions chip — bottom-LEFT (the BEST/Keep affordances
|
||||
* own the top-right corner). */}
|
||||
<div className="pointer-events-none absolute bottom-1 left-1 z-10 rounded bg-black/70 px-1.5 py-0.5 text-[10px] font-mono text-white">
|
||||
{/* Dimensions chip — bottom-LEFT. Neutral metadata variant
|
||||
* matches the family. Rare collision with a manual rating
|
||||
* (also bottom-left) is tolerated: rated duplicates are
|
||||
* uncommon in practice. */}
|
||||
<div
|
||||
className={clsx(
|
||||
'pointer-events-none absolute bottom-1 left-1 z-10 font-mono',
|
||||
THUMB_BADGE_BASE,
|
||||
THUMB_BADGE_NEUTRAL
|
||||
)}
|
||||
>
|
||||
{formatDimensions(member)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -8,6 +8,24 @@ import { usePhotoStore } from '../../store/photoStore'
|
||||
/** 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, ring, typography are
|
||||
// fixed; only the colour variant varies by semantics:
|
||||
// PRIMARY → user-affirmed state (selection, rating, active-heap)
|
||||
// NEUTRAL → informational metadata (duplicate flag, discard, file type, dims)
|
||||
// PICK → auto-suggested "best" in duplicate groups
|
||||
export const THUMB_BADGE_BASE =
|
||||
'inline-flex h-5 items-center gap-1 rounded-full px-1.5 text-[10px] font-medium leading-none text-white shadow-md ring-1 ring-white/90'
|
||||
/** 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'
|
||||
export const THUMB_BADGE_PRIMARY = 'bg-primary'
|
||||
export const THUMB_BADGE_NEUTRAL = 'bg-black/70 backdrop-blur-sm'
|
||||
export const THUMB_BADGE_PICK = 'bg-pick'
|
||||
|
||||
// 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
|
||||
@@ -205,67 +223,81 @@ export function PhotoThumbnail({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Selection Indicator — white ring around the primary fill so the
|
||||
* badge stays visible against any photo (light or dark). */}
|
||||
{/* ── 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="absolute left-1 top-1 flex h-6 w-6 items-center justify-center rounded-full bg-primary text-white shadow-md ring-2 ring-white/90">
|
||||
<Check className="h-4 w-4" strokeWidth={3} />
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* Rating Stars — same primary blue as every other indicator,
|
||||
* in a matching pill so the rating reads as part of the same
|
||||
* badge family instead of a yellow accent from a different
|
||||
* palette. */}
|
||||
{/* BL — rating */}
|
||||
{photo.rating > 0 && (
|
||||
<div className="absolute bottom-1 left-1 flex h-5 items-center gap-0.5 rounded-full bg-primary px-1.5 text-white shadow-md ring-2 ring-white/90">
|
||||
<div
|
||||
className={clsx(
|
||||
'absolute bottom-1 left-1 gap-0.5',
|
||||
THUMB_BADGE_BASE,
|
||||
THUMB_BADGE_PRIMARY
|
||||
)}
|
||||
>
|
||||
{Array.from({ length: photo.rating }).map((_, i) => (
|
||||
<Star key={i} className="h-3 w-3 fill-white text-white" />
|
||||
<Star key={i} className={clsx(THUMB_BADGE_ICON, 'fill-white')} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Flag Indicators — all share the same blue-primary chip style
|
||||
* + white ring so the family reads as one thing. The discard
|
||||
* indicator is the only exception: it stays neutral-dark
|
||||
* because mixing it with the positive blue chips would conflate
|
||||
* "in this collection" with "trashed". */}
|
||||
{/* 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="flex h-5 max-w-[120px] items-center gap-1 rounded-full bg-primary px-1.5 text-white shadow-md ring-2 ring-white/90"
|
||||
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_PRIMARY, 'max-w-[120px]')}
|
||||
title={activeHeapName ? `In heap: ${activeHeapName}` : 'In active heap'}
|
||||
>
|
||||
<ShoppingBasket className="h-3 w-3 flex-shrink-0" strokeWidth={2.5} />
|
||||
{activeHeapName && (
|
||||
<span className="truncate text-[10px] font-medium leading-none">
|
||||
{activeHeapName}
|
||||
</span>
|
||||
)}
|
||||
<ShoppingBasket
|
||||
className={clsx(THUMB_BADGE_ICON, 'flex-shrink-0')}
|
||||
strokeWidth={2.5}
|
||||
/>
|
||||
{activeHeapName && <span className="truncate">{activeHeapName}</span>}
|
||||
</div>
|
||||
)}
|
||||
{photo.is_duplicate && (
|
||||
<div
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full bg-primary text-white shadow-md ring-2 ring-white/90"
|
||||
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)}
|
||||
title="Duplicate (matches another photo's hash)"
|
||||
>
|
||||
<Copy className="h-3 w-3" strokeWidth={2.5} />
|
||||
<Copy className={THUMB_BADGE_ICON} strokeWidth={2.5} />
|
||||
</div>
|
||||
)}
|
||||
{photo.is_discarded && (
|
||||
<div
|
||||
className="flex h-5 w-5 items-center justify-center rounded-full bg-black/75 text-white shadow-md ring-2 ring-white/90"
|
||||
className={clsx(THUMB_BADGE_BASE, THUMB_BADGE_SQUARE, THUMB_BADGE_NEUTRAL)}
|
||||
title="Discarded"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" strokeWidth={2.5} />
|
||||
<Trash2 className={THUMB_BADGE_ICON} strokeWidth={2.5} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* File Type Badge for RAW/Video */}
|
||||
|
||||
{/* 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="absolute right-1 top-1 rounded bg-black/50 px-1 py-0.5 text-[10px] font-medium text-white">
|
||||
<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>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user