// app.jsx — top-level state, routing, and API wiring. // Convert a DB upload row to the shape the History component expects. function adaptUpload(row) { const statusMap = { in_library: { label: 'In library', tone: 'ok' }, queued: { label: 'In queue', tone: 'neutral' }, scanning: { label: 'Scanning…', tone: 'neutral' }, infected: { label: 'Rejected', tone: 'danger' }, scan_error: { label: 'Error', tone: 'danger' }, }; const { label, tone } = statusMap[row.status] || { label: row.status, tone: 'neutral' }; const gb = row.size_bytes / (1024 ** 3); const size = gb >= 1 ? gb.toFixed(1) + ' GB' : Math.round(row.size_bytes / (1024 ** 2)) + ' MB'; const ext = (row.filename || '').split('.').pop().toLowerCase(); const title = (row.filename || '').replace(/\.[^.]+$/, '').replace(/[._-]+/g, ' '); const dt = new Date(row.queued_at.replace(' ', 'T')); return { id: row.id, title, runtime: '—', size, format: ext, when: formatWhen(dt), status: label, statusTone: tone, uploader: row.uploader, uploaderUsername: row.uploader, }; } function formatWhen(dt) { const now = new Date(); const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); const yesterday = new Date(+today - 864e5); const day = new Date(dt.getFullYear(), dt.getMonth(), dt.getDate()); const hh = String(dt.getHours()).padStart(2, '0'); const mm = String(dt.getMinutes()).padStart(2, '0'); const t = `${hh}:${mm}`; if (+day >= +today) return `Today · ${t}`; if (+day >= +yesterday) return `Yesterday · ${t}`; const months = ['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec']; return `${dt.getDate()} ${months[dt.getMonth()]} · ${t}`; } function App() { // Auth + view const [user, setUser] = React.useState(null); const [view, setView] = React.useState('upload'); // Upload state: idle | dragover | uploading | success | rejected | error const [upState, setUpState] = React.useState('idle'); const [file, setFile] = React.useState(null); const [progress, setProgress] = React.useState(0); const [scanPhase, setScanPhase] = React.useState('idle'); const [readyAt, setReadyAt] = React.useState(''); // History — fetched from /api/uploads const [history, setHistory] = React.useState([]); // Follow OS dark/light: dark → Arthouse, light → Archive (default). const [osDark, setOsDark] = React.useState( () => typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-color-scheme: dark)').matches, ); React.useEffect(() => { const mq = window.matchMedia?.('(prefers-color-scheme: dark)'); if (!mq) return; const onChange = (e) => setOsDark(e.matches); mq.addEventListener?.('change', onChange); return () => mq.removeEventListener?.('change', onChange); }, []); const effectivePalette = osDark ? 'arthouse' : 'archive'; const rootRef = React.useRef(null); React.useEffect(() => { if (rootRef.current) applyPalette(rootRef.current, effectivePalette); }, [effectivePalette]); // On mount: restore session and load history. React.useEffect(() => { fetch('/api/me') .then(r => r.ok ? r.json() : null) .then(u => { if (u) setUser(u); }); fetchHistory(); }, []); const fetchHistory = () => { fetch('/api/uploads') .then(r => r.ok ? r.json() : []) .then(rows => setHistory(rows.map(adaptUpload))); }; // Document-level drag guards — stop browser navigating away on a miss. React.useEffect(() => { const stop = (e) => { e.preventDefault(); e.stopPropagation(); }; document.addEventListener('dragover', stop); document.addEventListener('drop', stop); return () => { document.removeEventListener('dragover', stop); document.removeEventListener('drop', stop); }; }, []); const startUpload = (f) => { setFile(f); setUpState('uploading'); setProgress(0); setScanPhase('idle'); const xhr = new XMLHttpRequest(); xhr.upload.onprogress = (e) => { if (e.lengthComputable) setProgress((e.loaded / e.total) * 100); }; xhr.onload = () => { if (xhr.status === 202) { setProgress(100); setScanPhase('scanning'); pollUntilDone(JSON.parse(xhr.responseText).id); } else if (xhr.status === 403) { try { if (JSON.parse(xhr.responseText).error === 'quota_exceeded') { setUpState('quota'); return; } } catch {} setUpState('error'); } else { setUpState('error'); } }; xhr.onerror = () => setUpState('error'); xhr.open('POST', '/api/upload'); const fd = new FormData(); fd.append('file', f); xhr.send(fd); }; const pollUntilDone = async (id) => { while (true) { await new Promise(r => setTimeout(r, 3000)); let res; try { res = await fetch(`/api/uploads/${id}`).then(r => r.json()); } catch { continue; } if (res.status === 'in_library') { setScanPhase('finalizing'); setTimeout(() => { const ready = new Date(Date.now() + 60_000); const hh = ready.getHours(); const mm = String(ready.getMinutes()).padStart(2, '0'); const ampm = hh >= 12 ? 'pm' : 'am'; const hh12 = ((hh + 11) % 12) + 1; setReadyAt(`${hh12}:${mm} ${ampm}`); fetchHistory(); setUpState('success'); }, 800); return; } else if (res.status === 'infected') { setUpState('rejected'); return; } else if (res.status === 'scan_error') { setUpState('error'); return; } // still queued or scanning — keep polling } }; const onPickFile = (f) => startUpload(f); const onDragOver = (e) => { e.preventDefault(); if (upState === 'idle') setUpState('dragover'); }; const onDragLeave = (e) => { e.preventDefault(); if (upState === 'dragover') { const r = e.currentTarget.getBoundingClientRect(); if (e.clientX <= r.left || e.clientX >= r.right || e.clientY <= r.top || e.clientY >= r.bottom) { setUpState('idle'); } } }; const onDrop = (e) => { e.preventDefault(); if (upState !== 'idle' && upState !== 'dragover') return; const f = e.dataTransfer?.files?.[0]; if (f) startUpload(f); else setUpState('idle'); }; const onReset = () => { setUpState('idle'); setFile(null); setProgress(0); setScanPhase('idle'); setReadyAt(''); }; const onSignOut = async () => { await fetch('/api/logout', { method: 'POST' }).catch(() => {}); setUser(null); setView('upload'); onReset(); }; const paletteName = window.PALETTES?.[effectivePalette]?.name || effectivePalette; return (
{!user ? ( ) : ( <>
{view === 'upload' && (
)} {view === 'history' && ( )} {view === 'admin' && user.isAdmin && ( )}
); } ReactDOM.createRoot(document.getElementById('root')).render();