Spaces:
Running
Running
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>Admin — DiffMT Results</title> | |
| <style> | |
| *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } | |
| :root { | |
| --bg: #f2f5fb; --surface: #fff; --surface2: #eaeff8; | |
| --border: #d0d7ea; --accent: #1a3a70; --red: #c84323; | |
| --green: #27733a; --text: #18243c; --muted: #5a6a85; --r: 10px; | |
| --font: system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif; | |
| } | |
| body { font-family: var(--font); background: var(--bg); color: var(--text); min-height: 100dvh; padding: 2rem 1rem; } | |
| #dashboard { max-width: 920px; margin: 0 auto; } | |
| .page-header { | |
| display: flex; align-items: center; justify-content: space-between; | |
| flex-wrap: wrap; gap: 1rem; margin-bottom: 1.75rem; | |
| } | |
| .page-header h1 { font-size: 1.6rem; } | |
| .page-header .sub { color: var(--muted); font-size: .85rem; margin-top: .2rem; } | |
| .stats-row { display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.75rem; } | |
| .stat-card { | |
| flex: 1; min-width: 120px; background: var(--surface); | |
| border: 1px solid var(--border); border-radius: var(--r); | |
| padding: 1rem 1.25rem; text-align: center; | |
| } | |
| .stat-card .val { font-size: 2rem; font-weight: 700; color: var(--accent); line-height: 1; } | |
| .stat-card .lbl { font-size: .75rem; color: var(--muted); margin-top: .25rem; text-transform: uppercase; letter-spacing: .07em; } | |
| .table-wrap { | |
| background: var(--surface); border: 1px solid var(--border); | |
| border-radius: var(--r); overflow-x: auto; | |
| box-shadow: 0 2px 10px rgba(26,58,112,.06); | |
| } | |
| table { width: 100%; border-collapse: collapse; font-size: .88rem; } | |
| th { | |
| background: var(--surface2); padding: .7rem 1rem; text-align: left; | |
| color: var(--muted); font-weight: 600; font-size: .75rem; | |
| text-transform: uppercase; letter-spacing: .06em; | |
| border-bottom: 1px solid var(--border); white-space: nowrap; | |
| } | |
| th.sortable { cursor: pointer; user-select: none; } | |
| th.sortable:hover { color: var(--accent); } | |
| th .arrow { margin-left: .3em; opacity: .45; } | |
| td { padding: .6rem 1rem; border-bottom: 1px solid var(--border); } | |
| tr:last-child td { border-bottom: none; } | |
| td.mono { font-family: monospace; font-size: .78rem; color: var(--muted); } | |
| .btn { | |
| display: inline-flex; align-items: center; gap: .4rem; | |
| background: var(--accent); color: #fff; border: none; | |
| border-radius: var(--r); padding: .6rem 1.2rem; | |
| font-size: .88rem; font-weight: 600; cursor: pointer; | |
| text-decoration: none; transition: opacity .15s; | |
| } | |
| .btn:hover { opacity: .85; } | |
| .btn.outline { background: none; color: var(--accent); border: 1px solid var(--accent); } | |
| #status { color: var(--muted); font-size: .82rem; font-style: italic; } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="dashboard"> | |
| <div class="page-header"> | |
| <div> | |
| <h1>DiffMT Results</h1> | |
| <div class="sub" id="status">Loading…</div> | |
| </div> | |
| <div style="display:flex;gap:.6rem;flex-wrap:wrap"> | |
| <button class="btn outline" onclick="refresh()">↻ Refresh</button> | |
| <button class="btn" onclick="downloadCSV()">⬇ Download CSV</button> | |
| </div> | |
| </div> | |
| <div class="stats-row"> | |
| <div class="stat-card"><div class="val" id="stat-sessions">—</div><div class="lbl">Sessions</div></div> | |
| <div class="stat-card"><div class="val" id="stat-avg">—</div><div class="lbl">Avg accuracy</div></div> | |
| <div class="stat-card"><div class="val" id="stat-best">—</div><div class="lbl">Best score</div></div> | |
| <div class="stat-card"><div class="val" id="stat-players">—</div><div class="lbl">Unique players</div></div> | |
| </div> | |
| <div class="table-wrap"> | |
| <table> | |
| <thead> | |
| <tr> | |
| <th>#</th> | |
| <th class="sortable" onclick="sortBy('player_name')">Name<span class="arrow" id="arr-player_name"></span></th> | |
| <th class="sortable" onclick="sortBy('score')">Score<span class="arrow" id="arr-score"></span></th> | |
| <th class="sortable" onclick="sortBy('percentage')">Accuracy<span class="arrow" id="arr-percentage">↓</span></th> | |
| <th class="sortable" onclick="sortBy('timestamp')">Date<span class="arrow" id="arr-timestamp"></span></th> | |
| <th>Session ID</th> | |
| </tr> | |
| </thead> | |
| <tbody id="tbody"></tbody> | |
| </table> | |
| </div> | |
| </div> | |
| <script> | |
| let entries = []; | |
| let sortCol = 'percentage'; | |
| let sortAsc = false; | |
| async function load() { | |
| const res = await fetch('/api/admin/sessions'); | |
| if (!res.ok) { document.getElementById('status').textContent = 'Error loading data.'; return; } | |
| entries = await res.json(); | |
| renderAll(); | |
| } | |
| async function refresh() { | |
| document.getElementById('status').textContent = 'Refreshing…'; | |
| await load(); | |
| } | |
| function renderAll() { | |
| renderStats(); | |
| renderTable(); | |
| document.getElementById('status').textContent = | |
| `Last updated ${new Date().toLocaleTimeString()}`; | |
| } | |
| function renderStats() { | |
| const n = entries.length; | |
| document.getElementById('stat-sessions').textContent = n; | |
| if (!n) { | |
| ['stat-avg','stat-best','stat-players'].forEach(id => | |
| document.getElementById(id).textContent = '—'); | |
| return; | |
| } | |
| const avg = Math.round(entries.reduce((s, e) => s + e.percentage, 0) / n); | |
| const best = Math.max(...entries.map(e => e.percentage)); | |
| const uniq = new Set(entries.map(e => e.player_name)).size; | |
| document.getElementById('stat-avg').textContent = avg + '%'; | |
| document.getElementById('stat-best').textContent = best + '%'; | |
| document.getElementById('stat-players').textContent = uniq; | |
| } | |
| function renderTable() { | |
| const sorted = [...entries].sort((a, b) => { | |
| let va = a[sortCol], vb = b[sortCol]; | |
| if (sortCol === 'timestamp') { va = new Date(va); vb = new Date(vb); } | |
| return sortAsc ? (va > vb ? 1 : -1) : (va < vb ? 1 : -1); | |
| }); | |
| ['player_name','score','percentage','timestamp'].forEach(col => { | |
| const el = document.getElementById('arr-' + col); | |
| if (!el) return; | |
| el.textContent = col === sortCol ? (sortAsc ? '↑' : '↓') : ''; | |
| el.style.opacity = col === sortCol ? '1' : '.45'; | |
| }); | |
| const tbody = document.getElementById('tbody'); | |
| tbody.innerHTML = ''; | |
| if (!sorted.length) { | |
| tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:2rem">No sessions yet.</td></tr>'; | |
| return; | |
| } | |
| sorted.forEach((e, i) => { | |
| const tr = document.createElement('tr'); | |
| const dt = new Date(e.timestamp); | |
| const date = dt.toLocaleDateString('en-GB', { day:'2-digit', month:'short', year:'2-digit' }); | |
| const time = dt.toLocaleTimeString('en-GB', { hour:'2-digit', minute:'2-digit' }); | |
| tr.innerHTML = ` | |
| <td>${i + 1}</td> | |
| <td>${esc(e.player_name)}</td> | |
| <td>${e.score} / ${e.total}</td> | |
| <td>${e.percentage}%</td> | |
| <td>${date} ${time}</td> | |
| <td class="mono">${e.session_id}</td> | |
| `; | |
| tbody.appendChild(tr); | |
| }); | |
| } | |
| function sortBy(col) { | |
| sortAsc = sortCol === col ? !sortAsc : false; | |
| sortCol = col; | |
| renderTable(); | |
| } | |
| function downloadCSV() { | |
| const header = ['rank','player_name','score','total','percentage','timestamp','session_id']; | |
| const sorted = [...entries].sort((a, b) => b.percentage - a.percentage || new Date(b.timestamp) - new Date(a.timestamp)); | |
| const rows = sorted.map((e, i) => [ | |
| i + 1, csvCell(e.player_name), e.score, e.total, e.percentage, | |
| new Date(e.timestamp).toISOString(), e.session_id | |
| ].join(',')); | |
| const csv = [header.join(','), ...rows].join('\n'); | |
| const blob = new Blob([csv], { type: 'text/csv' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `diffmt_leaderboard_${new Date().toISOString().slice(0,10)}.csv`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| } | |
| function csvCell(v) { | |
| const s = String(v ?? ''); | |
| return s.includes(',') || s.includes('"') || s.includes('\n') | |
| ? '"' + s.replace(/"/g, '""') + '"' : s; | |
| } | |
| function esc(str) { | |
| return String(str ?? '').replace(/[&<>"']/g, c => | |
| ({ '&':'&','<':'<','>':'>','"':'"',"'":''' }[c])); | |
| } | |
| load(); | |
| </script> | |
| </body> | |
| </html> |