| import React, { useState, useRef, useEffect } from 'react'; |
| import { useNavigate } from 'react-router-dom'; |
| import { UploadIcon, StackIcon, DownloadIcon, ArrowLeftIcon, CheckCircleIcon, XCircleIcon } from './Icons'; |
| import { BatchItem } from '../types'; |
| import { uploadMultiple, classifyMultipleStream, clearUploads, getSamples, useSample } from '../services/apiService'; |
|
|
| const BatchAnalysis: React.FC = () => { |
| const navigate = useNavigate(); |
| const [items, setItems] = useState<BatchItem[]>([]); |
| const [processing, setProcessing] = useState(false); |
| const [showSamples, setShowSamples] = useState(false); |
| const [samples, setSamples] = useState<{ id: number, path: string, name: string }[]>([]); |
| const fileInputRef = useRef<HTMLInputElement>(null); |
|
|
| useEffect(() => { |
| const fetchSamples = async () => { |
| try { |
| const data = await getSamples(); |
| if (Array.isArray(data)) { |
| setSamples(data); |
| } |
| } catch (err) { |
| console.error("Failed to fetch samples", err); |
| } |
| }; |
| fetchSamples(); |
| }, []); |
|
|
| const handleFileChange = async (e: React.ChangeEvent<HTMLInputElement>) => { |
| if (e.target.files && e.target.files.length > 0) { |
| const newFiles = Array.from(e.target.files) as File[]; |
|
|
| |
| const newItems: BatchItem[] = newFiles.map(file => ({ |
| id: Math.random().toString(36).substr(2, 9), |
| file: file, |
| previewUrl: URL.createObjectURL(file), |
| status: 'pending' |
| })); |
|
|
| setItems(prev => [...prev, ...newItems]); |
|
|
| |
| try { |
| await uploadMultiple(newFiles); |
| } catch (err) { |
| console.error("Upload failed", err); |
| |
| setItems(prev => prev.map(item => |
| newItems.find(ni => ni.id === item.id) ? { ...item, status: 'error' } : item |
| )); |
| } |
| } |
| }; |
|
|
| const addSampleToQueue = async (filename: string, url: string) => { |
| try { |
| |
| await useSample(filename, 'multiple'); |
|
|
| |
| |
| const file = new File([""], filename, { type: "image/png" }); |
|
|
| const newItem: BatchItem = { |
| id: Math.random().toString(36).substr(2, 9), |
| file, |
| previewUrl: url, |
| status: 'pending' |
| }; |
|
|
| setItems(prev => [...prev, newItem]); |
|
|
| } catch (err) { |
| console.error("Failed to load sample", err); |
| } |
| }; |
|
|
| const normalizeFilename = (name: string) => { |
| |
| |
| |
| |
| let normalized = name.replace(/\s+/g, '_'); |
| normalized = normalized.replace(/[^a-zA-Z0-9._-]/g, ''); |
| return normalized; |
| }; |
|
|
| const runBatchProcessing = async () => { |
| setProcessing(true); |
| setItems(prev => prev.map(item => ({ ...item, status: 'processing', error: undefined }))); |
|
|
| try { |
| |
| for await (const result of classifyMultipleStream()) { |
| console.log("Received result:", result); |
|
|
| if (result.error) { |
| console.error("Error for file:", result.filename, result.error); |
| setItems(prev => prev.map(item => { |
| |
| if (item.file.name === result.filename || normalizeFilename(item.file.name) === result.filename) { |
| return { ...item, status: 'error', error: result.error }; |
| } |
| return item; |
| })); |
| continue; |
| } |
|
|
| setItems(prev => prev.map(item => { |
| |
| if (item.file.name === result.filename || normalizeFilename(item.file.name) === result.filename) { |
| return { |
| ...item, |
| status: 'completed', |
| result: result.status === 'pass' ? 'pass' : 'fail', |
| labels: result.labels |
| }; |
| } |
| return item; |
| })); |
| } |
|
|
| } catch (err) { |
| console.error("Batch processing error:", err); |
| setItems(prev => prev.map(item => |
| item.status === 'processing' ? { ...item, status: 'error', error: 'Network or server error' } : item |
| )); |
| } finally { |
| setProcessing(false); |
| |
| setItems(prev => prev.map(item => |
| item.status === 'processing' ? { |
| ...item, |
| status: 'error', |
| error: 'No result from server (Filename mismatch or timeout)' |
| } : item |
| )); |
| } |
| }; |
|
|
| const getProgress = () => { |
| if (items.length === 0) return 0; |
| const completed = items.filter(i => i.status === 'completed' || i.status === 'error').length; |
| return (completed / items.length) * 100; |
| }; |
|
|
| const downloadReport = () => { |
| const timestamp = new Date().toISOString().replace(/[:.]/g, '-'); |
| const htmlContent = ` |
| <!DOCTYPE html> |
| <html> |
| <head> |
| <title>Prism Batch Report - ${timestamp}</title> |
| <style> |
| body { font-family: sans-serif; background: #f8fafc; padding: 40px; } |
| h1 { color: #0f172a; } |
| table { width: 100%; border-collapse: collapse; background: white; box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); border-radius: 8px; overflow: hidden; } |
| th { background: #1e293b; color: white; text-align: left; padding: 12px 20px; } |
| td { border-bottom: 1px solid #e2e8f0; padding: 12px 20px; color: #334155; } |
| .pass { color: #059669; font-weight: bold; } |
| .fail { color: #e11d48; font-weight: bold; } |
| .labels { font-family: monospace; background: #f1f5f9; padding: 2px 6px; rounded: 4px; color: #475569; } |
| </style> |
| </head> |
| <body> |
| <h1>Batch Classification Report</h1> |
| <p>Generated on: ${new Date().toLocaleString()}</p> |
| <table> |
| <thead> |
| <tr> |
| <th>Filename</th> |
| <th>Status</th> |
| <th>Result</th> |
| <th>Failure Reason</th> |
| </tr> |
| </thead> |
| <tbody> |
| ${items.map(item => ` |
| <tr> |
| <td>${item.file.name}</td> |
| <td>${item.status}</td> |
| <td class="${item.result}">${item.result ? item.result.toUpperCase() : '-'}</td> |
| <td>${item.labels && item.labels.length > 0 ? `<span class="labels">${item.labels.join(', ')}</span>` : '-'}</td> |
| </tr> |
| `).join('')} |
| </tbody> |
| </table> |
| </body> |
| </html> |
| `; |
|
|
| const blob = new Blob([htmlContent], { type: 'text/html' }); |
| const url = URL.createObjectURL(blob); |
| const a = document.createElement('a'); |
| a.href = url; |
| a.download = `prism-batch-report-${timestamp}.html`; |
| document.body.appendChild(a); |
| a.click(); |
| document.body.removeChild(a); |
| URL.revokeObjectURL(url); |
| }; |
|
|
| const clearAll = async () => { |
| setItems([]); |
| await clearUploads(); |
| }; |
|
|
| const isComplete = items.length > 0 && items.every(i => i.status === 'completed' || i.status === 'error'); |
|
|
| return ( |
| <div className="min-h-screen flex flex-col p-4 md:p-8 max-w-7xl mx-auto"> |
| <header className="flex items-center justify-between mb-8"> |
| <h2 className="text-2xl font-light tracking-wide">Batch Image <span className="font-bold text-cyan-400">Analysis</span></h2> |
| </header> |
| |
| {/* Controls */} |
| <div className="glass-panel rounded-2xl p-6 mb-8"> |
| <div className="flex flex-col md:flex-row items-center justify-between gap-6"> |
| <div className="flex items-center gap-4 w-full md:w-auto"> |
| <button |
| onClick={() => fileInputRef.current?.click()} |
| className="flex items-center gap-2 bg-cyan-500 hover:bg-cyan-400 text-black font-bold py-3 px-6 rounded-lg transition-all hover:shadow-[0_0_20px_rgba(34,211,238,0.4)]" |
| > |
| <UploadIcon /> Upload Files |
| </button> |
| <input |
| type="file" |
| ref={fileInputRef} |
| className="hidden" |
| multiple |
| accept="image/*" |
| onChange={handleFileChange} |
| /> |
| |
| {items.length > 0 && ( |
| <button |
| onClick={clearAll} |
| className="text-slate-400 hover:text-white transition-colors text-sm" |
| > |
| Clear Queue |
| </button> |
| )} |
| </div> |
| |
| <div className="flex items-center gap-4 w-full md:w-auto"> |
| <div className="flex-1 md:w-64 h-2 bg-slate-700 rounded-full overflow-hidden"> |
| <div |
| className="h-full bg-cyan-400 transition-all duration-500 ease-out" |
| style={{ width: `${getProgress()}%` }} |
| /> |
| </div> |
| <span className="text-sm font-mono text-cyan-400 w-12">{Math.round(getProgress())}%</span> |
| </div> |
| </div> |
| |
| {/* Sample Gallery Toggle */} |
| <button |
| onClick={() => setShowSamples(!showSamples)} |
| className="mt-6 w-full py-2 border-t border-white/5 text-slate-400 hover:text-cyan-400 text-sm uppercase tracking-widest font-medium transition-colors flex items-center justify-center gap-2" |
| > |
| <StackIcon /> |
| {showSamples ? 'Close Test Deck' : 'Load Test Data'} |
| </button> |
| |
| <div className={`w-full transition-all duration-500 ease-in-out overflow-hidden ${showSamples ? 'max-h-[400px] opacity-100' : 'max-h-0 opacity-0'}`}> |
| <div className="p-6 bg-slate-800/30 rounded-b-2xl border-x border-b border-slate-700/50"> |
| <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 overflow-y-auto max-h-[350px] pr-2 custom-scrollbar"> |
| {samples.map((sample) => { |
| const isSelected = items.some(item => item.previewUrl === sample.url); |
| return ( |
| <div |
| key={sample.id} |
| className={`group relative aspect-square rounded-xl overflow-hidden cursor-pointer border-2 transition-all duration-300 ${isSelected ? 'border-cyan-400 ring-2 ring-cyan-400/50' : 'border-slate-700 hover:border-cyan-500' |
| }`} |
| onClick={() => addSampleToQueue(sample.filename, sample.url)} |
| > |
| <img |
| src={sample.url} |
| alt={`Sample ${sample.id}`} |
| className="w-full h-full object-cover transition-transform duration-500 group-hover:scale-110" |
| /> |
| <div className={`absolute inset-0 transition-colors duration-300 ${isSelected ? 'bg-cyan-500/20' : 'bg-black/0 group-hover:bg-black/20' |
| }`}> |
| {isSelected && ( |
| <div className="absolute top-2 right-2 bg-cyan-500 rounded-full p-1"> |
| <CheckCircleIcon className="w-4 h-4 text-white" /> |
| </div> |
| )} |
| </div> |
| </div> |
| ); |
| })} |
| </div> |
| </div> |
| </div> |
| </div> |
| |
| {/* Status Bar */} |
| {items.length > 0 && ( |
| <div className="flex items-center justify-between mb-6 animate-fade-in"> |
| <div> |
| <p className="text-white font-medium">{items.length} items in queue</p> |
| {processing && ( |
| <p className="text-[10px] text-center text-purple-300/80 animate-pulse"> |
| Running on CPU: Classification takes time, please be patient 🐨✨ |
| </p> |
| )} |
| </div> |
| <div className="flex gap-4"> |
| <button |
| onClick={runBatchProcessing} |
| disabled={processing || isComplete} |
| className="bg-white text-black font-bold py-2 px-6 rounded-lg hover:bg-slate-200 disabled:opacity-50 disabled:cursor-not-allowed transition-colors shadow-[0_0_20px_rgba(255,255,255,0.2)]" |
| > |
| {processing ? 'Processing...' : isComplete ? 'Analysis Complete' : 'Start Analysis'} |
| </button> |
| <button |
| onClick={downloadReport} |
| disabled={!isComplete} |
| className="flex items-center gap-2 bg-slate-800 text-white py-2 px-6 rounded-lg border border-slate-700 hover:bg-slate-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors" |
| > |
| <DownloadIcon /> Report |
| </button> |
| </div> |
| </div> |
| )} |
| |
| {/* Grid */} |
| <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4 pb-20"> |
| {items.map((item) => ( |
| <div |
| key={item.id} |
| className={`relative aspect-[9/16] rounded-xl overflow-hidden group border animate-fade-in ${item.status === 'completed' |
| ? (item.result === 'pass' ? 'border-emerald-500/50' : 'border-rose-500/50') |
| : 'border-white/5' |
| }`} |
| > |
| <img src={item.previewUrl} className="w-full h-full object-cover" alt="Batch Item" /> |
| |
| {/* Overlay Status */} |
| <div className="absolute inset-0 bg-gradient-to-t from-black/90 to-transparent opacity-80 flex flex-col justify-end p-3"> |
| {item.status === 'processing' && ( |
| <span className="text-cyan-400 text-xs font-bold animate-pulse">ANALYZING...</span> |
| )} |
| {item.status === 'pending' && ( |
| <span className="text-slate-400 text-xs">PENDING</span> |
| )} |
| {item.status === 'error' && ( |
| <div className="flex flex-col"> |
| <span className="text-rose-400 text-xs font-bold">ERROR</span> |
| {item.error && ( |
| <span className="text-[10px] text-rose-200 leading-tight mt-1 break-words"> |
| {item.error.length > 50 ? item.error.substring(0, 50) + '...' : item.error} |
| </span> |
| )} |
| </div> |
| )} |
| {item.status === 'completed' && ( |
| <div className="flex flex-col gap-1"> |
| <div className="flex items-center gap-1"> |
| {item.result === 'pass' |
| ? <CheckCircleIcon className="text-emerald-400 w-5 h-5" /> |
| : <XCircleIcon className="text-rose-400 w-5 h-5" /> |
| } |
| <span className={`text-sm font-bold uppercase ${item.result === 'pass' ? 'text-emerald-400' : 'text-rose-400'}`}> |
| {item.result} |
| </span> |
| </div> |
| {item.labels && item.labels.length > 0 && ( |
| <div className="flex flex-wrap gap-1 mt-1"> |
| {item.labels.map((label, idx) => ( |
| <span key={idx} className="text-[10px] bg-rose-500/20 text-rose-200 px-1.5 py-0.5 rounded border border-rose-500/30"> |
| {label} |
| </span> |
| ))} |
| </div> |
| )} |
| </div> |
| )} |
| </div> |
| </div> |
| ))} |
| </div> |
| </div> |
| ); |
| }; |
|
|
| export default BatchAnalysis; |