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:
@@ -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)
|
||||
|
||||
|
||||
64
frontend/src/hooks/useScanActivity.ts
Normal file
64
frontend/src/hooks/useScanActivity.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user