Two related sidebar UX bugs. 1. Parent folders weren't clickable renderTreeItem's onClick called toggleExpanded(item.id) for any row with children — so a parent folder only expanded/collapsed, never applied its filter. Restructured: folder rows always call applyLibraryNode (which the photos endpoint already expands to include descendants), and the chevron remains a separate stopPropagation button for expansion. Other group headers (Library, Folders, Tags) still toggle expansion on row click since they have no associated filter. Result: clicking any folder at any depth filters the timeline to that folder + every descendant, matching the Lightroom model the user expects. 2. New files not appearing after Scan all folders scanLibraryMutation.onSettled invalidated ['photos'] when the trigger returned, but POST /library/scan just queues the celery task and returns immediately. By the time the worker finishes walking the directory and inserting new rows, the photos query has already refetched (with no new data) and is sitting on a 30-second staleTime — so newly-indexed photos stayed invisible until the next manual refetch. Fix: ScanProgress already polls /library/scan/status. Track the previous is_scanning value via a ref; when it transitions from true → false, invalidate ['photos'], ['folders'], ['folders', 'tree'], ['heaps'], and ['tags']. That's the actual moment new data is available, regardless of how the scan was triggered (button, watcher, startup). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
184 lines
6.1 KiB
TypeScript
184 lines
6.1 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { FolderOpen, Loader2, Check, AlertCircle, X } from 'lucide-react'
|
|
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
|
import { library } from '../services/api'
|
|
import clsx from 'clsx'
|
|
|
|
interface ScanStatus {
|
|
is_scanning: boolean
|
|
current_folder?: string
|
|
processed_files: number
|
|
total_files: number
|
|
errors: string[]
|
|
}
|
|
|
|
export function ScanProgress() {
|
|
const [isVisible, setIsVisible] = useState(false)
|
|
const [isMinimized, setIsMinimized] = useState(false)
|
|
const queryClient = useQueryClient()
|
|
const wasScanningRef = useRef(false)
|
|
|
|
// Poll scan status every 2 seconds when scanning
|
|
const { data: scanStatus } = useQuery<ScanStatus>({
|
|
queryKey: ['scan-status'],
|
|
queryFn: async () => {
|
|
const response = await library.scanStatus()
|
|
return response
|
|
},
|
|
refetchInterval: (query) => {
|
|
// Poll every 2 seconds if scanning, otherwise every 10 seconds
|
|
return query.state.data?.is_scanning ? 2000 : 10000
|
|
},
|
|
enabled: true,
|
|
})
|
|
|
|
useEffect(() => {
|
|
const isScanning = scanStatus?.is_scanning ?? false
|
|
|
|
if (isScanning) {
|
|
setIsVisible(true)
|
|
setIsMinimized(false)
|
|
wasScanningRef.current = true
|
|
} else if (wasScanningRef.current) {
|
|
// Just transitioned from scanning → done. THIS is the right moment
|
|
// to invalidate caches that might have new data: the photos query
|
|
// (new files indexed), the folder tree (new folders walked), the
|
|
// heap counts (in case a heap photo got reattached).
|
|
wasScanningRef.current = false
|
|
queryClient.invalidateQueries({ queryKey: ['photos'] })
|
|
queryClient.invalidateQueries({ queryKey: ['folders'] })
|
|
queryClient.invalidateQueries({ queryKey: ['folders', 'tree'] })
|
|
queryClient.invalidateQueries({ queryKey: ['heaps'] })
|
|
queryClient.invalidateQueries({ queryKey: ['tags'] })
|
|
|
|
if (isVisible && (scanStatus?.processed_files ?? 0) > 0) {
|
|
// Keep showing for 3 seconds after scan completes
|
|
setTimeout(() => {
|
|
if (!scanStatus?.is_scanning) {
|
|
setIsVisible(false)
|
|
}
|
|
}, 3000)
|
|
}
|
|
}
|
|
}, [scanStatus?.is_scanning, scanStatus?.processed_files, isVisible, queryClient])
|
|
|
|
if (!isVisible || !scanStatus) return null
|
|
|
|
const progress = scanStatus.total_files > 0
|
|
? (scanStatus.processed_files / scanStatus.total_files) * 100
|
|
: 0
|
|
|
|
const isComplete = !scanStatus.is_scanning && scanStatus.processed_files > 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">
|
|
{scanStatus.is_scanning ? (
|
|
<Loader2 className="h-4 w-4 animate-spin text-primary" />
|
|
) : 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">
|
|
{scanStatus.is_scanning
|
|
? 'Scanning Folders'
|
|
: isComplete
|
|
? 'Scan Complete'
|
|
: 'Scan 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">
|
|
{/* Current folder */}
|
|
{scanStatus.current_folder && (
|
|
<div className="mb-2 text-xs text-text-muted">
|
|
<span className="font-mono">{scanStatus.current_folder}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Progress bar */}
|
|
<div className="mb-2">
|
|
<div className="h-1.5 overflow-hidden rounded-full bg-surface-offset">
|
|
<div
|
|
className={clsx(
|
|
'h-full transition-all duration-300',
|
|
scanStatus.is_scanning
|
|
? 'bg-primary'
|
|
: hasErrors
|
|
? 'bg-reject'
|
|
: 'bg-pick'
|
|
)}
|
|
style={{ width: `${progress}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Stats */}
|
|
<div className="flex items-center justify-between text-xs">
|
|
<span className="text-text-muted">
|
|
{scanStatus.processed_files} / {scanStatus.total_files || '?'} files
|
|
</span>
|
|
<span className={clsx(
|
|
'font-medium',
|
|
scanStatus.is_scanning ? 'text-primary' : hasErrors ? 'text-reject' : 'text-pick'
|
|
)}>
|
|
{scanStatus.is_scanning
|
|
? `${Math.round(progress)}%`
|
|
: isComplete
|
|
? 'Done'
|
|
: 'Idle'}
|
|
</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>
|
|
)
|
|
} |