domma-cms 1.8.32 → 1.8.34
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.
- package/CHANGELOG.md +9 -0
- package/docs/DommaFeatures.md +13 -1
- package/package.json +1 -1
- package/plugins/_lib/admin/games/games.css +1 -1
- package/plugins/_lib/admin/games/kit.js +329 -124
- package/plugins/_lib/games/http.js +11 -1
- package/plugins/_lib/games/plays.js +28 -7
- package/plugins/crossword/CLAUDE.md +37 -0
- package/plugins/crossword/admin/css/index.css +18 -1
- package/plugins/crossword/admin/views/crossword.js +337 -205
- package/plugins/crossword/docs/guide.md +53 -0
- package/plugins/crossword/inject/body.html +1 -1
- package/plugins/crossword/plugin.js +38 -5
- package/plugins/crossword/plugin.json +4 -3
- package/plugins/crossword/public/crossword.css +36 -1
- package/plugins/crossword/public/crossword.js +468 -6
- package/plugins/docs/CLAUDE.md +318 -0
- package/plugins/docs/admin/css/docs.css +783 -0
- package/plugins/docs/admin/lib/api.js +340 -0
- package/plugins/docs/admin/lib/convert.js +482 -0
- package/plugins/docs/admin/lib/drafts.js +115 -0
- package/plugins/docs/admin/lib/editor.js +851 -0
- package/plugins/docs/admin/lib/find-replace.js +426 -0
- package/plugins/docs/admin/lib/folders.js +203 -0
- package/plugins/docs/admin/lib/panel-guard.js +73 -0
- package/plugins/docs/admin/lib/pdf-view.js +126 -0
- package/plugins/docs/admin/lib/send.js +156 -0
- package/plugins/docs/admin/lib/settings.js +142 -0
- package/plugins/docs/admin/lib/share.js +323 -0
- package/plugins/docs/admin/lib/shortcuts.js +44 -0
- package/plugins/docs/admin/lib/templates.js +194 -0
- package/plugins/docs/admin/lib/versions.js +192 -0
- package/plugins/docs/admin/templates/docs.html +92 -0
- package/plugins/docs/admin/templates/editor.html +86 -0
- package/plugins/docs/admin/views/docs.js +777 -0
- package/plugins/docs/admin/views/editor.js +978 -0
- package/plugins/docs/config.js +24 -0
- package/plugins/docs/docs/guide.md +270 -0
- package/plugins/docs/lib/extensions.js +213 -0
- package/plugins/docs/lib/importer.js +72 -0
- package/plugins/docs/lib/pdf-worker.js +48 -0
- package/plugins/docs/lib/pdf.js +222 -0
- package/plugins/docs/lib/store.js +748 -0
- package/plugins/docs/plugin.js +1108 -0
- package/plugins/docs/plugin.json +89 -0
- package/plugins/docs/plugin.public.js +114 -0
- package/plugins/docs/templates/public-doc.html +36 -0
- package/plugins/docs/tests/_env.js +10 -0
- package/plugins/docs/tests/_pdf.js +73 -0
- package/plugins/docs/tests/convert.test.js +95 -0
- package/plugins/docs/tests/drafts.test.js +82 -0
- package/plugins/docs/tests/pdf.test.js +86 -0
- package/plugins/docs/tests/plugin.test.js +816 -0
- package/plugins/docs/tests/shortcuts.test.js +30 -0
- package/plugins/free-tier.lock.json +147 -1
- package/plugins/garage/CLAUDE.md +51 -0
- package/plugins/garage/admin/css/garage.css +73 -0
- package/plugins/garage/admin/lib/garage.js +236 -0
- package/plugins/garage/admin/templates/garage.html +86 -0
- package/plugins/garage/admin/views/garage.js +581 -0
- package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
- package/plugins/garage/config.js +18 -0
- package/plugins/garage/docs/guide.md +118 -0
- package/plugins/garage/plugin.js +471 -0
- package/plugins/garage/plugin.json +68 -0
- package/plugins/garage/tests/access.test.js +134 -0
- package/plugins/garage/tests/garage.test.js +106 -0
- package/plugins/messages/CLAUDE.md +230 -0
- package/plugins/messages/admin/css/messages.css +117 -0
- package/plugins/messages/admin/lib/messages.js +452 -0
- package/plugins/messages/admin/templates/messages.html +76 -0
- package/plugins/messages/admin/views/messages.js +1219 -0
- package/plugins/messages/collections/user-message-threads/schema.json +22 -0
- package/plugins/messages/collections/user-messages/schema.json +23 -0
- package/plugins/messages/config.js +14 -0
- package/plugins/messages/docs/guide.md +176 -0
- package/plugins/messages/lib/call.js +138 -0
- package/plugins/messages/plugin.js +994 -0
- package/plugins/messages/plugin.json +59 -0
- package/plugins/messages/plugin.public.js +52 -0
- package/plugins/messages/templates/call.html +6 -0
- package/plugins/messages/tests/after-send.test.js +22 -0
- package/plugins/messages/tests/api.test.js +187 -0
- package/plugins/messages/tests/call.test.js +497 -0
- package/plugins/messages/tests/drafts.test.js +25 -0
- package/plugins/messages/tests/messages.test.js +167 -0
- package/plugins/quiz/CLAUDE.md +43 -0
- package/plugins/quiz/admin/css/index.css +13 -1
- package/plugins/quiz/admin/views/quiz.js +423 -245
- package/plugins/quiz/docs/guide.md +61 -0
- package/plugins/quiz/inject/body.html +1 -1
- package/plugins/quiz/plugin.js +39 -5
- package/plugins/quiz/plugin.json +4 -3
- package/plugins/quiz/public/quiz.css +36 -1
- package/plugins/quiz/public/quiz.js +298 -6
- package/plugins/theme-roller/CLAUDE.md +65 -0
- package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
- package/plugins/theme-roller/admin/lib/prompt.js +22 -0
- package/plugins/theme-roller/admin/lib/themes.js +196 -0
- package/plugins/theme-roller/admin/lib/view.js +88 -0
- package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
- package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
- package/plugins/theme-roller/config.js +1 -0
- package/plugins/theme-roller/docs/guide.md +104 -0
- package/plugins/theme-roller/plugin.js +263 -0
- package/plugins/theme-roller/plugin.json +42 -0
- package/plugins/theme-roller/tests/api.test.js +161 -0
- package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
- package/plugins/theme-roller/tests/themes.test.js +77 -0
- package/plugins/theme-roller/tests/view.test.js +55 -0
|
@@ -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
|
|
5
|
-
* (every go at one game, its
|
|
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
|
|
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
|
-
|
|
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
|
|
63
|
+
/** E.confirm, toasts and templates render HTML: user text goes in escaped. */
|
|
52
64
|
export const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
/**
|
|
80
|
-
|
|
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
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
/**
|
|
93
|
-
export function
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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.
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
265
|
+
data = await loading(root, request('GET', url), k, 'table');
|
|
143
266
|
} catch (err) {
|
|
144
|
-
root
|
|
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
|
-
|
|
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
|
-
|
|
153
|
-
const finished =
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
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
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
}
|
|
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
|
-
|
|
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
|
-
|
|
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`,
|