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

@@ -6,7 +6,7 @@ import { HEAPS_QUERY_KEY } from './useHeapsQuery'
import { toast } from '../components/ToastContainer'
import { registerUndoable, useUndoStore } from '../store/undoStore'
import { LIBRARY_STATS_QUERY_KEY } from './useLibraryStatsQuery'
import { stripPhotosFromCache } from './usePhotosQuery'
import type { Photo } from '../types/photo'
interface KeyboardShortcutsProps {
onToggleLeftSidebar: () => void
@@ -77,16 +77,61 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
onError: (e: any) => toast.error('Bulk color failed', e.message || 'Unknown error'),
})
const bulkDiscardMutation = useMutation({
mutationFn: (ids: string[]) => photosApi.bulkDiscard(ids),
onSuccess: invalidatePhotoQueries,
onError: (e: any) => toast.error('Bulk discard failed', e.message || 'Unknown error'),
})
/** Flip the cached photos to is_discarded=value in every list query
* without removing them. Lets the grid grey them in place instead of
* reflowing — they stay until a hard reload, which gives the user
* visual context for the undo. */
const markCachedDiscarded = (ids: string[], discarded: boolean) => {
const set = new Set(ids)
queryClient.setQueriesData<Photo[]>({ queryKey: ['photos'] }, (prev) =>
prev
? prev.map((p) => (set.has(p.id) ? { ...p, is_discarded: discarded } : p))
: prev
)
}
const bulkRestoreMutation = useMutation({
mutationFn: (ids: string[]) => photosApi.bulkRestore(ids),
onSuccess: invalidatePhotoQueries,
onError: (e: any) => toast.error('Bulk restore failed', e.message || 'Unknown error'),
/** Look up a photo's CURRENT cached state (is_discarded etc) without
* triggering a refetch. Walks every ['photos', _] entry first
* (timeline lists), then falls back to the per-photo cache. */
const findCachedPhoto = (id: string): Photo | undefined => {
const entries = queryClient.getQueriesData<Photo[]>({ queryKey: ['photos'] })
for (const [, list] of entries) {
if (!list) continue
const p = list.find((x) => x.id === id)
if (p) return p
}
return queryClient.getQueryData<Photo>(['photo', id])
}
// Discard / restore mutation — the only path that doesn't auto-
// invalidate ['photos']. Invalidating would refetch with the active
// filter (which excludes discarded photos in every section except
// Discarded, and vice-versa) and yank the just-changed photos off
// the screen. We want the opposite: the photos stay where they are,
// re-tinted via PhotoThumbnail's `is_discarded` styling, until the
// user reloads. Same goes for the preview filmstrip.
const discardMutation = useMutation({
mutationFn: ({ ids, discarded }: { ids: string[]; discarded: boolean }) =>
ids.length === 1
? photosApi.update(ids[0], { is_discarded: discarded })
: discarded
? photosApi.bulkDiscard(ids)
: photosApi.bulkRestore(ids),
onMutate: ({ ids, discarded }) => markCachedDiscarded(ids, discarded),
onError: (e: any, { ids, discarded }) => {
// Roll back the optimistic flip.
markCachedDiscarded(ids, !discarded)
toast.error(
discarded ? 'Discard failed' : 'Restore failed',
e?.message || 'Unknown error'
)
},
onSuccess: (_data, { ids }) => {
queryClient.invalidateQueries({ queryKey: LIBRARY_STATS_QUERY_KEY })
ids.forEach((id) =>
queryClient.invalidateQueries({ queryKey: ['photo', id] })
)
},
})
/** The set of photo ids the next culling action should apply to.
@@ -109,44 +154,43 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
const ids = cullTargets()
if (ids.length === 0) return
// Discard is a removal from the timeline view: yank it from the
// selection / cache before the network round-trip so the grid
// reflows immediately and the next photo takes over the active
// cursor. Restore goes through the same removal path because it
// only fires from views (discard pile) where restored photos no
// longer match the filter.
// Discard / restore: leave the photos in place and just flip the
// tint via the dedicated mutation (no cache strip, no timeline
// removal). They disappear on hard reload because the section
// filter excludes them in the wrong direction.
if (data.is_discarded === true || data.is_discarded === false) {
usePhotoStore.getState().removePhotosFromTimeline(ids)
stripPhotosFromCache(queryClient, ids)
}
if (ids.length === 1) {
const id = ids[0]
updateMutation.mutate(
{ id, data },
const discarded = data.is_discarded
discardMutation.mutate(
{ ids, discarded },
{
onSuccess: () => {
// Only the discard/restore subset of single-photo updates is
// undoable today — rating and color round-trip cleanly enough
// that the manual fix is faster than maintaining per-photo
// previous-value snapshots.
if (data.is_discarded === true) {
registerUndoable('Discarded 1 photo', async () => {
await photosApi.bulkRestore([id])
invalidatePhotoQueries()
})
} else if (data.is_discarded === false) {
registerUndoable('Restored 1 photo', async () => {
await photosApi.bulkDiscard([id])
invalidatePhotoQueries()
})
}
const verb = discarded ? 'Discarded' : 'Restored'
registerUndoable(
`${verb} ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
async () => {
markCachedDiscarded(ids, !discarded)
await (discarded
? photosApi.bulkRestore(ids)
: photosApi.bulkDiscard(ids))
queryClient.invalidateQueries({
queryKey: LIBRARY_STATS_QUERY_KEY,
})
ids.forEach((id) =>
queryClient.invalidateQueries({ queryKey: ['photo', id] })
)
}
)
},
}
)
return
}
if (ids.length === 1) {
updateMutation.mutate({ id: ids[0], data })
return
}
// Multi-selection — fan out to the right bulk endpoint per field.
if (data.rating !== undefined) {
bulkRatingMutation.mutate({ ids, rating: data.rating })
@@ -154,34 +198,20 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
if (data.color_label !== undefined) {
bulkColorMutation.mutate({ ids, color: data.color_label })
}
if (data.is_discarded === true) {
bulkDiscardMutation.mutate(ids, {
onSuccess: () => {
registerUndoable(
`Discarded ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
async () => {
await photosApi.bulkRestore(ids)
invalidatePhotoQueries()
}
)
},
})
} else if (data.is_discarded === false) {
bulkRestoreMutation.mutate(ids, {
onSuccess: () => {
registerUndoable(
`Restored ${ids.length} photo${ids.length === 1 ? '' : 's'}`,
async () => {
await photosApi.bulkDiscard(ids)
invalidatePhotoQueries()
}
)
},
})
}
}
// P key (Pick): toggle the current selection's membership in the active
/** X toggles the discard flag based on the FIRST target's current
* state — so pressing X on a photo that's already discarded restores
* it. Mirrors the way Lightroom's flag-toggle works for selections. */
const toggleDiscardOnTargets = () => {
const ids = cullTargets()
if (ids.length === 0) return
const first = findCachedPhoto(ids[0])
const willDiscard = !(first?.is_discarded ?? false)
updateActive({ is_discarded: willDiscard })
}
// S key (Select): toggle the current selection's membership in the active
// heap. If every selected photo is already a member, remove them; otherwise
// add the missing ones. No active heap → toast hint.
const heapMutation = useMutation({
@@ -251,7 +281,7 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
? [state.activePhotoId]
: []
if (ids.length === 0) {
toast.info('Nothing selected', 'Select photos first, then press P')
toast.info('Nothing selected', 'Select photos first, then press S')
return
}
const heapsList = queryClient.getQueryData<Heap[]>(HEAPS_QUERY_KEY) ?? []
@@ -342,12 +372,12 @@ export function useKeyboardShortcuts(props: KeyboardShortcutsProps) {
useHotkeys('0', () => updateActive({ rating: 0 }), HK_OPTS)
// P (Pick) is unified with "add to active heap" — Pick a photo and you're
// adding it to the heap you set as active. Toggling on already-picked
// photos removes them from the heap.
useHotkeys('p', togglePickOnSelection, HK_OPTS)
// S (Select) is unified with "add to active heap" — Select a photo and
// you're adding it to the heap you set as active. Toggling on already-
// selected photos removes them from the heap.
useHotkeys('s', togglePickOnSelection, HK_OPTS)
useHotkeys('x', () => updateActive({ is_discarded: true }), HK_OPTS)
useHotkeys('x', toggleDiscardOnTargets, HK_OPTS)
useHotkeys('u', () => updateActive({ is_discarded: false }), HK_OPTS)

View File

@@ -0,0 +1,64 @@
import { useQuery } from '@tanstack/react-query'
import { library, type WorkerStatus } from '../services/api'
interface ScanStatus {
is_scanning: boolean
current_folder?: string
processed_files: number
total_files: number
errors: string[]
}
/** Pending tasks waiting in a queue. We deliberately ignore worker
* `active`/`reserved` counts here because they can stay non-zero
* briefly after a queue drains (workers hold prefetched tasks) and
* would otherwise leave the spinner running with nothing to do. */
function pendingTasks(ws: WorkerStatus | undefined): number {
if (!ws) return 0
return Object.values(ws.queues ?? {}).reduce((a, b) => a + b, 0)
}
/** Tasks actively being executed by workers right now. Distinct from
* pendingTasks so we can drop the spinner the moment no real work is
* in flight, even if reserved tasks linger. */
function activeTasks(ws: WorkerStatus | undefined): number {
if (!ws) return 0
return (
ws.workers?.reduce((sum, w) => sum + (w.active ?? 0), 0) ?? 0
)
}
/**
* Read the current background-activity status (filesystem scan +
* processing queues) so consumers can render a small inline spinner
* instead of the old bottom-right popover. Shares query keys with
* ScanProgress so polling stays deduplicated.
*/
export function useScanActivity() {
const { data: scanStatus } = useQuery<ScanStatus>({
queryKey: ['scan-status'],
queryFn: () => library.scanStatus(),
refetchInterval: (query) =>
query.state.data?.is_scanning ? 2000 : 10000,
})
const { data: workerStatus } = useQuery<WorkerStatus>({
queryKey: ['worker-status-progress'],
queryFn: () => library.maintenance.workerStatus(),
refetchInterval: (query) => {
const data = query.state.data
return pendingTasks(data) + activeTasks(data) > 0 ? 3000 : 15000
},
})
const isScanning = scanStatus?.is_scanning ?? false
const isProcessing =
pendingTasks(workerStatus) + activeTasks(workerStatus) > 0
return {
active: isScanning || isProcessing,
isScanning,
isProcessing,
/** Path of the folder currently being scanned, when known. */
currentFolder: scanStatus?.current_folder ?? null,
}
}