DiffMT / public /admin.html
Koddenbrock's picture
add admin dashboard for viewing DiffMT results
76bec74
<!DOCTYPE html>
<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 =>
({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
}
load();
</script>
</body>
</html>