// screens.jsx — Login, Upload (with idle/dragover/uploading/success), History, Admin.
// ── Login ───────────────────────────────────────────────────────────────────
function Login({ onAuthed }) {
const [un, setUn] = React.useState('');
const [pw, setPw] = React.useState('');
const [err, setErr] = React.useState('');
const unRef = React.useRef(null);
React.useEffect(() => { unRef.current?.focus(); }, []);
const submit = async (e) => {
e.preventDefault();
const r = await fetch('/api/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username: un.trim().toLowerCase(), passphrase: pw }),
});
if (r.ok) {
onAuthed(await r.json());
} else {
const body = await r.json().catch(() => ({}));
setErr(body.error || "Those details don't match. Have another go.");
}
};
return (
);
}
// ── Drop zone (the projection screen) ───────────────────────────────────────
// One element drives idle / dragover / uploading / success states via a
// data-state attribute. The visual difference is the screen's brightness +
// the inner content; surface chrome stays put.
function DropZone({ state, file, progress, scanPhase, readyAt, onPickFile,
onDragOver, onDragLeave, onDrop, onReset }) {
const fileInputRef = React.useRef(null);
const pickHandler = () => fileInputRef.current?.click();
return (
File upload · ready
{state === 'success' ? 'Received.' :
state === 'uploading' ? 'Receiving your file' :
state === 'dragover' ? 'Release to receive' :
state === 'rejected' ? 'File stopped.' :
state === 'error' ? "That didn't go through." :
state === 'quota' ? 'Upload limit reached.' :
'Upload your file here.'}
{state === 'success' ? (
<>It's in the library now. {readyAt && <>It'll be playable in about
five minutes — by {readyAt} .>}>
) : state === 'uploading' ? (
<>Sit tight. We're moving it across, scanning it, and tucking it into
the library. You don't need to wait on this screen.>
) : state === 'dragover' ? (
<>Let it go. We'll do the rest.>
) : state === 'rejected' ? (
<>Something on that file tripped the scanner, so we stopped it from being
published. Hunter's been notified — he'll take a look.
You haven't done anything wrong.>
) : state === 'error' ? (
<>Connection dropped before we finished. Give it another go when you're ready.>
) : state === 'quota' ? (
<>You've used your share of the library. Ask Hunter and he'll clear the counter
— your files are all still safe.>
) : (
<>Drag a file from your USB stick or desktop onto the screen below.
One at a time is plenty.>
)}
{(state === 'idle' || state === 'dragover') && (
)}
{state === 'uploading' && (
)}
{state === 'success' && (
)}
{state === 'rejected' && (
)}
{state === 'error' && (
)}
{state === 'quota' && (
)}
{ if (e.target.files?.[0]) onPickFile(e.target.files[0]); }} />
Every file is scanned before it's received
·
.mp4 · .mkv · .mov · .avi up to 40 GB
);
}
function IdleContent({ dragover, onPick }) {
return (
{dragover ? 'Drop to receive' : 'Drop your file onto this screen'}
{dragover ? '\u00a0' : 'or'}
{!dragover && (
Choose a file from this device
)}
);
}
function UploadingContent({ file, progress, scanPhase }) {
const mb = (file?.size || 0) / (1024 * 1024);
const sentMb = (mb * progress) / 100;
return (
{file?.name || 'film.mkv'}
{mb.toFixed(0)} MB ·{' '}
{scanPhase === 'scanning'
? <>scanning with ClamAV…>
: scanPhase === 'finalizing'
? <>moving into the library…>
: <>{Math.min(100, Math.round(progress))}% received · {sentMb.toFixed(0)} MB of {mb.toFixed(0)} MB>}
= 100} active={progress < 100} />
);
}
function Phase({ label, done, active }) {
return (
{done ? : }
{label}
);
}
const PhaseDash = () => ;
function SuccessContent({ file, readyAt, onReset }) {
return (
Your file has been received.
{file?.name || 'film.mkv'}
Ready to play at {readyAt}
Send another
);
}
function FailureContent({ heading, buttonLabel, onReset }) {
return (
);
}
// ── Recent uploads list ─────────────────────────────────────────────────────
function History({ items, user }) {
const isAdmin = !!user?.isAdmin;
// Admin sees the full library (with uploader visible); users see only what
// they put in.
const visible = isAdmin
? items
: items.filter((it) => it.uploaderUsername === user?.username);
if (visible.length === 0) {
return (
{isAdmin ? 'Library · everyone' : 'Recently received'}
Nothing here yet.
{isAdmin ? (
<>No one has sent anything in yet. Uploads will land here as they're received.>
) : user?.name ? (
<>Files you upload, {user.name}, will appear here once they've been
scanned and tucked into the library.>
) : (
<>Files you upload will appear here once they've been received.>
)}
);
}
return (
{isAdmin ? 'Library · everyone' : 'Recently received'}
{isAdmin && (
Showing every upload, tagged with who routed it.
)}
{visible.length} {visible.length === 1 ? 'file' : 'files'}
{visible.map((it) => (
{Array.from({ length: 6 }, (_, i) => )}
{it.title}
{isAdmin && it.uploader && (
<>
{it.uploader}
·
>
)}
{it.runtime}
·
{it.size}
·
{it.format}
{it.when}
{it.status}
))}
);
}
// ── Admin ───────────────────────────────────────────────────────────────────
// Sample passphrase words — short, warm, low-friction to read aloud over the
// phone. Used by the enrolment generator.
const __PHRASE_WORDS = [
'amber', 'quiet', 'paper', 'lamp', 'river', 'reel', 'warm', 'soft',
'slow', 'grain', 'linen', 'dusk', 'copper', 'ember', 'still', 'open',
'field', 'hum', 'sand', 'smoke', 'index', 'plate', 'fold', 'margin',
'archive', 'page', 'hour', 'note',
];
function __genPhrase() {
const pick = () => __PHRASE_WORDS[Math.floor(Math.random() * __PHRASE_WORDS.length)];
let words = [pick(), pick(), pick()];
// de-dupe — small word list, easy to hit collisions
while (new Set(words).size < 3) words = [pick(), pick(), pick()];
return words.join('-');
}
const __SAMPLE_PEOPLE = [
{ id: 'p-hunter', username: 'hunter', name: 'Hunter', role: 'admin', passphrase: 'admin',
enrolled: '12 Mar 2026', lastSeen: 'just now', status: 'Active' },
{ id: 'p-drm', username: 'drm', name: 'Dr. M.', role: 'uploader', passphrase: 'cinema',
enrolled: '14 Mar 2026', lastSeen: 'yesterday · 21:14', status: 'Active' },
{ id: 'p-annika', username: 'annika', name: 'Annika R.', role: 'uploader', passphrase: 'paper-lamp-river',
enrolled: '2 Apr 2026', lastSeen: '12 May · 11:08', status: 'Active' },
{ id: 'p-fellow', username: 'fellow', name: 'Visiting fellow', role: 'uploader', passphrase: 'quiet-amber-hold',
enrolled: '20 May 2026', lastSeen: '—', status: 'Pending first sign-in' },
];
// Suggest a username from a freeform name. First word, lowercased, alphanum
// only; collapses to "fellow" as a fallback so the field never goes empty.
function __suggestUsername(name) {
const cleaned = (name || '').trim().toLowerCase()
.split(/\s+/)[0].replace(/[^a-z0-9]/g, '');
return cleaned || 'fellow';
}
function Checkbox({ checked, indeterminate, onChange, label }) {
const ref = React.useRef(null);
React.useEffect(() => {
if (ref.current) ref.current.indeterminate = !!indeterminate;
}, [indeterminate]);
return (
e.stopPropagation()}
aria-label={label || 'Select'}>
{checked && !indeterminate && }
{indeterminate && }
);
}
function Admin({ user }) {
const [section, setSection] = React.useState('activity');
return (
Admin console · {user?.name || 'Hunter'}
The room behind the screen.
setSection('activity')}>Activity
setSection('people')}>People
{section === 'activity' ? : }
);
}
function _fmtWhen(ts) {
if (!ts) return '—';
const dt = new Date(ts.replace(' ', 'T'));
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 adaptPerson(row) {
return {
username: row.username,
name: row.name,
role: row.role,
status: row.status === 'active' ? 'Active' : 'Inactive',
mediaFolder: row.media_folder || '',
quotaBytes: row.quota_bytes ?? 10737418240,
quotaUsed: row.quota_used ?? 0,
lastSeen: _fmtWhen(row.last_seen_at),
};
}
function InlineEdit({ value, onSave, type = 'text', placeholder = '' }) {
const [editing, setEditing] = React.useState(false);
const [draft, setDraft] = React.useState(String(value ?? ''));
const ref = React.useRef(null);
React.useEffect(() => { if (editing) ref.current?.select(); }, [editing]);
const commit = () => {
setEditing(false);
const v = draft.trim();
if (v !== String(value ?? '')) onSave(v);
};
if (editing) {
return (
setDraft(e.target.value)}
onBlur={commit}
onKeyDown={e => {
if (e.key === 'Enter') commit();
if (e.key === 'Escape') { setDraft(String(value ?? '')); setEditing(false); }
}} />
);
}
return (
{ setDraft(String(value ?? '')); setEditing(true); }}>
{value || {placeholder} }
);
}
function adaptScanLog(row) {
const map = {
in_library: { label: 'Clean', tone: 'ok' },
infected: { label: 'Infected', tone: 'danger' },
scan_error: { label: 'Error', tone: 'warn' },
queued: { label: 'Pending', tone: 'neutral' },
scanning: { label: 'Scanning', tone: 'neutral' },
};
const { label, tone } = map[row.status] || { label: row.status, tone: 'neutral' };
const dt = new Date(row.queued_at.replace(' ', 'T'));
const hh = String(dt.getHours()).padStart(2, '0');
const mm = String(dt.getMinutes()).padStart(2, '0');
const gb = row.size_bytes / (1024 ** 3);
const size = gb >= 1 ? gb.toFixed(1) + ' GB'
: Math.round(row.size_bytes / (1024 ** 2)) + ' MB';
return { id: row.id, time: `${hh}:${mm}`, file: row.filename,
size, verdict: label, tone, from: row.uploader };
}
function AdminActivity() {
const [scanLog, setScanLog] = React.useState([]);
const [selected, setSelected] = React.useState(() => new Set());
const [lastDeleted, setLastDeleted] = React.useState(null);
React.useEffect(() => {
fetch('/api/scan-log')
.then(r => r.ok ? r.json() : [])
.then(rows => setScanLog(rows.map(adaptScanLog)));
}, []);
const allSelected = selected.size > 0 && selected.size === scanLog.length;
const someSelected = selected.size > 0 && !allSelected;
const toggle = (id) => setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id); else next.add(id);
return next;
});
const toggleAll = () => allSelected
? setSelected(new Set())
: setSelected(new Set(scanLog.map(r => r.id)));
const clearSelection = () => setSelected(new Set());
const deleteSelected = async () => {
const ids = [...selected];
const kept = scanLog.filter(r => !selected.has(r.id));
const removed = scanLog.filter(r => selected.has(r.id));
setScanLog(kept);
setLastDeleted(removed);
setSelected(new Set());
await fetch('/api/scan-log', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }),
});
};
const undoDelete = () => {
if (!lastDeleted) return;
setScanLog([...lastDeleted, ...scanLog]);
setLastDeleted(null);
};
return (
<>
Scan log · last 24 hours
{lastDeleted && selected.size === 0 && (
Undo delete ({lastDeleted.length})
)}
{selected.size > 0 && (
<>
{selected.size} selected
Clear
Delete {selected.size}
>
)}
Time File Size Verdict From
{scanLog.map((row) => {
const isSel = selected.has(row.id);
return (
toggle(row.id)}>
toggle(row.id)}
label={'Select ' + row.file} />
{row.time}
{row.file}
{row.size}
{row.verdict}
{row.from}
);
})}
{scanLog.length === 0 && (
The log is clear.
)}
Approvals queue
Nothing's asking for your eye right now.
System
Host Waratah (Pi 5, Linux)
Reverse proxy Caddy · TLS auto
Hosted at collabs.eidosfactum.org
Quarantine path /srv/collabs/_quarantine
Library path /srv/jellyfin/files
Notify on infected hunter@eidosfactum.org
>
);
}
function AdminPeople({ currentUser }) {
const [people, setPeople] = React.useState([]);
const [freshPass, setFreshPass] = React.useState({});
const [enrolling, setEnrolling] = React.useState(false);
const [revealed, setRevealed] = React.useState(() => new Set());
const [form, setForm] = React.useState({
name: '', username: '', usernameTouched: false, role: 'uploader',
});
const [draftPass, setDraftPass] = React.useState(__genPhrase());
const [copied, setCopied] = React.useState(null);
const loadPeople = () =>
fetch('/api/people')
.then(r => r.ok ? r.json() : [])
.then(rows => setPeople(rows.map(adaptPerson)));
React.useEffect(() => { loadPeople(); }, []);
const setName = (name) => setForm(f => ({
...f, name,
username: f.usernameTouched ? f.username : __suggestUsername(name),
}));
const setUsername = (username) => setForm(f => ({
...f, username: username.toLowerCase().replace(/[^a-z0-9_.-]/g, ''),
usernameTouched: true,
}));
const toggleReveal = (username) => setRevealed(prev => {
const next = new Set(prev);
if (next.has(username)) next.delete(username); else next.add(username);
return next;
});
const copyPass = async (username, pass) => {
try { await navigator.clipboard.writeText(pass); } catch {}
setCopied(username);
setTimeout(() => setCopied(c => c === username ? null : c), 1400);
};
const patchPerson = (username, updates) =>
fetch(`/api/people/${username}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates),
});
const saveFolder = (username, value) => {
setPeople(prev => prev.map(p =>
p.username === username ? { ...p, mediaFolder: value } : p));
patchPerson(username, { media_folder: value });
};
const saveQuota = (username, gbString) => {
const gb = parseFloat(gbString);
if (isNaN(gb) || gb < 0) return;
const bytes = Math.round(gb * 1024 ** 3);
setPeople(prev => prev.map(p =>
p.username === username ? { ...p, quotaBytes: bytes } : p));
patchPerson(username, { quota_bytes: bytes });
};
const resetQuota = async (username) => {
await fetch(`/api/people/${username}/reset-quota`, { method: 'POST' });
setPeople(prev => prev.map(p =>
p.username === username ? { ...p, quotaUsed: 0 } : p));
};
const rerollPass = async (username) => {
const r = await fetch(`/api/people/${username}/reroll`, { method: 'POST' });
if (r.ok) {
const { passphrase } = await r.json();
setFreshPass(fp => ({ ...fp, [username]: passphrase }));
setRevealed(prev => new Set([...prev, username]));
}
};
const revoke = (username) => {
setPeople(prev => prev.map(p =>
p.username === username ? { ...p, status: 'Inactive' } : p));
patchPerson(username, { status: 'inactive' });
};
const reinstate = (username) => {
setPeople(prev => prev.map(p =>
p.username === username ? { ...p, status: 'Active' } : p));
patchPerson(username, { status: 'active' });
};
const startEnrol = () => {
setForm({ name: '', username: '', usernameTouched: false, role: 'uploader' });
setDraftPass(__genPhrase());
setEnrolling(true);
};
const submitEnrol = async (e) => {
e?.preventDefault();
if (!form.name.trim() || !form.username.trim()) return;
const r = await fetch('/api/people', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
name: form.name.trim(), username: form.username.trim(),
role: form.role, passphrase: draftPass,
}),
});
if (!r.ok) return;
const { username, passphrase } = await r.json();
setFreshPass(fp => ({ ...fp, [username]: passphrase }));
setRevealed(prev => new Set([...prev, username]));
await loadPeople();
setEnrolling(false);
};
return (
People · enrolled passphrases
{people.length} enrolled
{!enrolling && (
+ Enrol someone
)}
{enrolling && (
)}
Name
Username
Role
Passphrase
Media folder
Storage
Last seen
Status
{people.map(p => {
const isRev = revealed.has(p.username);
const pass = freshPass[p.username] || null;
const isMe = p.username === currentUser?.username;
const usedGB = (p.quotaUsed / (1024 ** 3)).toFixed(1);
const limitGB = (p.quotaBytes / (1024 ** 3)).toFixed(0);
const tripped = p.quotaUsed >= p.quotaBytes;
return (
{p.name}
{p.username}
{p.role}
{pass ? (
{isRev ? pass : '•••• •••• ••••'}
toggleReveal(p.username)}>
{isRev ? 'Hide' : 'Reveal'}
{isRev && (
copyPass(p.username, pass)}>
{copied === p.username ? 'Copied' : 'Copy'}
)}
) : (
••••••
rerollPass(p.username)}>
Re-roll
)}
saveFolder(p.username, v)}
/>
{usedGB} GB
{' / '}
saveQuota(p.username, v)}
/>
{' GB'}
{p.quotaUsed > 0 && (
resetQuota(p.username)}>Reset
)}
{p.lastSeen}
{p.status}
{isMe ? (
You
) : p.status === 'Active' ? (
revoke(p.username)}>Revoke
) : (
reinstate(p.username)}>Reinstate
)}
);
})}
{people.length === 0 && (
No one enrolled yet.
)}
);
}
function StatusBlock({ label, value, sub, tone }) {
return (
);
}
Object.assign(window, { Login, DropZone, History, Admin });