- 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>
102 lines
3.5 KiB
TypeScript
102 lines
3.5 KiB
TypeScript
import { useEffect, useRef } from 'react'
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { library, WorkerStatus } from '../services/api'
|
|
|
|
interface ScanStatus {
|
|
is_scanning: boolean
|
|
current_folder?: string
|
|
processed_files: number
|
|
total_files: number
|
|
errors: string[]
|
|
}
|
|
|
|
type Phase = 'idle' | 'scanning' | 'processing' | 'done'
|
|
|
|
/**
|
|
* Headless background-activity orchestrator. Polls scan + worker status
|
|
* and invalidates affected query caches when a scan/processing pass
|
|
* completes. The visible status indicator now lives inline in the
|
|
* LeftSidebar (small spinner next to the FOLDERS header / specific
|
|
* folder rows) — see useScanActivity.
|
|
*/
|
|
export function ScanProgress() {
|
|
const queryClient = useQueryClient()
|
|
const wasScanningRef = useRef(false)
|
|
const wasProcessingRef = useRef(false)
|
|
|
|
const { data: scanStatus } = useQuery<ScanStatus>({
|
|
queryKey: ['scan-status'],
|
|
queryFn: () => library.scanStatus(),
|
|
refetchInterval: (query) =>
|
|
query.state.data?.is_scanning ? 2000 : 10000,
|
|
enabled: true,
|
|
})
|
|
|
|
const isScanning = scanStatus?.is_scanning ?? false
|
|
|
|
// Poll worker status to track vision queue activity.
|
|
// Fast polling (3s) while processing, slow (15s) otherwise.
|
|
const { data: workerStatus } = useQuery<WorkerStatus>({
|
|
queryKey: ['worker-status-progress'],
|
|
queryFn: () => library.maintenance.workerStatus(),
|
|
refetchInterval: (query) => {
|
|
const q = totalQueued(query.state.data)
|
|
return q > 0 ? 3000 : 15000
|
|
},
|
|
enabled: true,
|
|
})
|
|
|
|
const totalActive = totalQueued(workerStatus)
|
|
|
|
const phase: Phase = isScanning
|
|
? 'scanning'
|
|
: totalActive > 0
|
|
? 'processing'
|
|
: 'idle'
|
|
|
|
useEffect(() => {
|
|
if (phase === 'scanning') {
|
|
wasScanningRef.current = true
|
|
wasProcessingRef.current = false
|
|
} else if (phase === 'processing') {
|
|
wasProcessingRef.current = true
|
|
if (wasScanningRef.current) {
|
|
wasScanningRef.current = false
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
|
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
|
queryClient.invalidateQueries({ queryKey: ['heaps'] })
|
|
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
|
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
|
|
}
|
|
} else if (phase === 'idle') {
|
|
if (wasScanningRef.current) {
|
|
wasScanningRef.current = false
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
|
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
|
queryClient.invalidateQueries({ queryKey: ['heaps'] })
|
|
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
|
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
|
|
}
|
|
if (wasProcessingRef.current) {
|
|
wasProcessingRef.current = false
|
|
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
|
|
}
|
|
}
|
|
}, [phase, queryClient])
|
|
|
|
return null
|
|
}
|
|
|
|
function totalQueued(ws: WorkerStatus | undefined): number {
|
|
if (!ws) return 0
|
|
const queued = Object.values(ws.queues ?? {}).reduce((a, b) => a + b, 0)
|
|
const active = ws.workers?.reduce(
|
|
(sum, w) => sum + (w.active ?? 0) + (w.reserved ?? 0), 0
|
|
) ?? 0
|
|
return queued + active
|
|
}
|