dehezhang2's picture
deploy rater (Docker/FastAPI): registration + completeness gate + /api/submit → dataset
3c88ed2 verified
Raw
History Blame Contribute Delete
21.6 kB
<!DOCTYPE html>
<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>