- FilterPill: drop the inline value text from the active state. Pills now stay the same width whether or not a filter is set; the popover is the canonical place to read the value, and the title attribute surfaces it on hover. - TopBar: remove the search input — search lives in the filter bar now. - FilterBar: add a search input on the left, with the pill cluster centered between it and a flex-shrink-0 Clear-all on the right. - LeftSidebar / HeapsPanel: count badges use a fixed-width slot (h-5 min-w-[24px], tabular-nums) so counts line up in the same visual column across rows. Empty rows reserve the slot. - LeftSidebar: pull section counts (All Photos, Rated, Duplicates, Discarded) from a new useLibraryStatsQuery hook backed by the expanded /library/stats endpoint. Tags count was already wired. - backend/library: stats endpoint returns per-section counts that match the filter the sidebar applies on click. - Stats invalidation hooked into the standard photo-mutation paths. - RightSidebar header: h-12 to match TopBar height. - Timeline sticky date overlay: only show once the natural in-grid header has scrolled OUT of the viewport. Avoids the duplicate-label flash when both labels would be visible. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
185 lines
6.2 KiB
TypeScript
185 lines
6.2 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'] })
|
|
queryClient.invalidateQueries({ queryKey: ['library', 'stats'] })
|
|
|
|
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>
|
|
)
|
|
} |