224 lines
7.3 KiB
JavaScript
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) => ({
|
|
"&": "&",
|
|
"<": "<",
|
|
">": ">",
|
|
'"': """,
|
|
"'": "'",
|
|
})[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();
|