Spaces:
Build error
Build error
| import { useEffect, useState, useCallback } from 'react'; | |
| import { PageParseAPI, getAirgapMode, setAirgapMode, subscribeToJobs, API_BASE_URL } from './services/api'; | |
| import type { Task, PageRecord } from './services/api'; | |
| import { FileUploader } from './components/FileUploader'; | |
| import { CodeEditor } from './components/CodeEditor'; | |
| import { TaskList } from './components/TaskList'; | |
| import { TelemetryPanel } from './components/TelemetryPanel'; | |
| import { PageHistory } from './components/PageHistory'; | |
| import { PageDetails } from './components/PageDetails'; | |
| import { ChatPanel } from './components/ChatPanel'; | |
| import { | |
| ScanFace, Upload, Database, History, MessageSquare, Code, Cpu, | |
| Wifi, WifiOff, Sun, Moon, Settings | |
| } from 'lucide-react'; | |
| type View = 'upload' | 'tasks' | 'history' | 'chat' | 'code'; | |
| function App() { | |
| const [tasks, setTasks] = useState<Task[]>([]); | |
| const [pages, setPages] = useState<PageRecord[]>([]); | |
| const [isAirgapped, setIsAirgapped] = useState(getAirgapMode()); | |
| const [serverOnline, setServerOnline] = useState(false); | |
| const [isProcessing, setIsProcessing] = useState(false); | |
| const [selectedPage, setSelectedPage] = useState<PageRecord | null>(null); | |
| const [activeView, setActiveView] = useState<View>('upload'); | |
| const [isDarkMode, setIsDarkMode] = useState(() => { | |
| const saved = localStorage.getItem('pageparse_theme'); | |
| if (saved) return saved === 'dark'; | |
| return window.matchMedia('(prefers-color-scheme: dark)').matches; | |
| }); | |
| const [showSettings, setShowSettings] = useState(false); | |
| const [webhookUrl, setWebhookUrl] = useState(''); | |
| const [webhookConfigured, setWebhookConfigured] = useState(false); | |
| const loadData = useCallback(async () => { | |
| try { | |
| const [allTasks, allPages] = await Promise.all([ | |
| PageParseAPI.getTasks(), | |
| PageParseAPI.getPages(), | |
| ]); | |
| setTasks(allTasks); | |
| setPages(allPages); | |
| if (selectedPage) { | |
| const updated = allPages.find(p => p.id === selectedPage.id); | |
| if (updated) setSelectedPage(updated); | |
| } | |
| } catch (err) { | |
| console.error('Error loading data:', err); | |
| } | |
| }, [selectedPage]); | |
| const checkConnectivity = useCallback(async () => { | |
| if (isAirgapped) { setServerOnline(false); return; } | |
| setServerOnline(await PageParseAPI.checkServerConnection()); | |
| }, [isAirgapped]); | |
| useEffect(() => { | |
| const unsub = subscribeToJobs((jobs) => { | |
| const processing = jobs.some(j => j.status !== 'saved' && j.status !== 'failed'); | |
| setIsProcessing(processing); | |
| if (jobs.some(j => j.status === 'saved')) loadData(); | |
| }); | |
| return () => unsub(); | |
| }, [loadData]); | |
| useEffect(() => { | |
| checkConnectivity(); | |
| loadData(); | |
| const interval = setInterval(() => { if (!isAirgapped) checkConnectivity(); }, 10000); | |
| return () => clearInterval(interval); | |
| }, [isAirgapped, checkConnectivity, loadData]); | |
| useEffect(() => { | |
| document.documentElement.setAttribute('data-theme', isDarkMode ? 'dark' : 'light'); | |
| localStorage.setItem('pageparse_theme', isDarkMode ? 'dark' : 'light'); | |
| }, [isDarkMode]); | |
| useEffect(() => { | |
| const handleKeyDown = (e: KeyboardEvent) => { | |
| if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement || e.target instanceof HTMLSelectElement) return; | |
| if (e.ctrlKey || e.metaKey) return; | |
| switch (e.key) { | |
| case '1': setActiveView('upload'); break; | |
| case '2': setActiveView('tasks'); break; | |
| case '3': setActiveView('history'); break; | |
| case '4': setActiveView('chat'); break; | |
| case '5': setActiveView('code'); break; | |
| case 't': case 'T': setIsDarkMode(prev => !prev); break; | |
| case '?': setShowSettings(prev => !prev); break; | |
| case 'Escape': setShowSettings(false); break; | |
| } | |
| }; | |
| window.addEventListener('keydown', handleKeyDown); | |
| return () => window.removeEventListener('keydown', handleKeyDown); | |
| }, []); | |
| useEffect(() => { | |
| if ('serviceWorker' in navigator) { | |
| navigator.serviceWorker.register('/static/sw.js').catch(() => {}); | |
| } | |
| }, []); | |
| const handleAirgapChange = () => { | |
| const newVal = !isAirgapped; | |
| setIsAirgapped(newVal); | |
| setAirgapMode(newVal); | |
| }; | |
| const handleWebhookConfig = async () => { | |
| try { | |
| await fetch(`${API_BASE_URL}/webhook`, { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'application/json' }, | |
| body: JSON.stringify({ url: webhookUrl, secret: '' }), | |
| }); | |
| setWebhookConfigured(true); | |
| setTimeout(() => setShowSettings(false), 1500); | |
| } catch (err) { | |
| console.error(err); | |
| } | |
| }; | |
| const navItems: { view: View; icon: typeof Upload; label: string; shortcut: string }[] = [ | |
| { view: 'upload', icon: Upload, label: 'Ingest', shortcut: '1' }, | |
| { view: 'tasks', icon: Database, label: 'Records', shortcut: '2' }, | |
| { view: 'history', icon: History, label: 'History', shortcut: '3' }, | |
| { view: 'chat', icon: MessageSquare, label: 'AI Chat', shortcut: '4' }, | |
| { view: 'code', icon: Code, label: 'Code', shortcut: '5' }, | |
| ]; | |
| if (selectedPage) { | |
| return ( | |
| <PageDetails | |
| page={selectedPage} | |
| tasks={tasks} | |
| onBack={() => setSelectedPage(null)} | |
| onPageChanged={loadData} | |
| /> | |
| ); | |
| } | |
| return ( | |
| <div className="app-shell"> | |
| {/* SIDEBAR */} | |
| <aside className="sidebar"> | |
| <div className="sidebar-brand" onClick={() => setActiveView('upload')}> | |
| <ScanFace /> | |
| <h1>PageParse</h1> | |
| </div> | |
| {navItems.map(item => ( | |
| <button | |
| key={item.view} | |
| className={`nav-item ${activeView === item.view ? 'active' : ''}`} | |
| onClick={() => setActiveView(item.view)} | |
| title={`${item.label} (${item.shortcut})`} | |
| > | |
| <item.icon /> | |
| <span>{item.label}</span> | |
| <span className="shortcut-hint">{item.shortcut}</span> | |
| </button> | |
| ))} | |
| <div className="sidebar-spacer" /> | |
| <div className="sidebar-footer"> | |
| <button className="nav-item" onClick={() => setIsDarkMode(prev => !prev)} title="Toggle theme (T)"> | |
| {isDarkMode ? <Sun size={18} /> : <Moon size={18} />} | |
| <span>{isDarkMode ? 'Light Mode' : 'Dark Mode'}</span> | |
| </button> | |
| <button className="nav-item" onClick={() => setShowSettings(true)} title="Settings (?)"> | |
| <Settings size={18} /> | |
| <span>Settings</span> | |
| </button> | |
| <div className="switch-row"> | |
| <span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>Air-gap</span> | |
| <label className="switch"> | |
| <input type="checkbox" checked={isAirgapped} onChange={handleAirgapChange} /> | |
| <span className="slider" /> | |
| </label> | |
| </div> | |
| <div className={`connection-badge ${serverOnline ? 'online' : 'offline'}`}> | |
| {serverOnline ? <Wifi size={14} /> : <WifiOff size={14} />} | |
| <span>{isAirgapped ? 'Air-gapped' : serverOnline ? 'Online' : 'Offline'}</span> | |
| </div> | |
| <div className="text-xs text-tertiary" style={{ textAlign: 'center' }}> | |
| ? for keyboard shortcuts | |
| </div> | |
| </div> | |
| </aside> | |
| {/* MAIN */} | |
| <main className="main-content"> | |
| <div className="page-header"> | |
| <h2> | |
| {activeView === 'upload' && 'Ingestion Portal'} | |
| {activeView === 'tasks' && `Records (${tasks.length})`} | |
| {activeView === 'history' && `History (${pages.length})`} | |
| {activeView === 'chat' && 'AI Assistant'} | |
| {activeView === 'code' && 'Code Paste & Analyze'} | |
| </h2> | |
| <div className="header-actions"> | |
| <div className="switch-row"> | |
| <Cpu size={16} className={isProcessing ? 'animate-pulse' : ''} style={{ color: isProcessing ? 'var(--purple)' : 'var(--text-tertiary)' }} /> | |
| <span className="text-xs text-tertiary">{isProcessing ? 'Inference Active' : 'Idle'}</span> | |
| </div> | |
| </div> | |
| </div> | |
| {activeView === 'upload' && ( | |
| <div style={{ display: 'flex', flexDirection: 'column', gap: 24 }}> | |
| <FileUploader | |
| onJobStarted={() => setIsProcessing(true)} | |
| onJobFinished={() => { setTimeout(loadData, 500); }} | |
| isAirgapped={isAirgapped} | |
| /> | |
| <TelemetryPanel isProcessing={isProcessing} /> | |
| <TaskList tasks={tasks} onTasksChanged={loadData} variant="compact" /> | |
| </div> | |
| )} | |
| {activeView === 'tasks' && ( | |
| <TaskList tasks={tasks} onTasksChanged={loadData} /> | |
| )} | |
| {activeView === 'history' && ( | |
| <PageHistory | |
| pages={pages} | |
| tasks={tasks} | |
| onPagesChanged={loadData} | |
| onSelectPage={setSelectedPage} | |
| /> | |
| )} | |
| {activeView === 'chat' && ( | |
| <div className="section" style={{ maxWidth: 720 }}> | |
| <div className="section-header"> | |
| <h3><MessageSquare size={16} className="text-secondary" /> PageParse AI Chat</h3> | |
| </div> | |
| <div className="section-body"> | |
| <ChatPanel /> | |
| </div> | |
| </div> | |
| )} | |
| {activeView === 'code' && ( | |
| <CodeEditor /> | |
| )} | |
| </main> | |
| {/* SETTINGS MODAL */} | |
| {showSettings && ( | |
| <div className="modal-overlay" onClick={() => setShowSettings(false)}> | |
| <div className="modal" style={{ maxWidth: 500 }} onClick={e => e.stopPropagation()}> | |
| <div className="modal-header"> | |
| <h3><Settings size={16} /> Settings</h3> | |
| </div> | |
| <div className="modal-body" style={{ gridTemplateColumns: '1fr' }}> | |
| <div className="flex flex-col gap-4"> | |
| <div> | |
| <label className="text-xs font-semibold text-tertiary" style={{ textTransform: 'uppercase', letterSpacing: '0.5px' }}>Webhook URL</label> | |
| <div className="flex gap-2 mt-2"> | |
| <input className="input" value={webhookUrl} onChange={e => setWebhookUrl(e.target.value)} placeholder="https://hooks.example.com/pageparse" /> | |
| <button className="btn btn-sm btn-primary" onClick={handleWebhookConfig}>Save</button> | |
| </div> | |
| {webhookConfigured && <div className="text-xs text-success mt-1">Webhook configured!</div>} | |
| </div> | |
| <div> | |
| <label className="text-xs font-semibold text-tertiary" style={{ textTransform: 'uppercase', letterSpacing: '0.5px' }}>Keyboard Shortcuts</label> | |
| <div className="flex flex-col gap-1 mt-2 text-sm"> | |
| <div className="flex justify-between"><span>Ingest view</span><span className="font-mono text-tertiary">1</span></div> | |
| <div className="flex justify-between"><span>Records view</span><span className="font-mono text-tertiary">2</span></div> | |
| <div className="flex justify-between"><span>History view</span><span className="font-mono text-tertiary">3</span></div> | |
| <div className="flex justify-between"><span>AI Chat view</span><span className="font-mono text-tertiary">4</span></div> | |
| <div className="flex justify-between"><span>Code view</span><span className="font-mono text-tertiary">5</span></div> | |
| <div className="flex justify-between"><span>Toggle theme</span><span className="font-mono text-tertiary">T</span></div> | |
| <div className="flex justify-between"><span>Settings</span><span className="font-mono text-tertiary">?</span></div> | |
| <div className="flex justify-between"><span>Close modal</span><span className="font-mono text-tertiary">Esc</span></div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| )} | |
| </div> | |
| ); | |
| } | |
| export default App; | |