From dfebfe2ee84f514a6cad86df60dfe06b0b02eb6b Mon Sep 17 00:00:00 2001 From: ameer Date: Sat, 2 May 2026 03:02:08 +0800 Subject: [PATCH] Add student and admin frontends --- static/admin.js | 212 ++++++++++++++++++++++++++++++++++- static/quiz.js | 223 ++++++++++++++++++++++++++++++++++++- static/style.css | 282 +++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 715 insertions(+), 2 deletions(-) diff --git a/static/admin.js b/static/admin.js index c5fea96..6571c9f 100644 --- a/static/admin.js +++ b/static/admin.js @@ -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) => ({ + "&": "&", + "<": "<", + ">": ">", + '"': """, + "'": "'", + })[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 = `
+

Admin Login

+
+ + ${error ? `

${escapeText(error)}

` : ""} + +
+
`; + 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 = `
+ +
${renderSession()}
+
`; + 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 `

No active session

Create or select a session.

`; + const session = sessions.find((item) => item.sid === activeSid); + return `
+

${escapeText(session?.title || activeSid)}

${escapeText(currentState?.state || session?.state || "")}
+

Session ID: ${activeSid}

+
+ + + + + Download CSV +
+ + +

Roster (${roster.length})

+
${roster.map((p) => `${escapeText(p.student_id)} ${escapeText(p.name)}`).join("") || "No students yet."}
+

Live Histogram

+ ${renderHistogram(liveHistogram?.histogram)} +

Leaderboard

+ ${renderLeaderboard(leaderboard)} +
`; +} + +function renderHistogram(histogram) { + const data = histogram || {A: 0, B: 0, C: 0, D: 0, missed: 0, pending: 0}; + return `
${Object.entries(data).map(([key, value]) => ( + `
${key}${value}
` + )).join("")}
`; +} + +function renderLeaderboard(rows) { + return `
    ${(rows || []).map((row) => ( + `
  1. ${row.rank}. ${escapeText(row.name)} ${row.student_id ? `(${escapeText(row.student_id)})` : ""}${row.score}
  2. ` + )).join("") || "
  3. No scores yet.
  4. "}
`; +} + +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 = `
+

Add Pool

+
+ + + +
+
`; + 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) => ``).join(""); + app.innerHTML = `
+

Create Session

+
+ + + +
+
+
`; + 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 = `

${result.sid}

${result.join_url}

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(); diff --git a/static/quiz.js b/static/quiz.js index 598e3bf..3428afc 100644 --- a/static/quiz.js +++ b/static/quiz.js @@ -1,2 +1,223 @@ const app = document.querySelector("#app"); -app.textContent = "Loading quiz..."; +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 = `
${markup}
`; +} + +function askForLink() { + setView(html`
+

Ask your instructor for the link

+

This quiz link is missing or no longer valid.

+
`); +} + +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`
+

Join Quiz

+
+ + + ${error ? `

${escapeText(error)}

` : ""} + +
+
`); + 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`

Connection closed

Refresh the page to reconnect.

`); + }); +} + +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`
+

${escapeText(message.title)}

+

You are in. Waiting for instructor to start.

+

${escapeText(me?.name || "")}

+
+
`); + } +} + +function renderQuestion(message) { + activeQuestion = message; + submitted = null; + const buttons = Object.entries(message.options).map(([key, value]) => ( + `` + )).join(""); + setView(html`
+
Question ${message.question_idx + 1}
+
+

${escapeText(message.text)}

+
${buttons}
+
`); + 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`
+

Submitted

+

Submitted in ${seconds}s, +${message.score} pts.

+

Wait for the reveal.

+
`); +} + +function renderReveal(message) { + clearInterval(countdownTimer); + const rows = Object.entries(message.histogram).map(([key, value]) => ( + `
${key}${value}
` + )).join(""); + const board = renderBoard(message.top5); + setView(html`
+

Correct answer: ${escapeText(message.correct)}

+

Reveal

+

Your answer: ${escapeText(message.your_answer || "missed")}. Score: ${message.your_score || 0}.

+ ${message.explanation ? `

${escapeText(message.explanation)}

` : ""} +
${rows}
+ ${board} +

Your rank: ${message.your_rank ?? "pending"}. Total: ${message.your_total ?? 0}.

+
`); +} + +function renderBetween(message) { + setView(html`
+

Next question coming up

+

Your rank: ${message.your_rank ?? "pending"}

+

Total: ${message.your_total ?? 0}

+ ${renderBoard(message.top5)} +
`); +} + +function renderFinished(message) { + setView(html`
+

Quiz finished

+

Your rank: ${message.your_rank ?? "pending"}. Total: ${message.your_total ?? 0}.

+

Answered ${message.questions_answered ?? 0}, correct ${message.questions_correct ?? 0}.

+ ${renderBoard(message.final_top5)} +
`); +} + +function renderBoard(rows = []) { + if (!rows.length) return "

No scores yet.

"; + return `
    ${rows.map((row) => ( + `
  1. ${row.rank}. ${escapeText(row.name)}${row.score}
  2. ` + )).join("")}
`; +} + +function renderError(message) { + setView(html`

Quiz message

${escapeText(message)}

`); +} + +boot(); diff --git a/static/style.css b/static/style.css index 93959ce..2fa50e8 100644 --- a/static/style.css +++ b/static/style.css @@ -1,6 +1,8 @@ :root { color-scheme: light dark; font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + background: #f6f7f9; + color: #18212f; } body { @@ -15,3 +17,283 @@ body { margin: 0 auto; padding: 24px; } + +.panel { + background: #ffffff; + border: 1px solid #d9dee7; + border-radius: 8px; + padding: 24px; + box-shadow: 0 10px 30px rgba(19, 33, 54, 0.08); +} + +.narrow { + max-width: 440px; + margin: 8vh auto; +} + +.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 { + display: grid; + place-items: center; + width: 36px; + height: 36px; + border-radius: 50%; + background: #254f7a; + color: #fff; +} + +.histogram { + 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 { + padding: 24px; +} + +.toolbar { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin: 16px 0; +} + +.roster { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.roster span { + border: 1px solid #ccd4df; + border-radius: 999px; + padding: 6px 10px; +} + +.qr { + width: min(280px, 100%); + height: auto; + background: #fff; + padding: 12px; +} + +@media (max-width: 720px) { + .shell, .workspace { + padding: 14px; + } + + .admin-layout { + grid-template-columns: 1fr; + } + + .sidebar { + position: static; + } + + .panel { + padding: 18px; + } +} + +@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; + } +}