Add student and admin frontends
This commit is contained in:
212
static/admin.js
212
static/admin.js
@@ -1,2 +1,212 @@
|
|||||||
const app = document.querySelector("#admin-app");
|
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 = `<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 & 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();
|
||||||
|
|||||||
223
static/quiz.js
223
static/quiz.js
@@ -1,2 +1,223 @@
|
|||||||
const app = document.querySelector("#app");
|
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 = `<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();
|
||||||
|
|||||||
282
static/style.css
282
static/style.css
@@ -1,6 +1,8 @@
|
|||||||
:root {
|
:root {
|
||||||
color-scheme: light dark;
|
color-scheme: light dark;
|
||||||
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
||||||
|
background: #f6f7f9;
|
||||||
|
color: #18212f;
|
||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
@@ -15,3 +17,283 @@ body {
|
|||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
padding: 24px;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user