Spaces:
Build error
Build error
| import { useState, useRef, useEffect } from 'react'; | |
| import type { DragEvent, ChangeEvent } from 'react'; | |
| import { PageParseAPI, INDIAN_LANGUAGES } from '../services/api'; | |
| import type { ProcessingJob } from '../services/api'; | |
| import { | |
| UploadCloud, FileText, CheckCircle2, AlertCircle, Mic, Square, Camera, Files | |
| } from 'lucide-react'; | |
| interface FileUploaderProps { | |
| onJobStarted: () => void; | |
| onJobFinished: () => void; | |
| isAirgapped: boolean; | |
| } | |
| export const FileUploader = ({ onJobStarted, onJobFinished, isAirgapped }: FileUploaderProps) => { | |
| const [isDragActive, setIsDragActive] = useState(false); | |
| const [currentJob, setCurrentJob] = useState<ProcessingJob | null>(null); | |
| const [activeJobs, setActiveJobs] = useState<ProcessingJob[]>([]); | |
| const [filePreview, setFilePreview] = useState<string | null>(null); | |
| const [selectedLanguage, setSelectedLanguage] = useState('English'); | |
| const [schemaType, setSchemaType] = useState('auto'); | |
| const [isRecording, setIsRecording] = useState(false); | |
| const [recordingDuration, setRecordingDuration] = useState(0); | |
| const [imageToConfigure, setImageToConfigure] = useState<File | null>(null); | |
| const [brightness, setBrightness] = useState(100); | |
| const [contrast, setContrast] = useState(100); | |
| const [binarize, setBinarize] = useState(false); | |
| const [threshold, setThreshold] = useState(128); | |
| const [rotation, setRotation] = useState(0); | |
| const fileInputRef = useRef<HTMLInputElement>(null); | |
| const batchInputRef = useRef<HTMLInputElement>(null); | |
| const cameraInputRef = useRef<HTMLInputElement>(null); | |
| const mediaRecorderRef = useRef<MediaRecorder | null>(null); | |
| const audioChunksRef = useRef<Blob[]>([]); | |
| const timerRef = useRef<any>(null); | |
| const canvasRef = useRef<HTMLCanvasElement>(null); | |
| useEffect(() => { | |
| if (!imageToConfigure || !canvasRef.current) return; | |
| const canvas = canvasRef.current; | |
| const ctx = canvas.getContext('2d'); | |
| if (!ctx) return; | |
| const img = new Image(); | |
| img.src = URL.createObjectURL(imageToConfigure); | |
| img.onload = () => { | |
| const is90 = Math.abs(rotation % 180) === 90; | |
| canvas.width = is90 ? img.height : img.width; | |
| canvas.height = is90 ? img.width : img.height; | |
| ctx.clearRect(0, 0, canvas.width, canvas.height); | |
| ctx.save(); | |
| ctx.translate(canvas.width / 2, canvas.height / 2); | |
| ctx.rotate((rotation * Math.PI) / 180); | |
| ctx.drawImage(img, -img.width / 2, -img.height / 2); | |
| ctx.restore(); | |
| let imgData = ctx.getImageData(0, 0, canvas.width, canvas.height); | |
| let data = imgData.data; | |
| const bFactor = brightness / 100; | |
| const cFactor = contrast / 100; | |
| for (let i = 0; i < data.length; i += 4) { | |
| for (let c = 0; c < 3; c++) { | |
| let val = data[i + c]; | |
| val = val * bFactor; | |
| val = ((val - 128) * cFactor) + 128; | |
| data[i + c] = Math.min(255, Math.max(0, val)); | |
| } | |
| if (binarize) { | |
| const gray = 0.299 * data[i] + 0.587 * data[i + 1] + 0.114 * data[i + 2]; | |
| const binVal = gray < threshold ? 0 : 255; | |
| data[i] = binVal; data[i + 1] = binVal; data[i + 2] = binVal; | |
| } | |
| } | |
| ctx.putImageData(imgData, 0, 0); | |
| }; | |
| }, [imageToConfigure, brightness, contrast, binarize, threshold, rotation]); | |
| const startRecording = async (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| audioChunksRef.current = []; | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); | |
| const recorder = new MediaRecorder(stream); | |
| mediaRecorderRef.current = recorder; | |
| recorder.ondataavailable = (event) => { if (event.data.size > 0) audioChunksRef.current.push(event.data); }; | |
| recorder.onstop = () => { | |
| const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/wav' }); | |
| const file = new File([audioBlob], `recorded_voice_${Date.now()}.wav`, { type: 'audio/wav' }); | |
| processSelectedFile(file); | |
| stream.getTracks().forEach(track => track.stop()); | |
| }; | |
| recorder.start(); | |
| setIsRecording(true); | |
| setRecordingDuration(0); | |
| timerRef.current = setInterval(() => setRecordingDuration(prev => prev + 1), 1000); | |
| } catch (err) { | |
| alert("Microphone access error: " + err); | |
| } | |
| }; | |
| const stopRecording = (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (mediaRecorderRef.current && isRecording) { | |
| mediaRecorderRef.current.stop(); | |
| setIsRecording(false); | |
| if (timerRef.current) clearInterval(timerRef.current); | |
| } | |
| }; | |
| const takePhoto = async (e: React.MouseEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| try { | |
| const stream = await navigator.mediaDevices.getUserMedia({ video: { facingMode: 'environment' } }); | |
| const track = stream.getVideoTracks()[0]; | |
| const imageCapture = new (window as any).ImageCapture(track); | |
| const photoBlob = await imageCapture.takePhoto(); | |
| track.stop(); | |
| const file = new File([photoBlob], `camera_${Date.now()}.jpg`, { type: 'image/jpeg' }); | |
| processSelectedFile(file); | |
| } catch (err) { | |
| cameraInputRef.current?.click(); | |
| } | |
| }; | |
| const uploadProcessedFile = async (file: File) => { | |
| try { | |
| const job = await PageParseAPI.uploadPage(file, selectedLanguage, schemaType, (updatedJob) => { | |
| setCurrentJob(updatedJob); | |
| if (updatedJob.status === 'saved' || updatedJob.status === 'failed') { | |
| onJobFinished(); | |
| setTimeout(() => { setCurrentJob(null); setFilePreview(null); }, 3000); | |
| } | |
| }); | |
| setCurrentJob(job); | |
| onJobStarted(); | |
| } catch (err: any) { | |
| console.error(err); | |
| setCurrentJob({ id: 'err', filename: file.name, status: 'failed', progress: 0, error: err.message || 'Processing failed' }); | |
| onJobFinished(); | |
| } | |
| }; | |
| const uploadBatchFiles = async (files: FileList) => { | |
| onJobStarted(); | |
| const jobs: ProcessingJob[] = []; | |
| for (const file of Array.from(files)) { | |
| jobs.push({ id: Math.random().toString(36).substring(2, 9), filename: file.name, status: 'queued', progress: 0 }); | |
| } | |
| setActiveJobs(jobs); | |
| for (let i = 0; i < files.length; i++) { | |
| const file = files[i]; | |
| try { | |
| await PageParseAPI.uploadPage(file, selectedLanguage, schemaType, (updatedJob) => { | |
| setActiveJobs(prev => prev.map(j => j.id === updatedJob.id ? updatedJob : j)); | |
| }); | |
| } catch (err) { | |
| console.error(`Failed to upload ${file.name}:`, err); | |
| } | |
| } | |
| onJobFinished(); | |
| setTimeout(() => setActiveJobs([]), 5000); | |
| }; | |
| const confirmAndProcess = () => { | |
| const canvas = canvasRef.current; | |
| if (!canvas || !imageToConfigure) return; | |
| canvas.toBlob((blob) => { | |
| if (blob) { | |
| const processedFile = new File([blob], imageToConfigure.name, { type: 'image/jpeg' }); | |
| setImageToConfigure(null); | |
| uploadProcessedFile(processedFile); | |
| } | |
| }, 'image/jpeg', 0.9); | |
| }; | |
| const handleDrag = (e: DragEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| if (e.type === "dragenter" || e.type === "dragover") setIsDragActive(true); | |
| else if (e.type === "dragleave") setIsDragActive(false); | |
| }; | |
| const processSelectedFile = async (file: File) => { | |
| if (!file) return; | |
| if (file.type.startsWith('image/')) { | |
| setImageToConfigure(file); | |
| const reader = new FileReader(); | |
| reader.onloadend = () => setFilePreview(reader.result as string); | |
| reader.readAsDataURL(file); | |
| } else { | |
| setFilePreview(null); | |
| uploadProcessedFile(file); | |
| } | |
| }; | |
| const handleDrop = (e: DragEvent) => { | |
| e.preventDefault(); | |
| e.stopPropagation(); | |
| setIsDragActive(false); | |
| if (e.dataTransfer.files && e.dataTransfer.files.length > 0) { | |
| if (e.dataTransfer.files.length > 1) { | |
| uploadBatchFiles(e.dataTransfer.files); | |
| } else { | |
| processSelectedFile(e.dataTransfer.files[0]); | |
| } | |
| } | |
| }; | |
| const handleChange = (e: ChangeEvent<HTMLInputElement>) => { | |
| if (e.target.files && e.target.files[0]) processSelectedFile(e.target.files[0]); | |
| }; | |
| const handleBatchChange = (e: ChangeEvent<HTMLInputElement>) => { | |
| if (e.target.files && e.target.files.length > 0) { | |
| uploadBatchFiles(e.target.files); | |
| } | |
| }; | |
| const handleCameraCapture = (e: ChangeEvent<HTMLInputElement>) => { | |
| if (e.target.files && e.target.files[0]) processSelectedFile(e.target.files[0]); | |
| }; | |
| const getStatusText = (status: string) => { | |
| const map: Record<string, string> = { | |
| queued: 'Queued', | |
| preprocessing: 'OpenCV Preprocessing', | |
| ocr: 'OCR Inference (CPU)', | |
| extracting: 'SLM + GBNF Structuring', | |
| saved: 'Saved to SQLite', | |
| failed: 'Failed', | |
| }; | |
| return map[status] || status; | |
| }; | |
| const schemaOptions = [ | |
| { value: 'auto', label: 'Auto-Detect' }, | |
| { value: 'todo', label: 'Planner / To-Do' }, | |
| { value: 'meeting', label: 'Meeting Minutes' }, | |
| { value: 'recipe', label: 'Recipe Card' }, | |
| { value: 'survey', label: 'Survey Form' }, | |
| { value: 'notes', label: 'General Notes' }, | |
| ]; | |
| return ( | |
| <div className="section"> | |
| <div className="section-header"> | |
| <h3><UploadCloud size={16} className="text-secondary" /> Local Ingestion</h3> | |
| {!currentJob && activeJobs.length === 0 && ( | |
| <div className="flex gap-3 items-center"> | |
| <select value={schemaType} onChange={e => setSchemaType(e.target.value)} className="select"> | |
| {schemaOptions.map(opt => <option key={opt.value} value={opt.value}>{opt.label}</option>)} | |
| </select> | |
| <select value={selectedLanguage} onChange={e => setSelectedLanguage(e.target.value)} className="select"> | |
| {INDIAN_LANGUAGES.map(lang => <option key={lang} value={lang}>{lang}</option>)} | |
| </select> | |
| </div> | |
| )} | |
| </div> | |
| <div className="section-body"> | |
| <input ref={fileInputRef} type="file" className="hidden-file-input" onChange={handleChange} accept="image/png, image/jpeg, image/jpg, application/pdf, audio/*, video/*, text/*" style={{ display: 'none' }} /> | |
| <input ref={batchInputRef} type="file" className="hidden-file-input" multiple onChange={handleBatchChange} accept="image/*,application/pdf,audio/*,video/*" style={{ display: 'none' }} /> | |
| <input ref={cameraInputRef} type="file" accept="image/*" capture="environment" onChange={handleCameraCapture} style={{ display: 'none' }} /> | |
| {activeJobs.length > 0 ? ( | |
| <div className="flex flex-col gap-3"> | |
| <div className="text-xs font-semibold text-tertiary mb-2">Batch Upload ({activeJobs.length} files)</div> | |
| {activeJobs.map(job => ( | |
| <div key={job.id} className="flex items-center gap-3" style={{ padding: '8px 12px', background: 'var(--bg-elevated)', borderRadius: 'var(--radius-sm)' }}> | |
| <FileText size={16} className="text-tertiary" /> | |
| <span className="text-sm flex-1">{job.filename}</span> | |
| <span className="text-xs text-tertiary">{getStatusText(job.status)}</span> | |
| {job.status === 'saved' && <CheckCircle2 size={14} style={{ color: 'var(--success)' }} />} | |
| {job.status === 'failed' && <AlertCircle size={14} style={{ color: 'var(--danger)' }} />} | |
| </div> | |
| ))} | |
| </div> | |
| ) : !currentJob ? ( | |
| imageToConfigure ? ( | |
| <div className="flex flex-col gap-4" style={{ padding: 16, background: 'var(--bg-elevated)', borderRadius: 'var(--radius-md)' }}> | |
| <h4 className="font-semibold text-sm text-secondary" style={{ margin: 0 }}>Adjust Handwritten Sheet</h4> | |
| <div style={{ display: 'grid', gridTemplateColumns: '1.2fr 1fr', gap: 16 }}> | |
| <div style={{ background: 'var(--bg-base)', border: '1px solid var(--border)', borderRadius: 'var(--radius-sm)', padding: 4, maxHeight: 300, display: 'flex', alignItems: 'center', justifyContent: 'center', overflow: 'hidden' }}> | |
| <canvas ref={canvasRef} style={{ maxWidth: '100%', maxHeight: 280, objectFit: 'contain', borderRadius: 4 }} /> | |
| </div> | |
| <div className="flex flex-col gap-3 text-sm"> | |
| <div> | |
| <div className="flex justify-between mb-3"><span>Brightness: {brightness}%</span></div> | |
| <input type="range" min="50" max="150" value={brightness} onChange={e => setBrightness(Number(e.target.value))} style={{ width: '100%' }} /> | |
| </div> | |
| <div> | |
| <div className="flex justify-between mb-3"><span>Contrast: {contrast}%</span></div> | |
| <input type="range" min="50" max="150" value={contrast} onChange={e => setContrast(Number(e.target.value))} style={{ width: '100%' }} /> | |
| </div> | |
| <div> | |
| <div className="flex justify-between mb-3"><span>Rotation: {rotation}°</span></div> | |
| <input type="range" min="-180" max="180" value={rotation} onChange={e => setRotation(Number(e.target.value))} style={{ width: '100%' }} /> | |
| </div> | |
| <label className="flex items-center gap-2" style={{ cursor: 'pointer' }} onClick={e => e.stopPropagation()}> | |
| <input type="checkbox" checked={binarize} onChange={e => setBinarize(e.target.checked)} /> | |
| <span>Binarize Image</span> | |
| </label> | |
| {binarize && ( | |
| <div> | |
| <div className="flex justify-between mb-3"><span>Threshold: {threshold}</span></div> | |
| <input type="range" min="0" max="255" value={threshold} onChange={e => setThreshold(Number(e.target.value))} style={{ width: '100%' }} /> | |
| </div> | |
| )} | |
| <div className="flex gap-3 mt-2" onClick={e => e.stopPropagation()}> | |
| <button className="btn btn-primary" onClick={confirmAndProcess} style={{ flex: 1, justifyContent: 'center' }}>Confirm & Process</button> | |
| <button className="btn" onClick={() => setImageToConfigure(null)} style={{ flex: 1, justifyContent: 'center' }}>Cancel</button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| ) : ( | |
| <div | |
| className={`dropzone ${isDragActive ? 'drag-over' : ''}`} | |
| onDragEnter={handleDrag} onDragOver={handleDrag} | |
| onDragLeave={handleDrag} onDrop={handleDrop} | |
| onClick={() => fileInputRef.current?.click()} | |
| > | |
| <UploadCloud className="dropzone-icon" /> | |
| <div className="dropzone-title">Drop files here</div> | |
| <div className="dropzone-subtitle">Images · Audio · Video · PDF · DOCX · TXT · CSV</div> | |
| <div className="dropzone-actions" onClick={e => e.stopPropagation()}> | |
| <button className="btn btn-primary" onClick={(e) => { e.preventDefault(); fileInputRef.current?.click(); }}>Browse Files</button> | |
| <button className="btn" onClick={(e) => { e.preventDefault(); batchInputRef.current?.click(); }}><Files size={14} /> Batch</button> | |
| <button className="btn" onClick={takePhoto}><Camera size={14} /> Camera</button> | |
| {!isRecording ? ( | |
| <button className="btn" onClick={startRecording}><Mic size={14} /> Record</button> | |
| ) : ( | |
| <button className="btn" onClick={stopRecording} style={{ borderColor: 'var(--danger)', color: 'var(--danger)' }}> | |
| <Square size={14} /> Stop ({Math.floor(recordingDuration / 60)}:{(recordingDuration % 60).toString().padStart(2, '0')}) | |
| </button> | |
| )} | |
| </div> | |
| {isAirgapped && <div className="text-xs text-tertiary" style={{ marginTop: 4 }}>Air-gap mode active — no network calls</div>} | |
| </div> | |
| ) | |
| ) : ( | |
| <div className="upload-progress"> | |
| <div className="flex gap-3 items-center mb-3"> | |
| {filePreview ? <img src={filePreview} alt="" style={{ width: 40, height: 40, borderRadius: 4, objectFit: 'cover', border: '1px solid var(--border)' }} /> | |
| : <FileText size={32} className="text-tertiary" />} | |
| <div> | |
| <div className="font-semibold text-sm">{currentJob.filename}</div> | |
| <div className="text-xs text-tertiary">{getStatusText(currentJob.status)}</div> | |
| </div> | |
| </div> | |
| <div className="pipeline"> | |
| {['queued', 'preprocessing', 'ocr', 'extracting', 'saved'].map((step, i) => { | |
| const stages = ['queued', 'preprocessing', 'ocr', 'extracting', 'saved']; | |
| const idx = stages.indexOf(currentJob.status); | |
| const isActive = i <= idx && currentJob.status !== 'failed'; | |
| const isCurrent = i === idx; | |
| return ( | |
| <div key={step} className={`pipeline-step ${isActive ? 'active' : ''} ${isCurrent ? 'current' : ''}`}> | |
| <div className="pipeline-dot"> | |
| {i < idx || currentJob.status === 'saved' ? <CheckCircle2 size={14} /> : i === idx && currentJob.status === 'failed' ? <AlertCircle size={14} /> : i + 1} | |
| </div> | |
| <span className="pipeline-label">{['Queue', 'OpenCV', 'OCR', 'SLM', 'SQLite'][i]}</span> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| <div className="progress-track"> | |
| <div className="progress-fill" style={{ width: `${currentJob.progress}%` }} /> | |
| </div> | |
| <div className="progress-info"> | |
| <span> | |
| {currentJob.status === 'saved' ? 'Complete' : currentJob.status === 'failed' ? currentJob.error || 'Failed' : 'Processing on CPU...'} | |
| </span> | |
| <span className="font-mono">{currentJob.progress}%</span> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| </div> | |
| ); | |
| }; | |