ui: rework selection/heap visuals, contextual shortcut hints, inline scan activity

- Selection now reads as a blue ring + tint with a springy scale-down,
  hover stays a subtle gray ring so keyboard-driven and mouse-driven
  states are tellable apart.
- Heap membership is signalled with a green tint only (no badge, no
  ring, no scale).
- Discard/restore is optimistic and non-yanking: photos stay in the
  grid greyed out until the next reload, X toggles based on the
  current state, and the same treatment applies in preview.
- Filmstrip mirrors the grid styling (selection blue, heap green,
  discarded grey).
- Preview close restores the LAST viewed photo as the focused/selected
  one in the grid.
- Right sidebar collapses on view change and re-opens when a photo is
  in focus; Esc clears active selection so the panel collapses too.
- Keyboard hints panel is context-aware (grid / preview / discarded
  section), collapsible with H, persisted, and rendered inside the
  preview column above the filmstrip.
- "Pick (P)" renamed to "Select (S)" everywhere.
- Needs review moved into the Flag pill dropdown.
- Fixed vertical videos overflowing the preview column (min-h-0).
- Replaced the bottom-right ScanProgress popover with an inline
  spinner next to the FOLDERS sidebar header (and on the specific
  folder row being scanned). ScanProgress is now a headless
  invalidator; useScanActivity exposes the live status.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-14 23:45:36 +02:00
parent a6eb406052
commit 8529771122
17 changed files with 501 additions and 408 deletions

View File

@@ -182,21 +182,26 @@ export function Timeline() {
const prevViewModeRef = useRef(viewMode)
// Auto-focus the first photo on initial grid load so arrow-key nav
// works immediately without a pre-click. Only fires when there's no
// current active photo — we never clobber the user's selection or
// the one they restored by navigating back from preview.
// works immediately without a pre-click. One-shot — after the user
// explicitly clears the selection (Escape), we don't re-focus, so
// the metadata sidebar can collapse and stay collapsed.
const didAutoFocusRef = useRef(false)
useEffect(() => {
if (didAutoFocusRef.current) return
if (viewMode !== 'grid') return
if (activePhotoId) return
if (activePhotoId) {
didAutoFocusRef.current = true
return
}
if (photos.length === 0) return
didAutoFocusRef.current = true
selectPhoto(photos[0].id)
}, [viewMode, activePhotoId, photos, selectPhoto])
// Membership in the active heap (for the basket affordance). Subscribed
// once at this level so we don't have hundreds of thumbnails each
// subscribing to the same query.
const { memberIds: activeHeapMembers, activeHeap } = useActiveHeapMembers()
const activeHeapName = activeHeap?.name ?? null
// Membership in the active heap (drives the green tint on each
// thumbnail). Subscribed once at this level so we don't have hundreds
// of thumbnails each subscribing to the same query.
const { memberIds: activeHeapMembers } = useActiveHeapMembers()
// Build the flat virtualizer items: a mix of group headers and rows of
// photos. Date headers appear only in the main timeline (groupBy='date').
@@ -783,7 +788,6 @@ export function Timeline() {
fill
isSelected={selectedPhotos.includes(photo.id)}
isInActiveHeap={activeHeapMembers.has(photo.id)}
activeHeapName={activeHeapName}
onClick={(e) => {
if (e.shiftKey) {
selectRange(photo.id)