| <!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 { |
| 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 { 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> |
|
|
| |
| <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> |
|
|
| |
| <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, '"') + '">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> |
|
|