deploy rater (Docker/FastAPI): registration + completeness gate + /api/submit → dataset
3c88ed2 verified | <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <title>Admin · FrameWorker user study</title> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.4/dist/chart.umd.min.js"></script> | |
| <style> | |
| :root { | |
| --bg: #0f1115; --panel: #161922; --panel-2: #1c2030; --panel-3: #232839; | |
| --border: #2a2f42; --border-strong: #3a4060; | |
| --fg: #e6e8ef; --muted: #8b91a8; --muted-2: #5e6480; | |
| --accent: #6aa9ff; --good: #28c76f; --warn: #ff9f43; --bad: #ea5455; | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { margin: 0; padding: 0; background: var(--bg); color: var(--fg); | |
| font-family: -apple-system, "Inter", "PingFang SC", sans-serif; | |
| font-size: 14px; line-height: 1.5; } | |
| header { padding: 12px 24px; border-bottom: 1px solid var(--border); | |
| background: var(--panel); display: flex; gap: 14px; align-items: center; | |
| flex-wrap: wrap; } | |
| header h1 { font-size: 16px; margin: 0; } | |
| header .meta { color: var(--muted); font-size: 12px; } | |
| header .right { margin-left: auto; display: flex; gap: 10px; align-items: center; } | |
| main { padding: 18px 24px 80px; max-width: 1280px; margin: 0 auto; } | |
| section { background: var(--panel); border: 1px solid var(--border); | |
| border-radius: 8px; padding: 14px 16px; margin-bottom: 14px; } | |
| h2 { font-size: 14px; margin: 0 0 10px; color: var(--accent); } | |
| .row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; } | |
| .btn { background: var(--panel-3); border: 1px solid var(--border-strong); | |
| color: var(--fg); padding: 6px 12px; border-radius: 4px; cursor: pointer; | |
| font-family: inherit; font-size: 12px; } | |
| .btn:hover { background: #2c3450; } | |
| .btn.primary { background: var(--accent); color: #0a1a2a; | |
| border-color: var(--accent); font-weight: 600; } | |
| .btn.danger { background: var(--bad); color: #fff; border-color: var(--bad); } | |
| .btn:disabled { background: var(--panel-3); color: var(--muted-2); | |
| border-color: var(--border); cursor: not-allowed; } | |
| /* login screen */ | |
| .login-wrap { display: flex; align-items: center; justify-content: center; | |
| min-height: 70vh; } | |
| .login-card { background: var(--panel); border: 1px solid var(--border-strong); | |
| border-radius: 10px; padding: 22px 24px; max-width: 380px; width: 100%; } | |
| .login-card h2 { margin: 0 0 6px; color: var(--accent); } | |
| .login-card p { color: var(--muted); font-size: 13px; margin: 0 0 14px; } | |
| .login-card label { font-size: 11px; color: var(--muted); | |
| text-transform: uppercase; letter-spacing: 0.5px; } | |
| .login-card input { width: 100%; background: var(--panel-3); | |
| border: 1px solid var(--border); color: var(--fg); padding: 10px 12px; | |
| border-radius: 5px; font-family: inherit; font-size: 16px; margin-top: 4px; } | |
| .login-card .err { color: var(--bad); font-size: 12px; margin-top: 6px; | |
| min-height: 16px; } | |
| .login-card .login-btn { width: 100%; margin-top: 12px; padding: 10px; | |
| font-size: 14px; font-weight: 700; } | |
| /* table */ | |
| table { width: 100%; border-collapse: collapse; font-size: 12px; } | |
| th, td { padding: 7px 10px; text-align: left; border-bottom: 1px solid var(--border); } | |
| th { color: var(--muted); font-weight: 600; font-size: 11px; | |
| text-transform: uppercase; letter-spacing: 0.5px; | |
| background: var(--panel-2); position: sticky; top: 0; } | |
| tr:hover td { background: var(--panel-2); } | |
| td.path, td.email { font-family: ui-monospace, monospace; font-size: 11px; | |
| color: var(--muted); word-break: break-all; } | |
| td .badge { display: inline-block; padding: 2px 6px; border-radius: 8px; | |
| font-size: 10px; font-weight: 700; } | |
| td .badge.final { background: #1c3422; color: var(--good); } | |
| td .badge.in_progress { background: #3a3520; color: var(--warn); } | |
| td .badge.admin { background: #3a2f60; color: #d9b6ff; margin-left: 4px; } | |
| .progress-bar { background: var(--panel-3); height: 4px; border-radius: 2px; | |
| overflow: hidden; min-width: 80px; } | |
| .progress-bar .fill { background: var(--accent); height: 100%; } | |
| .filter-row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; | |
| margin-bottom: 10px; } | |
| .filter-row input { background: var(--panel-3); border: 1px solid var(--border); | |
| color: var(--fg); padding: 5px 8px; border-radius: 4px; font-size: 12px; | |
| font-family: inherit; min-width: 200px; } | |
| /* charts */ | |
| #viz { background: var(--panel); border: 1px solid var(--border); | |
| border-radius: 8px; padding: 14px 16px; margin-top: 14px; } | |
| .q-block { background: var(--panel-2); border: 1px solid var(--border); | |
| border-radius: 6px; padding: 10px 12px; margin: 8px 0; } | |
| .q-block .q-title { font-weight: 600; font-size: 13px; } | |
| .q-block .q-dim { color: var(--muted); font-size: 11px; } | |
| canvas { max-height: 220px; } | |
| .legend-dot { display: inline-block; width: 9px; height: 9px; | |
| border-radius: 50%; margin-right: 4px; vertical-align: middle; } | |
| .toast { position: fixed; bottom: 20px; right: 20px; | |
| background: var(--good); color: #0a1a0a; padding: 10px 16px; | |
| border-radius: 6px; font-weight: 600; opacity: 0; | |
| transition: opacity 0.2s; pointer-events: none; z-index: 100; } | |
| .toast.show { opacity: 1; } | |
| .toast.error { background: var(--bad); color: #fff; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="app"></div> | |
| <div class="toast" id="toast"></div> | |
| <script> | |
| const ADMIN_PW_KEY = "user_study_admin_pw_v1"; | |
| // Canonical rubric (must stay in sync with index.html DIMENSIONS). | |
| const DIMENSIONS = [ | |
| { key: "instruction_following", label: "Instruction following", | |
| questions: [ | |
| ["controllability", "Controllability"], | |
| ["prompt_adherence", "Prompt adherence"], | |
| ["no_irrelevant_frames", "No irrelevant frames"], | |
| ] }, | |
| { key: "temporal_consistency", label: "Temporal consistency", | |
| questions: [ | |
| ["person_stability", "Person stability"], | |
| ["object_stability", "Object stability"], | |
| ["environment_stability", "Environment stability"], | |
| ] }, | |
| { key: "motion_realism", label: "Realism", | |
| questions: [ | |
| ["realism", "Static realism"], | |
| ["motion_logic", "Motion logic"], | |
| ["camera", "Camera"], | |
| ] }, | |
| ]; | |
| const ALL_QKEYS = DIMENSIONS.flatMap(d => d.questions.map(([k]) => k)); | |
| let SUBMISSIONS = []; // list from /api/admin/submissions | |
| let SELECTED = new Set(); // paths the admin has ticked | |
| function el(tag, attrs={}, ...children) { | |
| const e = document.createElement(tag); | |
| for (const [k,v] of Object.entries(attrs)) { | |
| if (k === "class") e.className = v; | |
| else if (k === "html") e.innerHTML = v; | |
| else if (k.startsWith("on") && typeof v === "function") | |
| e.addEventListener(k.slice(2).toLowerCase(), v); | |
| else if (v !== false && v != null) e.setAttribute(k, v); | |
| } | |
| for (const c of children) { | |
| if (c == null || c === false) continue; | |
| e.appendChild(typeof c === "string" ? document.createTextNode(c) : c); | |
| } | |
| return e; | |
| } | |
| function showToast(msg, isErr=false) { | |
| const t = document.getElementById("toast"); | |
| t.textContent = msg; | |
| t.classList.toggle("error", !!isErr); | |
| t.classList.add("show"); | |
| setTimeout(()=>t.classList.remove("show"), 1800); | |
| } | |
| function adminPw() { try { return sessionStorage.getItem(ADMIN_PW_KEY) || ""; } catch(e){ return ""; } } | |
| function setAdminPw(pw) { try { sessionStorage.setItem(ADMIN_PW_KEY, pw); } catch(e){} } | |
| function clearAdminPw() { try { sessionStorage.removeItem(ADMIN_PW_KEY); } catch(e){} } | |
| /* ---------- login ---------- */ | |
| function renderLogin() { | |
| const app = document.getElementById("app"); | |
| app.innerHTML = ""; | |
| app.appendChild(el("header", {}, | |
| el("h1", {}, "Admin · FrameWorker user study"), | |
| el("span", { class: "meta" }, "password-gated"), | |
| el("div", { class: "right" }, | |
| el("a", { href: "./", style: "color:var(--accent);font-size:12px" }, "← back to study"), | |
| ), | |
| )); | |
| const wrap = el("div", { class: "login-wrap" }); | |
| const card = el("div", { class: "login-card" }); | |
| card.appendChild(el("h2", {}, "Admin login")); | |
| card.appendChild(el("p", {}, "Enter the admin password to view all study submissions.")); | |
| card.appendChild(el("label", { "for": "pw" }, "Password")); | |
| const input = el("input", { | |
| id: "pw", type: "password", autocomplete: "current-password", | |
| placeholder: "·····", | |
| onkeydown: e => { if (e.key === "Enter") submitLogin(); }, | |
| }); | |
| card.appendChild(input); | |
| const err = el("div", { class: "err", id: "loginErr" }); | |
| card.appendChild(err); | |
| card.appendChild(el("button", { | |
| class: "btn primary login-btn", | |
| onclick: submitLogin, | |
| }, "Sign in")); | |
| wrap.appendChild(card); | |
| app.appendChild(wrap); | |
| setTimeout(() => input.focus(), 30); | |
| } | |
| async function submitLogin() { | |
| const pw = document.getElementById("pw").value; | |
| const err = document.getElementById("loginErr"); | |
| err.textContent = ""; | |
| if (!pw) { err.textContent = "Password required."; return; } | |
| try { | |
| const r = await fetch("./api/admin/login", { | |
| method: "POST", headers: { "Content-Type": "application/json" }, | |
| body: JSON.stringify({ password: pw }), | |
| }); | |
| if (!r.ok) { err.textContent = "Wrong password."; return; } | |
| setAdminPw(pw); | |
| renderDashboard(); | |
| fetchSubmissions(); | |
| } catch (e) { | |
| err.textContent = "Login failed: " + e.message; | |
| } | |
| } | |
| /* ---------- dashboard ---------- */ | |
| function renderDashboard() { | |
| const app = document.getElementById("app"); | |
| app.innerHTML = ""; | |
| app.appendChild(el("header", {}, | |
| el("h1", {}, "Admin · FrameWorker user study"), | |
| el("span", { class: "meta", id: "summaryMeta" }, "loading…"), | |
| el("div", { class: "right" }, | |
| el("a", { href: "./stats.html", target: "_blank", | |
| style: "color:var(--accent);font-size:12px;text-decoration:none" }, | |
| "📊 aggregate stats →"), | |
| el("button", { class: "btn", | |
| onclick: () => { clearAdminPw(); renderLogin(); } }, | |
| "Sign out"), | |
| ), | |
| )); | |
| const main = el("main"); | |
| // submissions list | |
| const sec1 = el("section"); | |
| sec1.appendChild(el("h2", {}, "Submissions")); | |
| const filterRow = el("div", { class: "filter-row" }); | |
| filterRow.appendChild(el("input", { | |
| type: "search", placeholder: "Filter by name / email…", | |
| oninput: e => filterTable(e.target.value.toLowerCase()), | |
| })); | |
| filterRow.appendChild(el("button", { | |
| class: "btn", onclick: () => toggleSelectAll(true) }, "Select all")); | |
| filterRow.appendChild(el("button", { | |
| class: "btn", onclick: () => toggleSelectAll(false) }, "Clear")); | |
| filterRow.appendChild(el("button", { | |
| class: "btn primary", id: "vizBtn", onclick: visualizeSelected, | |
| disabled: "" }, "Visualize selected")); | |
| filterRow.appendChild(el("button", { | |
| class: "btn", onclick: () => fetchSubmissions() }, "↻ Refresh")); | |
| sec1.appendChild(filterRow); | |
| sec1.appendChild(el("div", { id: "tableWrap" })); | |
| main.appendChild(sec1); | |
| // visualisation slot | |
| const sec2 = el("div", { id: "viz", style: "display:none" }); | |
| main.appendChild(sec2); | |
| app.appendChild(main); | |
| } | |
| async function fetchSubmissions() { | |
| try { | |
| const r = await fetch("./api/admin/submissions", { | |
| headers: { "X-Admin-Password": adminPw() }, | |
| }); | |
| if (r.status === 401) { clearAdminPw(); renderLogin(); return; } | |
| if (!r.ok) { | |
| showToast(`Fetch failed (HTTP ${r.status})`, true); | |
| return; | |
| } | |
| const j = await r.json(); | |
| SUBMISSIONS = j.submissions || []; | |
| SELECTED = new Set(); | |
| renderTable(); | |
| document.getElementById("summaryMeta").textContent = | |
| `${SUBMISSIONS.length} submission${SUBMISSIONS.length===1?"":"s"}, ` + | |
| `${new Set(SUBMISSIONS.map(s=>s.rater_email)).size} unique raters`; | |
| } catch (e) { | |
| showToast("Fetch failed: " + e.message, true); | |
| } | |
| } | |
| function renderTable() { | |
| const wrap = document.getElementById("tableWrap"); | |
| wrap.innerHTML = ""; | |
| const table = el("table"); | |
| const thead = el("thead"); | |
| thead.appendChild(el("tr", {}, | |
| el("th", { style: "width:32px" }, ""), | |
| el("th", {}, "Rater"), | |
| el("th", { class: "email" }, "Email"), | |
| el("th", {}, "Status"), | |
| el("th", {}, "Scores"), | |
| el("th", {}, "Cases"), | |
| el("th", {}, "Received (UTC)"), | |
| el("th", {}, "Action"), | |
| el("th", { class: "path" }, "Path"), | |
| )); | |
| table.appendChild(thead); | |
| const tbody = el("tbody", { id: "tbody" }); | |
| for (const s of SUBMISSIONS) { | |
| tbody.appendChild(renderRow(s)); | |
| } | |
| table.appendChild(tbody); | |
| wrap.appendChild(table); | |
| updateVizBtn(); | |
| } | |
| function renderRow(s) { | |
| const tr = el("tr", { "data-path": s.path }); | |
| const cb = el("input", { type: "checkbox" }); | |
| cb.addEventListener("change", () => { | |
| if (cb.checked) SELECTED.add(s.path); else SELECTED.delete(s.path); | |
| updateVizBtn(); | |
| }); | |
| if (SELECTED.has(s.path)) cb.checked = true; | |
| tr.appendChild(el("td", {}, cb)); | |
| tr.appendChild(el("td", {}, | |
| s.rater_name || "(unnamed)", | |
| s.is_admin ? el("span", { class: "badge admin" }, "admin") : null, | |
| )); | |
| tr.appendChild(el("td", { class: "email" }, s.rater_email || "—")); | |
| const status = s.status || (s.path.includes("in_progress") ? "in_progress" : "final"); | |
| tr.appendChild(el("td", {}, | |
| el("span", { class: "badge " + status }, status))); | |
| tr.appendChild(el("td", {}, String(s.n_scores ?? "—"))); | |
| tr.appendChild(el("td", {}, String(s.n_cases ?? "—"))); | |
| tr.appendChild(el("td", {}, | |
| (s.received_at_utc || "—").replace("T"," ").replace("Z",""))); | |
| // Row actions: view-as + delete | |
| const actionCell = el("td", { style: "white-space:nowrap" }); | |
| if (s.rater_email) { | |
| actionCell.appendChild(el("button", { | |
| class: "btn", | |
| style: "padding:3px 8px;margin-right:4px", | |
| title: "open the main study page with this user's data preloaded", | |
| onclick: () => viewAs(s), | |
| }, "👁 View")); | |
| } | |
| actionCell.appendChild(el("button", { | |
| class: "btn danger", | |
| style: "padding:3px 8px;font-size:11px", | |
| title: "permanently delete this submission JSON from the dataset", | |
| onclick: () => deleteSubmission(s), | |
| }, "🗑")); | |
| tr.appendChild(actionCell); | |
| // Path → link to dataset blob view | |
| const pathLink = el("a", { | |
| href: `https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/blob/main/${s.path}`, | |
| target: "_blank", rel: "noopener", | |
| style: "color:var(--muted);text-decoration:none", | |
| title: "open raw JSON on the dataset", | |
| }, s.path); | |
| tr.appendChild(el("td", { class: "path" }, pathLink)); | |
| return tr; | |
| } | |
| function filterTable(needle) { | |
| for (const tr of document.querySelectorAll("#tbody tr")) { | |
| const txt = tr.textContent.toLowerCase(); | |
| tr.style.display = txt.includes(needle) ? "" : "none"; | |
| } | |
| } | |
| function toggleSelectAll(on) { | |
| SELECTED = new Set(on ? SUBMISSIONS.map(s=>s.path) : []); | |
| renderTable(); | |
| } | |
| function updateVizBtn() { | |
| const btn = document.getElementById("vizBtn"); | |
| if (!btn) return; | |
| btn.textContent = `Visualize selected (${SELECTED.size})`; | |
| btn.disabled = SELECTED.size === 0; | |
| } | |
| /* ---------- delete a submission ---------- */ | |
| async function deleteSubmission(s) { | |
| const who = s.rater_name || s.rater_email || "this submission"; | |
| if (!confirm(`Permanently delete ${s.path}?\n\nRater: ${who}\nScores: ${s.n_scores ?? "?"}\n\nThis cannot be undone.`)) return; | |
| try { | |
| const r = await fetch("./api/admin/submission", { | |
| method: "DELETE", | |
| headers: { "Content-Type": "application/json", "X-Admin-Password": adminPw() }, | |
| body: JSON.stringify({ path: s.path }), | |
| }); | |
| if (r.status === 401) { clearAdminPw(); renderLogin(); return; } | |
| if (!r.ok) { | |
| const text = await r.text().catch(() => ""); | |
| showToast(`Delete failed (HTTP ${r.status}): ${text.slice(0, 80)}`, true); | |
| return; | |
| } | |
| showToast(`Deleted ${who}`); | |
| // Remove from local list + selection, re-render | |
| SUBMISSIONS = SUBMISSIONS.filter(x => x.path !== s.path); | |
| SELECTED.delete(s.path); | |
| renderTable(); | |
| document.getElementById("summaryMeta").textContent = | |
| `${SUBMISSIONS.length} submission${SUBMISSIONS.length===1?"":"s"}, ` + | |
| `${new Set(SUBMISSIONS.map(x=>x.rater_email)).size} unique raters`; | |
| } catch (e) { | |
| showToast("Delete failed: " + e.message, true); | |
| } | |
| } | |
| /* ---------- view-as: open main UI populated with this user's submission ---- */ | |
| async function viewAs(submissionMeta) { | |
| try { | |
| const url = `https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/${submissionMeta.path}`; | |
| const r = await fetch(url, { cache: "no-cache" }); | |
| if (!r.ok) { showToast(`Fetch failed: HTTP ${r.status}`, true); return; } | |
| const payload = await r.json(); | |
| // Persist for the main page to pick up — sessionStorage so it doesn't | |
| // contaminate localStorage long-term, and sits in this tab/window only. | |
| sessionStorage.setItem("user_study_view_as", JSON.stringify({ | |
| opened_at: new Date().toISOString(), | |
| meta: submissionMeta, | |
| payload, | |
| })); | |
| window.open("./?view_as=1", "_blank"); | |
| showToast(`Opening main UI as ${submissionMeta.rater_name || submissionMeta.rater_email}`); | |
| } catch (e) { | |
| showToast("View-as failed: " + e.message, true); | |
| } | |
| } | |
| /* ---------- visualization ---------- */ | |
| async function visualizeSelected() { | |
| if (SELECTED.size === 0) return; | |
| const viz = document.getElementById("viz"); | |
| viz.style.display = "block"; | |
| viz.innerHTML = "<h2>Loading selected submissions…</h2>"; | |
| const submissions = []; | |
| for (const path of SELECTED) { | |
| try { | |
| const url = `https://huggingface.co/datasets/dehezhang2/Frameworker_User_Study/resolve/main/${path}`; | |
| const r = await fetch(url, { cache: "no-cache" }); | |
| if (r.ok) submissions.push(await r.json()); | |
| } catch (e) { /* skip */ } | |
| } | |
| renderViz(submissions); | |
| } | |
| function renderViz(payloads) { | |
| // bag: (model, qkey) -> scores | |
| const bag = {}; | |
| for (const p of payloads) { | |
| for (const c of (p.cases || [])) { | |
| for (const v of (c.videos || [])) { | |
| const m = v.model || "unknown"; | |
| for (const [qk, score] of Object.entries(v.ratings || {})) { | |
| if (typeof score === "number" && score >= 1 && score <= 5) { | |
| (bag[`${m}|${qk}`] ||= []).push(score); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| const models = Array.from(new Set(Object.keys(bag).map(k => k.split("|")[0]))); | |
| models.sort((a,b) => (a === "ours" ? -1 : b === "ours" ? 1 : a.localeCompare(b))); | |
| const COLORS = ["#6aa9ff","#ff9f43","#28c76f","#ea5455","#b46aff"]; | |
| const mcolor = Object.fromEntries(models.map((m,i) => [m, COLORS[i % COLORS.length]])); | |
| const viz = document.getElementById("viz"); | |
| viz.innerHTML = ""; | |
| viz.appendChild(el("h2", {}, | |
| `Aggregate over ${payloads.length} selected submission(s)`)); | |
| // Per dimension | |
| const dimDiv = el("section", { style: "background:transparent;border:none;padding:0" }); | |
| dimDiv.appendChild(el("h2", {}, "Per dimension")); | |
| const dimCanvas = el("canvas"); | |
| dimDiv.appendChild(dimCanvas); | |
| viz.appendChild(dimDiv); | |
| function mean(arr){ return arr.length ? arr.reduce((a,b)=>a+b,0)/arr.length : null; } | |
| function statsFor(qkeys, m){ | |
| const all = []; | |
| for (const qk of qkeys) all.push(...(bag[`${m}|${qk}`] || [])); | |
| return all; | |
| } | |
| const dimLabels = DIMENSIONS.map(d => d.label); | |
| const dimData = models.map(m => ({ | |
| label: m, | |
| data: DIMENSIONS.map(d => mean(statsFor(d.questions.map(q=>q[0]), m))), | |
| backgroundColor: mcolor[m] + "cc", | |
| borderColor: mcolor[m], borderWidth: 1, | |
| })); | |
| new Chart(dimCanvas, { | |
| type: "bar", | |
| data: { labels: dimLabels, datasets: dimData }, | |
| options: { | |
| scales: { | |
| y: { min: 0, max: 5, ticks: { color: "#8b91a8" }, grid: { color: "#2a2f42" } }, | |
| x: { ticks: { color: "#e6e8ef" }, grid: { display: false } }, | |
| }, | |
| plugins: { legend: { labels: { color: "#e6e8ef" } } }, | |
| }, | |
| }); | |
| // Per question | |
| viz.appendChild(el("h2", { style: "margin-top:14px" }, "Per sub-question")); | |
| for (const d of DIMENSIONS) { | |
| for (const [qk, qlabel] of d.questions) { | |
| const block = el("div", { class: "q-block" }); | |
| const total = models.reduce((n,m) => n + (bag[`${m}|${qk}`]||[]).length, 0); | |
| block.appendChild(el("div", { class: "q-title" }, qlabel)); | |
| block.appendChild(el("div", { class: "q-dim" }, d.label + (total ? ` · n=${total}` : " · (no data)"))); | |
| if (total) { | |
| const c = el("canvas"); | |
| block.appendChild(c); | |
| const datasets = models.map(m => ({ | |
| label: m, | |
| data: [mean(bag[`${m}|${qk}`] || [])], | |
| backgroundColor: mcolor[m] + "cc", | |
| borderColor: mcolor[m], borderWidth: 1, | |
| })); | |
| new Chart(c, { | |
| type: "bar", | |
| data: { labels: [qlabel], datasets }, | |
| options: { | |
| indexAxis: "y", scales: { | |
| x: { min: 0, max: 5, ticks: { color: "#8b91a8" }, grid: { color: "#2a2f42" } }, | |
| y: { ticks: { display: false }, grid: { display: false } }, | |
| }, | |
| plugins: { legend: { labels: { color: "#e6e8ef" } } }, | |
| }, | |
| }); | |
| } | |
| viz.appendChild(block); | |
| } | |
| } | |
| viz.scrollIntoView({ behavior: "smooth", block: "start" }); | |
| } | |
| /* ---------- boot ---------- */ | |
| (function init(){ | |
| if (adminPw()) { | |
| // Probe a cheap endpoint to validate the cached password. | |
| fetch("./api/admin/submissions", { | |
| headers: { "X-Admin-Password": adminPw() }, | |
| }).then(r => { | |
| if (r.status === 401) { clearAdminPw(); renderLogin(); } | |
| else { renderDashboard(); fetchSubmissions(); } | |
| }).catch(() => renderLogin()); | |
| } else { | |
| renderLogin(); | |
| } | |
| })(); | |
| </script> | |
| </body> | |
| </html> | |