Files
mule-image/frontend/src/components/ScanProgress.tsx
dtoro 6917e618e5 fix: parent folders clickable; refresh photos when scan completes
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>
2026-04-08 11:54:31 +02:00

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>
)
}