ring-sizer / web_demo /templates /admin.html
feng-x's picture
Upload folder using huggingface_hub
405e42a verified
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Ring Sizer — Admin</title>
<style>
:root {
--bg-1: #f5f1e7;
--ink: #2b1f1f;
--ink-soft: #4b3d3d;
--accent: #bf3a2b;
--sand: #f9f4ec;
--shadow: rgba(34, 26, 26, 0.12);
--border: rgba(45, 33, 33, 0.18);
}
* { box-sizing: border-box; }
body {
margin: 0; padding: 24px;
color: var(--ink);
background: var(--bg-1);
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
font-size: 14px;
}
h1 { font-size: 1.5rem; margin: 0 0 8px; }
/* --- Login gate --- */
.login-gate {
max-width: 320px; margin: 120px auto; text-align: center;
}
.login-gate h1 { margin-bottom: 16px; }
.login-gate input {
width: 100%; padding: 10px 12px; border: 1px solid var(--border);
border-radius: 8px; font-size: 14px; margin-bottom: 12px;
}
.login-gate button {
width: 100%; padding: 10px; border: none; border-radius: 8px;
background: var(--accent); color: #fff; font-size: 14px; cursor: pointer;
}
.login-gate button:hover { opacity: 0.9; }
.login-gate .error { color: var(--accent); font-size: 13px; margin-top: 8px; }
.login-gate .back { display: inline-block; margin-top: 16px; color: var(--ink-soft); font-size: 13px; text-decoration: none; }
/* --- Admin content --- */
.admin-content { display: none; }
.toolbar {
display: flex; gap: 12px; align-items: center;
margin-bottom: 16px; flex-wrap: wrap;
}
.toolbar a, .toolbar button {
padding: 6px 14px; border-radius: 6px; text-decoration: none;
font-size: 13px; cursor: pointer; border: 1px solid var(--border);
background: #fff; color: var(--ink);
}
.toolbar a:hover, .toolbar button:hover { background: var(--sand); }
.toolbar .count { color: var(--ink-soft); font-size: 13px; }
table {
width: 100%; border-collapse: collapse;
background: #fff; border-radius: 8px;
box-shadow: 0 1px 4px var(--shadow);
overflow: hidden;
}
th, td {
padding: 8px 10px; text-align: left;
border-bottom: 1px solid var(--border);
white-space: nowrap; font-size: 13px;
}
th { background: var(--sand); font-weight: 600; position: sticky; top: 0; z-index: 1; }
tr:hover td { background: #faf7f2; }
.thumb { width: 48px; height: 48px; object-fit: cover; border-radius: 4px; cursor: pointer; }
.fail { color: var(--accent); font-weight: 500; }
.gt-input {
width: 48px; padding: 2px 4px; border: 1px solid var(--border);
border-radius: 4px; font-size: 12px; text-align: center;
}
.gt-select {
padding: 2px 4px; border: 1px solid var(--border);
border-radius: 4px; font-size: 12px;
}
.gt-notes-input {
width: 100px; padding: 2px 4px; border: 1px solid var(--border);
border-radius: 4px; font-size: 12px;
}
.save-btn {
padding: 2px 8px; font-size: 11px; cursor: pointer;
border: 1px solid #2a7; border-radius: 4px;
background: #fff; color: #2a7;
}
.save-btn:hover { background: #2a7; color: #fff; }
.save-btn.saved { border-color: #2a7; color: #2a7; }
.del-btn {
padding: 2px 8px; font-size: 11px; cursor: pointer;
border: 1px solid var(--accent); border-radius: 4px;
background: #fff; color: var(--accent); margin-left: 4px;
}
.del-btn:hover { background: var(--accent); color: #fff; }
.ai-peek { color: var(--accent); cursor: pointer; text-decoration: underline; font-size: 12px; }
.finger-cell { font-size: 12px; }
.finger-cell .size { font-weight: 600; }
.finger-cell .detail { color: var(--ink-soft); }
.empty { text-align: center; padding: 48px; color: var(--ink-soft); }
.scroll-wrap { overflow-x: auto; }
</style>
</head>
<body>
<!-- Login gate -->
<div class="login-gate" id="loginGate">
<h1>Admin Access</h1>
<form id="loginForm">
<input type="password" id="tokenInput" placeholder="Enter passcode" autofocus />
<button type="submit">Unlock</button>
</form>
<div class="error" id="loginError"></div>
<a class="back" href="/">Back to Demo</a>
</div>
<!-- Admin content (hidden until authenticated) -->
<div class="admin-content" id="adminContent">
<h1>KOL Measurement Records</h1>
<div class="toolbar">
<a href="/">Back to Demo</a>
<a id="exportCsvLink" href="#" download>Export CSV</a>
<button id="refreshBtn">Refresh</button>
<span class="count" id="countLabel">Loading...</span>
</div>
<div class="scroll-wrap">
<table>
<thead>
<tr>
<th>KOL</th>
<th>Date</th>
<th>Photo</th>
<th>Mode</th>
<th>Size</th>
<th>Range</th>
<th>Index</th>
<th>Middle</th>
<th>Ring</th>
<th>Conf</th>
<th>Fail</th>
<th>AI Explain</th>
<th>Ring Fit</th>
<th>Best Finger</th>
<th>GT Idx</th>
<th>GT Mid</th>
<th>GT Ring</th>
<th>GT Notes</th>
<th></th>
</tr>
</thead>
<tbody id="tableBody">
<tr><td colspan="19" class="empty">Loading...</td></tr>
</tbody>
</table>
</div>
</div>
<script>
const loginGate = document.getElementById("loginGate");
const adminContent = document.getElementById("adminContent");
const loginForm = document.getElementById("loginForm");
const tokenInput = document.getElementById("tokenInput");
const loginError = document.getElementById("loginError");
const tbody = document.getElementById("tableBody");
const countLabel = document.getElementById("countLabel");
let adminToken = sessionStorage.getItem("admin_token") || "";
const unlock = async (token) => {
const resp = await fetch(`/api/admin/measurements?token=${encodeURIComponent(token)}`);
if (resp.status === 401) return false;
adminToken = token;
sessionStorage.setItem("admin_token", token);
loginGate.style.display = "none";
adminContent.style.display = "block";
document.getElementById("exportCsvLink").href = `/api/admin/export-csv?token=${encodeURIComponent(adminToken)}`;
loadData();
return true;
};
// Auto-unlock if token saved in session
if (adminToken) {
unlock(adminToken).then((ok) => {
if (!ok) { adminToken = ""; sessionStorage.removeItem("admin_token"); }
});
}
loginForm.addEventListener("submit", async (e) => {
e.preventDefault();
const token = tokenInput.value.trim();
if (!token) return;
const ok = await unlock(token);
if (!ok) {
loginError.textContent = "Incorrect passcode";
tokenInput.value = "";
tokenInput.focus();
}
});
const fmtDate = (iso) => {
if (!iso) return "";
const d = new Date(iso);
return d.toLocaleDateString("en-CA") + " " + d.toLocaleTimeString("en-GB", { hour: "2-digit", minute: "2-digit" });
};
const fmtFinger = (pf, name) => {
if (!pf || !pf[name]) return '<span class="detail">-</span>';
const f = pf[name];
if (f.status !== "ok") return `<span class="fail">${f.fail_reason || "fail"}</span>`;
const size = f.best_match != null ? f.best_match : "-";
const diam = f.diameter_cm != null ? (f.diameter_cm * 10).toFixed(1) + "mm" : "";
const conf = f.confidence != null ? (f.confidence * 100).toFixed(0) + "%" : "";
return `<span class="size">${size}</span> <span class="detail">${diam} ${conf}</span>`;
};
const loadData = async () => {
try {
const resp = await fetch(`/api/admin/measurements?token=${encodeURIComponent(adminToken)}`);
if (resp.status === 401) {
sessionStorage.removeItem("admin_token");
loginGate.style.display = "";
adminContent.style.display = "none";
loginError.textContent = "Session expired. Please log in again.";
return;
}
const rows = await resp.json();
countLabel.textContent = `${rows.length} records`;
if (rows.length === 0) {
tbody.innerHTML = '<tr><td colspan="17" class="empty">No measurements yet</td></tr>';
return;
}
tbody.innerHTML = rows.map((r) => {
const pf = r.per_finger || {};
const photoThumb = r.photo_url
? `<img class="thumb" src="${r.photo_url}" onclick="window.open('${r.photo_url}')" />`
: "-";
return `<tr data-id="${r.id}">
<td><strong>${r.kol_name || "-"}</strong></td>
<td>${fmtDate(r.created_at)}</td>
<td>${photoThumb}</td>
<td>${r.mode || "-"}</td>
<td><strong>${r.overall_best_size != null ? r.overall_best_size : "-"}</strong></td>
<td>${r.overall_range_min != null ? r.overall_range_min + "\u2013" + r.overall_range_max : "-"}</td>
<td class="finger-cell">${fmtFinger(pf, "index")}</td>
<td class="finger-cell">${fmtFinger(pf, "middle")}</td>
<td class="finger-cell">${fmtFinger(pf, "ring")}</td>
<td>${r.confidence != null ? (r.confidence * 100).toFixed(0) + "%" : "-"}</td>
<td>${r.fail_reason ? '<span class="fail">' + r.fail_reason + "</span>" : ""}</td>
<td>${(r.result_json && r.result_json.ai_explanation) ? '<span class="ai-peek" title="Click to view" onclick="alert(this.dataset.full)" data-full="' + r.result_json.ai_explanation.replace(/"/g, '&quot;') + '">View</span>' : "-"}</td>
<td><select class="gt-select" data-field="ring_fit">
<option value="">-</option>
<option value="fits_well" ${r.ring_fit === "fits_well" ? "selected" : ""}>Fits well</option>
<option value="too_tight" ${r.ring_fit === "too_tight" ? "selected" : ""}>Too tight</option>
<option value="too_loose" ${r.ring_fit === "too_loose" ? "selected" : ""}>Too loose</option>
</select></td>
<td><select class="gt-select" data-field="gt_best_finger">
<option value="">-</option>
<option value="index" ${r.gt_best_finger === "index" ? "selected" : ""}>Index</option>
<option value="middle" ${r.gt_best_finger === "middle" ? "selected" : ""}>Middle</option>
<option value="ring" ${r.gt_best_finger === "ring" ? "selected" : ""}>Ring</option>
</select></td>
<td><input class="gt-input" data-field="gt_index_size" type="number" min="4" max="15" value="${r.gt_index_size ?? ""}" /></td>
<td><input class="gt-input" data-field="gt_middle_size" type="number" min="4" max="15" value="${r.gt_middle_size ?? ""}" /></td>
<td><input class="gt-input" data-field="gt_ring_size" type="number" min="4" max="15" value="${r.gt_ring_size ?? ""}" /></td>
<td><input class="gt-notes-input" data-field="gt_notes" type="text" value="${r.gt_notes || ""}" placeholder="notes" /></td>
<td><button class="save-btn" onclick="saveGT(this)">Save</button> <button class="del-btn" onclick="deleteRow(this)">Delete</button></td>
</tr>`;
}).join("");
} catch (e) {
tbody.innerHTML = `<tr><td colspan="17" class="empty">Error loading data: ${e.message}</td></tr>`;
}
};
window.saveGT = async (btn) => {
const tr = btn.closest("tr");
const id = tr.dataset.id;
const data = {};
tr.querySelectorAll("[data-field]").forEach((el) => {
const field = el.dataset.field;
if (el.type === "number") {
data[field] = el.value ? parseInt(el.value, 10) : null;
} else {
data[field] = el.value || null;
}
});
try {
const resp = await fetch(`/api/admin/measurements/${id}/ground-truth?token=${encodeURIComponent(adminToken)}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(data),
});
const result = await resp.json();
if (result.success) {
btn.textContent = "Saved";
btn.classList.add("saved");
setTimeout(() => { btn.textContent = "Save"; btn.classList.remove("saved"); }, 2000);
} else {
alert("Save failed: " + (result.error || "unknown"));
}
} catch (e) {
alert("Network error: " + e.message);
}
};
window.deleteRow = async (btn) => {
if (!confirm("Delete this measurement record?")) return;
const tr = btn.closest("tr");
const id = tr.dataset.id;
try {
const resp = await fetch(`/api/admin/measurements/${id}?token=${encodeURIComponent(adminToken)}`, {
method: "DELETE",
});
const result = await resp.json();
if (result.success) {
tr.remove();
const rows = tbody.querySelectorAll("tr");
countLabel.textContent = `${rows.length} records`;
} else {
alert("Delete failed: " + (result.error || "unknown"));
}
} catch (e) {
alert("Network error: " + e.message);
}
};
document.getElementById("refreshBtn").addEventListener("click", loadData);
</script>
</body>
</html>