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:
2026-04-09 17:57:55 +02:00
parent 6d8e92940a
commit 938c5dce68
2 changed files with 104 additions and 47 deletions

View File

@@ -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>