overhaul: single-session deployment + redesigned frontend

Backend simplification:
- The server now loads ONE pool JSON from $QUIZ_POOL_PATH at startup and
  upserts a single canonical session. The session id comes from the pool
  JSON's optional "session_id" field, falling back to $QUIZ_SESSION_ID.
- The multi-quiz / multi-session CRUD API is gone:
    DELETED  GET/POST /admin/api/quizzes
    DELETED  POST    /admin/api/quizzes/upload
    DELETED  GET/POST /admin/api/sessions
    DELETED  GET     /admin/login (HTML stub)
    DELETED  GET     /admin/api/sessions/{sid}/csv (replaced by /admin/api/csv)
  Replaced with a single-session control surface:
    GET  /admin/                — serves admin.html unconditionally
    GET  /admin/api/state       — admin-gated; pool meta + state + QR + join URL
    POST /admin/api/reset       — admin-gated; wipe submissions + back to lobby
    POST /admin/logout          — clear admin cookie
    GET  /admin/api/csv         — single-session results
    WS   /ws/instructor/{sid}   — kept; new commands "next" + "reset"
- Instructor "Next" button is now a single state-driving command
  (RoomManager.advance_to_next): from lobby it opens Q0; from question_open
  it closes the current Q and opens the next; from question_closed it
  opens the next; if past the last question it ends the session.
- New RoomManager.reset wipes submissions, participants, and per-question
  state, then broadcasts a clean lobby.
- Student GET / now redirects to /?sid=<canonical> when no sid is given,
  so the QR / share URL is fully deterministic.

Frontend rewrite (functional baseline; visual polish to follow):
- /admin/ is now a single SPA: GET /admin/api/state decides login form
  vs dashboard. No separate /admin/login URL bar.
- Admin dashboard is state-driven with one primary action per state.
  QR code, join URL, and live participant list are always visible on the
  left so the operator can leave the page on a projector.
- Student answer buttons are big and tappable; reveal screen highlights
  correct/wrong choice + shows score, total, and rank.
- Static admin/student SPAs share a CSS palette with light/dark support.

Tests rewritten around the single canonical session id.
The auto-bootstrapped session lets each test fixture skip the old
quiz/session creation dance. 39/39 tests pass.

Cleanup:
- Deleted CODEX_PROMPT.md, IMPLEMENTATION_REPORT.md, NOTES.md, SPEC.md,
  static/observer.html (obsolete codex-build artifacts and the unused
  observer view).
- .gitignore now blocks /pool.json (the runtime file the operator drops
  on the server) and the leftover .codex_done / codex_run.log / etc.
- bootstrap.sh seeds /opt/quiz/pool.json from examples/pool_example.json
  on first deploy so a fresh box reaches a usable state without manual
  intervention; .env now includes QUIZ_POOL_PATH.
This commit is contained in:
ameer
2026-05-02 21:13:54 +08:00
parent 32c531247d
commit e7a2f0387b
29 changed files with 1696 additions and 1533 deletions

View File

@@ -1,299 +1,490 @@
/* ============================================================
* Quiz portal — functional baseline stylesheet.
* Visual polish (typography, palette, micro-interactions) is layered
* on top of this by the frontend-design pass; structural rules and
* accessibility-relevant defaults live here.
* ============================================================ */
:root {
color-scheme: light dark;
--bg: #f6f7f9;
--surface: #ffffff;
--border: #d9dee7;
--text: #18212f;
--muted: #5b6573;
--primary: #0d6b57;
--primary-text: #ffffff;
--warn: #b67700;
--warn-text: #ffffff;
--danger: #a43831;
--danger-text: #ffffff;
--info: #254f7a;
--accent: #1c8a72;
--correct-bg: #e6f4ed;
--correct-border: #199870;
--wrong-border: #d04040;
--shadow: 0 8px 28px rgba(15, 25, 42, 0.06);
font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
background: #f6f7f9;
color: #18212f;
background: var(--bg);
color: var(--text);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
background: #f6f7f9;
color: #18212f;
min-height: 100dvh;
background: var(--bg);
color: var(--text);
font-size: 16px;
line-height: 1.45;
}
.shell {
max-width: 960px;
margin: 0 auto;
padding: 24px;
}
h1, h2, h3 { margin: 0 0 0.4rem; line-height: 1.2; }
h1 { font-size: 1.4rem; font-weight: 700; letter-spacing: -0.01em; }
h2 { font-size: 1.05rem; font-weight: 700; }
h3 { font-size: 0.95rem; font-weight: 700; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; }
p { margin: 0 0 0.6rem; }
code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-size: 0.85em; }
.panel {
background: #ffffff;
border: 1px solid #d9dee7;
border-radius: 8px;
padding: 24px;
box-shadow: 0 10px 30px rgba(19, 33, 54, 0.08);
}
.muted { color: var(--muted); }
.small { font-size: 0.85rem; }
.eyebrow { color: var(--muted); font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.08em; margin: 0 0 0.4rem; }
.narrow {
max-width: 440px;
margin: 8vh auto;
}
/* ---------- Layout containers ---------- */
.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 {
.bootstrap-loading {
display: grid;
place-items: center;
width: 36px;
height: 36px;
border-radius: 50%;
background: #254f7a;
color: #fff;
min-height: 100dvh;
color: var(--muted);
font-size: 0.95rem;
}
.histogram {
.centered-shell {
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 {
place-items: center;
min-height: 100dvh;
padding: 24px;
}
.toolbar {
/* ---------- Cards & panels ---------- */
.card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
box-shadow: var(--shadow);
padding: 24px;
}
.card.narrow { width: min(440px, 100%); }
.card-header { margin-bottom: 16px; }
.card.center { text-align: center; }
.panel { padding: 20px; }
.panel + .panel { margin-top: 16px; }
.panel h2 { margin-bottom: 12px; display: flex; align-items: center; gap: 8px; }
.panel h2 .count {
background: var(--info);
color: #fff;
border-radius: 999px;
padding: 2px 10px;
font-size: 0.75rem;
}
.stack { display: grid; gap: 14px; }
/* ---------- Forms ---------- */
.field { display: grid; gap: 6px; }
.field > span { font-weight: 600; font-size: 0.9rem; color: var(--muted); }
input, textarea, select {
font: inherit;
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 12px;
background: var(--surface);
color: var(--text);
outline: none;
}
input:focus, textarea:focus, select:focus {
border-color: var(--primary);
box-shadow: 0 0 0 3px color-mix(in srgb, var(--primary) 25%, transparent);
}
/* ---------- Buttons ---------- */
.btn, button {
font: inherit;
border: 1px solid var(--border);
border-radius: 10px;
padding: 10px 16px;
background: var(--surface);
color: var(--text);
cursor: pointer;
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 6px;
transition: transform 0.05s ease, background 0.15s ease, border-color 0.15s ease;
}
.btn:active, button:active { transform: translateY(1px); }
.btn:disabled, button:disabled { cursor: not-allowed; opacity: 0.55; }
.btn.primary { background: var(--primary); border-color: var(--primary); color: var(--primary-text); }
.btn.warn { background: var(--warn); border-color: var(--warn); color: var(--warn-text); }
.btn.danger { background: var(--danger); border-color: var(--danger); color: var(--danger-text); }
.btn.ghost { background: transparent; }
.btn.block { width: 100%; }
.btn.big { padding: 14px 20px; font-size: 1.05rem; font-weight: 600; }
.btn.small { padding: 6px 10px; font-size: 0.85rem; }
/* ---------- Alerts ---------- */
.alert { padding: 10px 14px; border-radius: 8px; font-size: 0.9rem; }
.alert.error { background: color-mix(in srgb, var(--danger) 15%, transparent); border: 1px solid var(--danger); color: var(--danger); }
.alert.info { background: color-mix(in srgb, var(--info) 12%, transparent); border: 1px solid var(--info); color: var(--info); }
/* ---------- Admin topbar ---------- */
.admin-body { padding-bottom: 32px; }
.topbar {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 16px;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--border);
flex-wrap: wrap;
gap: 10px;
margin: 16px 0;
}
.topbar-title h1 { font-size: 1.25rem; }
.topbar-title p { margin: 0; }
.topbar-actions { display: flex; gap: 10px; align-items: center; }
.state-badge {
border-radius: 999px;
padding: 4px 12px;
font-size: 0.8rem;
font-weight: 600;
border: 1px solid var(--border);
}
.state-badge.state-lobby { background: color-mix(in srgb, var(--info) 18%, transparent); color: var(--info); border-color: var(--info); }
.state-badge.state-question_open { background: color-mix(in srgb, var(--primary) 18%, transparent); color: var(--primary); border-color: var(--primary); }
.state-badge.state-question_closed { background: color-mix(in srgb, var(--warn) 18%, transparent); color: var(--warn); border-color: var(--warn); }
.state-badge.state-finished { background: color-mix(in srgb, var(--accent) 18%, transparent); color: var(--accent); border-color: var(--accent); }
.state-badge.state-correct { background: var(--correct-bg); color: var(--accent); border-color: var(--correct-border); }
.state-badge.state-wrong { background: color-mix(in srgb, var(--danger) 12%, transparent); color: var(--danger); border-color: var(--wrong-border); }
/* ---------- Admin dashboard layout ---------- */
.dashboard {
display: grid;
gap: 16px;
grid-template-columns: minmax(280px, 360px) 1fr;
padding: 20px 24px;
align-items: start;
}
@media (max-width: 800px) {
.dashboard { grid-template-columns: 1fr; }
}
.dashboard-side { display: grid; gap: 16px; }
.dashboard-main { display: grid; gap: 16px; }
/* ---------- Join panel (QR + URL + roster) ---------- */
.qr-wrap {
background: #fff;
padding: 14px;
border-radius: 10px;
border: 1px solid var(--border);
display: grid;
place-items: center;
margin-bottom: 12px;
}
.qr-wrap img { width: 100%; height: auto; max-width: 280px; display: block; }
.qr-fallback { padding: 40px; color: var(--muted); }
.join-url-row { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; }
.join-url {
flex: 1 1 200px;
background: color-mix(in srgb, var(--info) 8%, transparent);
padding: 6px 10px;
border-radius: 6px;
font-size: 0.85rem;
word-break: break-all;
}
.roster {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 6px;
max-height: 360px;
overflow-y: auto;
}
.roster li { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border); }
.roster li:last-child { border-bottom: none; }
.roster .dot { width: 8px; height: 8px; border-radius: 999px; background: var(--accent); flex-shrink: 0; }
.roster .who { display: grid; line-height: 1.2; }
.roster .who small { color: var(--muted); font-size: 0.8rem; }
/* ---------- State CTA panel (lobby / finished) ---------- */
.state-cta { display: grid; gap: 10px; }
.state-cta .btn.big { justify-self: start; }
.action-row { display: flex; gap: 10px; flex-wrap: wrap; margin-top: 16px; }
/* ---------- Question card (admin + student share) ---------- */
.question-head {
display: flex;
flex-wrap: wrap;
gap: 8px;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-bottom: 10px;
}
.qnum { font-weight: 700; color: var(--muted); font-size: 0.9rem; }
.countdown {
font-weight: 700;
font-size: 1.1rem;
font-variant-numeric: tabular-nums;
color: var(--primary);
}
.roster span {
border: 1px solid #ccd4df;
.qbar {
height: 8px;
background: color-mix(in srgb, var(--primary) 14%, transparent);
border-radius: 999px;
padding: 6px 10px;
overflow: hidden;
margin-bottom: 16px;
}
.qbar span {
display: block;
height: 100%;
width: 100%;
background: var(--primary);
transition: width 0.2s linear;
}
.qr {
width: min(280px, 100%);
height: auto;
background: #fff;
padding: 12px;
.question-text { font-size: 1.2rem; font-weight: 600; margin: 8px 0 16px; }
.question-text.small { font-size: 1rem; }
.options {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 10px;
}
.options li {
display: grid;
grid-template-columns: 36px 1fr auto;
gap: 12px;
align-items: center;
padding: 10px 14px;
background: color-mix(in srgb, var(--info) 4%, transparent);
border: 1px solid var(--border);
border-radius: 10px;
}
.options .key {
background: var(--info);
color: #fff;
font-weight: 700;
border-radius: 6px;
padding: 4px 0;
text-align: center;
width: 36px;
}
.options .opt-text { color: var(--text); }
.options .opt-count { color: var(--muted); font-size: 0.85rem; }
.options.reveal li.correct {
background: var(--correct-bg);
border-color: var(--correct-border);
}
.options.reveal li.correct .key { background: var(--correct-border); }
.options.reveal li.wrong-pick { border-color: var(--wrong-border); }
.options.reveal li.wrong-pick .key { background: var(--danger); }
.explanation {
background: color-mix(in srgb, var(--accent) 8%, transparent);
padding: 12px 14px;
border-radius: 10px;
border-left: 3px solid var(--accent);
margin-top: 14px;
font-size: 0.95rem;
}
@media (max-width: 720px) {
.shell, .workspace {
padding: 14px;
}
/* ---------- Histogram ---------- */
.admin-layout {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
}
.panel {
padding: 18px;
}
.hist { margin-top: 16px; display: grid; gap: 8px; }
.hist-summary { display: flex; gap: 12px; flex-wrap: wrap; font-size: 0.9rem; }
.hist-rows { display: grid; gap: 6px; }
.hist-row {
display: grid;
grid-template-columns: 36px 1fr 80px;
gap: 10px;
align-items: center;
}
.hist-row .bar {
height: 8px;
background: color-mix(in srgb, var(--info) 12%, transparent);
border-radius: 999px;
overflow: hidden;
}
.hist-row .bar .fill {
display: block;
height: 100%;
background: var(--info);
transition: width 0.3s ease;
}
.hist-row.is-correct .bar .fill { background: var(--correct-border); }
.hist-row.missed .bar { background: transparent; }
.hist-row .num { font-size: 0.85rem; text-align: right; color: var(--muted); }
/* ---------- Leaderboard ---------- */
.leaderboard {
list-style: none;
margin: 0;
padding: 0;
display: grid;
gap: 4px;
}
.leaderboard li {
display: grid;
grid-template-columns: 32px 1fr auto;
gap: 12px;
align-items: center;
padding: 8px 12px;
border-radius: 8px;
}
.leaderboard li:nth-child(odd) { background: color-mix(in srgb, var(--info) 5%, transparent); }
.leaderboard .rank { font-weight: 700; color: var(--muted); font-variant-numeric: tabular-nums; }
.leaderboard .who { display: grid; line-height: 1.2; }
.leaderboard .who small { color: var(--muted); font-size: 0.78rem; }
.leaderboard .score { font-weight: 700; font-variant-numeric: tabular-nums; color: var(--primary); }
/* ---------- Student-side answer buttons (big, tappable) ---------- */
.quiz-card { width: min(640px, 100%); }
.answer-grid {
display: grid;
gap: 12px;
margin: 18px 0 0;
}
.answer-btn {
display: grid;
grid-template-columns: 48px 1fr;
gap: 14px;
align-items: center;
text-align: left;
background: var(--surface);
border: 2px solid var(--border);
border-radius: 12px;
padding: 18px 18px;
font-size: 1rem;
min-height: 64px;
}
.answer-btn:hover:not(:disabled) {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 6%, transparent);
}
.answer-btn.picked {
border-color: var(--primary);
background: color-mix(in srgb, var(--primary) 12%, transparent);
}
.answer-btn .answer-key {
background: var(--info);
color: #fff;
font-weight: 700;
border-radius: 8px;
padding: 6px 0;
text-align: center;
width: 48px;
font-size: 1.05rem;
}
.answer-btn .answer-text { font-weight: 500; }
.big-score {
font-size: 3rem;
font-weight: 800;
color: var(--primary);
margin: 8px 0 4px;
font-variant-numeric: tabular-nums;
}
.spinner {
width: 28px;
height: 28px;
margin: 12px auto 0;
border: 3px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* ---------- Reveal stats (student) ---------- */
.reveal-stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 12px;
margin: 16px 0;
}
.reveal-stats .stat {
text-align: center;
padding: 12px 8px;
background: color-mix(in srgb, var(--info) 6%, transparent);
border-radius: 10px;
}
.reveal-stats .stat span { display: block; font-size: 0.8rem; }
.reveal-stats .stat b { display: block; font-size: 1.4rem; margin-top: 4px; }
.reveal-stats .stat.big b { font-size: 2.2rem; color: var(--primary); }
.celebration-card {
text-align: center;
width: min(640px, 100%);
position: relative;
}
.celebration-banner {
background: var(--primary);
color: #fff;
padding: 14px 20px;
border-radius: 10px;
font-weight: 700;
font-size: 1.1rem;
margin: -8px 0 20px;
}
/* ---------- Dark mode ---------- */
@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;
:root {
--bg: #0f1117;
--surface: #181c25;
--border: #2c3340;
--text: #edf1f7;
--muted: #98a3b3;
--primary: #4ec9aa;
--primary-text: #061612;
--warn: #f0b441;
--warn-text: #1a1102;
--danger: #f06a6a;
--info: #5489c6;
--accent: #6dd2b6;
--correct-bg: rgba(78, 201, 170, 0.18);
--correct-border: #4ec9aa;
--shadow: 0 8px 28px rgba(0, 0, 0, 0.4);
}
.qr-wrap { background: #fff; }
.join-url { background: rgba(84, 137, 198, 0.12); }
}