Spaces:
Sleeping
Sleeping
| import { useState, useRef, useCallback } from 'react' | |
| const MAX_FILE_SIZE_MB = 30 | |
| const MAX_FILE_SIZE_BYTES = MAX_FILE_SIZE_MB * 1024 * 1024 | |
| function formatSize(bytes) { | |
| if (!Number.isFinite(bytes) || bytes <= 0) return '0 B' | |
| const units = ['B', 'KB', 'MB', 'GB'] | |
| let value = bytes | |
| let idx = 0 | |
| while (value >= 1024 && idx < units.length - 1) { | |
| value /= 1024 | |
| idx += 1 | |
| } | |
| return `${value.toFixed(value >= 10 || idx === 0 ? 0 : 1)} ${units[idx]}` | |
| } | |
| export default function UploadView({ onUpload, jobs, onOpenJob, onRetryJob }) { | |
| const [dragOver, setDragOver] = useState(false) | |
| const [isUploading, setIsUploading] = useState(false) | |
| const [validationErrors, setValidationErrors] = useState([]) | |
| const [lastSelection, setLastSelection] = useState({ accepted: 0, rejected: 0 }) | |
| const fileRef = useRef() | |
| const liveRegionRef = useRef() | |
| const announce = useCallback((message) => { | |
| if (!liveRegionRef.current) return | |
| liveRegionRef.current.textContent = '' | |
| window.requestAnimationFrame(() => { | |
| if (liveRegionRef.current) { | |
| liveRegionRef.current.textContent = message | |
| } | |
| }) | |
| }, []) | |
| const handleFiles = useCallback(async (files) => { | |
| const accepted = [] | |
| const rejected = [] | |
| for (const file of Array.from(files || [])) { | |
| const validType = /\.(jpe?g|png|bmp|tiff?|pdf)$/i.test(file.name) | |
| if (!validType) { | |
| rejected.push(`${file.name}: unsupported file type`) | |
| continue | |
| } | |
| if (file.size > MAX_FILE_SIZE_BYTES) { | |
| rejected.push(`${file.name}: exceeds ${MAX_FILE_SIZE_MB} MB`) | |
| continue | |
| } | |
| accepted.push(file) | |
| } | |
| setValidationErrors(rejected) | |
| setLastSelection({ accepted: accepted.length, rejected: rejected.length }) | |
| if (!accepted.length) { | |
| announce('No valid files selected.') | |
| return | |
| } | |
| setIsUploading(true) | |
| announce(`Uploading ${accepted.length} file${accepted.length > 1 ? 's' : ''}.`) | |
| try { | |
| await onUpload(accepted) | |
| announce(`${accepted.length} file${accepted.length > 1 ? 's' : ''} queued for processing.`) | |
| } finally { | |
| setIsUploading(false) | |
| } | |
| }, [onUpload]) | |
| const onDrop = useCallback((e) => { | |
| e.preventDefault() | |
| setDragOver(false) | |
| void handleFiles(e.dataTransfer.files) | |
| }, [handleFiles]) | |
| const doneJobs = jobs.filter(j => j.status === 'done') | |
| const activeJobs = jobs.filter(j => j.status !== 'done').slice(0, 8) | |
| return ( | |
| <> | |
| <section className="upload-section"> | |
| <span className="sr-only" ref={liveRegionRef} aria-live="polite" aria-atomic="true" /> | |
| <div className="upload-hero"> | |
| <h1> | |
| Extract Tables with <span className="gradient-text">AI Precision</span> | |
| </h1> | |
| <p> | |
| Upload document images or PDFs and let our 5-phase pipeline detect tables, | |
| recognize structure, perform OCR, and extract editable data — all in seconds. | |
| </p> | |
| </div> | |
| <div | |
| className={`upload-zone glass${dragOver ? ' drag-over' : ''}${isUploading ? ' is-uploading' : ''}`} | |
| role="button" | |
| tabIndex={0} | |
| aria-label="Upload documents" | |
| onDragOver={(e) => { e.preventDefault(); setDragOver(true) }} | |
| onDragLeave={() => setDragOver(false)} | |
| onDrop={onDrop} | |
| onClick={() => fileRef.current?.click()} | |
| onKeyDown={(e) => { | |
| if (e.key === 'Enter' || e.key === ' ') { | |
| e.preventDefault() | |
| fileRef.current?.click() | |
| } | |
| }} | |
| > | |
| <div className="upload-zone-icon">📄</div> | |
| <h3>{isUploading ? 'Uploading...' : 'Drop files here or click to browse'}</h3> | |
| <p>Supports JPG, PNG, BMP, TIFF, and PDF up to {MAX_FILE_SIZE_MB} MB each</p> | |
| <input | |
| ref={fileRef} | |
| type="file" | |
| accept=".jpg,.jpeg,.png,.bmp,.tiff,.tif,.pdf" | |
| multiple | |
| onChange={(e) => { void handleFiles(e.target.files) }} | |
| /> | |
| <div className="upload-meta" aria-live="polite"> | |
| <span>{lastSelection.accepted} accepted</span> | |
| <span>{lastSelection.rejected} rejected</span> | |
| <span>{jobs.filter((job) => job.status === 'processing' || job.status === 'queued').length} active</span> | |
| </div> | |
| </div> | |
| {validationErrors.length > 0 && ( | |
| <div className="upload-errors" role="alert"> | |
| {validationErrors.slice(0, 4).map((error) => ( | |
| <div key={error} className="upload-error-item">{error}</div> | |
| ))} | |
| </div> | |
| )} | |
| <div className="pipeline-steps"> | |
| {[ | |
| ['1', 'Table Detection'], | |
| ['2', 'Structure Recognition'], | |
| ['3', 'Text Detection'], | |
| ['4', 'OCR Recognition'], | |
| ['5', 'Cell Assignment'], | |
| ].map(([n, label]) => ( | |
| <div className="pipeline-step" key={n}> | |
| <span className="step-num">{n}</span> | |
| {label} | |
| </div> | |
| ))} | |
| </div> | |
| <section className="upload-guide glass" aria-label="User guide"> | |
| <h3>User Guide</h3> | |
| <div className="upload-guide-grid"> | |
| <div className="upload-guide-item"> | |
| <strong>1. Upload files</strong> | |
| <p>Drag and drop or browse to select one or many images/PDFs.</p> | |
| </div> | |
| <div className="upload-guide-item"> | |
| <strong>2. Wait for processing</strong> | |
| <p>Track queued and running jobs in the Upload Queue panel.</p> | |
| </div> | |
| <div className="upload-guide-item"> | |
| <strong>3. Review results</strong> | |
| <p>Open any completed job to edit table structure and cell text.</p> | |
| </div> | |
| <div className="upload-guide-item"> | |
| <strong>4. Export data</strong> | |
| <p>Export cleaned tables to CSV, Excel, or HTML from the studio.</p> | |
| </div> | |
| </div> | |
| </section> | |
| {!!activeJobs.length && ( | |
| <div className="upload-queue glass"> | |
| <div className="upload-queue-header"> | |
| <h3>Upload Queue</h3> | |
| <span>{activeJobs.length} active</span> | |
| </div> | |
| <div className="upload-queue-list"> | |
| {activeJobs.map((job) => ( | |
| <div key={job.id} className="upload-queue-item"> | |
| <div> | |
| <div className="upload-queue-name">{job.filename}</div> | |
| <div className="upload-queue-meta">{job.status}</div> | |
| </div> | |
| <div className="upload-queue-actions"> | |
| {job.status === 'error' && ( | |
| <button type="button" className="btn btn-sm" onClick={() => onRetryJob?.(job)}> | |
| Retry | |
| </button> | |
| )} | |
| {job.status === 'processing' && <span className="badge">Running</span>} | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </div> | |
| )} | |
| </section> | |
| {doneJobs.length > 0 && ( | |
| <section className="jobs-section"> | |
| <div className="jobs-header"> | |
| <h2>Processed Files</h2> | |
| <span style={{ color: 'var(--text-muted)', fontSize: 14 }}> | |
| {doneJobs.length} file{doneJobs.length !== 1 ? 's' : ''} | |
| </span> | |
| </div> | |
| <div className="jobs-grid"> | |
| {doneJobs.map(job => ( | |
| <div key={job.id} className="job-card" onClick={() => onOpenJob(job)}> | |
| <img | |
| className="job-card-thumb" | |
| src={job.imageUrl} | |
| alt={job.filename} | |
| loading="lazy" | |
| /> | |
| <div className="job-card-body"> | |
| <div> | |
| <div className="job-card-name">{job.filename}</div> | |
| <div className="job-card-meta"> | |
| {job.annotation?.tables?.length || 0} table(s) • {job.duration}s • {formatSize(job.size_bytes)} | |
| </div> | |
| </div> | |
| <span className="badge badge-done">Done</span> | |
| </div> | |
| </div> | |
| ))} | |
| </div> | |
| </section> | |
| )} | |
| </> | |
| ) | |
| } | |