pageparse.ai / frontend /src /components /FileUploader.tsx
Varun2007's picture
initial clean deployment commit with compilers
8c3e275
Raw
History Blame Contribute Delete
18.2 kB
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>
);
};