domma-cms 1.8.32 → 1.8.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,8 +1,14 @@
1
1
  /**
2
2
  * Games admin - what the Quiz and Crossword screens share: the admin's helpers
3
3
  * (loaded at run time; a plugin never imports admin/ statically - the updater
4
- * replaces it), API calls, a small element builder, and the Plays screen
5
- * (every go at one game, its leaderboard, hide / remove / reset).
4
+ * replaces it), API calls, mounting a domma-reactive screen, list rows with a
5
+ * context menu, drag to reorder, the Plays screen (every go at one game, its
6
+ * leaderboard, hide / remove / reset) and the Test screen (the real player,
7
+ * playing a test go that never reaches a board).
8
+ *
9
+ * Every screen is domma-reactive: a template string (in the JS - a templateUrl
10
+ * goes through _.render, which eats the {{ }} of a data-each row), observables,
11
+ * and one M.applyBindings, disposed when the view goes.
6
12
  *
7
13
  * Served from /plugins/_lib/admin/games/kit.js; its stylesheet is games.css
8
14
  * beside it, listed in each Tool's manifest `admin.css`.
@@ -16,17 +22,23 @@ import {fmtDuration, ordinal} from '/plugins/_lib/public/games/format.js';
16
22
  export {fmtDateTime, fmtDuration, ordinal};
17
23
 
18
24
  let kit = null;
19
- /** tool-kit, plugin-chrome and loading, once, with stand-ins for an older admin. */
25
+ /** tool-kit, plugin-chrome, loading and help popovers, once, with stand-ins for an older admin. */
20
26
  export function loadKit() {
21
27
  kit ??= Promise.all([
22
28
  import('/admin/js/lib/tool-kit.js').catch(() => ({})),
23
29
  import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
24
- import('/admin/js/lib/loading.js').catch(() => ({}))
25
- ]).then(([tk, chrome, loading]) => ({
30
+ import('/admin/js/lib/loading.js').catch(() => ({})),
31
+ import('/admin/js/lib/help-popover.js').catch(() => ({}))
32
+ ]).then(([tk, chrome, loading, help]) => ({
26
33
  viewLifetime: tk.viewLifetime || (() => ({add() {}, reset() {}})),
34
+ openMenuAt: tk.openMenuAt || ((el) => {
35
+ const r = el.getBoundingClientRect();
36
+ el.dispatchEvent(new MouseEvent('contextmenu', {bubbles: true, cancelable: true, clientX: r.left, clientY: r.bottom, view: window}));
37
+ }),
27
38
  addBannerAction: chrome.addBannerAction || (() => null),
28
39
  whileLoading: loading.whileLoading || ((slot, p) => (typeof p === 'function' ? p() : p)),
29
- SHAPES: loading.SHAPES || {}
40
+ SHAPES: loading.SHAPES || {},
41
+ helpPopovers: help.helpPopovers || (() => () => {})
30
42
  }));
31
43
  return kit;
32
44
  }
@@ -48,53 +60,103 @@ export function request(method, path, body) {
48
60
 
49
61
  export const errText = (err) => err?.body?.error || err?.message || String(err);
50
62
 
51
- /** E.confirm and toasts render HTML: user text goes in escaped. */
63
+ /** E.confirm, toasts and templates render HTML: user text goes in escaped. */
52
64
  export const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
53
65
 
66
+ /** An icon for a template: drawn by I.scan. Never put a data-bind-* on it (I.scan swaps the span for an svg). */
67
+ export const ico = (name, size = 14) => `<span data-icon="${name}" data-icon-size="${size}" aria-hidden="true"></span>`;
68
+
54
69
  /**
55
- * A DOM element. `props`: className, text, html (static markup only - never
56
- * user data), dataset, on<Event> handlers, anything else as an attribute
57
- * (false/null leave it off). Children: elements, strings (as text) or falsy.
70
+ * Put `html` in `root` and bind `model` to it: the one M.applyBindings of a
71
+ * screen. `before(el)` runs first (take element refs and wire help popovers
72
+ * there - data-if lifts elements out). Icons are drawn after binding, and again
73
+ * whenever `rescan` (observables) change, so rows added later get theirs.
74
+ *
75
+ * @returns {HTMLElement} the screen's element; everything is disposed through `lifetime`
58
76
  */
59
- export function h(tag, props = {}, ...children) {
60
- const el = document.createElement(tag);
61
- for (const [k, v] of Object.entries(props || {})) {
62
- if (v === false || v == null) continue;
63
- if (k === 'className') el.className = v;
64
- else if (k === 'text') el.textContent = v;
65
- else if (k === 'html') el.innerHTML = v;
66
- else if (k === 'dataset') Object.assign(el.dataset, v);
67
- else if (k.startsWith('on') && typeof v === 'function') el.addEventListener(k.slice(2).toLowerCase(), v);
68
- else if (k === 'value') el.value = v;
69
- else if (k === 'checked') el.checked = Boolean(v);
70
- else el.setAttribute(k, v === true ? '' : String(v));
71
- }
72
- for (const c of children.flat()) {
73
- if (c == null || c === false) continue;
74
- el.append(c instanceof Node ? c : document.createTextNode(String(c)));
75
- }
77
+ export function mountScreen(root, {html, model, lifetime, k, className = '', before = null, rescan = []}) {
78
+ root.innerHTML = '';
79
+ const el = document.createElement('div');
80
+ el.className = `gma-screen ${className}`.trim();
81
+ el.innerHTML = html;
82
+ root.append(el);
83
+ const disposeHelp = k?.helpPopovers ? k.helpPopovers(el, {position: 'bottom'}) : () => {};
84
+ before?.(el);
85
+ const bindings = M.applyBindings(model, el, {methods: model});
86
+ const scan = () => { if (el.isConnected && typeof I !== 'undefined') I.scan(el); };
87
+ scan();
88
+ const stops = rescan.map(obs => M.effect(() => { obs.value; queueMicrotask(scan); }));
89
+ lifetime?.add(() => { stops.forEach(s => s?.()); bindings?.dispose?.(); disposeHelp?.(); });
76
90
  return el;
77
91
  }
78
92
 
79
- /** An icon span, drawn by I.scan on the container afterwards. */
80
- export const icon = (name, size = 14) => h('span', {'data-icon': name, 'data-icon-size': size, 'aria-hidden': 'true'});
93
+ /**
94
+ * Rows with one menu and three ways in: right-click, the ⋮ button, Shift+F10.
95
+ * A click (or Enter) on the row does `main`. `items(id)` lists the menu.
96
+ */
97
+ export function wireRows(list, {items, main, k, lifetime, match = '.gma-row'}) {
98
+ const menu = typeof E !== 'undefined' && E.contextMenu
99
+ ? E.contextMenu(list, {cursor: 'glyph', match, inherit: false, density: 'compact', items: (row) => items(row.dataset.id, row)})
100
+ : null;
101
+ const onClick = (e) => {
102
+ const row = e.target.closest(match);
103
+ if (!row) return;
104
+ if (e.target.closest('.gma-kebab')) { e.stopPropagation(); k.openMenuAt(row); return; }
105
+ if (e.target.closest('a, button, input, [data-stop]')) return;
106
+ main(row.dataset.id, row);
107
+ };
108
+ const onKey = (e) => {
109
+ const row = e.target.closest(match);
110
+ if (!row) return;
111
+ if (e.key === 'F10' && e.shiftKey) { e.preventDefault(); k.openMenuAt(row); }
112
+ else if (e.key === 'Enter' && e.target === row) { e.preventDefault(); main(row.dataset.id, row); }
113
+ else if ((e.key === 'ArrowDown' || e.key === 'ArrowUp') && e.target === row) {
114
+ e.preventDefault();
115
+ const rows = [...list.querySelectorAll(match)];
116
+ rows[rows.indexOf(row) + (e.key === 'ArrowDown' ? 1 : -1)]?.focus();
117
+ }
118
+ };
119
+ list.addEventListener('click', onClick);
120
+ list.addEventListener('keydown', onKey);
121
+ lifetime?.add(() => { list.removeEventListener('click', onClick); list.removeEventListener('keydown', onKey); menu?.destroy?.(); });
122
+ }
81
123
 
82
- /** A labelled field: label, control, optional hint. */
83
- export function field(label, control, hint = '', {id} = {}) {
84
- const fid = id || control.id || `gmf-${Math.random().toString(36).slice(2, 8)}`;
85
- control.id = fid;
86
- return h('div', {className: 'form-group gma-field'},
87
- h('label', {className: 'form-label', for: fid, text: label}),
88
- control,
89
- hint ? h('p', {className: 'form-hint', text: hint}) : null);
124
+ /**
125
+ * Drag to reorder the rows of a data-each list (by their grip). E.sortable in
126
+ * live mode moves the row element itself, but a data-each row sits between a
127
+ * pair of comment anchors the reconciler places it by - so the row is put back
128
+ * where it was first, and the move is made in the data (`onMove(from, to)`),
129
+ * which redraws the list.
130
+ */
131
+ export function sortableRows(list, {items, handle, key = 'data-id', onMove, lifetime}) {
132
+ if (typeof E === 'undefined' || typeof E.sortable !== 'function') return;
133
+ let at = null;
134
+ const restore = (item) => {
135
+ if (!at) return;
136
+ at.parent.insertBefore(item, at.next?.parentNode === at.parent ? at.next : null);
137
+ at = null;
138
+ };
139
+ const drag = E.sortable(list, {
140
+ items, handle, key,
141
+ // Alt+arrows would move the row without onStart, so it could not be put back by its anchors.
142
+ keyboard: false,
143
+ onStart: ({item}) => { at = {parent: item.parentNode, next: item.nextSibling}; },
144
+ onEnd: ({item}) => restore(item),
145
+ onSort: ({item, from, to}) => { restore(item); if (from !== to) onMove(from, to); }
146
+ });
147
+ lifetime?.add(() => drag?.destroy?.());
90
148
  }
91
149
 
92
- /** A switch (checkbox) with its words beside it. */
93
- export function toggle(label, checked, onChange, hint = '') {
94
- const input = h('input', {type: 'checkbox', checked, onChange: (e) => onChange(e.target.checked)});
95
- return h('div', {className: 'form-group gma-field'},
96
- h('label', {className: 'gma-toggle'}, input, h('span', {text: label})),
97
- hint ? h('p', {className: 'form-hint', text: hint}) : null);
150
+ /** Ctrl+S (Cmd+S) runs `save` while the screen is up. Captured: app.js has a global Ctrl+S of its own. */
151
+ export function saveKey(save, here, lifetime) {
152
+ const onKey = (e) => {
153
+ if (!(e.ctrlKey || e.metaKey) || e.key !== 's' || !here()) return;
154
+ e.preventDefault();
155
+ e.stopImmediatePropagation();
156
+ save();
157
+ };
158
+ document.addEventListener('keydown', onKey, true);
159
+ lifetime?.add(() => document.removeEventListener('keydown', onKey, true));
98
160
  }
99
161
 
100
162
  export async function copyText(text, done = 'Copied') {
@@ -112,121 +174,217 @@ export function refreshBadge(url) {
112
174
  try { window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url}})); } catch { /* no sidebar */ }
113
175
  }
114
176
 
115
- /** "Quiz › Capitals" in the plugin banner. */
177
+ /** "Quiz › Capitals" in the plugin banner (the chrome's, outside the screen's bindings). */
116
178
  export function bannerTrail(container, parts) {
117
179
  const name = container.querySelector('.dm-plugin-banner-name');
118
180
  if (!name) return;
119
- name.textContent = '';
120
- parts.forEach((p, i) => {
121
- if (i) name.append(h('span', {className: 'dm-plugin-banner-sep', 'aria-hidden': 'true', text: '›'}));
122
- name.append(i < parts.length - 1 ? h('a', {className: 'dm-plugin-banner-parent', href: p.href || '#', text: p.text}) : document.createTextNode(p.text));
123
- });
181
+ name.innerHTML = parts.map((p, i) => (i ? '<span class="dm-plugin-banner-sep" aria-hidden="true">›</span>' : '')
182
+ + (i < parts.length - 1 ? `<a class="dm-plugin-banner-parent" href="${esc(p.href || '#')}">${esc(p.text)}</a>` : esc(p.text))).join('');
183
+ }
184
+
185
+ /** A loading note while `promise` runs (the admin's skeleton where it has one). */
186
+ export async function loading(root, promise, k, shape = 'rows') {
187
+ root.innerHTML = '<div class="gma-note dm-loading-slot">Loading…</div>';
188
+ return k.whileLoading(root.firstElementChild, promise, k.SHAPES[shape]);
189
+ }
190
+
191
+ /** A screen that could not load: the reason, and the way back. */
192
+ export function failed(root, text, back = null) {
193
+ root.innerHTML = `<p class="gma-error">${esc(text)}</p>${back ? `<p><a href="${esc(back.href)}">${esc(back.text)}</a></p>` : ''}`;
124
194
  }
125
195
 
196
+ // ---- Plays ---------------------------------------------------------------------------
197
+
126
198
  const STATUS_LABEL = {playing: 'Playing', finished: 'Finished', abandoned: 'Left unfinished', timeout: 'Out of time'};
127
199
 
200
+ const PLAYS_HTML = `
201
+ <div class="gma-bar">
202
+ <a class="btn btn-sm btn-ghost" data-bind-href="back">${ico('arrow-left')} All</a>
203
+ <a class="btn btn-sm btn-ghost" data-bind-href="testHref" hidden data-bind-hidden="!testHref">${ico('play')} Test it</a>
204
+ <span class="gma-spacer"></span>
205
+ <button type="button" class="btn btn-sm btn-outline gma-danger-text" hidden data-bind-hidden="!canManage || !rows.value.length"
206
+ data-on-click="reset">${ico('refresh-cw')} Reset the leaderboard…</button>
207
+ </div>
208
+ <div class="gma-stats">
209
+ <div class="stat-card gma-stat"><div class="stat-value" data-bind-text="stats.value.plays"></div><div class="stat-label">Plays</div></div>
210
+ <div class="stat-card gma-stat"><div class="stat-value" data-bind-text="stats.value.finished"></div><div class="stat-label">Finished</div></div>
211
+ <div class="stat-card gma-stat" hidden data-bind-hidden="!showScore"><div class="stat-value" data-bind-text="stats.value.best"></div><div class="stat-label">Best score</div></div>
212
+ <div class="stat-card gma-stat"><div class="stat-value" data-bind-text="stats.value.fastest"></div><div class="stat-label">Fastest</div></div>
213
+ </div>
214
+ <div class="gma-two">
215
+ <section class="gma-card">
216
+ <h3 class="gma-h">Every play <span data-help-title="Every play" data-help="Every go anyone has had, newest first. Hide takes a play off the leaderboard without deleting it (a rude name, a known cheat); Show puts it back. Your own test goes are never listed or counted."></span></h3>
217
+ <p class="gma-muted" hidden data-bind-hidden="rows.value.length">No plays yet. Once people play, every go shows here.</p>
218
+ <div class="gma-table-wrap" hidden data-bind-hidden="!rows.value.length">
219
+ <table class="gma-table">
220
+ <thead><tr><th>Name</th><th>State</th><th class="gma-num" hidden data-bind-hidden="!showScore">Score</th><th class="gma-num">Time</th><th>Started</th><th hidden data-bind-hidden="!canManage"></th></tr></thead>
221
+ <tbody data-part="rows" data-each="rows.value key=id"></tbody>
222
+ </table>
223
+ </div>
224
+ </section>
225
+ <section class="gma-card">
226
+ <h3 class="gma-h">Leaderboard <span class="gma-muted">(all time, best go per name)</span></h3>
227
+ <p class="gma-muted" hidden data-bind-hidden="board.value.length">Nobody has finished yet.</p>
228
+ <ol class="gma-board" data-each="board.value key=id"></ol>
229
+ </section>
230
+ </div>`;
231
+
232
+ const PLAY_ROW = `
233
+ <tr data-bind-class="hidden && 'is-hidden'">
234
+ <td><strong data-bind-text="name"></strong><span class="gma-chip" hidden data-bind-hidden="!hidden">Hidden</span></td>
235
+ <td data-bind-text="state"></td>
236
+ <td class="gma-num" hidden data-bind-hidden="!$root.showScore" data-bind-text="scoreText"></td>
237
+ <td class="gma-num" data-bind-text="timeText"></td>
238
+ <td data-bind-text="started"></td>
239
+ <td class="gma-actions" hidden data-bind-hidden="!$root.canManage">
240
+ <button type="button" class="btn btn-xs btn-ghost" data-bind-title="hidden ? 'Show on the leaderboard' : 'Hide from the leaderboard'"
241
+ data-on-click="$root.hide($data)"><span data-bind-text="hidden ? 'Show' : 'Hide'"></span></button>
242
+ <button type="button" class="btn btn-xs btn-ghost gma-quiet" title="Delete this play" aria-label="Delete this play"
243
+ data-on-click="$root.remove($data)">${ico('x')}</button>
244
+ </td>
245
+ </tr>`;
246
+
247
+ const BOARD_ROW = `
248
+ <li><span class="gma-rank" data-bind-text="place"></span><span class="gma-name" data-bind-text="name"></span>
249
+ <strong hidden data-bind-hidden="!$root.showScore" data-bind-text="scoreText"></strong><span class="gma-muted" data-bind-text="timeText"></span></li>`;
250
+
128
251
  /**
129
252
  * The Plays screen for one game.
130
253
  *
131
254
  * @param {HTMLElement} root
132
- * @param {{api: string, gameId: string, title: string, order: 'score'|'time', canManage: boolean, back: string, refreshBadge?: () => void}} opts
255
+ * @param {{api: string, gameId: string, title: string, order: 'score'|'time', canManage?: boolean, back: string,
256
+ * base?: string, badge?: () => void, lifetime: object, testHref?: string, alive?: () => boolean}} opts
257
+ * `alive` says the admin is still on this screen (checked after the load).
133
258
  * `api` is the Tool's base under /api ('/plugins/quiz'), `back` the list's address.
134
259
  */
135
- export async function playsScreen(root, {api, gameId, title, order, canManage = true, back, base = '/quizzes', badge = () => {}}) {
260
+ export async function playsScreen(root, {api, gameId, title, order, canManage = true, back, base = '/quizzes', badge = () => {}, lifetime, testHref = '', alive = () => true}) {
136
261
  const k = await loadKit();
137
- root.innerHTML = '';
138
- const slot = h('div', {className: 'gma-note dm-loading-slot', text: 'Loading…'});
139
- root.append(slot);
262
+ const url = `${api}${base}/${encodeURIComponent(gameId)}/plays`;
140
263
  let data;
141
264
  try {
142
- data = await k.whileLoading(slot, request('GET', `${api}${base}/${encodeURIComponent(gameId)}/plays`), k.SHAPES.table);
265
+ data = await loading(root, request('GET', url), k, 'table');
143
266
  } catch (err) {
144
- root.innerHTML = '';
145
- root.append(h('p', {className: 'gma-error', text: `The plays could not load: ${errText(err)}`}));
267
+ if (alive()) failed(root, `The plays could not load: ${errText(err)}`, {href: back, text: 'Back'});
146
268
  return;
147
269
  }
148
- root.innerHTML = '';
270
+ if (!alive()) return;
149
271
 
150
272
  const showScore = order === 'score';
273
+ const plays = M.observable(data.plays);
274
+ const boardRows = M.observable(data.board);
275
+ const rows = M.observable([]);
276
+ const board = M.observable([]);
277
+ const stats = M.observable({});
151
278
  const draw = () => {
152
- root.innerHTML = '';
153
- const finished = data.plays.filter(p => p.status === 'finished');
154
- const bar = h('div', {className: 'gma-bar'},
155
- h('a', {className: 'btn btn-sm btn-ghost', href: back}, icon('arrow-left'), ' All'),
156
- h('span', {className: 'gma-spacer'}),
157
- canManage && data.plays.length ? h('button', {type: 'button', className: 'btn btn-sm btn-outline gma-danger-text', onClick: reset}, icon('refresh-cw'), ' Reset the leaderboard…') : null);
158
-
159
- const stats = h('div', {className: 'gma-stats'},
160
- stat('Plays', data.plays.length), stat('Finished', finished.length),
161
- showScore ? stat('Best score', finished.reduce((m, p) => Math.max(m, p.score || 0), 0)) : null,
162
- stat('Fastest', finished.length ? fmtDuration(Math.min(...finished.map(p => p.timeMs || 0))) : '-'));
163
-
164
- const boardCard = h('section', {className: 'gma-card'},
165
- h('h3', {className: 'gma-h', text: 'Leaderboard (all time, best go per name)'}),
166
- data.board.length
167
- ? h('ol', {className: 'gma-board'}, data.board.slice(0, 20).map(r => h('li', {},
168
- h('span', {className: 'gma-rank', text: ordinal(r.rank)}), h('span', {className: 'gma-name', text: r.name}),
169
- showScore ? h('strong', {text: `${r.score}`}) : null, h('span', {className: 'gma-muted', text: fmtDuration(r.timeMs)}))))
170
- : h('p', {className: 'gma-muted', text: 'Nobody has finished yet.'}));
171
-
172
- const rows = data.plays.map(p => h('tr', {className: p.hidden ? 'is-hidden' : ''},
173
- h('td', {}, h('strong', {text: p.name}), p.hidden ? h('span', {className: 'gma-chip', text: 'Hidden'}) : null),
174
- h('td', {text: STATUS_LABEL[p.status] || p.status}),
175
- showScore ? h('td', {className: 'gma-num', text: p.status === 'finished' ? `${p.score} / ${p.maxScore}` : '-'}) : null,
176
- h('td', {className: 'gma-num', text: p.status === 'finished' ? fmtDuration(p.timeMs) : '-'}),
177
- h('td', {text: fmtDateTime(p.startedAt)}),
178
- canManage ? h('td', {className: 'gma-actions'},
179
- h('button', {type: 'button', className: 'btn btn-xs btn-ghost', title: p.hidden ? 'Show on the leaderboard' : 'Hide from the leaderboard',
180
- onClick: () => hide(p)}, icon(p.hidden ? 'eye' : 'eye-off'), p.hidden ? ' Show' : ' Hide'),
181
- h('button', {type: 'button', className: 'btn btn-xs btn-ghost gma-danger-text', title: 'Delete this play', onClick: () => remove(p)}, icon('trash'))) : null));
182
-
183
- const table = data.plays.length
184
- ? h('div', {className: 'gma-table-wrap'}, h('table', {className: 'gma-table'},
185
- h('thead', {}, h('tr', {}, h('th', {text: 'Name'}), h('th', {text: 'State'}), showScore ? h('th', {className: 'gma-num', text: 'Score'}) : null,
186
- h('th', {className: 'gma-num', text: 'Time'}), h('th', {text: 'Started'}), canManage ? h('th', {}) : null)),
187
- h('tbody', {}, rows)))
188
- : h('p', {className: 'gma-muted', text: 'No plays yet. Once people play, every go shows here.'});
189
-
190
- root.append(bar, stats, h('div', {className: 'gma-two'}, h('section', {className: 'gma-card'}, h('h3', {className: 'gma-h', text: 'Every play'}), table), boardCard));
191
- I.scan(root);
279
+ const list = plays.peek();
280
+ const finished = list.filter(p => p.status === 'finished');
281
+ rows.value = list.map(p => ({
282
+ id: p.id, name: p.name, hidden: p.hidden === true, state: STATUS_LABEL[p.status] || p.status,
283
+ scoreText: p.status === 'finished' ? `${p.score} / ${p.maxScore}` : '-',
284
+ timeText: p.status === 'finished' ? fmtDuration(p.timeMs) : '-', started: fmtDateTime(p.startedAt)
285
+ }));
286
+ board.value = boardRows.peek().slice(0, 20).map(r => ({id: r.id, name: r.name, place: ordinal(r.rank), scoreText: `${r.score}`, timeText: fmtDuration(r.timeMs)}));
287
+ stats.value = {
288
+ plays: list.length, finished: finished.length,
289
+ best: finished.reduce((m, p) => Math.max(m, p.score || 0), 0),
290
+ fastest: finished.length ? fmtDuration(Math.min(...finished.map(p => p.timeMs || 0))) : '-'
291
+ };
192
292
  };
193
-
194
- const stat = (label, value) => h('div', {className: 'stat-card gma-stat'}, h('div', {className: 'stat-value', text: String(value)}), h('div', {className: 'stat-label', text: label}));
293
+ draw();
195
294
 
196
295
  async function reload() {
197
- data = await request('GET', `${api}${base}/${encodeURIComponent(gameId)}/plays`);
296
+ const next = await request('GET', url);
297
+ plays.value = next.plays;
298
+ boardRows.value = next.board;
198
299
  draw();
199
300
  badge();
200
301
  }
201
302
 
202
- async function hide(p) {
203
- try {
204
- await request('PUT', `${api}/plays/${encodeURIComponent(p.id)}`, {hidden: !p.hidden});
205
- E.toast(p.hidden ? `${p.name} is back on the leaderboard.` : `${p.name} is hidden from the leaderboard.`, {type: 'success'});
206
- await reload();
207
- } catch (err) { E.toast(errText(err), {type: 'error'}); }
208
- }
303
+ const byId = (id) => plays.peek().find(p => p.id === id);
304
+ const model = {
305
+ back, testHref, showScore, canManage, rows, board, stats,
306
+ async hide(row) {
307
+ const p = byId(row.id);
308
+ if (!p) return;
309
+ try {
310
+ await request('PUT', `${api}/plays/${encodeURIComponent(p.id)}`, {hidden: !p.hidden});
311
+ E.toast(p.hidden ? `${p.name} is back on the leaderboard.` : `${p.name} is hidden from the leaderboard.`, {type: 'success'});
312
+ await reload();
313
+ } catch (err) { E.toast(errText(err), {type: 'error'}); }
314
+ },
315
+ async remove(row) {
316
+ const p = byId(row.id);
317
+ if (!p || !await E.confirm(`Delete <strong>${esc(p.name)}</strong>'s play? It leaves the leaderboard for good.`)) return;
318
+ try {
319
+ await request('DELETE', `${api}/plays/${encodeURIComponent(p.id)}`);
320
+ await reload();
321
+ } catch (err) { E.toast(errText(err), {type: 'error'}); }
322
+ },
323
+ async reset() {
324
+ if (!await E.confirm(`Reset the leaderboard for <strong>${esc(title)}</strong>? Every play (${plays.peek().length}) is deleted. This cannot be undone.`)) return;
325
+ try {
326
+ const r = await request('DELETE', url);
327
+ E.toast(`${r.removed} play${r.removed === 1 ? '' : 's'} deleted. The leaderboard starts again.`, {type: 'success'});
328
+ await reload();
329
+ } catch (err) { E.toast(errText(err), {type: 'error'}); }
330
+ }
331
+ };
209
332
 
210
- async function remove(p) {
211
- if (!await E.confirm(`Delete <strong>${esc(p.name)}</strong>'s play? It leaves the leaderboard for good.`)) return;
212
- try {
213
- await request('DELETE', `${api}/plays/${encodeURIComponent(p.id)}`);
214
- await reload();
215
- } catch (err) { E.toast(errText(err), {type: 'error'}); }
216
- }
333
+ mountScreen(root, {
334
+ html: PLAYS_HTML, model, lifetime, k, className: 'gma-plays', rescan: [rows],
335
+ before: (el) => {
336
+ el.querySelector('[data-part="rows"]').innerHTML = PLAY_ROW;
337
+ el.querySelector('.gma-board').innerHTML = BOARD_ROW;
338
+ }
339
+ });
340
+ }
217
341
 
218
- async function reset() {
219
- if (!await E.confirm(`Reset the leaderboard for <strong>${esc(title)}</strong>? Every play (${data.plays.length}) is deleted. This cannot be undone.`)) return;
220
- try {
221
- const r = await request('DELETE', `${api}${base}/${encodeURIComponent(gameId)}/plays`);
222
- E.toast(`${r.removed} play${r.removed === 1 ? '' : 's'} deleted. The leaderboard starts again.`, {type: 'success'});
223
- await reload();
224
- } catch (err) { E.toast(errText(err), {type: 'error'}); }
225
- }
342
+ // ---- Test ----------------------------------------------------------------------------
226
343
 
227
- draw();
344
+ const TEST_HTML = `
345
+ <div class="gma-bar">
346
+ <a class="btn btn-sm btn-ghost" data-bind-href="editHref">${ico('arrow-left')} Back to the editor</a>
347
+ <a class="btn btn-sm btn-ghost" data-bind-href="playsHref">${ico('award')} Plays</a>
348
+ <span class="gma-spacer"></span>
349
+ <span class="gma-chip is-primary" data-bind-text="statusText"></span>
350
+ <span data-help-title="Testing" data-help="This is the real player, exactly as it is on a page - the same clock, the same marking, the same screens. It plays the version you last saved, even a draft or a closed one. A test go is not saved to the leaderboard or counted in the plays, and the next test clears it away."></span>
351
+ </div>
352
+ <p class="gma-note">Plays the saved version as your players see it. Test goes never reach the leaderboard.</p>
353
+ <div class="gma-test-stage"><div data-part="player"></div></div>`;
354
+
355
+ /**
356
+ * The Test screen: the Tool's own public player, mounted with test hooks that
357
+ * fetch the start screen and start a test play through the admin API.
358
+ *
359
+ * @param {HTMLElement} root
360
+ * @param {{api: string, base: string, gameId: string, player: string, appClass: string,
361
+ * editHref: string, playsHref: string, statusText?: string, lifetime: object, alive?: () => boolean}} opts
362
+ * `player` is the public script that exports `mount(el, {test})`.
363
+ */
364
+ export async function testScreen(root, {api, base, gameId, player, appClass, editHref, playsHref, statusText = '', lifetime, alive = () => true}) {
365
+ const k = await loadKit();
366
+ let mod;
367
+ try {
368
+ mod = await loading(root, import(player), k, 'card');
369
+ } catch (err) {
370
+ if (alive()) failed(root, `The player could not load: ${errText(err)}`, {href: editHref, text: 'Back to the editor'});
371
+ return;
372
+ }
373
+ if (!alive()) return;
374
+ const url = `${api}${base}/${encodeURIComponent(gameId)}/test`;
375
+ let host = null;
376
+ mountScreen(root, {
377
+ html: TEST_HTML, model: {editHref, playsHref, statusText: statusText ? `${statusText} - testing` : 'Testing'},
378
+ lifetime, k, className: 'gma-test',
379
+ before: (el) => { host = el.querySelector('[data-part="player"]'); }
380
+ });
381
+ host.className = `gm-app ${appClass}`;
382
+ const handle = mod.mount(host, {test: {info: () => request('GET', url).then(r => r.info), start: () => request('POST', url)}});
383
+ lifetime?.add(() => handle?.dispose?.());
228
384
  }
229
385
 
386
+ // ---- Dates ---------------------------------------------------------------------------
387
+
230
388
  /** A datetime-local value ('YYYY-MM-DDTHH:mm', the browser's time) from ISO, and back. */
231
389
  export const toLocalInput = (iso) => {
232
390
  const t = Date.parse(iso || '');
@@ -239,3 +397,50 @@ export const fromLocalInput = (v) => {
239
397
  const t = Date.parse(v || '');
240
398
  return Number.isFinite(t) ? new Date(t).toISOString() : '';
241
399
  };
400
+
401
+ // ---- 1.0.0 ---------------------------------------------------------------------------
402
+ // Quiz and Crossword 1.0.0 drew their screens by hand with these. 1.1.0 is
403
+ // domma-reactive throughout and uses none of them; they stay so a 1.0.0 copy
404
+ // installed from the catalogue keeps working on a newer engine.
405
+
406
+ /** @deprecated 1.0.0 screens only. */
407
+ export function h(tag, props = {}, ...children) {
408
+ const el = document.createElement(tag);
409
+ for (const [key, v] of Object.entries(props || {})) {
410
+ if (v === false || v == null) continue;
411
+ if (key === 'className') el.className = v;
412
+ else if (key === 'text') el.textContent = v;
413
+ else if (key === 'html') el.innerHTML = v;
414
+ else if (key === 'dataset') Object.assign(el.dataset, v);
415
+ else if (key.startsWith('on') && typeof v === 'function') el.addEventListener(key.slice(2).toLowerCase(), v);
416
+ else if (key === 'value') el.value = v;
417
+ else if (key === 'checked') el.checked = Boolean(v);
418
+ else el.setAttribute(key, v === true ? '' : String(v));
419
+ }
420
+ for (const c of children.flat()) {
421
+ if (c == null || c === false) continue;
422
+ el.append(c instanceof Node ? c : document.createTextNode(String(c)));
423
+ }
424
+ return el;
425
+ }
426
+
427
+ /** @deprecated 1.0.0 screens only. */
428
+ export const icon = (name, size = 14) => h('span', {'data-icon': name, 'data-icon-size': size, 'aria-hidden': 'true'});
429
+
430
+ /** @deprecated 1.0.0 screens only. */
431
+ export function field(label, control, hint = '', {id} = {}) {
432
+ const fid = id || control.id || `gmf-${Math.random().toString(36).slice(2, 8)}`;
433
+ control.id = fid;
434
+ return h('div', {className: 'form-group gma-field'},
435
+ h('label', {className: 'form-label', for: fid, text: label}),
436
+ control,
437
+ hint ? h('p', {className: 'form-hint', text: hint}) : null);
438
+ }
439
+
440
+ /** @deprecated 1.0.0 screens only. */
441
+ export function toggle(label, checked, onChange, hint = '') {
442
+ const input = h('input', {type: 'checkbox', checked, onChange: (e) => onChange(e.target.checked)});
443
+ return h('div', {className: 'form-group gma-field'},
444
+ h('label', {className: 'gma-toggle'}, input, h('span', {text: label})),
445
+ hint ? h('p', {className: 'form-hint', text: hint}) : null);
446
+ }
@@ -22,6 +22,16 @@ export const limited = (max, minutes = 1) => ({config: {rateLimit: {max, timeWin
22
22
  */
23
23
  export const isBot = (body) => Boolean(String(body?.website ?? '').trim());
24
24
 
25
+ /**
26
+ * The name on an admin's test play: their first name (or the part of the
27
+ * email before the @) and "(test)", within a player name's 24 characters.
28
+ */
29
+ export function testerName(request) {
30
+ const u = request?.user || {};
31
+ const who = String(u.name || u.username || u.email || '').trim().split(/[\s@]/)[0];
32
+ return who ? `${who.slice(0, 17)} (test)` : 'Test';
33
+ }
34
+
25
35
  /** `?period=&limit=` for a board, the limit within the game's own size. */
26
36
  export function boardQuery(query, size = 10) {
27
37
  const max = Math.max(1, Math.min(100, Number(size) || 10));
@@ -66,7 +76,7 @@ export function mountPlayAdmin(fastify, {store, base, can, exists}) {
66
76
  export async function todayBadge(store, noun = 'play') {
67
77
  const start = new Date();
68
78
  start.setHours(0, 0, 0, 0);
69
- const today = (await store.all()).filter(p => p.status === 'finished' && Date.parse(p.finishedAt) >= start.getTime());
79
+ const today = (await store.all()).filter(p => p.test !== true && p.status === 'finished' && Date.parse(p.finishedAt) >= start.getTime());
70
80
  return {
71
81
  count: today.length,
72
82
  label: `${today.length} ${noun}${today.length === 1 ? '' : 's'} finished today`,