Add student and admin frontends

This commit is contained in:
ameer
2026-05-02 03:02:08 +08:00
parent 81e8173fb9
commit dfebfe2ee8
3 changed files with 715 additions and 2 deletions

View File

@@ -1,2 +1,212 @@
const app = document.querySelector("#admin-app");
app.textContent = "Loading admin...";
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"}
]
};
function escapeText(value) {
return String(value ?? "").replace(/[&<>"']/g, (char) => ({
"&": "&amp;",
"<": "&lt;",
">": "&gt;",
'"': "&quot;",
"'": "&#039;",
})[char]);
}
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 contentType = response.headers.get("content-type") || "";
return contentType.includes("json") ? response.json() : response.text();
}
async function boot() {
try {
await refresh();
render();
} catch {
renderLogin();
}
}
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>`;
document.querySelector("#login-form").addEventListener("submit", async (event) => {
event.preventDefault();
const form = new FormData(event.currentTarget);
try {
await api("/admin/login", {method: "POST", body: JSON.stringify({password: form.get("password")})});
await refresh();
render();
} catch {
renderLogin("Login failed.");
}
});
}
async function refresh() {
quizzes = (await api("/admin/api/quizzes")).quizzes;
sessions = (await api("/admin/api/sessions")).sessions;
}
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 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>
</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 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 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 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();
const protocol = window.location.protocol === "https:" ? "wss" : "ws";
ws = new WebSocket(`${protocol}://${window.location.host}/ws/instructor/${sid}`);
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();
});
render();
}
boot();