// ============================================================================ // Screens — Login, StudentHome, Solve, MyResults, AdminUsers, AdminGradebook // ============================================================================ // --------------------------------------------------------------- MARKDOWN ---- // Tiny, dependency-free renderer for the subset used in problem statements: // `code` spans, **bold**, ordered/bulleted lists, and paragraphs with line // breaks. Everything is rendered as React nodes (auto-escaped), so angle-bracket // placeholders like show literally and there is no XSS surface. const mdCodeStyle = { fontFamily: 'var(--mono)', fontSize: '0.92em', background: 'rgba(127,127,127,0.14)', borderRadius: 4, padding: '1px 5px' }; const mdListStyle = { margin: '2px 0 9px', paddingLeft: 22 }; function mdInline(text, keyBase) { const out = []; const re = /`([^`]+)`|\*\*([^*]+)\*\*/g; let last = 0, m, k = 0; while ((m = re.exec(text)) !== null) { if (m.index > last) out.push(text.slice(last, m.index)); if (m[1] != null) out.push({m[1]}); else out.push({m[2]}); last = re.lastIndex; } if (last < text.length) out.push(text.slice(last)); return out; } function Markdown({ text }) { const lines = String(text || '').split('\n'); const blocks = []; let para = [], list = null, code = null; // code = null when not inside a ``` fence const flushPara = () => { if (para.length) { blocks.push({ t: 'p', lines: para }); para = []; } }; const flushList = () => { if (list) { blocks.push({ t: 'list', ordered: list.ordered, items: list.items }); list = null; } }; const flushCode = () => { if (code !== null) { blocks.push({ t: 'code', lines: code }); code = null; } }; for (const line of lines) { // Fenced code block: ``` toggles a verbatim, whitespace-preserving block. // Critical for example I/O — inside
 the exact spaces survive (an inline
    // `code` span collapses runs of whitespace, which silently mangles aligned output).
    if (/^\s*```/.test(line)) {
      if (code !== null) flushCode(); else { flushPara(); flushList(); code = []; }
      continue;
    }
    if (code !== null) { code.push(line); continue; }
    const om = line.match(/^\s*\d+\.\s+(.*)$/);
    const um = line.match(/^\s*[-*]\s+(.*)$/);
    if (om) { flushPara(); if (!list || !list.ordered) { flushList(); list = { ordered: true, items: [] }; } list.items.push(om[1]); }
    else if (um) { flushPara(); if (!list || list.ordered) { flushList(); list = { ordered: false, items: [] }; } list.items.push(um[1]); }
    else if (line.trim() === '') { flushPara(); flushList(); }
    else { flushList(); para.push(line); }
  }
  flushPara(); flushList(); flushCode();
  return (
    
{blocks.map((b, i) => { if (b.t === 'code') { return
{b.lines.join('\n')}
; } if (b.t === 'list') { const items = b.items.map((it, j) =>
  • {mdInline(it, i + '-' + j)}
  • ); return b.ordered ?
      {items}
    :
      {items}
    ; } return (

    {b.lines.map((ln, j) => {j > 0 &&
    }{mdInline(ln, i + '-' + j)}
    )}

    ); })}
    ); } // Flatten markdown to plain text for the compact card teaser. function mdStrip(text) { return String(text || '').replace(/[`*]/g, '').replace(/\s+/g, ' ').trim(); } // ---------------------------------------------------------------- LOGIN ------ function Login({ onAuthed }) { const [u, setU] = useState(''); const [p, setP] = useState(''); const [err, setErr] = useState(''); const [busy, setBusy] = useState(false); const submit = async (uu = u, pp = p) => { setErr(''); if (!uu || !pp) { setErr('Enter a username and password.'); return; } setBusy(true); try { const r = await api.login(uu, pp); onAuthed(r.data); } catch (e) { setErr(e.error || 'Login failed.'); } finally { setBusy(false); } }; return (
    SC
    Stellan's Classroom
    console · v1.0

    Sign in

    Authenticate to access your assignments.

    { e.preventDefault(); submit(); }} style={{ display: 'flex', flexDirection: 'column', gap: 16 }}>
    setU(e.target.value)} placeholder="alice" />
    setP(e.target.value)} placeholder="••••••••" />
    {err &&
    {err}
    }
    ); } // Per-problem status chip for a library card: passed / in-progress (best score // so far) / not started — so a student can see at a glance which problems still // need fixing. function ProblemStatus({ result }) { if (!result) return Not started; if (result.passed) return Passed; return {result.best}/{result.total}; } // ---------------------------------------------------- STUDENT · HOME -------- function StudentHome({ onOpen, folderId, setFolderId }) { const [list, setList] = useState(null); const [results, setResults] = useState({}); // assignmentId -> report-card row useEffect(() => { api.assignments().then((r) => setList(r.data.assignments)); // Drives the pass/fail badges. Best-effort: on failure the cards just read "Not started". api.myResults().then((r) => setResults(Object.fromEntries(r.data.results.map((x) => [x.assignmentId, x])))).catch(() => {}); }, []); // Group the granted problems into topic folders by their server-provided // `category` (uncategorised → "Other problems"). Folder grouping is now // backend-driven; no client-side id→folder table. const folders = React.useMemo(() => { if (!list) return []; const byCat = new Map(); for (const a of list) { const name = a.category || 'Other problems'; if (!byCat.has(name)) byCat.set(name, []); byCat.get(name).push(a.id); } return [...byCat.entries()].map(([name, items]) => ({ id: name, name, items })); }, [list]); const openFolder = folders.find((f) => f.id === folderId); const metaOf = (id) => list.find((a) => a.id === id); return (

    Assignments

    {!openFolder ? ( <>

    Problem library

    Your granted problems, organised into folders by topic. Open a folder to see the problems inside — test cases are never shown.

    ) : ( <>
    / {openFolder.name}

    {openFolder.name}

    )}
    {!list ? : folders.length === 0 ? : !openFolder ? (
    {folders.map((f, i) => ( ))}
    ) : ( <>
    {openFolder.items.map((id, i) => { const a = metaOf(id); return ( ); })}
    )}
    ); } // ------------------------------------------------ SOURCE VIEW --------------- // Read-only view of one stored submission's source — the admin's gradebook // viewer. `submission` is undefined while loading, null if none, else the row. function SourceView({ submission }) { if (submission === undefined) return ; if (!submission) return ; const s = submission; return (
    {s.awarded} / {s.total} {s.at && {fmtTime(s.at)} UTC+8}
    {s.source != null ?
    {s.source}
    :
    Source was not recorded for this submission.
    }
    ); } // ---------------------------------------------------- STUDENT · SOLVE ------- function Solve({ assignmentId, meta, onBack }) { const starter = meta?.starter || ''; const [src, setSrc] = useState(starter); const [busy, setBusy] = useState(false); const [res, setRes] = useState(null); const [err, setErr] = useState(null); const [last, setLast] = useState(undefined); // undefined=loading · null=none · {…}=have one const [origin, setOrigin] = useState('starter'); // which starting point is in the editor const toast = useToast(); // Offer "resume from last version": fetch the student's latest submission once. useEffect(() => { let live = true; api.myLastSubmission(assignmentId) .then((r) => { if (live) setLast(r.data.submission); }) .catch(() => { if (live) setLast(null); }); return () => { live = false; }; }, [assignmentId]); // The two starting points. Each replaces the editor contents with one click. const pickStarter = () => { setSrc(starter); setOrigin('starter'); setRes(null); setErr(null); }; const pickLast = () => { if (last && last.source != null) { setSrc(last.source); setOrigin('last'); setRes(null); setErr(null); } }; const submit = async () => { setBusy(true); setErr(null); try { const r = await api.submit(assignmentId, src); setRes(r.data); // What's in the editor is now the saved "last version". setLast({ source: src, passed: r.data.passed, awarded: r.data.awardedPoints, total: r.data.totalPoints, at: null }); setOrigin('last'); toast(r.data.passed ? 'All tests passed — nice.' : 'Submission graded.', r.data.passed ? '' : 'err'); } catch (e) { setErr(e); if (e.status === 401) toast('Session expired — please sign in.', 'err'); else toast(e.error || 'Submission failed.', 'err'); } finally { setBusy(false); } }; const bytes = new Blob([src]).size; return (
    #{assignmentId}

    {meta?.title || assignmentId}

    {meta?.description && (
    )} {/* starting point: the starter template, or resume from your last submission */}
    Start from Picking one replaces the editor contents.
    {/* editor column */}
    pseudocode.igcse 64*1024 ? 'var(--fail)' : 'var(--faint)' }}> {(bytes/1024).toFixed(1)} KB / 64 KB
    one submission at a time
    {/* result column */}
    RESULT
    {busy ? : err ? : res ? : }
    ); } function ResultIdle() { return (

    Awaiting submission

    Write your IGCSE pseudocode, then hit Submit. You'll see a pass/fail for each sample case and a count of the hidden ones.

    ); } function ResultGrading() { return (
    Running test cases…
    {[0,1,2].map((i) => (
    ))}
    ); } function ResultError({ err }) { const map = { 401: 'Session expired', 403: 'No access', 404: 'Not found', 413: 'Program too long', 429: 'Rate limited', 503: 'Server busy', 500: 'Server error' }; return (
    {map[err.status] || 'Request rejected'} · {err.status}
    {err.error}

    This is a request error — your program wasn't run. A broken program returns 200 with failing cases instead.

    ); } function ResultPanel({ res }) { return (
    {res.passed ? 'Every case passed.' : `${res.awardedPoints} of ${res.totalPoints} points awarded.`}
    Sample cases
    {res.cases.map((c, i) => (
    {c.name}
    {c.stdin && c.stdin.length > 0 && (
    input {c.stdin.map((v, k) => {v})}
    )} {c.expected != null && (
    expected output
    {c.expected}
    )} {c.error &&
    {c.error}
    }
    {c.passed ? 'PASS' : 'FAIL'}
    ))}
    Hidden tests {res.hidden.passed} / {res.hidden.total} passed
    ); } // ---------------------------------------------------- STUDENT · RESULTS ----- function MyResults() { const [rows, setRows] = useState(null); const [titles, setTitles] = useState({}); useEffect(() => { api.myResults().then((r) => setRows(r.data.results)); api.assignments().then((r) => setTitles(Object.fromEntries(r.data.assignments.map((a) => [a.id, a.title])))); }, []); const titleOf = (id) => titles[id] || id; return (

    Report card

    My results

    One row per assignment you've attempted. Best is your highest score across attempts.

    {!rows ? : rows.length === 0 ? :
    {rows.map((r) => ( ))}
    AssignmentStatusAttemptsBestLast submitted (UTC+8)
    {titleOf(r.assignmentId)}
    #{r.assignmentId}
    {r.attempts} {r.best} / {r.total} {fmtTime(r.lastAt)}
    }
    ); } Object.assign(window, { Login, StudentHome, Solve, MyResults, SourceView });