Files
mule-image/frontend/src/components/ScanProgress.tsx

166 lines
5.3 KiB
TypeScript

import { useEffect, useState } from 'react'
import { FolderOpen, Loader2, Check, AlertCircle, X } from 'lucide-react'
import { useQuery } 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)
// 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(() => {
if (scanStatus?.is_scanning) {
setIsVisible(true)
setIsMinimized(false)
} else if (isVisible && !scanStatus?.is_scanning && (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])
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>
)
}