Files
quiz/static/quiz.js
2026-05-02 03:02:08 +08:00

224 lines
7.3 KiB
JavaScript

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;
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) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
})[char]);
}
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>`);
}
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());
return response.json();
}
async function boot() {
if (!sid) {
askForLink();
return;
}
try {
await api(`/api/session/${sid}`);
} catch {
askForLink();
return;
}
try {
me = await api(`/api/session/${sid}/me`);
connect();
} catch {
renderJoin();
}
}
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>
</form>
</div>`);
document.querySelector("#join-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = 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"),
}),
});
me = await api(`/api/session/${sid}/me`);
connect();
} catch {
renderJoin("Could not join this session.");
}
});
}
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)));
ws.addEventListener("close", () => {
clearInterval(countdownTimer);
setView(html`<div class="panel center"><h1>Connection closed</h1><p>Refresh the page to reconnect.</p></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);
}
function renderState(message) {
activeQuestion = null;
submitted = null;
clearInterval(countdownTimer);
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>`);
}
}
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));
});
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);
}
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);
}
function renderSubmitted(message) {
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>`);
}
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>`);
}
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>`);
}
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>`);
}
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>`;
}
function renderError(message) {
setView(html`<div class="panel center"><h1>Quiz message</h1><p>${escapeText(message)}</p></div>`);
}
boot();