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

@@ -1,8 +1,6 @@
import { useEffect, useRef, useState } from 'react'
import { FolderOpen, Loader2, Check, AlertCircle, X, Brain, Sparkles } from 'lucide-react'
import { useEffect, useRef } from 'react'
import { useQuery, useQueryClient } from '@tanstack/react-query'
import { library, WorkerStatus } from '../services/api'
import clsx from 'clsx'
interface ScanStatus {
is_scanning: boolean
@@ -14,9 +12,14 @@ interface ScanStatus {
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 [isVisible, setIsVisible] = useState(false)
const [isMinimized, setIsMinimized] = useState(false)
const queryClient = useQueryClient()
const wasScanningRef = useRef(false)
const wasProcessingRef = useRef(false)
@@ -43,7 +46,6 @@ export function ScanProgress() {
enabled: true,
})
const visionActive = visionQueued(workerStatus)
const totalActive = totalQueued(workerStatus)
const phase: Phase = isScanning
@@ -54,18 +56,11 @@ export function ScanProgress() {
useEffect(() => {
if (phase === 'scanning') {
setIsVisible(true)
setIsMinimized(false)
wasScanningRef.current = true
wasProcessingRef.current = false
} else if (phase === 'processing') {
// Show widget when processing starts (even without a prior scan,
// e.g. backfill triggered from Settings).
if (!isVisible) setIsVisible(true)
wasProcessingRef.current = true
if (wasScanningRef.current) {
// Scan just finished — invalidate data caches.
wasScanningRef.current = false
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['folders'] })
@@ -76,7 +71,6 @@ export function ScanProgress() {
}
} else if (phase === 'idle') {
if (wasScanningRef.current) {
// Scan finished with no queued processing (small import).
wasScanningRef.current = false
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['folders'] })
@@ -86,172 +80,15 @@ export function ScanProgress() {
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
}
if (wasProcessingRef.current) {
// Processing just drained — refresh tags (new clusters/objects).
wasProcessingRef.current = false
queryClient.invalidateQueries({ queryKey: ['tags'] })
queryClient.invalidateQueries({ queryKey: ['photos'] })
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
}
if (isVisible) {
setTimeout(() => setIsVisible(false), 3000)
}
}
}, [phase, isVisible, queryClient])
}, [phase, queryClient])
if (!isVisible) return null
// Scan progress percentage
const scanProgress = scanStatus && scanStatus.total_files > 0
? (scanStatus.processed_files / scanStatus.total_files) * 100
: 0
const isComplete = phase === 'idle' && (scanStatus?.processed_files ?? 0) > 0
const hasErrors = scanStatus?.errors && scanStatus.errors.length > 0
return (
<div
className={clsx(
'fixed bottom-4 right-4 z-40 overflow-hidden rounded-lg border border-border bg-surface shadow-xl transition-all duration-300',
isMinimized ? 'w-12' : 'w-80'
)}
>
{/* Header */}
<div
className="flex cursor-pointer items-center justify-between bg-surface-2 px-3 py-2"
onClick={() => setIsMinimized(!isMinimized)}
>
<div className="flex items-center gap-2">
{phase === 'scanning' ? (
<Loader2 className="h-4 w-4 animate-spin text-primary" />
) : phase === 'processing' ? (
<Sparkles className="h-4 w-4 animate-pulse text-amber-400" />
) : isComplete && !hasErrors ? (
<Check className="h-4 w-4 text-pick" />
) : hasErrors ? (
<AlertCircle className="h-4 w-4 text-reject" />
) : (
<FolderOpen className="h-4 w-4 text-text-muted" />
)}
{!isMinimized && (
<span className="text-sm font-medium text-text">
{phase === 'scanning'
? 'Scanning Folders'
: phase === 'processing'
? 'Processing Photos'
: isComplete
? 'Complete'
: 'Status'}
</span>
)}
</div>
{!isMinimized && (
<button
onClick={(e) => {
e.stopPropagation()
setIsVisible(false)
}}
className="rounded p-0.5 text-text-muted hover:bg-surface-offset hover:text-text"
>
<X className="h-3 w-3" />
</button>
)}
</div>
{/* Content */}
{!isMinimized && (
<div className="p-3">
{/* Scan phase */}
{phase === 'scanning' && scanStatus && (
<>
{scanStatus.current_folder && (
<div className="mb-2 text-xs text-text-muted">
<span className="font-mono">{scanStatus.current_folder}</span>
</div>
)}
<div className="mb-2">
<div className="h-1.5 overflow-hidden rounded-full bg-surface-offset">
<div
className="h-full bg-primary transition-all duration-300"
style={{ width: `${scanProgress}%` }}
/>
</div>
</div>
<div className="flex items-center justify-between text-xs">
<span className="text-text-muted">
{scanStatus.processed_files} / {scanStatus.total_files || '?'} files
</span>
<span className="font-medium text-primary">
{Math.round(scanProgress)}%
</span>
</div>
</>
)}
{/* Processing phase */}
{phase === 'processing' && workerStatus && (
<>
<div className="mb-2 flex items-center gap-2 text-xs text-text-muted">
<Brain className="h-3.5 w-3.5 text-amber-400" />
<span>Analyzing photos&hellip;</span>
</div>
<div className="space-y-1 text-xs">
{visionActive > 0 && (
<div className="flex items-center justify-between">
<span className="text-text-muted">Vision pipeline</span>
<span className="font-mono text-amber-400">{visionActive} queued</span>
</div>
)}
{(totalActive - visionActive) > 0 && (
<div className="flex items-center justify-between">
<span className="text-text-muted">Other tasks</span>
<span className="font-mono text-text-muted">{totalActive - visionActive} queued</span>
</div>
)}
</div>
<div className="mt-2 text-[11px] text-text-muted/60">
Thumbnails, embeddings, faces, tags &mdash; runs in background
</div>
</>
)}
{/* Done phase */}
{phase === 'idle' && isComplete && (
<div className="flex items-center gap-2 text-xs text-pick">
<Check className="h-3.5 w-3.5" />
<span>All processing complete</span>
</div>
)}
{/* Errors */}
{hasErrors && (
<div className="mt-2 max-h-20 overflow-y-auto rounded bg-reject/10 p-2">
<div className="text-xs text-reject">
{scanStatus!.errors.slice(0, 3).map((error, i) => (
<div key={i} className="truncate">
{error}
</div>
))}
{scanStatus!.errors.length > 3 && (
<div className="mt-1 text-text-muted">
+{scanStatus!.errors.length - 3} more errors
</div>
)}
</div>
</div>
)}
</div>
)}
</div>
)
}
function visionQueued(ws: WorkerStatus | undefined): number {
if (!ws) return 0
const queued = ws.queues?.['vision'] ?? 0
const active = ws.workers
?.filter(w => w.queues?.includes('vision'))
.reduce((sum, w) => sum + (w.active ?? 0) + (w.reserved ?? 0), 0) ?? 0
return queued + active
return null
}
function totalQueued(ws: WorkerStatus | undefined): number {