Refinement pass on top of the validated v1.2 base.
Hardening / quick fixes
- Caddyfile.tpl: CSP / HSTS / X-Frame / Referrer-Policy / Permissions-Policy,
1 MiB request body cap, X-Forwarded-For pass-through; stock-Caddy compatible.
- auth.py: STUDENT_MAX_AGE 1y -> 30d.
- bootstrap.sh: stage 5 prepends `git config --add safe.directory` so the
re-bootstrap path no longer 'detected dubious ownership' faults.
- admin.js: drop the misleading `closedPayload?.state || session.state`
shim; state derives from session only.
Anti-cheat
- New `student_events` audit table; new POST /api/session/{sid}/event for
blur / visibility_hidden / focus / visibility_visible. quiz.js debounces
events at 1.5s and uses sendBeacon for visibility_hidden so the event
survives a navigation. Counts surface in admin presence + CSV export.
- First-claim-wins on join: add_participant raises DuplicateStudentId on
PK violation; route returns 409 + records a duplicate_join audit event
with attempted name + IP + UA. Admin dashboard surfaces a per-row red
badge for hits on real participants and a top-of-page alert for orphan
attempts.
- DELETE /admin/api/students/{id} as the recovery hatch: clears the
participant + submissions, kicks active WS sockets so a stale cookie
cannot continue submitting. quiz.js surfaces the FastAPI detail message
in the join form so users see the 'already in use' guidance.
Presence panel
- New presence_update WS message; in-process presence map keyed on
student_id tracks ws_count + last_seen_ms. Admin dashboard renders
per-student rows: connected/idle/dropped dot, blur+hidden+duplicate
badges, 'answered current Q' tick, and a clear-student button.
Projector view (public, read-only)
- /projector/?sid=..., GET /api/session/{sid}/projector, WS
/ws/projector/{sid}. Single self-contained projector_state snapshot
pushed on every state change. Public leaderboard strips student_id;
QR rendered server-side as data: URL (CSP-compliant).
- Includes per-Q answer histogram, 8-bucket response-time distribution,
10-bucket score distribution.
- static/projector.{html,css,js}: editorial-broadside design — masthead,
registration crosses, conic-gradient countdown ring, SVG stepped-area
score distribution with median tick, leaderboard row-stagger. Inherits
light/dark tokens from style.css; honours prefers-reduced-motion. No
scroll at 1366x768 / 1920x1080 / 3440x1440.
Tests
- tests/test_anti_cheat.py: blur logging + CSV count, unknown-kind 422,
unauthenticated event 401, duplicate-join 409 + audit, admin
clear-student happy + 404, server-side submit lockout regression.
- tests/test_projector.py: snapshot shape, leaderboard student_id
redaction, WS push on state change, 404 for unknown sid, page redirect
when no sid.
- Existing tests updated for the new presence_update snapshot frame +
CSV header columns + first-claim-wins refusal of re-key.
57/57 pytest green; smoke-tested locally end-to-end.
686 lines
25 KiB
JavaScript
686 lines
25 KiB
JavaScript
/* Quiz admin SPA.
|
||
*
|
||
* Single page, no router. boot() decides between login form and dashboard
|
||
* based on whether GET /admin/api/state returns 200 (authed) or 401.
|
||
*
|
||
* The dashboard is state-driven: a single primary action button per
|
||
* session state (Start / Stop early / Next / Finish / Reset). The QR
|
||
* code, join URL, and participant list are always visible on the left
|
||
* so the operator can leave the page on a projector.
|
||
*/
|
||
|
||
const app = document.querySelector("#admin-app");
|
||
|
||
const store = {
|
||
session: null, // /admin/api/state response
|
||
ws: null,
|
||
roster: [],
|
||
presence: [], // presence_update.rows — richer than roster
|
||
orphanDuplicates: [], // presence_update.orphan_duplicate_joins
|
||
currentQIdx: null, // tracked for "answered current?" rendering
|
||
currentQuestion: null,
|
||
histogram: null,
|
||
totalCount: 0,
|
||
submittedCount: 0,
|
||
closedPayload: null, // last question_closed message
|
||
leaderboard: [],
|
||
endedPayload: null,
|
||
notice: null,
|
||
questionDeadlineMs: null,
|
||
};
|
||
|
||
let countdownTimer = null;
|
||
|
||
function fmtScore(value) {
|
||
// Scores are floats on a 0.05 grid in [0, 1]; sums can run up to N
|
||
// (one per question). Always render as fixed two-decimal so the
|
||
// leaderboard reads "0.85" / "1.20" / "5.00" cleanly.
|
||
return Number(value || 0).toFixed(2);
|
||
}
|
||
|
||
function escapeText(value) {
|
||
return String(value ?? "").replace(/[&<>"']/g, (c) => ({
|
||
"&": "&",
|
||
"<": "<",
|
||
">": ">",
|
||
'"': """,
|
||
"'": "'",
|
||
})[c]);
|
||
}
|
||
|
||
async function api(path, options = {}) {
|
||
const headers = options.body ? { "Content-Type": "application/json", ...(options.headers || {}) } : options.headers;
|
||
const response = await fetch(path, {
|
||
credentials: "same-origin",
|
||
...options,
|
||
headers,
|
||
});
|
||
if (response.status === 401) {
|
||
const error = new Error("unauthorized");
|
||
error.status = 401;
|
||
throw error;
|
||
}
|
||
if (!response.ok) {
|
||
const error = new Error(await response.text());
|
||
error.status = response.status;
|
||
throw error;
|
||
}
|
||
const contentType = response.headers.get("content-type") || "";
|
||
return contentType.includes("json") ? response.json() : response.text();
|
||
}
|
||
|
||
async function boot() {
|
||
try {
|
||
store.session = await api("/admin/api/state");
|
||
store.notice = null;
|
||
renderDashboard();
|
||
connectWS();
|
||
} catch (err) {
|
||
if (err.status === 401) {
|
||
renderLogin();
|
||
} else if (err.status === 503) {
|
||
renderUnavailable(err.message || "Session not initialised on the server.");
|
||
} else {
|
||
renderUnavailable(err.message || "Could not load admin state.");
|
||
}
|
||
}
|
||
}
|
||
|
||
function renderUnavailable(detail) {
|
||
app.innerHTML = `
|
||
<section class="centered-shell">
|
||
<div class="card narrow">
|
||
<h1>Quiz unavailable</h1>
|
||
<p>${escapeText(detail)}</p>
|
||
<p class="muted">Verify <code>QUIZ_POOL_PATH</code> on the server points at a valid pool JSON, then restart <code>quiz.service</code>.</p>
|
||
</div>
|
||
</section>
|
||
`;
|
||
}
|
||
|
||
function renderLogin(error = null) {
|
||
app.innerHTML = `
|
||
<section class="centered-shell">
|
||
<form id="login-form" class="card narrow stack">
|
||
<header class="card-header">
|
||
<h1>Quiz admin</h1>
|
||
<p class="muted">Sign in to control the live session.</p>
|
||
</header>
|
||
<label class="field">
|
||
<span>Password</span>
|
||
<input name="password" type="password" autocomplete="current-password" required autofocus>
|
||
</label>
|
||
${error ? `<p class="alert error">${escapeText(error)}</p>` : ""}
|
||
<button class="btn primary block" type="submit">Sign in</button>
|
||
</form>
|
||
</section>
|
||
`;
|
||
document.querySelector("#login-form").addEventListener("submit", async (event) => {
|
||
event.preventDefault();
|
||
const submit = event.submitter || event.currentTarget.querySelector("button");
|
||
submit.disabled = true;
|
||
const password = new FormData(event.currentTarget).get("password");
|
||
try {
|
||
await api("/admin/login", { method: "POST", body: JSON.stringify({ password }) });
|
||
await boot();
|
||
} catch (err) {
|
||
submit.disabled = false;
|
||
renderLogin(err.status === 401 ? "Wrong password." : "Could not sign in.");
|
||
}
|
||
});
|
||
}
|
||
|
||
function renderDashboard() {
|
||
const session = store.session;
|
||
if (!session) return;
|
||
// state derives from session (server-authoritative); endedPayload short-
|
||
// circuits to "finished" for the post-final render where we may not
|
||
// have re-fetched session.state yet.
|
||
const state = store.endedPayload ? "finished" : session.state;
|
||
app.innerHTML = `
|
||
<header class="topbar">
|
||
<div class="topbar-title">
|
||
<h1>${escapeText(session.title)}</h1>
|
||
<p class="muted">${escapeText(session.pool_meta.question_count)} questions · ${escapeText(session.pool_meta.score_fn.replace(/_/g, " "))} · ${escapeText(session.pool_meta.time_limit_default)} s default</p>
|
||
</div>
|
||
<div class="topbar-actions">
|
||
<span class="state-badge state-${escapeText(state)}">${escapeText(stateLabel(state))}</span>
|
||
<button id="logout-btn" class="btn ghost">Sign out</button>
|
||
</div>
|
||
</header>
|
||
${store.notice ? `<div class="alert info">${escapeText(store.notice)}</div>` : ""}
|
||
${renderDuplicateJoinAlerts()}
|
||
<section class="dashboard">
|
||
<aside class="dashboard-side">
|
||
${renderJoinPanel()}
|
||
${renderPresencePanel()}
|
||
</aside>
|
||
<main class="dashboard-main">
|
||
${renderStatePanel(state)}
|
||
</main>
|
||
</section>
|
||
`;
|
||
document.querySelector("#logout-btn").addEventListener("click", logout);
|
||
bindStateActions();
|
||
bindPresenceActions();
|
||
if (state === "question_open") startCountdown();
|
||
}
|
||
|
||
function stateLabel(state) {
|
||
return ({
|
||
lobby: "Lobby",
|
||
question_open: "Question live",
|
||
question_closed: "Reveal",
|
||
between_questions: "Between",
|
||
finished: "Finished",
|
||
})[state] || state || "—";
|
||
}
|
||
|
||
function renderJoinPanel() {
|
||
const session = store.session;
|
||
return `
|
||
<div class="card panel join-panel">
|
||
<h2>Join</h2>
|
||
<div class="qr-wrap">${session.qr_url ? `<img class="qr" src="${session.qr_url}" alt="Join QR">` : "<div class='qr-fallback'>QR unavailable</div>"}</div>
|
||
<div class="join-url-row">
|
||
<code class="join-url">${escapeText(session.join_url)}</code>
|
||
<button id="copy-url" class="btn ghost small" type="button">Copy</button>
|
||
</div>
|
||
<p class="muted small">Session id: <code>${escapeText(session.sid)}</code></p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderPresencePanel() {
|
||
const presence = store.presence || [];
|
||
const rosterCount = (store.roster || []).length;
|
||
const connected = presence.filter((p) => p.connected).length;
|
||
const idleStaleMs = 30_000;
|
||
const now = Date.now();
|
||
// Newest-first so late joiners stay visible at the top.
|
||
const ordered = presence.slice().reverse();
|
||
if (!ordered.length) {
|
||
return `
|
||
<div class="card panel">
|
||
<h2>Joined <span class="count">${rosterCount}</span></h2>
|
||
<p class="muted">No students have joined yet. Share the QR or URL.</p>
|
||
</div>
|
||
`;
|
||
}
|
||
const currentQIdx = store.currentQIdx ?? store.session?.current_question_idx;
|
||
const isQuestionOpen = currentQIdx != null && store.session?.state === "question_open";
|
||
return `
|
||
<div class="card panel presence-panel">
|
||
<h2>Presence <span class="count">${connected}/${presence.length}</span></h2>
|
||
<ul class="presence-list">
|
||
${ordered.map((row, i) => {
|
||
const lastSeen = row.last_seen_ms || 0;
|
||
const stale = !row.connected && lastSeen && (now - lastSeen) > idleStaleMs;
|
||
const dotState = row.connected ? "is-online" : (stale ? "is-stale" : "is-offline");
|
||
const blur = row.blur_count || 0;
|
||
const hidden = row.hidden_count || 0;
|
||
const dupCount = row.duplicate_join_attempts?.count || 0;
|
||
const answered = row.answered_current;
|
||
const fresh = i < 3 && row.connected ? "is-fresh" : "";
|
||
return `
|
||
<li class="presence-row ${dotState} ${fresh}" data-student-id="${escapeText(row.student_id)}">
|
||
<span class="dot" title="${row.connected ? "Connected" : "Disconnected"}"></span>
|
||
<span class="who">
|
||
<b>${escapeText(row.name)}</b>
|
||
<small>${escapeText(row.student_id)}</small>
|
||
</span>
|
||
<span class="presence-flags">
|
||
${isQuestionOpen
|
||
? `<span class="flag ${answered ? "flag-ok" : "flag-pending"}" title="${answered ? "Answered current question" : "Has not answered current question"}">${answered ? "✓" : "·"}</span>`
|
||
: ""}
|
||
${blur > 0 ? `<span class="flag flag-warn" title="Tab blur events">${blur}↗</span>` : ""}
|
||
${hidden > 0 ? `<span class="flag flag-warn" title="Tab hidden events">${hidden}◌</span>` : ""}
|
||
${dupCount > 0 ? `<span class="flag flag-danger" title="Duplicate-join attempts">!${dupCount}</span>` : ""}
|
||
</span>
|
||
<button class="btn ghost xtiny" data-clear-student="${escapeText(row.student_id)}" title="Remove this student so they can re-join (recovery for hijack / lost cookie)">×</button>
|
||
</li>
|
||
`;
|
||
}).join("")}
|
||
</ul>
|
||
<p class="muted xsmall">
|
||
<span class="legend-dot is-online"></span> connected
|
||
<span class="legend-dot is-stale"></span> idle
|
||
<span class="legend-dot is-offline"></span> dropped
|
||
</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderDuplicateJoinAlerts() {
|
||
const orphans = store.orphanDuplicates || [];
|
||
if (!orphans.length) return "";
|
||
// An orphan attempt is a duplicate-join on a student_id that no real
|
||
// participant currently holds — surface separately because it suggests
|
||
// someone is probing student_ids that aren't even claimed yet.
|
||
return `
|
||
<div class="alert error duplicate-alerts">
|
||
<h2 class="alert-title">Suspicious join attempts</h2>
|
||
<ul class="dup-list">
|
||
${orphans.map((o) => `
|
||
<li>
|
||
<code>${escapeText(o.student_id)}</code>
|
||
<span class="muted small">${o.count}× attempted${o.latest_ts ? ` · last ${escapeText(o.latest_ts)}` : ""}</span>
|
||
</li>
|
||
`).join("")}
|
||
</ul>
|
||
<p class="muted small">No real participant holds these IDs yet. If a student claims one of them and asks for help, you can clear it from the presence list.</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderStatePanel(state) {
|
||
if (state === "lobby") return renderLobby();
|
||
if (state === "question_open") return renderQuestionOpen();
|
||
if (state === "question_closed" || state === "between_questions") return renderQuestionClosed();
|
||
if (state === "finished") return renderFinished();
|
||
return `<div class="card panel"><p class="muted">Unknown state: ${escapeText(state)}</p></div>`;
|
||
}
|
||
|
||
function renderLobby() {
|
||
const total = store.session.pool_meta.question_count;
|
||
const joined = (store.roster || []).length;
|
||
return `
|
||
<div class="card panel state-cta-card">
|
||
<div class="state-cta">
|
||
<p class="cta-eyebrow"><span class="cta-num">02</span> Pre-flight</p>
|
||
<h2>Ready to start.</h2>
|
||
<p>When you start, question 1 of ${total} opens for everyone in the room. Late joiners can still hop in mid-question; they get whatever time remains on the clock.</p>
|
||
<div class="cta-stats">
|
||
<div class="cta-stat"><span class="muted">Joined</span><b>${joined}</b></div>
|
||
<div class="cta-stat"><span class="muted">Questions</span><b>${total}</b></div>
|
||
<div class="cta-stat"><span class="muted">Per question</span><b>${store.session.pool_meta.time_limit_default}<small>s</small></b></div>
|
||
</div>
|
||
<button class="btn primary big" data-action="next">Start quiz →</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderQuestionOpen() {
|
||
const q = store.currentQuestion;
|
||
if (!q) {
|
||
return `<div class="card panel"><p class="muted">Waiting for question to broadcast…</p></div>`;
|
||
}
|
||
const total = store.session.pool_meta.question_count;
|
||
const idx = q.question_idx;
|
||
return `
|
||
<div class="card panel question-card">
|
||
<div class="question-head">
|
||
<span class="qnum">Q${idx + 1} / ${total}</span>
|
||
<span id="countdown" class="countdown" data-deadline="${store.questionDeadlineMs ?? 0}">—</span>
|
||
</div>
|
||
<div class="qbar"><span id="qbar-fill"></span></div>
|
||
<h2 class="question-text">${escapeText(q.text)}</h2>
|
||
<ol class="options">
|
||
${["A","B","C","D"].map((k) =>
|
||
`<li><span class="key">${k}</span><span class="opt-text">${escapeText(q.options[k] || "")}</span></li>`
|
||
).join("")}
|
||
</ol>
|
||
${renderLiveHistogram()}
|
||
<div class="action-row">
|
||
<button class="btn warn" data-action="close">Stop early</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderLiveHistogram() {
|
||
if (!store.histogram) return `<p class="muted small">Awaiting the first submission…</p>`;
|
||
const h = store.histogram;
|
||
const submitted = store.submittedCount || 0;
|
||
const total = Math.max(1, store.totalCount || 0);
|
||
// While nobody has submitted yet, suppress the bar rows — empty bars
|
||
// read as broken rather than "no data". Show a calm awaiting line.
|
||
if (submitted === 0) {
|
||
return `
|
||
<div class="hist live">
|
||
<div class="hist-summary">
|
||
<span><b>0</b> submitted</span>
|
||
${store.totalCount ? `<span class="muted">of ${store.totalCount} joined</span>` : ""}
|
||
</div>
|
||
<p class="muted small hist-awaiting">Bars appear once the first answer comes in.</p>
|
||
</div>
|
||
`;
|
||
}
|
||
return `
|
||
<div class="hist live">
|
||
<div class="hist-summary">
|
||
<span><b>${submitted}</b> submitted</span>
|
||
${store.totalCount ? `<span class="muted">of ${store.totalCount} joined</span>` : ""}
|
||
${h.pending != null && h.pending > 0 ? `<span class="muted">${h.pending} pending</span>` : ""}
|
||
</div>
|
||
<div class="hist-rows">
|
||
${["A","B","C","D"].map((k) => {
|
||
const v = h[k] || 0;
|
||
const pct = Math.round(100 * v / total);
|
||
return `
|
||
<div class="hist-row">
|
||
<span class="key">${k}</span>
|
||
<div class="bar"><span class="fill" style="width:${pct}%"></span></div>
|
||
<span class="num">${v}</span>
|
||
</div>
|
||
`;
|
||
}).join("")}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderQuestionClosed() {
|
||
const c = store.closedPayload;
|
||
const q = store.currentQuestion;
|
||
if (!c || !q) {
|
||
return `<div class="card panel"><p class="muted">Reveal pending…</p></div>`;
|
||
}
|
||
const total = store.session.pool_meta.question_count;
|
||
const idx = q.question_idx;
|
||
const isLast = idx >= total - 1;
|
||
const totalSubmitters = ["A","B","C","D"].reduce((a, k) => a + (c.histogram[k] || 0), 0) + (c.histogram.missed || 0);
|
||
const denom = Math.max(1, totalSubmitters);
|
||
return `
|
||
<div class="card panel reveal-card">
|
||
<div class="question-head">
|
||
<span class="qnum">Q${idx + 1} / ${total}</span>
|
||
<span class="state-badge state-question_closed">Closed</span>
|
||
</div>
|
||
<h2 class="question-text">${escapeText(q.text)}</h2>
|
||
<ol class="options reveal">
|
||
${["A","B","C","D"].map((k) => {
|
||
const correct = k === c.correct;
|
||
return `
|
||
<li class="${correct ? "correct" : ""}">
|
||
<span class="key">${k}${correct ? " ✓" : ""}</span>
|
||
<span class="opt-text">${escapeText(q.options[k] || "")}</span>
|
||
<span class="opt-count muted">${c.histogram[k] || 0}</span>
|
||
</li>
|
||
`;
|
||
}).join("")}
|
||
</ol>
|
||
${c.explanation ? `<p class="explanation">${escapeText(c.explanation)}</p>` : ""}
|
||
<div class="hist final">
|
||
<div class="hist-rows">
|
||
${["A","B","C","D"].map((k) => {
|
||
const v = c.histogram[k] || 0;
|
||
const pct = Math.round(100 * v / denom);
|
||
const correct = k === c.correct;
|
||
return `
|
||
<div class="hist-row ${correct ? "is-correct" : ""}">
|
||
<span class="key">${k}</span>
|
||
<div class="bar"><span class="fill" style="width:${pct}%"></span></div>
|
||
<span class="num">${v} (${pct}%)</span>
|
||
</div>
|
||
`;
|
||
}).join("")}
|
||
${c.histogram.missed ? `<div class="hist-row missed"><span class="key">—</span><div class="bar"></div><span class="num">${c.histogram.missed} missed</span></div>` : ""}
|
||
</div>
|
||
</div>
|
||
<h3>Top so far</h3>
|
||
${renderLeaderboardList(store.leaderboard.slice(0, 10))}
|
||
<div class="action-row">
|
||
<button class="btn primary big" data-action="next">${isLast ? "Finish quiz →" : "Next question →"}</button>
|
||
<button class="btn ghost" data-action="end">Finish now</button>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderFinished() {
|
||
const total = store.session.pool_meta.question_count;
|
||
return `
|
||
<div class="card panel">
|
||
<div class="state-cta">
|
||
<h2>That's a wrap.</h2>
|
||
<p>${total} question${total === 1 ? "" : "s"} complete. Final standings below; download the CSV when you're ready to grade.</p>
|
||
</div>
|
||
<h3>Final leaderboard</h3>
|
||
${renderLeaderboardList(store.leaderboard)}
|
||
<div class="action-row">
|
||
<a class="btn ghost" href="/admin/api/csv" target="_blank" rel="noopener">Download CSV</a>
|
||
<button class="btn warn" data-action="reset">Reset session</button>
|
||
</div>
|
||
<p class="muted small">Reset clears all submissions and the participant list, then returns to the lobby. The QR / join URL stays the same.</p>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function renderLeaderboardList(rows) {
|
||
if (!rows || !rows.length) return `<p class="muted">No scores yet.</p>`;
|
||
return `
|
||
<ol class="leaderboard">
|
||
${rows.map((r) => `
|
||
<li>
|
||
<span class="rank">${r.rank}</span>
|
||
<span class="who"><b>${escapeText(r.name)}</b>${r.student_id ? `<small>${escapeText(r.student_id)}</small>` : ""}</span>
|
||
<span class="score">${fmtScore(r.score)}</span>
|
||
</li>
|
||
`).join("")}
|
||
</ol>
|
||
`;
|
||
}
|
||
|
||
function bindStateActions() {
|
||
document.querySelectorAll("[data-action]").forEach((btn) => {
|
||
btn.addEventListener("click", () => onAction(btn.dataset.action, btn));
|
||
});
|
||
const copy = document.querySelector("#copy-url");
|
||
if (copy) copy.addEventListener("click", copyJoinUrl);
|
||
}
|
||
|
||
function bindPresenceActions() {
|
||
document.querySelectorAll("[data-clear-student]").forEach((btn) => {
|
||
btn.addEventListener("click", async () => {
|
||
const studentId = btn.dataset.clearStudent;
|
||
if (!studentId) return;
|
||
if (!confirm(`Clear ${studentId}? Their submissions and presence row will be removed; they can then re-join with the same ID.`)) return;
|
||
btn.disabled = true;
|
||
try {
|
||
await api(`/admin/api/students/${encodeURIComponent(studentId)}`, { method: "DELETE" });
|
||
} catch (err) {
|
||
alert(err.message || "Could not clear student.");
|
||
btn.disabled = false;
|
||
}
|
||
// Server pushes presence_update so the row will disappear naturally.
|
||
});
|
||
});
|
||
}
|
||
|
||
async function onAction(action, btn) {
|
||
if (action === "reset") {
|
||
if (!confirm("Reset clears all participants and submissions. Continue?")) return;
|
||
btn.disabled = true;
|
||
try {
|
||
await api("/admin/api/reset", { method: "POST" });
|
||
// Server pushes a state=lobby broadcast over WS; rerender once the
|
||
// message lands, plus optimistically clear local accumulators.
|
||
store.roster = [];
|
||
store.histogram = null;
|
||
store.currentQuestion = null;
|
||
store.closedPayload = null;
|
||
store.endedPayload = null;
|
||
store.leaderboard = [];
|
||
store.session.state = "lobby";
|
||
store.session.current_question_idx = null;
|
||
renderDashboard();
|
||
} catch (err) {
|
||
alert(err.message || "Reset failed.");
|
||
btn.disabled = false;
|
||
}
|
||
return;
|
||
}
|
||
if (!store.ws || store.ws.readyState !== WebSocket.OPEN) {
|
||
store.notice = "Reconnecting to live channel…";
|
||
renderDashboard();
|
||
connectWS();
|
||
return;
|
||
}
|
||
const msg = ({
|
||
next: { type: "next" },
|
||
close: { type: "close_question" },
|
||
end: { type: "end_session" },
|
||
})[action];
|
||
if (msg) {
|
||
btn.disabled = true;
|
||
store.ws.send(JSON.stringify(msg));
|
||
}
|
||
}
|
||
|
||
async function logout() {
|
||
try {
|
||
await api("/admin/logout", { method: "POST" });
|
||
} catch {}
|
||
if (store.ws) store.ws.close();
|
||
store.ws = null;
|
||
store.session = null;
|
||
renderLogin();
|
||
}
|
||
|
||
function copyJoinUrl() {
|
||
const url = store.session?.join_url;
|
||
if (!url) return;
|
||
navigator.clipboard.writeText(url).then(() => {
|
||
const btn = document.querySelector("#copy-url");
|
||
if (!btn) return;
|
||
const original = btn.textContent;
|
||
btn.textContent = "Copied!";
|
||
setTimeout(() => { btn.textContent = original; }, 1500);
|
||
});
|
||
}
|
||
|
||
function connectWS() {
|
||
if (store.ws) {
|
||
try { store.ws.close(); } catch {}
|
||
}
|
||
const sid = store.session.sid;
|
||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/instructor/${sid}`);
|
||
store.ws = ws;
|
||
ws.addEventListener("message", (event) => {
|
||
try { handleWSMessage(JSON.parse(event.data)); } catch (e) { console.warn("bad ws msg", e); }
|
||
});
|
||
ws.addEventListener("close", () => {
|
||
store.notice = "Live connection dropped. Trying to reconnect…";
|
||
renderDashboard();
|
||
setTimeout(() => { if (store.session) connectWS(); }, 2000);
|
||
});
|
||
ws.addEventListener("open", () => {
|
||
if (store.notice && store.notice.startsWith("Live connection")) {
|
||
store.notice = null;
|
||
renderDashboard();
|
||
}
|
||
});
|
||
}
|
||
|
||
function handleWSMessage(message) {
|
||
switch (message.type) {
|
||
case "state":
|
||
store.session.state = message.state;
|
||
store.session.current_question_idx = message.current_question_idx;
|
||
if (message.state === "lobby") {
|
||
store.currentQuestion = null;
|
||
store.closedPayload = null;
|
||
store.endedPayload = null;
|
||
store.histogram = null;
|
||
}
|
||
renderDashboard();
|
||
break;
|
||
case "lobby_update":
|
||
store.roster = message.participants || [];
|
||
renderDashboard();
|
||
break;
|
||
case "presence_update":
|
||
store.presence = message.rows || [];
|
||
store.orphanDuplicates = message.orphan_duplicate_joins || [];
|
||
store.currentQIdx = message.current_question_idx ?? null;
|
||
renderDashboard();
|
||
break;
|
||
case "question_open":
|
||
store.session.state = "question_open";
|
||
store.session.current_question_idx = message.question_idx;
|
||
store.currentQuestion = message;
|
||
store.closedPayload = null;
|
||
store.histogram = null;
|
||
store.submittedCount = 0;
|
||
store.totalCount = 0;
|
||
store.questionDeadlineMs = Date.now() + (message.remaining_ms ?? message.time_limit * 1000);
|
||
renderDashboard();
|
||
break;
|
||
case "live_histogram":
|
||
store.histogram = message.histogram;
|
||
store.submittedCount = message.submitted_count;
|
||
store.totalCount = message.total_count;
|
||
patchHistogramOnly();
|
||
break;
|
||
case "question_closed":
|
||
store.session.state = "question_closed";
|
||
store.closedPayload = message;
|
||
store.histogram = message.histogram;
|
||
stopCountdown();
|
||
renderDashboard();
|
||
break;
|
||
case "between_questions":
|
||
// Not currently emitted by the new advance_to_next; safe to ignore.
|
||
break;
|
||
case "full_leaderboard":
|
||
store.leaderboard = message.leaderboard || [];
|
||
renderDashboard();
|
||
break;
|
||
case "session_ended":
|
||
store.session.state = "finished";
|
||
store.endedPayload = message;
|
||
stopCountdown();
|
||
renderDashboard();
|
||
break;
|
||
case "error":
|
||
store.notice = `Server error: ${message.message || message.code || "unknown"}`;
|
||
renderDashboard();
|
||
break;
|
||
}
|
||
}
|
||
|
||
function patchHistogramOnly() {
|
||
// Update histogram without re-rendering the entire dashboard, so the
|
||
// countdown bar doesn't flicker.
|
||
const target = document.querySelector(".question-card");
|
||
if (!target) { renderDashboard(); return; }
|
||
const live = target.querySelector(".hist.live");
|
||
const replacement = renderLiveHistogram();
|
||
if (live) {
|
||
const wrap = document.createElement("div");
|
||
wrap.innerHTML = replacement;
|
||
live.replaceWith(wrap.firstElementChild);
|
||
} else {
|
||
// No histogram yet; do a full render.
|
||
renderDashboard();
|
||
}
|
||
}
|
||
|
||
function startCountdown() {
|
||
stopCountdown();
|
||
countdownTimer = setInterval(tickCountdown, 250);
|
||
tickCountdown();
|
||
}
|
||
|
||
function stopCountdown() {
|
||
if (countdownTimer) clearInterval(countdownTimer);
|
||
countdownTimer = null;
|
||
}
|
||
|
||
function tickCountdown() {
|
||
const el = document.querySelector("#countdown");
|
||
const fill = document.querySelector("#qbar-fill");
|
||
if (!el || !fill || !store.questionDeadlineMs) return;
|
||
const remaining = Math.max(0, store.questionDeadlineMs - Date.now());
|
||
const limit = (store.currentQuestion?.time_limit ?? 60) * 1000;
|
||
el.textContent = `${Math.ceil(remaining / 1000)}s`;
|
||
el.classList.toggle("urgent", remaining > 0 && remaining <= 10000);
|
||
fill.style.width = `${Math.max(0, Math.min(100, remaining / limit * 100))}%`;
|
||
if (remaining <= 0) stopCountdown();
|
||
}
|
||
|
||
boot();
|