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:
@@ -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…</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 — 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 {
|
||||
|
||||
Reference in New Issue
Block a user