Spaces:
Sleeping
Sleeping
| import { useCallback, useEffect, useRef, useState } from 'react' | |
| import './index.css' | |
| import UploadView from './components/UploadView' | |
| import ProcessingView from './components/ProcessingView' | |
| import ResultsView from './components/ResultsView' | |
| import HealthDashboard from './components/HealthDashboard' | |
| import LandingPage from './components/LandingPage' | |
| import API from 'api' | |
| function hasSeenLanding() { | |
| try { | |
| return window.sessionStorage.getItem('landingSeen') === '1' | |
| } catch { | |
| return false | |
| } | |
| } | |
| function markLandingSeen() { | |
| try { | |
| window.sessionStorage.setItem('landingSeen', '1') | |
| } catch { | |
| // Ignore storage failures (private mode / blocked storage). | |
| } | |
| } | |
| export default function App() { | |
| const [showLanding, setShowLanding] = useState(() => !hasSeenLanding()) | |
| const [tool, setTool] = useState('extractor') | |
| const [view, setView] = useState('upload') | |
| const [jobs, setJobs] = useState([]) | |
| const [activeJob, setActiveJob] = useState(null) | |
| const pollersRef = useRef(new Map()) | |
| const updateJob = useCallback((jobId, updates) => { | |
| setJobs((currentJobs) => currentJobs.map((job) => ( | |
| job.id === jobId ? { ...job, ...updates } : job | |
| ))) | |
| setActiveJob((currentJob) => ( | |
| currentJob?.id === jobId ? { ...currentJob, ...updates } : currentJob | |
| )) | |
| }, []) | |
| const stopPolling = useCallback((jobId) => { | |
| const poller = pollersRef.current.get(jobId) | |
| if (poller) { | |
| window.clearInterval(poller) | |
| pollersRef.current.delete(jobId) | |
| } | |
| }, []) | |
| const pollJob = useCallback((jobId) => { | |
| if (pollersRef.current.has(jobId)) { | |
| return | |
| } | |
| const interval = window.setInterval(async () => { | |
| try { | |
| const statusResponse = await fetch(`${API}/api/status/${jobId}`) | |
| const statusData = await statusResponse.json() | |
| if (statusData.status === 'done') { | |
| stopPolling(jobId) | |
| const resultsResponse = await fetch(`${API}/api/results/${jobId}`) | |
| const resultsData = await resultsResponse.json() | |
| updateJob(jobId, { | |
| status: 'done', | |
| annotation: resultsData.annotation, | |
| duration: resultsData.duration, | |
| }) | |
| setActiveJob((currentJob) => { | |
| if (currentJob?.id === jobId) { | |
| setView('results') | |
| return { | |
| ...currentJob, | |
| status: 'done', | |
| annotation: resultsData.annotation, | |
| duration: resultsData.duration, | |
| } | |
| } | |
| return currentJob | |
| }) | |
| } else if (statusData.status === 'error') { | |
| stopPolling(jobId) | |
| updateJob(jobId, { | |
| status: 'error', | |
| error: statusData.error, | |
| }) | |
| } | |
| } catch (error) { | |
| console.error('Polling failed', error) | |
| } | |
| }, 1000) | |
| pollersRef.current.set(jobId, interval) | |
| }, [stopPolling, updateJob]) | |
| useEffect(() => () => { | |
| for (const poller of pollersRef.current.values()) { | |
| window.clearInterval(poller) | |
| } | |
| pollersRef.current.clear() | |
| }, []) | |
| const handleUpload = useCallback(async (files) => { | |
| const newJobs = [] | |
| for (const file of files) { | |
| const formData = new FormData() | |
| formData.append('file', file) | |
| try { | |
| const response = await fetch(`${API}/api/upload-and-process`, { | |
| method: 'POST', | |
| body: formData, | |
| }) | |
| const data = await response.json() | |
| if (!response.ok || !data?.job_id) { | |
| console.error('Upload failed', data) | |
| continue | |
| } | |
| newJobs.push({ | |
| id: data.job_id, | |
| filename: data.filename, | |
| status: data.status || 'queued', | |
| annotation: null, | |
| duration: null, | |
| imageUrl: `${API}/api/image/${data.job_id}`, | |
| size_bytes: file.size, | |
| }) | |
| } catch (error) { | |
| console.error('Upload failed', error) | |
| } | |
| } | |
| if (!newJobs.length) { | |
| return | |
| } | |
| setJobs((currentJobs) => [...newJobs, ...currentJobs]) | |
| setActiveJob(newJobs[0]) | |
| setView('processing') | |
| newJobs.forEach((job) => pollJob(job.id)) | |
| }, [pollJob]) | |
| const handleRetryJob = useCallback(async (job) => { | |
| if (!job?.id) return | |
| try { | |
| const response = await fetch(`${API}/api/process/${job.id}`, { | |
| method: 'POST', | |
| }) | |
| if (!response.ok) { | |
| throw new Error(`Retry failed with status ${response.status}`) | |
| } | |
| updateJob(job.id, { status: 'queued', error: null }) | |
| pollJob(job.id) | |
| } catch (error) { | |
| console.error('Retry failed', error) | |
| } | |
| }, [pollJob, updateJob]) | |
| const handleOpenJob = useCallback((job) => { | |
| setTool('extractor') | |
| setActiveJob(job) | |
| if (job.status === 'done') { | |
| setView('results') | |
| return | |
| } | |
| setView('processing') | |
| pollJob(job.id) | |
| }, [pollJob]) | |
| const handleGoHome = useCallback(() => { | |
| setView('upload') | |
| setActiveJob(null) | |
| }, []) | |
| const handleDismissLanding = useCallback(() => { | |
| markLandingSeen() | |
| setShowLanding(false) | |
| }, []) | |
| const showStudio = tool === 'extractor' && view === 'results' && activeJob | |
| const processedCount = jobs.filter((job) => job.status === 'done').length | |
| return ( | |
| <> | |
| {showLanding && <LandingPage onDismiss={handleDismissLanding} />} | |
| <div className={`app-shell${showStudio ? ' is-studio' : ''}`}> | |
| {!showStudio && ( | |
| <header className="app-header"> | |
| <button | |
| type="button" | |
| className="app-brand" | |
| onClick={() => { | |
| setTool('extractor') | |
| handleGoHome() | |
| }} | |
| > | |
| <div className="app-brand-mark"> | |
| <svg | |
| className="fedora-boomerang" | |
| viewBox="0 0 24 24" | |
| width="24" | |
| height="24" | |
| fill="currentColor" | |
| > | |
| <path d="M2,16C2,16 5,14 12,14C19,14 22,16 22,16V17H2V16M12,5C8,5 6,8 6,10H18C18,8 16,5 12,5Z" /> | |
| </svg> | |
| </div> | |
| <span className="app-brand-copy"> | |
| <strong>Agent P-DF</strong> | |
| <small>AI Table Extraction Studio</small> | |
| </span> | |
| </button> | |
| <nav className="app-header-nav"> | |
| <button | |
| type="button" | |
| className={`btn btn-sm${tool === 'extractor' ? ' btn-primary' : ''}`} | |
| onClick={() => setTool('extractor')} | |
| > | |
| Extractor | |
| </button> | |
| <button | |
| type="button" | |
| className={`btn btn-sm${tool === 'health' ? ' btn-primary' : ''}`} | |
| onClick={() => setTool('health')} | |
| > | |
| System Health | |
| </button> | |
| {tool === 'extractor' && view !== 'upload' && ( | |
| <button type="button" className="btn btn-sm" onClick={handleGoHome}> | |
| New Upload | |
| </button> | |
| )} | |
| {processedCount > 0 && ( | |
| <button type="button" className="btn btn-sm btn-ghost"> | |
| {processedCount} processed | |
| </button> | |
| )} | |
| </nav> | |
| </header> | |
| )} | |
| {tool === 'health' ? ( | |
| <HealthDashboard /> | |
| ) : ( | |
| <> | |
| {view === 'upload' && ( | |
| <UploadView | |
| onUpload={handleUpload} | |
| jobs={jobs} | |
| onOpenJob={handleOpenJob} | |
| onRetryJob={handleRetryJob} | |
| /> | |
| )} | |
| {view === 'processing' && activeJob && ( | |
| <ProcessingView job={activeJob} onBack={handleGoHome} /> | |
| )} | |
| {view === 'results' && activeJob && ( | |
| <ResultsView | |
| job={activeJob} | |
| onBack={handleGoHome} | |
| onJobUpdate={updateJob} | |
| /> | |
| )} | |
| </> | |
| )} | |
| </div> | |
| </> | |
| ) | |
| } | |