overhaul: single-session deployment + redesigned frontend
Backend simplification:
- The server now loads ONE pool JSON from $QUIZ_POOL_PATH at startup and
upserts a single canonical session. The session id comes from the pool
JSON's optional "session_id" field, falling back to $QUIZ_SESSION_ID.
- The multi-quiz / multi-session CRUD API is gone:
DELETED GET/POST /admin/api/quizzes
DELETED POST /admin/api/quizzes/upload
DELETED GET/POST /admin/api/sessions
DELETED GET /admin/login (HTML stub)
DELETED GET /admin/api/sessions/{sid}/csv (replaced by /admin/api/csv)
Replaced with a single-session control surface:
GET /admin/ — serves admin.html unconditionally
GET /admin/api/state — admin-gated; pool meta + state + QR + join URL
POST /admin/api/reset — admin-gated; wipe submissions + back to lobby
POST /admin/logout — clear admin cookie
GET /admin/api/csv — single-session results
WS /ws/instructor/{sid} — kept; new commands "next" + "reset"
- Instructor "Next" button is now a single state-driving command
(RoomManager.advance_to_next): from lobby it opens Q0; from question_open
it closes the current Q and opens the next; from question_closed it
opens the next; if past the last question it ends the session.
- New RoomManager.reset wipes submissions, participants, and per-question
state, then broadcasts a clean lobby.
- Student GET / now redirects to /?sid=<canonical> when no sid is given,
so the QR / share URL is fully deterministic.
Frontend rewrite (functional baseline; visual polish to follow):
- /admin/ is now a single SPA: GET /admin/api/state decides login form
vs dashboard. No separate /admin/login URL bar.
- Admin dashboard is state-driven with one primary action per state.
QR code, join URL, and live participant list are always visible on the
left so the operator can leave the page on a projector.
- Student answer buttons are big and tappable; reveal screen highlights
correct/wrong choice + shows score, total, and rank.
- Static admin/student SPAs share a CSS palette with light/dark support.
Tests rewritten around the single canonical session id.
The auto-bootstrapped session lets each test fixture skip the old
quiz/session creation dance. 39/39 tests pass.
Cleanup:
- Deleted CODEX_PROMPT.md, IMPLEMENTATION_REPORT.md, NOTES.md, SPEC.md,
static/observer.html (obsolete codex-build artifacts and the unused
observer view).
- .gitignore now blocks /pool.json (the runtime file the operator drops
on the server) and the leftover .codex_done / codex_run.log / etc.
- bootstrap.sh seeds /opt/quiz/pool.json from examples/pool_example.json
on first deploy so a fresh box reaches a usable state without manual
intervention; .env now includes QUIZ_POOL_PATH.
This commit is contained in:
659
static/admin.js
659
static/admin.js
@@ -1,212 +1,553 @@
|
||||
/* 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");
|
||||
|
||||
let quizzes = [];
|
||||
let sessions = [];
|
||||
let activeSid = null;
|
||||
let ws = null;
|
||||
let leaderboard = [];
|
||||
let roster = [];
|
||||
let liveHistogram = null;
|
||||
let currentState = null;
|
||||
|
||||
const samplePool = {
|
||||
title: "Week 9 Recap: Computer Organization",
|
||||
score_fn: "linear_decay",
|
||||
time_limit_default: 60,
|
||||
questions: [
|
||||
{id: "q1", text: "Which unit sequences control signals in a multi-cycle datapath?", options: {A: "ALU", B: "Control unit", C: "Register file", D: "Instruction memory"}, correct: "B"}
|
||||
]
|
||||
const store = {
|
||||
session: null, // /admin/api/state response
|
||||
ws: null,
|
||||
roster: [],
|
||||
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 escapeText(value) {
|
||||
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
|
||||
return String(value ?? "").replace(/[&<>"']/g, (c) => ({
|
||||
"&": "&",
|
||||
"<": "<",
|
||||
">": ">",
|
||||
'"': """,
|
||||
"'": "'",
|
||||
})[char]);
|
||||
})[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",
|
||||
headers: {"Content-Type": "application/json", ...(options.headers || {})},
|
||||
...options,
|
||||
headers,
|
||||
});
|
||||
if (!response.ok) throw new Error(await response.text());
|
||||
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 {
|
||||
await refresh();
|
||||
render();
|
||||
} catch {
|
||||
renderLogin();
|
||||
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 renderLogin(error = "") {
|
||||
app.innerHTML = `<section class="shell"><div class="panel narrow">
|
||||
<h1>Admin Login</h1>
|
||||
<form id="login-form" class="stack">
|
||||
<label>Password <input name="password" type="password" required></label>
|
||||
${error ? `<p class="error">${escapeText(error)}</p>` : ""}
|
||||
<button class="primary">Log in</button>
|
||||
</form>
|
||||
</div></section>`;
|
||||
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 form = new FormData(event.currentTarget);
|
||||
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: form.get("password")})});
|
||||
await refresh();
|
||||
render();
|
||||
} catch {
|
||||
renderLogin("Login failed.");
|
||||
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.");
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
quizzes = (await api("/admin/api/quizzes")).quizzes;
|
||||
sessions = (await api("/admin/api/sessions")).sessions;
|
||||
function renderDashboard() {
|
||||
const session = store.session;
|
||||
if (!session) return;
|
||||
const state = store.endedPayload ? "finished" : (store.closedPayload?.state || 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>` : ""}
|
||||
<section class="dashboard">
|
||||
<aside class="dashboard-side">
|
||||
${renderJoinPanel()}
|
||||
${renderRosterPanel()}
|
||||
</aside>
|
||||
<main class="dashboard-main">
|
||||
${renderStatePanel(state)}
|
||||
</main>
|
||||
</section>
|
||||
`;
|
||||
document.querySelector("#logout-btn").addEventListener("click", logout);
|
||||
bindStateActions();
|
||||
if (state === "question_open") startCountdown();
|
||||
}
|
||||
|
||||
function render() {
|
||||
app.innerHTML = `<section class="admin-layout">
|
||||
<aside class="sidebar">
|
||||
<h1>Quiz Admin</h1>
|
||||
<button id="new-quiz" class="secondary">Add Pool</button>
|
||||
<button id="new-session" class="primary" ${quizzes.length ? "" : "disabled"}>Create Session</button>
|
||||
<h2>Quizzes</h2>
|
||||
<div class="list">${quizzes.map((quiz) => `<button data-quiz="${quiz.id}">${escapeText(quiz.title)}</button>`).join("") || "<p>No quizzes yet.</p>"}</div>
|
||||
<h2>Sessions</h2>
|
||||
<div class="list">${sessions.map((session) => `<button data-session="${session.sid}">${session.sid} ${escapeText(session.state)}</button>`).join("") || "<p>No sessions yet.</p>"}</div>
|
||||
</aside>
|
||||
<main class="workspace">${renderSession()}</main>
|
||||
</section>`;
|
||||
document.querySelector("#new-quiz").addEventListener("click", renderQuizModal);
|
||||
document.querySelector("#new-session").addEventListener("click", renderSessionModal);
|
||||
document.querySelectorAll("[data-session]").forEach((button) => {
|
||||
button.addEventListener("click", () => connectSession(button.dataset.session));
|
||||
});
|
||||
bindControls();
|
||||
function stateLabel(state) {
|
||||
return ({
|
||||
lobby: "Lobby",
|
||||
question_open: "Question live",
|
||||
question_closed: "Reveal",
|
||||
between_questions: "Between",
|
||||
finished: "Finished",
|
||||
})[state] || state || "—";
|
||||
}
|
||||
|
||||
function renderSession() {
|
||||
if (!activeSid) return `<div class="panel"><h1>No active session</h1><p>Create or select a session.</p></div>`;
|
||||
const session = sessions.find((item) => item.sid === activeSid);
|
||||
return `<div class="panel">
|
||||
<div class="topline"><h1>${escapeText(session?.title || activeSid)}</h1><span>${escapeText(currentState?.state || session?.state || "")}</span></div>
|
||||
<p>Session ID: <strong>${activeSid}</strong></p>
|
||||
<div class="toolbar">
|
||||
<button data-command="open_question" class="primary">Open</button>
|
||||
<button data-command="close_question">Close & Reveal</button>
|
||||
<button data-command="next">Next</button>
|
||||
<button data-command="end_session" class="danger">End</button>
|
||||
<a class="button" href="/admin/api/sessions/${activeSid}/csv">Download CSV</a>
|
||||
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>
|
||||
<label>Question index <input id="question-idx" type="number" min="0" value="${currentState?.current_question_idx ?? 0}"></label>
|
||||
<label>Time limit <input id="time-limit" type="number" min="1" value="60"></label>
|
||||
<h2>Roster (${roster.length})</h2>
|
||||
<div class="roster">${roster.map((p) => `<span>${escapeText(p.student_id)} ${escapeText(p.name)}</span>`).join("") || "No students yet."}</div>
|
||||
<h2>Live Histogram</h2>
|
||||
${renderHistogram(liveHistogram?.histogram)}
|
||||
<h2>Leaderboard</h2>
|
||||
${renderLeaderboard(leaderboard)}
|
||||
</div>`;
|
||||
`;
|
||||
}
|
||||
|
||||
function renderHistogram(histogram) {
|
||||
const data = histogram || {A: 0, B: 0, C: 0, D: 0, missed: 0, pending: 0};
|
||||
return `<div class="histogram">${Object.entries(data).map(([key, value]) => (
|
||||
`<div class="hist-row"><span>${key}</span><meter min="0" max="50" value="${value}"></meter><b>${value}</b></div>`
|
||||
)).join("")}</div>`;
|
||||
function renderRosterPanel() {
|
||||
const r = store.roster || [];
|
||||
return `
|
||||
<div class="card panel">
|
||||
<h2>Joined <span class="count">${r.length}</span></h2>
|
||||
${r.length
|
||||
? `<ul class="roster">${r.map((p) =>
|
||||
`<li><span class="dot"></span><span class="who"><b>${escapeText(p.name)}</b><small>${escapeText(p.student_id)}</small></span></li>`
|
||||
).join("")}</ul>`
|
||||
: `<p class="muted">No students have joined yet. Share the QR or URL.</p>`}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
function renderLeaderboard(rows) {
|
||||
return `<ol class="leaderboard">${(rows || []).map((row) => (
|
||||
`<li><span>${row.rank}. ${escapeText(row.name)} ${row.student_id ? `(${escapeText(row.student_id)})` : ""}</span><strong>${row.score}</strong></li>`
|
||||
)).join("") || "<li>No scores yet.</li>"}</ol>`;
|
||||
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 bindControls() {
|
||||
document.querySelectorAll("[data-command]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return;
|
||||
const command = button.dataset.command;
|
||||
if (command === "open_question") {
|
||||
ws.send(JSON.stringify({
|
||||
type: command,
|
||||
question_idx: Number(document.querySelector("#question-idx").value || 0),
|
||||
time_limit: Number(document.querySelector("#time-limit").value || 60),
|
||||
}));
|
||||
} else {
|
||||
ws.send(JSON.stringify({type: command}));
|
||||
}
|
||||
});
|
||||
function renderLobby() {
|
||||
const total = store.session.pool_meta.question_count;
|
||||
return `
|
||||
<div class="card panel">
|
||||
<div class="state-cta">
|
||||
<h2>Ready to start</h2>
|
||||
<p>When you start, question 1 of ${total} opens for everyone in the room. Late joiners can still join after a question opens; they get whatever time remains.</p>
|
||||
<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">Waiting for first submission…</p>`;
|
||||
const h = store.histogram;
|
||||
const total = Math.max(1, store.totalCount || 0);
|
||||
return `
|
||||
<div class="hist live">
|
||||
<div class="hist-summary">
|
||||
<span><b>${store.submittedCount}</b> submitted</span>
|
||||
${store.totalCount ? `<span class="muted">of ${store.totalCount} joined</span>` : ""}
|
||||
${h.pending != null ? `<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() {
|
||||
return `
|
||||
<div class="card panel">
|
||||
<h2>Quiz finished</h2>
|
||||
<p class="muted">${store.session.pool_meta.question_count} questions complete. Final leaderboard below.</p>
|
||||
<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">${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);
|
||||
}
|
||||
|
||||
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 renderQuizModal() {
|
||||
app.innerHTML = `<section class="shell"><div class="panel">
|
||||
<h1>Add Pool</h1>
|
||||
<form id="quiz-form" class="stack">
|
||||
<label>Pool JSON <textarea name="pool" rows="18">${escapeText(JSON.stringify(samplePool, null, 2))}</textarea></label>
|
||||
<button class="primary">Create Quiz</button>
|
||||
<button type="button" id="cancel">Cancel</button>
|
||||
</form>
|
||||
</div></section>`;
|
||||
document.querySelector("#cancel").addEventListener("click", render);
|
||||
document.querySelector("#quiz-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const pool = JSON.parse(new FormData(event.currentTarget).get("pool"));
|
||||
await api("/admin/api/quizzes", {method: "POST", body: JSON.stringify({pool_json: pool})});
|
||||
await refresh();
|
||||
render();
|
||||
});
|
||||
}
|
||||
|
||||
async function renderSessionModal() {
|
||||
const options = quizzes.map((quiz) => `<option value="${quiz.id}">${escapeText(quiz.title)}</option>`).join("");
|
||||
app.innerHTML = `<section class="shell"><div class="panel narrow">
|
||||
<h1>Create Session</h1>
|
||||
<form id="session-form" class="stack">
|
||||
<label>Quiz <select name="quiz_id">${options}</select></label>
|
||||
<button class="primary">Create</button>
|
||||
<button type="button" id="cancel">Cancel</button>
|
||||
</form>
|
||||
<div id="session-result"></div>
|
||||
</div></section>`;
|
||||
document.querySelector("#cancel").addEventListener("click", render);
|
||||
document.querySelector("#session-form").addEventListener("submit", async (event) => {
|
||||
event.preventDefault();
|
||||
const quizId = Number(new FormData(event.currentTarget).get("quiz_id"));
|
||||
const result = await api("/admin/api/sessions", {method: "POST", body: JSON.stringify({quiz_id: quizId})});
|
||||
document.querySelector("#session-result").innerHTML = `<h2>${result.sid}</h2><p><a href="${result.join_url}">${result.join_url}</a></p><img class="qr" src="${result.qr_url}" alt="QR code">`;
|
||||
await refresh();
|
||||
connectSession(result.sid);
|
||||
});
|
||||
}
|
||||
|
||||
function connectSession(sid) {
|
||||
activeSid = sid;
|
||||
if (ws) ws.close();
|
||||
function connectWS() {
|
||||
if (store.ws) {
|
||||
try { store.ws.close(); } catch {}
|
||||
}
|
||||
const sid = store.session.sid;
|
||||
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
|
||||
ws = new WebSocket(`${protocol}://${window.location.host}/ws/instructor/${sid}`);
|
||||
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/instructor/${sid}`);
|
||||
store.ws = ws;
|
||||
ws.addEventListener("message", (event) => {
|
||||
const message = JSON.parse(event.data);
|
||||
if (message.type === "state") currentState = message;
|
||||
if (message.type === "lobby_update") roster = message.participants;
|
||||
if (message.type === "live_histogram") liveHistogram = message;
|
||||
if (message.type === "full_leaderboard") leaderboard = message.leaderboard;
|
||||
if (message.type === "question_closed") liveHistogram = {histogram: message.histogram};
|
||||
render();
|
||||
try { handleWSMessage(JSON.parse(event.data)); } catch (e) { console.warn("bad ws msg", e); }
|
||||
});
|
||||
render();
|
||||
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 "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`;
|
||||
fill.style.width = `${Math.max(0, Math.min(100, remaining / limit * 100))}%`;
|
||||
if (remaining <= 0) stopCountdown();
|
||||
}
|
||||
|
||||
boot();
|
||||
|
||||
Reference in New Issue
Block a user