166 lines
5.3 KiB
TypeScript
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>
|
|
)
|
|
} |