Koddenbrock commited on
Commit
c5541a7
Β·
1 Parent(s): 6895b85

add admin dashboard for viewing DiffMT results

Browse files
Files changed (1) hide show
  1. public/admin.html +288 -0
public/admin.html ADDED
@@ -0,0 +1,288 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Admin β€” DiffMT Results</title>
7
+ <style>
8
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
9
+ :root {
10
+ --bg: #f2f5fb; --surface: #fff; --surface2: #eaeff8;
11
+ --border: #d0d7ea; --accent: #1a3a70; --red: #c84323;
12
+ --green: #27733a; --text: #18243c; --muted: #5a6a85; --r: 10px;
13
+ --font: system-ui, -apple-system, 'Segoe UI', Helvetica, Arial, sans-serif;
14
+ }
15
+ body { font-family: var(--font); background: var(--bg); color: var(--text); min-height: 100dvh; padding: 2rem 1rem; }
16
+
17
+ #auth-screen { display: flex; justify-content: center; align-items: center; min-height: 80dvh; }
18
+ .auth-card {
19
+ background: var(--surface); border: 1px solid var(--border); border-radius: 16px;
20
+ padding: 2.5rem; width: min(420px, 100%); box-shadow: 0 6px 32px rgba(26,58,112,.09);
21
+ }
22
+ .auth-card h2 { margin-bottom: 1.25rem; }
23
+ .auth-card p { color: var(--muted); font-size: .9rem; margin-bottom: 1.5rem; }
24
+
25
+ #dashboard { max-width: 920px; margin: 0 auto; }
26
+
27
+ .page-header {
28
+ display: flex; align-items: center; justify-content: space-between;
29
+ flex-wrap: wrap; gap: 1rem; margin-bottom: 1.75rem;
30
+ }
31
+ .page-header h1 { font-size: 1.6rem; }
32
+ .page-header .sub { color: var(--muted); font-size: .85rem; margin-top: .2rem; }
33
+
34
+ .stats-row {
35
+ display: flex; gap: 1rem; flex-wrap: wrap; margin-bottom: 1.75rem;
36
+ }
37
+ .stat-card {
38
+ flex: 1; min-width: 120px; background: var(--surface);
39
+ border: 1px solid var(--border); border-radius: var(--r);
40
+ padding: 1rem 1.25rem; text-align: center;
41
+ }
42
+ .stat-card .val { font-size: 2rem; font-weight: 700; color: var(--accent); line-height: 1; }
43
+ .stat-card .lbl { font-size: .75rem; color: var(--muted); margin-top: .25rem; text-transform: uppercase; letter-spacing: .07em; }
44
+
45
+ .table-wrap {
46
+ background: var(--surface); border: 1px solid var(--border);
47
+ border-radius: var(--r); overflow-x: auto;
48
+ box-shadow: 0 2px 10px rgba(26,58,112,.06);
49
+ }
50
+ table { width: 100%; border-collapse: collapse; font-size: .88rem; }
51
+ th {
52
+ background: var(--surface2); padding: .7rem 1rem; text-align: left;
53
+ color: var(--muted); font-weight: 600; font-size: .75rem;
54
+ text-transform: uppercase; letter-spacing: .06em;
55
+ border-bottom: 1px solid var(--border);
56
+ white-space: nowrap;
57
+ }
58
+ th.sortable { cursor: pointer; user-select: none; }
59
+ th.sortable:hover { color: var(--accent); }
60
+ th .arrow { margin-left: .3em; opacity: .45; }
61
+ td { padding: .6rem 1rem; border-bottom: 1px solid var(--border); }
62
+ tr:last-child td { border-bottom: none; }
63
+ td.mono { font-family: monospace; font-size: .78rem; color: var(--muted); }
64
+
65
+ .btn {
66
+ display: inline-flex; align-items: center; gap: .4rem;
67
+ background: var(--accent); color: #fff; border: none;
68
+ border-radius: var(--r); padding: .6rem 1.2rem;
69
+ font-size: .88rem; font-weight: 600; cursor: pointer;
70
+ text-decoration: none; transition: opacity .15s;
71
+ }
72
+ .btn:hover { opacity: .85; }
73
+ .btn.outline {
74
+ background: none; color: var(--accent);
75
+ border: 1px solid var(--accent);
76
+ }
77
+
78
+ .input {
79
+ background: var(--surface2); border: 1px solid var(--border);
80
+ color: var(--text); padding: .65rem 1rem; border-radius: var(--r);
81
+ font-size: .95rem; font-family: var(--font); outline: none; width: 100%;
82
+ margin-bottom: .75rem;
83
+ }
84
+ .input:focus { border-color: var(--accent); }
85
+
86
+ .error-msg { color: var(--red); font-size: .85rem; margin-top: .5rem; }
87
+ #status { color: var(--muted); font-size: .82rem; font-style: italic; }
88
+ </style>
89
+ </head>
90
+ <body>
91
+
92
+ <!-- ── Auth gate ────────────────────────────────────────────── -->
93
+ <div id="auth-screen">
94
+ <div class="auth-card">
95
+ <h2>Admin Dashboard</h2>
96
+ <p>Enter your admin token to view results.</p>
97
+ <input id="token-input" class="input" type="password" placeholder="Admin token…" autocomplete="off">
98
+ <button class="btn" style="width:100%" onclick="login()">Unlock</button>
99
+ <div id="auth-error" class="error-msg"></div>
100
+ </div>
101
+ </div>
102
+
103
+ <!-- ── Dashboard ────────────────────────────────────────────── -->
104
+ <div id="dashboard" style="display:none">
105
+ <div class="page-header">
106
+ <div>
107
+ <h1>DiffMT Results</h1>
108
+ <div class="sub" id="status">Loading…</div>
109
+ </div>
110
+ <div style="display:flex;gap:.6rem;flex-wrap:wrap">
111
+ <button class="btn outline" onclick="refresh()">↻ Refresh</button>
112
+ <button class="btn" onclick="downloadCSV()">⬇ Download CSV</button>
113
+ </div>
114
+ </div>
115
+
116
+ <div class="stats-row">
117
+ <div class="stat-card"><div class="val" id="stat-sessions">β€”</div><div class="lbl">Sessions</div></div>
118
+ <div class="stat-card"><div class="val" id="stat-avg">β€”</div><div class="lbl">Avg accuracy</div></div>
119
+ <div class="stat-card"><div class="val" id="stat-best">β€”</div><div class="lbl">Best score</div></div>
120
+ <div class="stat-card"><div class="val" id="stat-players">β€”</div><div class="lbl">Unique players</div></div>
121
+ </div>
122
+
123
+ <div class="table-wrap">
124
+ <table>
125
+ <thead>
126
+ <tr>
127
+ <th class="sortable" data-col="rank" onclick="sortBy('rank')">#<span class="arrow" id="arr-rank"></span></th>
128
+ <th class="sortable" data-col="player_name" onclick="sortBy('player_name')">Name<span class="arrow" id="arr-player_name"></span></th>
129
+ <th class="sortable" data-col="score" onclick="sortBy('score')">Score<span class="arrow" id="arr-score"></span></th>
130
+ <th class="sortable" data-col="percentage" onclick="sortBy('percentage')">Accuracy<span class="arrow" id="arr-percentage">↓</span></th>
131
+ <th class="sortable" data-col="timestamp" onclick="sortBy('timestamp')">Date<span class="arrow" id="arr-timestamp"></span></th>
132
+ <th>Session ID</th>
133
+ </tr>
134
+ </thead>
135
+ <tbody id="tbody"></tbody>
136
+ </table>
137
+ </div>
138
+ </div>
139
+
140
+ <script>
141
+ let token = '';
142
+ let entries = [];
143
+ let sortCol = 'percentage';
144
+ let sortAsc = false;
145
+
146
+ // ── Auth ──────────────────────────────────────────────────────
147
+ // Accept token from ?token= URL param so you can bookmark the page
148
+ const urlToken = new URLSearchParams(location.search).get('token');
149
+ if (urlToken) {
150
+ token = urlToken;
151
+ // Remove token from URL bar without reloading
152
+ history.replaceState(null, '', location.pathname);
153
+ showDashboard();
154
+ }
155
+
156
+ async function login() {
157
+ token = document.getElementById('token-input').value.trim();
158
+ if (!token) return;
159
+ const ok = await showDashboard();
160
+ if (!ok) {
161
+ document.getElementById('auth-error').textContent = 'Invalid token.';
162
+ token = '';
163
+ }
164
+ }
165
+ document.getElementById('token-input').addEventListener('keydown', e => {
166
+ if (e.key === 'Enter') login();
167
+ });
168
+
169
+ // ── Load data ─────────────────────────────────────────────────
170
+ async function showDashboard() {
171
+ const res = await fetch('/api/admin/sessions', {
172
+ headers: { 'X-Admin-Token': token }
173
+ });
174
+ if (!res.ok) return false;
175
+ entries = await res.json();
176
+ document.getElementById('auth-screen').style.display = 'none';
177
+ document.getElementById('dashboard').style.display = '';
178
+ renderAll();
179
+ return true;
180
+ }
181
+
182
+ async function refresh() {
183
+ document.getElementById('status').textContent = 'Refreshing…';
184
+ await showDashboard();
185
+ }
186
+
187
+ // ── Render ────────────────────────────────────────────────────
188
+ function renderAll() {
189
+ renderStats();
190
+ renderTable();
191
+ document.getElementById('status').textContent =
192
+ `Last updated ${new Date().toLocaleTimeString()}`;
193
+ }
194
+
195
+ function renderStats() {
196
+ const n = entries.length;
197
+ document.getElementById('stat-sessions').textContent = n;
198
+ if (!n) {
199
+ ['stat-avg','stat-best','stat-players'].forEach(id =>
200
+ document.getElementById(id).textContent = 'β€”');
201
+ return;
202
+ }
203
+ const avg = Math.round(entries.reduce((s, e) => s + e.percentage, 0) / n);
204
+ const best = Math.max(...entries.map(e => e.percentage));
205
+ const uniq = new Set(entries.map(e => e.player_name)).size;
206
+ document.getElementById('stat-avg').textContent = avg + '%';
207
+ document.getElementById('stat-best').textContent = best + '%';
208
+ document.getElementById('stat-players').textContent = uniq;
209
+ }
210
+
211
+ function renderTable() {
212
+ const sorted = [...entries].sort((a, b) => {
213
+ let va = a[sortCol], vb = b[sortCol];
214
+ if (sortCol === 'timestamp') { va = new Date(va); vb = new Date(vb); }
215
+ if (sortCol === 'rank') return 0; // handled by order
216
+ return sortAsc ? (va > vb ? 1 : -1) : (va < vb ? 1 : -1);
217
+ });
218
+
219
+ // Update arrow indicators
220
+ ['rank','player_name','score','percentage','timestamp'].forEach(col => {
221
+ const el = document.getElementById('arr-' + col);
222
+ if (!el) return;
223
+ el.textContent = col === sortCol ? (sortAsc ? '↑' : '↓') : '';
224
+ el.style.opacity = col === sortCol ? '1' : '.45';
225
+ });
226
+
227
+ const tbody = document.getElementById('tbody');
228
+ tbody.innerHTML = '';
229
+
230
+ if (!sorted.length) {
231
+ tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:var(--muted);padding:2rem">No sessions yet.</td></tr>';
232
+ return;
233
+ }
234
+
235
+ sorted.forEach((e, i) => {
236
+ const tr = document.createElement('tr');
237
+ const dt = new Date(e.timestamp);
238
+ const date = dt.toLocaleDateString('en-GB', { day:'2-digit', month:'short', year:'2-digit' });
239
+ const time = dt.toLocaleTimeString('en-GB', { hour:'2-digit', minute:'2-digit' });
240
+ tr.innerHTML = `
241
+ <td>${i + 1}</td>
242
+ <td>${esc(e.player_name)}</td>
243
+ <td>${e.score} / ${e.total}</td>
244
+ <td>${e.percentage}%</td>
245
+ <td>${date} ${time}</td>
246
+ <td class="mono">${e.session_id}</td>
247
+ `;
248
+ tbody.appendChild(tr);
249
+ });
250
+ }
251
+
252
+ function sortBy(col) {
253
+ sortAsc = sortCol === col ? !sortAsc : false;
254
+ sortCol = col;
255
+ renderTable();
256
+ }
257
+
258
+ // ── CSV download ──────────────────────────────────────────────
259
+ function downloadCSV() {
260
+ const header = ['rank','player_name','score','total','percentage','timestamp','session_id'];
261
+ const sorted = [...entries].sort((a, b) => b.percentage - a.percentage || new Date(b.timestamp) - new Date(a.timestamp));
262
+ const rows = sorted.map((e, i) => [
263
+ i + 1, csvCell(e.player_name), e.score, e.total, e.percentage,
264
+ new Date(e.timestamp).toISOString(), e.session_id
265
+ ].join(','));
266
+ const csv = [header.join(','), ...rows].join('\n');
267
+ const blob = new Blob([csv], { type: 'text/csv' });
268
+ const url = URL.createObjectURL(blob);
269
+ const a = document.createElement('a');
270
+ a.href = url;
271
+ a.download = `diffmt_leaderboard_${new Date().toISOString().slice(0,10)}.csv`;
272
+ a.click();
273
+ URL.revokeObjectURL(url);
274
+ }
275
+
276
+ function csvCell(v) {
277
+ const s = String(v ?? '');
278
+ return s.includes(',') || s.includes('"') || s.includes('\n')
279
+ ? '"' + s.replace(/"/g, '""') + '"' : s;
280
+ }
281
+
282
+ function esc(str) {
283
+ return String(str ?? '').replace(/[&<>"']/g, c =>
284
+ ({ '&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;' }[c]));
285
+ }
286
+ </script>
287
+ </body>
288
+ </html>