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 = ``;
+ 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 = ``;
+ 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}
+
+
+
+
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) => (
+ `- ${row.rank}. ${escapeText(row.name)} ${row.student_id ? `(${escapeText(row.student_id)})` : ""}${row.score}
`
+ )).join("") || "- No scores yet.
"}
`;
+}
+
+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 = ``;
+ 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}
`;
+ 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 = ``;
+}
+
+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``);
+ 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) => (
+ `- ${row.rank}. ${escapeText(row.name)}${row.score}
`
+ )).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;
+ }
+}