// ============================================================================ // Admin screens — Users & access (two views) + Gradebook // // Access management is a master–detail with two switchable views, not a wide // user×assignment matrix (which doesn't scale past a handful of problems): // • By student — pick a student → category-grouped checklist of all problems // • By assignment — pick a problem → student list with multi-select distribute // Both use only POST/DELETE /admin/users/:id/access. New problems are authored // from the command line, not here. // ============================================================================ function groupByCategory(asgs) { const m = new Map(); for (const a of asgs) { const c = a.category || 'Other'; if (!m.has(c)) m.set(c, []); m.get(c).push(a); } return [...m.entries()].map(([name, items]) => ({ name, items })); } // Square grant toggle (✓ granted / dash not-granted). `locked` = admin row. function GrantToggle({ granted, locked, onClick }) { return ( ); } function CreateStudent({ onCreated }) { const [nu, setNu] = useState(''); const [np, setNp] = useState(''); const [ferr, setFerr] = useState(''); const [busy, setBusy] = useState(false); const toast = useToast(); const submit = async (e) => { e.preventDefault(); setFerr(''); if (!/^[A-Za-z0-9_.-]{3,32}$/.test(nu)) { setFerr('Username: 3–32 chars of A–Z a–z 0–9 _ . -'); return; } if (np.length < 8) { setFerr('Password must be at least 8 characters.'); return; } setBusy(true); try { await api.createUser(nu, np); toast(`Created ${nu}.`); setNu(''); setNp(''); onCreated(); } catch (er) { setFerr(er.error); } finally { setBusy(false); } }; return (
setNu(e.target.value)} placeholder="e.g. dara_k" />
setNp(e.target.value)} placeholder="min 8 chars" />
{ferr &&
{ferr}
}
); } // --------------------------------------------------- ADMIN · USERS ---------- function AdminUsers() { const [view, setView] = useState('student'); const [users, setUsers] = useState(null); const [asgs, setAsgs] = useState(null); const toast = useToast(); const loadUsers = () => api.adminUsers().then((r) => setUsers(r.data.users)); useEffect(() => { loadUsers(); api.assignments().then((r) => setAsgs(r.data.assignments)); }, []); // grant/revoke one (user, assignment), optimistic on the users list const setGrant = async (userId, asgId, on) => { setUsers((list) => list.map((u) => u.id === userId ? { ...u, access: on ? [...new Set([...u.access, asgId])] : u.access.filter((a) => a !== asgId) } : u)); try { await api.grant(userId, asgId, on); } catch (er) { toast(er.error, 'err'); loadUsers(); } }; const ready = users && asgs; return (

Admin · access control

Users & access

Grant which problems each student can attempt — pick a student, or distribute one problem to many students. New problems are added from the command line.

{[['student', 'user', 'By student'], ['assignment', 'book', 'By assignment']].map(([v, ico, label]) => ( ))}
{!ready ? : view === 'student' ? : }
); } // ---- view A: by student ---------------------------------------------------- function ByStudent({ users, asgs, setGrant, reload }) { const [selId, setSelId] = useState(null); const [q, setQ] = useState(''); const toast = useToast(); const students = users.filter((u) => u.role === 'student'); const sel = users.find((u) => u.id === selId) || null; const groups = React.useMemo(() => groupByCategory(asgs), [asgs]); // Multi-select: tick any number of problems (across groups) and grant/revoke // them for the selected student in one action — student↔assignment is many-to-many. const [picked, setPicked] = useState(() => new Set()); React.useEffect(() => { setPicked(new Set()); }, [selId]); const togglePick = (id) => setPicked((p) => { const n = new Set(p); n.has(id) ? n.delete(id) : n.add(id); return n; }); const applyPicked = (on) => { if (!sel) return; [...picked].forEach((id) => { const g = sel.access.includes(id); if (on ? !g : g) setGrant(sel.id, id, on); }); setPicked(new Set()); }; const del = async (u, e) => { e.stopPropagation(); if (!window.confirm(`Delete ${u.username}? This cascades their grants and results.`)) return; try { await api.deleteUser(u.id); toast(`Deleted ${u.username}.`); if (selId === u.id) setSelId(null); reload(); } catch (er) { toast(er.error, 'err'); } }; const ql = q.trim().toLowerCase(); const match = (a) => !ql || a.id.toLowerCase().includes(ql) || a.title.toLowerCase().includes(ql); return (
{/* left: create + students */}
{students.length === 0 ?
No students yet.
: students.map((u) => (
setSelId(u.id)} style={{ display: 'flex', alignItems: 'center', gap: 11, padding: '12px 14px', cursor: 'pointer', borderBottom: '1px solid var(--line)', background: selId === u.id ? 'var(--panel-3)' : 'transparent', }}> {u.username[0].toUpperCase()}
{u.username}
{u.access.length} / {asgs.length} granted
))}
{/* right: access editor */} {!sel ? :
{sel.username[0].toUpperCase()}
{sel.username}
granted {sel.access.length} / {asgs.length}
setQ(e.target.value)} placeholder="Search problems…" style={{ width: 220 }} />
{picked.size > 0 && (
{picked.size} selected
)} {groups.map((g) => { const items = g.items.filter(match); if (!items.length) return null; return (
{g.name}
{items.map((a) => { const granted = sel.access.includes(a.id); const isPicked = picked.has(a.id); return (
togglePick(a.id)} style={{ display: 'flex', alignItems: 'center', gap: 12, padding: '9px 12px', borderRadius: 9, cursor: 'pointer', border: `1px solid ${isPicked ? 'var(--acc-2)' : 'var(--line)'}`, background: isPicked ? 'oklch(0.88 0.19 138 / 0.06)' : 'transparent' }}>
{a.title}
#{a.id} · {a.totalPoints} pts
e.stopPropagation()} style={{ display: 'inline-flex' }}> setGrant(sel.id, a.id, !granted)} />
); })}
); })}
}
); } // ---- view B: by assignment ------------------------------------------------- function ByAssignment({ users, asgs, setGrant }) { const [selId, setSelId] = useState(null); const [q, setQ] = useState(''); const [picked, setPicked] = useState(() => new Set()); const students = users.filter((u) => u.role === 'student'); const sel = asgs.find((a) => a.id === selId) || null; const groups = React.useMemo(() => groupByCategory(asgs), [asgs]); useEffect(() => { setPicked(new Set()); }, [selId]); const countFor = (asgId) => students.filter((u) => u.access.includes(asgId)).length; const ql = q.trim().toLowerCase(); const shown = students.filter((u) => !ql || u.username.toLowerCase().includes(ql)); const togglePick = (id) => setPicked((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; }); const bulk = (on) => { shown.forEach((u) => { if (picked.has(u.id) && (on ? !u.access.includes(sel.id) : u.access.includes(sel.id))) setGrant(u.id, sel.id, on); }); setPicked(new Set()); }; return (
{/* left: problems by category */}
{groups.map((g) => (
{g.name}
{g.items.map((a) => (
setSelId(a.id)} style={{ display: 'flex', alignItems: 'center', gap: 10, padding: '11px 14px', cursor: 'pointer', borderBottom: '1px solid var(--line)', background: selId === a.id ? 'var(--panel-3)' : 'transparent', }}>
{a.title}
#{a.id} · {countFor(a.id)} students
))}
))}
{/* right: students for the selected problem */} {!sel ? :
{sel.title}
#{sel.id} · granted to {countFor(sel.id)} / {students.length}
setQ(e.target.value)} placeholder="Search students…" style={{ width: 200 }} />
{picked.size > 0 && (
{picked.size} selected
)}
{shown.length === 0 ?
No students match.
: shown.map((u) => { const granted = u.access.includes(sel.id); return (
togglePick(u.id)} style={{ accentColor: 'var(--acc)', width: 16, height: 16 }} /> {u.username[0].toUpperCase()}
{u.username}
setGrant(u.id, sel.id, !granted)} />
); })}
}
); } // --------------------------------------------------- ADMIN · GRADEBOOK ------ function AdminGradebook() { const [rows, setRows] = useState(null); const [users, setUsers] = useState([]); const [asgs, setAsgs] = useState([]); const [fUser, setFUser] = useState(''); const [fAsg, setFAsg] = useState(''); const [modal, setModal] = useState(null); // { username, title, submission } — source viewer const toast = useToast(); const [reloading, setReloading] = useState(false); // Hot-reload the bank, then refresh the assignment axis so newly synced // problems appear in the filter immediately. const reload = async () => { setReloading(true); try { const r = await api.reloadProblems(); const a = await api.assignments(); setAsgs(a.data.assignments); toast(`Reloaded ${r.data.count} problems.`); } catch (e) { toast(e.error || 'Reload failed.', 'err'); } finally { setReloading(false); } }; // Open the source viewer for one (student, assignment) cell — their latest code. const openSubs = async (r) => { setModal({ username: r.username, title: titleOf(r.assignmentId), submission: undefined }); try { const res = await api.adminLastSubmission(r.userId, r.assignmentId); setModal((m) => (m ? { ...m, submission: res.data.submission } : m)); } catch (e) { setModal(null); toast(e.error || 'Could not load the submission.', 'err'); } }; useEffect(() => { api.adminUsers().then((r) => setUsers(r.data.users.filter((u) => u.role === 'student'))); api.assignments().then((r) => setAsgs(r.data.assignments)); }, []); useEffect(() => { setRows(null); const f = {}; if (fUser) f.userId = Number(fUser); if (fAsg) f.assignmentId = fAsg; api.gradebook(f).then((r) => setRows(r.data.gradebook)); }, [fUser, fAsg]); const titleOf = (id) => asgs.find((a) => a.id === id)?.title || id; const stats = rows ? { rows: rows.length, passed: rows.filter((r) => r.passed).length, avg: rows.length ? Math.round(rows.reduce((s, r) => s + (r.best / r.total) * 100, 0) / rows.length) : 0, } : null; return (

Admin · whole class

Gradebook

Best score per student per assignment. Filter by student or problem.

{stats && (
)}
FILTERS
{(fUser || fAsg) && }
{!rows ? : rows.length === 0 ? :
{rows.map((r, i) => ( ))}
StudentAssignmentStatusAttemptsBestLast submitted (UTC+8)Code
{r.username[0].toUpperCase()}{r.username}
{titleOf(r.assignmentId)}
#{r.assignmentId}
{r.attempts} {r.best} / {r.total} {fmtTime(r.lastAt)}
} {modal && ( setModal(null)} title={`${modal.username} · ${modal.title}`} sub="The student's latest submission — the exact source they last submitted."> )}
); } function Stat({ label, value, accent }) { return (
{label}
{value}
); } Object.assign(window, { AdminUsers, AdminGradebook });