From 938c5dce6881a5458ef501e75da7ce0b6486e96c Mon Sep 17 00:00:00 2001 From: dtoro Date: Thu, 9 Apr 2026 17:57:55 +0200 Subject: [PATCH] refactor: unify thumbnail ornaments into one chip family MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../components/duplicates/DuplicatesView.tsx | 61 +++++++++---- .../components/timeline/PhotoThumbnail.tsx | 90 +++++++++++++------ 2 files changed, 104 insertions(+), 47 deletions(-) diff --git a/frontend/src/components/duplicates/DuplicatesView.tsx b/frontend/src/components/duplicates/DuplicatesView.tsx index 6bc53b2..2318677 100644 --- a/frontend/src/components/duplicates/DuplicatesView.tsx +++ b/frontend/src/components/duplicates/DuplicatesView.tsx @@ -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 && ( - - + + Best )} - {/* "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 && ( )} - {/* Dimensions chip — bottom-LEFT (the BEST/Keep affordances - * own the top-right corner). */} -
+ {/* 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. */} +
{formatDimensions(member)}
diff --git a/frontend/src/components/timeline/PhotoThumbnail.tsx b/frontend/src/components/timeline/PhotoThumbnail.tsx index c573408..be55f26 100644 --- a/frontend/src/components/timeline/PhotoThumbnail.tsx +++ b/frontend/src/components/timeline/PhotoThumbnail.tsx @@ -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({ )} - {/* 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 && ( -
- +
+
)} - {/* 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 && ( -
+
{Array.from({ length: photo.rating }).map((_, i) => ( - + ))}
)} - {/* 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. */}
{isInActiveHeap && (
- - {activeHeapName && ( - - {activeHeapName} - - )} + + {activeHeapName && {activeHeapName}}
)} {photo.is_duplicate && (
- +
)} {photo.is_discarded && (
- +
)}
- - {/* 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)) && ( -
+
{photo.filepath.toLowerCase().match(/\.(mov|mp4|avi|mkv)$/i) ? 'VIDEO' : 'RAW'}
)}