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:
ameer
2026-05-02 21:13:54 +08:00
parent 32c531247d
commit e7a2f0387b
29 changed files with 1696 additions and 1533 deletions

View File

@@ -6,8 +6,10 @@
<title>Quiz Admin</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main id="admin-app"></main>
<body class="admin-body">
<main id="admin-app">
<div class="bootstrap-loading">Loading…</div>
</main>
<script type="module" src="/static/admin.js"></script>
</body>
</html>

View File

@@ -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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
})[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 &amp; 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();

View File

@@ -1,15 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Quiz Observer</title>
<link rel="stylesheet" href="/static/style.css">
</head>
<body>
<main class="shell">
<h1>Quiz Observer</h1>
<p>This read-only view is reserved for a future classroom display.</p>
</main>
</body>
</html>

View File

@@ -1,223 +1,361 @@
/* Student quiz SPA.
*
* Visit /?sid=<id>. If no cookie, render the join form. If cookie, open
* the student WS and follow server messages through the lifecycle:
* lobby → question_open → submitted → question_closed → … → session_ended
*
* The server is authoritative for state transitions and scoring. The
* client only animates the UI for whatever message the server sent.
*/
const app = document.querySelector("#app");
const params = new URLSearchParams(window.location.search);
const sid = params.get("sid");
let ws = null;
let me = null;
let activeQuestion = null;
let submitted = null;
const store = {
me: null,
ws: null,
currentQuestion: null,
submitted: null,
pickedAnswer: null,
deadlineMs: null,
};
let countdownTimer = null;
function html(strings, ...values) {
return strings.map((part, index) => part + (values[index] ?? "")).join("");
}
function escapeText(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
return String(value ?? "").replace(/[&<>"']/g, (c) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
})[char]);
})[c]);
}
function setView(markup) {
app.innerHTML = `<section class="shell">${markup}</section>`;
}
function askForLink() {
setView(html`<div class="panel center">
<h1>Ask your instructor for the link</h1>
<p>This quiz link is missing or no longer valid.</p>
</div>`);
app.innerHTML = `<section class="centered-shell">${markup}</section>`;
}
async function api(path, options = {}) {
const response = await fetch(path, {
credentials: "same-origin",
headers: {"Content-Type": "application/json", ...(options.headers || {})},
...options,
});
if (!response.ok) throw new Error(await response.text());
const headers = options.body ? { "Content-Type": "application/json", ...(options.headers || {}) } : options.headers;
const response = await fetch(path, { credentials: "same-origin", ...options, headers });
if (!response.ok) {
const error = new Error(await response.text());
error.status = response.status;
throw error;
}
return response.json();
}
function showAskInstructor() {
setView(`
<div class="card narrow">
<h1>Ask your instructor for the link</h1>
<p class="muted">This quiz link is missing or no longer valid.</p>
</div>
`);
}
async function boot() {
if (!sid) {
askForLink();
showAskInstructor();
return;
}
try {
await api(`/api/session/${sid}`);
} catch {
askForLink();
showAskInstructor();
return;
}
try {
me = await api(`/api/session/${sid}/me`);
connect();
} catch {
renderJoin();
store.me = await api(`/api/session/${sid}/me`);
} catch (err) {
if (err.status === 401) {
renderJoin();
return;
}
showAskInstructor();
return;
}
connect();
}
function renderJoin(error = "") {
setView(html`<div class="panel narrow">
<h1>Join Quiz</h1>
<form id="join-form" class="stack">
<label>Student ID <input name="student_id" autocomplete="username" required></label>
<label>Name <input name="name" autocomplete="name" required></label>
${error ? `<p class="error">${escapeText(error)}</p>` : ""}
<button class="primary" type="submit">Join</button>
function renderJoin(error = null) {
setView(`
<form id="join-form" class="card narrow stack">
<header class="card-header">
<h1>Join the quiz</h1>
<p class="muted">Enter your student ID and name. The cookie is per-device; clear it to switch.</p>
</header>
<label class="field">
<span>Student ID</span>
<input name="student_id" autocomplete="username" required autofocus>
</label>
<label class="field">
<span>Name</span>
<input name="name" autocomplete="name" required>
</label>
${error ? `<p class="alert error">${escapeText(error)}</p>` : ""}
<button type="submit" class="btn primary block">Join</button>
</form>
</div>`);
`);
document.querySelector("#join-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 data = new FormData(event.currentTarget);
try {
await api(`/api/session/${sid}/join`, {
method: "POST",
body: JSON.stringify({
student_id: form.get("student_id"),
name: form.get("name"),
student_id: data.get("student_id"),
name: data.get("name"),
}),
});
me = await api(`/api/session/${sid}/me`);
store.me = await api(`/api/session/${sid}/me`);
connect();
} catch {
renderJoin("Could not join this session.");
} catch (err) {
submit.disabled = false;
renderJoin(err.message || "Could not join.");
}
});
}
function connect() {
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
ws = new WebSocket(`${protocol}://${window.location.host}/ws/student/${sid}`);
ws.addEventListener("message", (event) => handleMessage(JSON.parse(event.data)));
const ws = new WebSocket(`${protocol}://${window.location.host}/ws/student/${sid}`);
store.ws = ws;
ws.addEventListener("message", (event) => {
try { handleMessage(JSON.parse(event.data)); } catch (e) { console.warn("bad ws msg", e); }
});
ws.addEventListener("close", () => {
clearInterval(countdownTimer);
setView(html`<div class="panel center"><h1>Connection closed</h1><p>Refresh the page to reconnect.</p></div>`);
stopCountdown();
setView(`
<div class="card narrow center">
<h1>Disconnected</h1>
<p class="muted">Your connection dropped.</p>
<button class="btn primary block" onclick="window.location.reload()">Reconnect</button>
</div>
`);
});
}
function handleMessage(message) {
if (message.type === "state") renderState(message);
if (message.type === "question_open") renderQuestion(message);
if (message.type === "submit_ack") renderSubmitted(message);
if (message.type === "question_closed") renderReveal(message);
if (message.type === "between_questions") renderBetween(message);
if (message.type === "session_ended") renderFinished(message);
if (message.type === "error") renderError(message.message);
switch (message.type) {
case "state": return renderState(message);
case "question_open": return renderQuestion(message);
case "submit_ack": return renderSubmitted(message);
case "question_closed": return renderReveal(message);
case "between_questions": return renderBetween(message);
case "session_ended": return renderFinished(message);
case "error": return renderError(message);
}
}
function renderState(message) {
activeQuestion = null;
submitted = null;
clearInterval(countdownTimer);
store.currentQuestion = null;
store.submitted = null;
store.pickedAnswer = null;
stopCountdown();
if (message.state === "lobby") {
setView(html`<div class="panel center">
<h1>${escapeText(message.title)}</h1>
<p class="status">You are in. Waiting for instructor to start.</p>
<p>${escapeText(me?.name || "")}</p>
<div class="spinner"></div>
</div>`);
setView(`
<div class="card narrow center">
<p class="eyebrow">${escapeText(message.title || "Live quiz")}</p>
<h1>You're in.</h1>
<p class="muted">Hi <b>${escapeText(store.me?.name || "")}</b>. Waiting for your instructor to start.</p>
<div class="spinner" aria-hidden="true"></div>
</div>
`);
} else if (message.state === "finished") {
// Edge case: rejoin after the quiz already ended. Render a friendly
// placeholder and wait for a session_ended payload.
setView(`
<div class="card narrow center">
<h1>Quiz finished</h1>
<p class="muted">Final results coming through…</p>
</div>
`);
}
}
function renderQuestion(message) {
activeQuestion = message;
submitted = null;
const buttons = Object.entries(message.options).map(([key, value]) => (
`<button class="answer" data-answer="${key}"><strong>${key}</strong><span>${escapeText(value)}</span></button>`
)).join("");
setView(html`<article class="panel quiz-panel">
<div class="topline"><span>Question ${message.question_idx + 1}</span><span id="timer"></span></div>
<div class="bar"><span id="bar-fill"></span></div>
<h1>${escapeText(message.text)}</h1>
<div class="answers">${buttons}</div>
</article>`);
document.querySelectorAll("[data-answer]").forEach((button) => {
button.addEventListener("click", () => submitAnswer(button.dataset.answer));
store.currentQuestion = message;
store.submitted = null;
store.pickedAnswer = null;
store.deadlineMs = Date.now() + (message.remaining_ms ?? message.time_limit * 1000);
setView(`
<article class="card quiz-card">
<div class="question-head">
<span class="qnum">Question ${message.question_idx + 1}</span>
<span id="countdown" class="countdown">—</span>
</div>
<div class="qbar"><span id="qbar-fill"></span></div>
<h1 class="question-text">${escapeText(message.text)}</h1>
<div class="answer-grid">
${["A","B","C","D"].map((k) => `
<button class="answer-btn" data-answer="${k}">
<span class="answer-key">${k}</span>
<span class="answer-text">${escapeText(message.options[k] || "")}</span>
</button>
`).join("")}
</div>
</article>
`);
document.querySelectorAll("[data-answer]").forEach((btn) => {
btn.addEventListener("click", () => submitAnswer(btn.dataset.answer));
});
startCountdown(message);
}
function startCountdown(message) {
clearInterval(countdownTimer);
const endAt = Date.now() + message.remaining_ms;
const total = message.time_limit * 1000;
const tick = () => {
const remaining = Math.max(0, endAt - Date.now());
const timer = document.querySelector("#timer");
const fill = document.querySelector("#bar-fill");
if (timer) timer.textContent = `${Math.ceil(remaining / 1000)}s`;
if (fill) fill.style.width = `${Math.max(0, Math.min(100, remaining / total * 100))}%`;
};
tick();
countdownTimer = setInterval(tick, 250);
startCountdown();
}
function submitAnswer(answer) {
if (!ws || !activeQuestion || submitted) return;
ws.send(JSON.stringify({type: "submit", question_idx: activeQuestion.question_idx, answer}));
document.querySelectorAll("[data-answer]").forEach((button) => button.disabled = true);
if (!store.ws || !store.currentQuestion || store.submitted || store.pickedAnswer) return;
store.pickedAnswer = answer;
document.querySelectorAll("[data-answer]").forEach((btn) => {
btn.disabled = true;
if (btn.dataset.answer === answer) btn.classList.add("picked");
});
store.ws.send(JSON.stringify({
type: "submit",
question_idx: store.currentQuestion.question_idx,
answer,
}));
}
function renderSubmitted(message) {
submitted = message;
store.submitted = message;
const seconds = (message.elapsed_ms / 1000).toFixed(1);
setView(html`<div class="panel center">
<h1>Submitted</h1>
<p class="score">Submitted in ${seconds}s, +${message.score} pts.</p>
<p>Wait for the reveal.</p>
</div>`);
setView(`
<div class="card narrow center">
<p class="eyebrow">Question ${message.question_idx + 1}</p>
<h1 class="big-score">+${message.score}</h1>
<p class="muted">submitted in ${seconds}s</p>
<p class="muted small">Waiting for the reveal…</p>
<div class="spinner" aria-hidden="true"></div>
</div>
`);
}
function renderReveal(message) {
clearInterval(countdownTimer);
const rows = Object.entries(message.histogram).map(([key, value]) => (
`<div class="hist-row"><span>${key}</span><meter min="0" max="50" value="${value}"></meter><b>${value}</b></div>`
)).join("");
const board = renderBoard(message.top5);
setView(html`<article class="panel">
<p class="status">Correct answer: ${escapeText(message.correct)}</p>
<h1>Reveal</h1>
<p>Your answer: ${escapeText(message.your_answer || "missed")}. Score: ${message.your_score || 0}.</p>
${message.explanation ? `<p>${escapeText(message.explanation)}</p>` : ""}
<div class="histogram">${rows}</div>
${board}
<p>Your rank: ${message.your_rank ?? "pending"}. Total: ${message.your_total ?? 0}.</p>
</article>`);
stopCountdown();
const q = store.currentQuestion;
const yourAnswer = message.your_answer ?? null;
const correct = message.correct;
const won = yourAnswer === correct;
const totalSubmitters = ["A","B","C","D"].reduce((a, k) => a + (message.histogram[k] || 0), 0) + (message.histogram.missed || 0);
const denom = Math.max(1, totalSubmitters);
setView(`
<article class="card reveal-card">
<div class="question-head">
<span class="qnum">Q${message.question_idx + 1}</span>
<span class="state-badge ${won ? "state-correct" : "state-wrong"}">${won ? "Correct" : (yourAnswer ? "Wrong" : "Missed")}</span>
</div>
${q?.text ? `<h2 class="question-text small">${escapeText(q.text)}</h2>` : ""}
<ol class="options reveal student-reveal">
${["A","B","C","D"].map((k) => {
const isCorrect = k === correct;
const isYours = k === yourAnswer;
let cls = "";
if (isCorrect) cls += " correct";
if (isYours && !isCorrect) cls += " wrong-pick";
if (isYours) cls += " yours";
return `
<li class="${cls}">
<span class="key">${k}${isCorrect ? " ✓" : ""}${isYours && !isCorrect ? " ✗" : ""}</span>
<span class="opt-text">${escapeText(q?.options?.[k] || "")}</span>
<span class="opt-count muted">${message.histogram[k] || 0} (${Math.round(100 * (message.histogram[k] || 0) / denom)}%)</span>
</li>
`;
}).join("")}
</ol>
${message.explanation ? `<p class="explanation">${escapeText(message.explanation)}</p>` : ""}
<div class="reveal-stats">
<div class="stat"><span class="muted">Your score</span><b>+${message.your_score || 0}</b></div>
<div class="stat"><span class="muted">Total</span><b>${message.your_total ?? 0}</b></div>
<div class="stat"><span class="muted">Rank</span><b>${message.your_rank ?? "—"}</b></div>
</div>
<h3>Top 5</h3>
${renderBoard(message.top5)}
</article>
`);
}
function renderBetween(message) {
setView(html`<div class="panel center">
<h1>Next question coming up</h1>
<p>Your rank: ${message.your_rank ?? "pending"}</p>
<p>Total: ${message.your_total ?? 0}</p>
${renderBoard(message.top5)}
</div>`);
setView(`
<div class="card narrow center">
<p class="eyebrow">Up next</p>
<h1>Question ${(message.next_idx ?? 0) + 1}</h1>
<div class="reveal-stats">
<div class="stat"><span class="muted">Total</span><b>${message.your_total ?? 0}</b></div>
<div class="stat"><span class="muted">Rank</span><b>${message.your_rank ?? "—"}</b></div>
</div>
${renderBoard(message.top5)}
<div class="spinner" aria-hidden="true"></div>
</div>
`);
}
function renderFinished(message) {
setView(html`<div class="panel center celebration">
<h1>Quiz finished</h1>
<p>Your rank: ${message.your_rank ?? "pending"}. Total: ${message.your_total ?? 0}.</p>
<p>Answered ${message.questions_answered ?? 0}, correct ${message.questions_correct ?? 0}.</p>
${renderBoard(message.final_top5)}
</div>`);
stopCountdown();
setView(`
<article class="card celebration-card">
<div class="celebration-banner">Quiz complete</div>
<div class="reveal-stats">
<div class="stat big"><span class="muted">Your total</span><b>${message.your_total ?? 0}</b></div>
<div class="stat"><span class="muted">Rank</span><b>${message.your_rank ?? "—"}</b></div>
<div class="stat"><span class="muted">Correct</span><b>${message.questions_correct ?? 0} / ${message.questions_answered ?? 0}</b></div>
</div>
<h3>Final top 5</h3>
${renderBoard(message.final_top5)}
<p class="muted small">Thanks for playing.</p>
</article>
`);
}
function renderBoard(rows = []) {
if (!rows.length) return "<p>No scores yet.</p>";
return `<ol class="leaderboard">${rows.map((row) => (
`<li><span>${row.rank}. ${escapeText(row.name)}</span><strong>${row.score}</strong></li>`
)).join("")}</ol>`;
if (!rows || !rows.length) return `<p class="muted small">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></span>
<span class="score">${r.score}</span>
</li>
`).join("")}
</ol>
`;
}
function renderError(message) {
setView(html`<div class="panel center"><h1>Quiz message</h1><p>${escapeText(message)}</p></div>`);
setView(`
<div class="card narrow center">
<h1>Server message</h1>
<p class="muted">${escapeText(message.message || message.code || "Something went wrong.")}</p>
</div>
`);
}
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.deadlineMs) return;
const remaining = Math.max(0, store.deadlineMs - 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();

View File

@@ -1,299 +1,490 @@
/* ============================================================
* Quiz portal — functional baseline stylesheet.
* Visual polish (typography, palette, micro-interactions) is layered
* on top of this by the frontend-design pass; structural rules and
* accessibility-relevant defaults live here.
* ============================================================ */
:root {
color-scheme: light dark;
--bg: #f6f7f9;
--surface: #ffffff;
--border: #d9dee7;
--text: #18212f;
--muted: #5b6573;
--primary: #0d6b57;
--primary-text: #ffffff;
--warn: #b67700;
--warn-text: #ffffff;
--danger: #a43831;
--danger-text: #ffffff;
--info: #254f7a;
--accent: #1c8a72;
--correct-bg: #e6f4ed;
--correct-border: #199870;
--wrong-border: #d04040;
--shadow: 0 8px 28px rgba(15, 25, 42, 0.06);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f6f7f9;
color: #18212f;
background: var(--bg);
color: var(--text);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: #f6f7f9;
color: #18212f;
min-height: 100dvh;
background: var(--bg);
color: var(--text);
font-size: 16px;
line-height: 1.45;
}
.shell {
max-width: 960px;
margin: 0 auto;
padding: 24px;
}
h1, h2, h3 { margin: 0 0 0.4rem; line-height: 1.2; }
h1 { font-size: 1.4rem; font-weight: 700; letter-spacing: -0.01em; }
h2 { font-size: 1.05rem; font-weight: 700; }
h3 { font-size: 0.95rem; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
p { margin: 0 0 0.6rem; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.85em; }
.panel {
background: #ffffff;
border: 1px solid #d9dee7;
border-radius: 8px;
padding: 24px;
box-shadow: 0 10px 30px rgba(19, 33, 54, 0.08);
}
.muted { color: var(--muted); }
.small { font-size: 0.85rem; }
.eyebrow { color: var(--muted); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.08em; margin: 0 0 0.4rem; }
.narrow {
max-width: 440px;
margin: 8vh auto;
}
/* ---------- Layout containers ---------- */
.center {
text-align: center;
}
.stack {
display: grid;
gap: 16px;
}
h1, h2 {
margin-top: 0;
}
label {
display: grid;
gap: 6px;
font-weight: 700;
}
input, textarea, select, button, .button {
font: inherit;
}
input, textarea, select {
border: 1px solid #b8c0cc;
border-radius: 8px;
padding: 10px 12px;
background: #fff;
color: #18212f;
}
button, .button {
border: 1px solid #9aa7b8;
border-radius: 8px;
padding: 10px 14px;
background: #eef1f5;
color: #18212f;
cursor: pointer;
text-decoration: none;
}
button:disabled {
cursor: not-allowed;
opacity: 0.55;
}
.primary {
background: #0d6b57;
border-color: #0d6b57;
color: #fff;
}
.secondary {
background: #254f7a;
border-color: #254f7a;
color: #fff;
}
.danger {
background: #a43831;
border-color: #a43831;
color: #fff;
}
.error {
color: #a43831;
font-weight: 700;
}
.status, .score {
font-size: 1.2rem;
font-weight: 800;
}
.spinner {
width: 36px;
height: 36px;
margin: 20px auto 0;
border: 4px solid #cbd3df;
border-top-color: #0d6b57;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.quiz-panel h1 {
font-size: clamp(1.5rem, 4vw, 2.5rem);
}
.topline {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
font-weight: 800;
}
.bar {
height: 12px;
background: #e0e6ef;
border-radius: 999px;
overflow: hidden;
margin: 12px 0 24px;
}
.bar span {
display: block;
height: 100%;
width: 100%;
background: #0d6b57;
transition: width 0.2s linear;
}
.answers {
display: grid;
gap: 12px;
}
.answer {
display: grid;
grid-template-columns: 42px 1fr;
gap: 12px;
min-height: 68px;
align-items: center;
text-align: left;
background: #fff;
}
.answer strong {
.bootstrap-loading {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: #254f7a;
color: #fff;
min-height: 100dvh;
color: var(--muted);
font-size: 0.95rem;
}
.histogram {
.centered-shell {
display: grid;
gap: 10px;
margin: 16px 0;
}
.hist-row {
display: grid;
grid-template-columns: 72px 1fr 48px;
gap: 10px;
align-items: center;
}
meter {
width: 100%;
}
.leaderboard {
display: grid;
gap: 8px;
padding-left: 0;
list-style: none;
}
.leaderboard li {
display: flex;
justify-content: space-between;
gap: 16px;
padding: 10px 0;
border-bottom: 1px solid #e2e7ee;
}
.celebration {
outline: 6px solid #f2c94c;
}
.admin-layout {
display: grid;
grid-template-columns: 280px 1fr;
min-height: 100vh;
}
.sidebar {
padding: 20px;
background: #223044;
color: #fff;
}
.sidebar .list {
display: grid;
gap: 8px;
margin-bottom: 24px;
}
.sidebar button {
width: 100%;
text-align: left;
}
.workspace {
place-items: center;
min-height: 100dvh;
padding: 24px;
}
.toolbar {
/* ---------- Cards & panels ---------- */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: var(--shadow);
padding: 24px;
}
.card.narrow { width: min(440px, 100%); }
.card-header { margin-bottom: 16px; }
.card.center { text-align: center; }
.panel { padding: 20px; }
.panel + .panel { margin-top: 16px; }
.panel h2 { margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
.panel h2 .count {
background: var(--info);
color: #fff;
border-radius: 999px;
padding: 2px 10px;
font-size: 0.75rem;
}
.stack { display: grid; gap: 14px; }
/* ---------- Forms ---------- */
.field { display: grid; gap: 6px; }
.field > span { font-weight: 600; font-size: 0.9rem; color: var(--muted); }
input, textarea, select {
font: inherit;
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
background: var(--surface);
color: var(--text);
outline: none;
}
input:focus, textarea:focus, select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent);
}
/* ---------- Buttons ---------- */
.btn, button {
font: inherit;
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 16px;
background: var(--surface);
color: var(--text);
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: transform 0.05s ease, background 0.15s ease, border-color 0.15s ease;
}
.btn:active, button:active { transform: translateY(1px); }
.btn:disabled, button:disabled { cursor: not-allowed; opacity: 0.55; }
.btn.primary { background: var(--primary); border-color: var(--primary); color: var(--primary-text); }
.btn.warn { background: var(--warn); border-color: var(--warn); color: var(--warn-text); }
.btn.danger { background: var(--danger); border-color: var(--danger); color: var(--danger-text); }
.btn.ghost { background: transparent; }
.btn.block { width: 100%; }
.btn.big { padding: 14px 20px; font-size: 1.05rem; font-weight: 600; }
.btn.small { padding: 6px 10px; font-size: 0.85rem; }
/* ---------- Alerts ---------- */
.alert { padding: 10px 14px; border-radius: 8px; font-size: 0.9rem; }
.alert.error { background: color-mix(in srgb, var(--danger) 15%, transparent); border: 1px solid var(--danger); color: var(--danger); }
.alert.info { background: color-mix(in srgb, var(--info) 12%, transparent); border: 1px solid var(--info); color: var(--info); }
/* ---------- Admin topbar ---------- */
.admin-body { padding-bottom: 32px; }
.topbar {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
gap: 10px;
margin: 16px 0;
}
.topbar-title h1 { font-size: 1.25rem; }
.topbar-title p { margin: 0; }
.topbar-actions { display: flex; gap: 10px; align-items: center; }
.state-badge {
border-radius: 999px;
padding: 4px 12px;
font-size: 0.8rem;
font-weight: 600;
border: 1px solid var(--border);
}
.state-badge.state-lobby { background: color-mix(in srgb, var(--info) 18%, transparent); color: var(--info); border-color: var(--info); }
.state-badge.state-question_open { background: color-mix(in srgb, var(--primary) 18%, transparent); color: var(--primary); border-color: var(--primary); }
.state-badge.state-question_closed { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); border-color: var(--warn); }
.state-badge.state-finished { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); border-color: var(--accent); }
.state-badge.state-correct { background: var(--correct-bg); color: var(--accent); border-color: var(--correct-border); }
.state-badge.state-wrong { background: color-mix(in srgb, var(--danger) 12%, transparent); color: var(--danger); border-color: var(--wrong-border); }
/* ---------- Admin dashboard layout ---------- */
.dashboard {
display: grid;
gap: 16px;
grid-template-columns: minmax(280px, 360px) 1fr;
padding: 20px 24px;
align-items: start;
}
@media (max-width: 800px) {
.dashboard { grid-template-columns: 1fr; }
}
.dashboard-side { display: grid; gap: 16px; }
.dashboard-main { display: grid; gap: 16px; }
/* ---------- Join panel (QR + URL + roster) ---------- */
.qr-wrap {
background: #fff;
padding: 14px;
border-radius: 10px;
border: 1px solid var(--border);
display: grid;
place-items: center;
margin-bottom: 12px;
}
.qr-wrap img { width: 100%; height: auto; max-width: 280px; display: block; }
.qr-fallback { padding: 40px; color: var(--muted); }
.join-url-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.join-url {
flex: 1 1 200px;
background: color-mix(in srgb, var(--info) 8%, transparent);
padding: 6px 10px;
border-radius: 6px;
font-size: 0.85rem;
word-break: break-all;
}
.roster {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 6px;
max-height: 360px;
overflow-y: auto;
}
.roster li { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border); }
.roster li:last-child { border-bottom: none; }
.roster .dot { width: 8px; height: 8px; border-radius: 999px; background: var(--accent); flex-shrink: 0; }
.roster .who { display: grid; line-height: 1.2; }
.roster .who small { color: var(--muted); font-size: 0.8rem; }
/* ---------- State CTA panel (lobby / finished) ---------- */
.state-cta { display: grid; gap: 10px; }
.state-cta .btn.big { justify-self: start; }
.action-row { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 16px; }
/* ---------- Question card (admin + student share) ---------- */
.question-head {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-bottom: 10px;
}
.qnum { font-weight: 700; color: var(--muted); font-size: 0.9rem; }
.countdown {
font-weight: 700;
font-size: 1.1rem;
font-variant-numeric: tabular-nums;
color: var(--primary);
}
.roster span {
border: 1px solid #ccd4df;
.qbar {
height: 8px;
background: color-mix(in srgb, var(--primary) 14%, transparent);
border-radius: 999px;
padding: 6px 10px;
overflow: hidden;
margin-bottom: 16px;
}
.qbar span {
display: block;
height: 100%;
width: 100%;
background: var(--primary);
transition: width 0.2s linear;
}
.qr {
width: min(280px, 100%);
height: auto;
background: #fff;
padding: 12px;
.question-text { font-size: 1.2rem; font-weight: 600; margin: 8px 0 16px; }
.question-text.small { font-size: 1rem; }
.options {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 10px;
}
.options li {
display: grid;
grid-template-columns: 36px 1fr auto;
gap: 12px;
align-items: center;
padding: 10px 14px;
background: color-mix(in srgb, var(--info) 4%, transparent);
border: 1px solid var(--border);
border-radius: 10px;
}
.options .key {
background: var(--info);
color: #fff;
font-weight: 700;
border-radius: 6px;
padding: 4px 0;
text-align: center;
width: 36px;
}
.options .opt-text { color: var(--text); }
.options .opt-count { color: var(--muted); font-size: 0.85rem; }
.options.reveal li.correct {
background: var(--correct-bg);
border-color: var(--correct-border);
}
.options.reveal li.correct .key { background: var(--correct-border); }
.options.reveal li.wrong-pick { border-color: var(--wrong-border); }
.options.reveal li.wrong-pick .key { background: var(--danger); }
.explanation {
background: color-mix(in srgb, var(--accent) 8%, transparent);
padding: 12px 14px;
border-radius: 10px;
border-left: 3px solid var(--accent);
margin-top: 14px;
font-size: 0.95rem;
}
@media (max-width: 720px) {
.shell, .workspace {
padding: 14px;
}
/* ---------- Histogram ---------- */
.admin-layout {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
}
.panel {
padding: 18px;
}
.hist { margin-top: 16px; display: grid; gap: 8px; }
.hist-summary { display: flex; gap: 12px; flex-wrap: wrap; font-size: 0.9rem; }
.hist-rows { display: grid; gap: 6px; }
.hist-row {
display: grid;
grid-template-columns: 36px 1fr 80px;
gap: 10px;
align-items: center;
}
.hist-row .bar {
height: 8px;
background: color-mix(in srgb, var(--info) 12%, transparent);
border-radius: 999px;
overflow: hidden;
}
.hist-row .bar .fill {
display: block;
height: 100%;
background: var(--info);
transition: width 0.3s ease;
}
.hist-row.is-correct .bar .fill { background: var(--correct-border); }
.hist-row.missed .bar { background: transparent; }
.hist-row .num { font-size: 0.85rem; text-align: right; color: var(--muted); }
/* ---------- Leaderboard ---------- */
.leaderboard {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 4px;
}
.leaderboard li {
display: grid;
grid-template-columns: 32px 1fr auto;
gap: 12px;
align-items: center;
padding: 8px 12px;
border-radius: 8px;
}
.leaderboard li:nth-child(odd) { background: color-mix(in srgb, var(--info) 5%, transparent); }
.leaderboard .rank { font-weight: 700; color: var(--muted); font-variant-numeric: tabular-nums; }
.leaderboard .who { display: grid; line-height: 1.2; }
.leaderboard .who small { color: var(--muted); font-size: 0.78rem; }
.leaderboard .score { font-weight: 700; font-variant-numeric: tabular-nums; color: var(--primary); }
/* ---------- Student-side answer buttons (big, tappable) ---------- */
.quiz-card { width: min(640px, 100%); }
.answer-grid {
display: grid;
gap: 12px;
margin: 18px 0 0;
}
.answer-btn {
display: grid;
grid-template-columns: 48px 1fr;
gap: 14px;
align-items: center;
text-align: left;
background: var(--surface);
border: 2px solid var(--border);
border-radius: 12px;
padding: 18px 18px;
font-size: 1rem;
min-height: 64px;
}
.answer-btn:hover:not(:disabled) {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 6%, transparent);
}
.answer-btn.picked {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 12%, transparent);
}
.answer-btn .answer-key {
background: var(--info);
color: #fff;
font-weight: 700;
border-radius: 8px;
padding: 6px 0;
text-align: center;
width: 48px;
font-size: 1.05rem;
}
.answer-btn .answer-text { font-weight: 500; }
.big-score {
font-size: 3rem;
font-weight: 800;
color: var(--primary);
margin: 8px 0 4px;
font-variant-numeric: tabular-nums;
}
.spinner {
width: 28px;
height: 28px;
margin: 12px auto 0;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ---------- Reveal stats (student) ---------- */
.reveal-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin: 16px 0;
}
.reveal-stats .stat {
text-align: center;
padding: 12px 8px;
background: color-mix(in srgb, var(--info) 6%, transparent);
border-radius: 10px;
}
.reveal-stats .stat span { display: block; font-size: 0.8rem; }
.reveal-stats .stat b { display: block; font-size: 1.4rem; margin-top: 4px; }
.reveal-stats .stat.big b { font-size: 2.2rem; color: var(--primary); }
.celebration-card {
text-align: center;
width: min(640px, 100%);
position: relative;
}
.celebration-banner {
background: var(--primary);
color: #fff;
padding: 14px 20px;
border-radius: 10px;
font-weight: 700;
font-size: 1.1rem;
margin: -8px 0 20px;
}
/* ---------- Dark mode ---------- */
@media (prefers-color-scheme: dark) {
:root, body {
background: #10141b;
color: #edf1f7;
}
.panel, input, textarea, select, .answer {
background: #171d27;
color: #edf1f7;
border-color: #344052;
}
button, .button {
background: #263246;
color: #edf1f7;
border-color: #4a586d;
:root {
--bg: #0f1117;
--surface: #181c25;
--border: #2c3340;
--text: #edf1f7;
--muted: #98a3b3;
--primary: #4ec9aa;
--primary-text: #061612;
--warn: #f0b441;
--warn-text: #1a1102;
--danger: #f06a6a;
--info: #5489c6;
--accent: #6dd2b6;
--correct-bg: rgba(78, 201, 170, 0.18);
--correct-border: #4ec9aa;
--shadow: 0 8px 28px rgba(0, 0, 0, 0.4);
}
.qr-wrap { background: #fff; }
.join-url { background: rgba(84, 137, 198, 0.12); }
}