domma-cms 1.8.31 → 1.8.32

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.
Files changed (40) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/package.json +1 -1
  3. package/plugins/_lib/admin/games/games.css +1 -0
  4. package/plugins/_lib/admin/games/kit.js +241 -0
  5. package/plugins/_lib/games/http.js +82 -0
  6. package/plugins/_lib/games/names.js +42 -0
  7. package/plugins/_lib/games/plays.js +155 -0
  8. package/plugins/_lib/games/ranking.js +68 -0
  9. package/plugins/_lib/games/timing.js +27 -0
  10. package/plugins/_lib/public/games/format.js +38 -0
  11. package/plugins/_lib/public/games/games.css +1 -0
  12. package/plugins/_lib/public/games/games.js +204 -0
  13. package/plugins/crossword/admin/css/index.css +1 -0
  14. package/plugins/crossword/admin/templates/crossword.html +1 -0
  15. package/plugins/crossword/admin/views/crossword.js +317 -0
  16. package/plugins/crossword/collections/crossword-plays.json +20 -0
  17. package/plugins/crossword/collections/crossword-puzzles.json +17 -0
  18. package/plugins/crossword/config.js +6 -0
  19. package/plugins/crossword/inject/body.html +1 -0
  20. package/plugins/crossword/lib/crossword.js +170 -0
  21. package/plugins/crossword/lib/store.js +56 -0
  22. package/plugins/crossword/plugin.js +241 -0
  23. package/plugins/crossword/plugin.json +78 -0
  24. package/plugins/crossword/public/crossword.css +1 -0
  25. package/plugins/crossword/public/crossword.js +57 -0
  26. package/plugins/crossword/public/lib/layout.js +199 -0
  27. package/plugins/quiz/admin/css/index.css +1 -0
  28. package/plugins/quiz/admin/templates/quiz.html +1 -0
  29. package/plugins/quiz/admin/views/quiz.js +352 -0
  30. package/plugins/quiz/collections/quiz-plays.json +20 -0
  31. package/plugins/quiz/collections/quiz-quizzes.json +17 -0
  32. package/plugins/quiz/config.js +6 -0
  33. package/plugins/quiz/inject/body.html +1 -0
  34. package/plugins/quiz/lib/engine.js +115 -0
  35. package/plugins/quiz/lib/quiz.js +225 -0
  36. package/plugins/quiz/lib/store.js +56 -0
  37. package/plugins/quiz/plugin.js +241 -0
  38. package/plugins/quiz/plugin.json +78 -0
  39. package/plugins/quiz/public/quiz.css +1 -0
  40. package/plugins/quiz/public/quiz.js +80 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## [1.8.32] - 2026-10-20
4
+
5
+ - **Quiz and Crossword, two new free Tools under Tools > Games.** Visitors play by typing a name, and the server keeps the time and does the marking, so the answers never reach the browser before they are given.
6
+ - **Quiz:** questions with one answer, several answers, true or false, or a typed answer (case, accents and "the" do not matter). Time limits per question and for the whole quiz, shuffling, a random draw from a question bank, and answers shown after each question, at the end, or never. Put one on a page with `[quiz id="…" /]`.
7
+ - **Crossword:** type the answers and clues and the grid is laid out for you, with a live preview and "Another layout". Checking and wrong guesses cost time. Put one on a page with `[crossword id="…" /]`.
8
+ - **Both:** leaderboards for today, this week, this month and all time (each name's best go), `[quiz-leaderboard]` and `[crossword-leaderboard]` to show one anywhere, a Plays screen to hide, delete or reset, a sidebar badge, and a notification when someone takes the top spot. Both arrive switched off.
9
+
3
10
  ## [1.8.31] - 2026-10-20
4
11
 
5
12
  - Add `pdfjs-dist` and `pdf-lib` as optional dependencies, for the Docs Tool's PDF documents (Docs 2.13: a PDF kept as it is, searchable inside, shown in the browser's viewer) and Docs Pro's PDF signing and PDF tools (0.10 and 0.11: boxes placed on the pages, forms filled in, marks drawn, pages turned, removed, reordered, taken out and merged). A server without them still runs Docs; those features say so and stand aside.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.8.31",
3
+ "version": "1.8.32",
4
4
  "description": "File-based CMS powered by Domma and Fastify. Run npx domma-cms my-site to create a new project.",
5
5
  "type": "module",
6
6
  "main": "server/server.js",
@@ -0,0 +1 @@
1
+ .gma{--gma-line: var(--dm-border, rgba(128, 128, 128, .3));--gma-soft: color-mix(in srgb, var(--dm-text) 5%, transparent);display:flex;flex-direction:column;gap:1rem;min-width:0}.gma [hidden]{display:none!important}.gma-bar{display:flex;flex-wrap:wrap;gap:.5rem;align-items:center}.gma-spacer{flex:1}.gma-muted{color:var(--dm-text-muted)}.gma-note{margin:0;font-size:var(--dm-text-sm);color:var(--dm-text-muted)}.gma-error{color:var(--dm-danger);margin:0}.gma-danger-text{color:var(--dm-danger)!important}.gma-h{margin:0 0 .5rem;font-size:1rem;font-weight:600}.gma-card{border:1px solid var(--gma-line);border-radius:var(--dm-radius-md, 8px);padding:1rem;background:var(--dm-surface, transparent);display:flex;flex-direction:column;gap:.75rem;min-width:0}.gma-two{display:grid;grid-template-columns:minmax(0,2fr) minmax(16rem,1fr);gap:1rem;align-items:start}@media(max-width:1000px){.gma-two{grid-template-columns:minmax(0,1fr)}}.gma-chip{display:inline-flex;align-items:center;font-size:var(--dm-text-xs);font-weight:600;padding:.1rem .55rem;border-radius:999px;white-space:nowrap;background:color-mix(in srgb,var(--dm-text) 9%,transparent);color:var(--dm-text-muted);margin-left:.4rem}.gma-chip.is-success{background:color-mix(in srgb,var(--dm-success) 15%,transparent);color:var(--dm-success)}.gma-chip.is-warning{background:color-mix(in srgb,var(--dm-warning) 16%,transparent);color:color-mix(in srgb,var(--dm-warning) 55%,var(--dm-text))}.gma-chip.is-primary{background:color-mix(in srgb,var(--dm-primary) 14%,transparent);color:var(--dm-primary)}.gma-list{display:flex;flex-direction:column;gap:.5rem}.gma-row{display:flex;flex-wrap:wrap;align-items:center;gap:.75rem;padding:.75rem 1rem;border:1px solid var(--gma-line);border-radius:var(--dm-radius-md, 8px);background:var(--dm-surface, transparent)}.gma-row:hover{border-color:color-mix(in srgb,var(--dm-primary) 45%,var(--gma-line))}.gma-row-main{flex:1 1 16rem;min-width:0;display:flex;flex-direction:column;gap:.15rem}.gma-row-main a{font-weight:600;color:var(--dm-text);text-decoration:none}.gma-row-main a:hover{color:var(--dm-primary)}.gma-row-main small{color:var(--dm-text-muted)}.gma-row-actions{display:flex;flex-wrap:wrap;gap:.25rem}.gma-empty{display:flex;flex-direction:column;align-items:center;gap:.5rem;text-align:center;padding:2.5rem 1rem;color:var(--dm-text-muted)}.gma-empty strong{color:var(--dm-text)}.gma-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(14rem,1fr));gap:.25rem 1rem}.gma-field{margin:0;min-width:0}.gma-toggle{display:inline-flex;align-items:center;gap:.5rem;cursor:pointer;font-weight:500}.gma-shortcode{display:flex;gap:.5rem;align-items:center;flex-wrap:wrap}.gma-shortcode code{padding:.3rem .55rem;border-radius:6px;background:var(--gma-soft);font-size:var(--dm-text-sm)}.gma-save{position:sticky;bottom:0;z-index:5;display:flex;gap:.5rem;align-items:center;flex-wrap:wrap;padding:.75rem 1rem;border:1px solid var(--gma-line);border-radius:var(--dm-radius-md, 8px);background:var(--dm-background, var(--dm-surface))}.gma-save .is-dirty{color:color-mix(in srgb,var(--dm-warning) 60%,var(--dm-text));font-size:var(--dm-text-sm)}.gma-stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(9rem,1fr));gap:.75rem}.gma-stat{padding:.75rem 1rem}.gma-table-wrap{overflow-x:auto}.gma-table{width:100%;border-collapse:collapse;font-size:var(--dm-text-sm)}.gma-table th,.gma-table td{padding:.45rem .5rem;border-bottom:1px solid var(--gma-line);text-align:left;vertical-align:middle}.gma-table th{font-weight:600;color:var(--dm-text-muted)}.gma-table tr.is-hidden td{opacity:.55}.gma-num{text-align:right!important;font-variant-numeric:tabular-nums;white-space:nowrap}.gma-actions{white-space:nowrap;text-align:right!important}.gma-board{margin:0;padding:0;list-style:none;display:flex;flex-direction:column}.gma-board li{display:grid;grid-template-columns:2.6rem 1fr auto auto;gap:.5rem;padding:.35rem 0;border-bottom:1px solid var(--gma-line);align-items:center}.gma-board li:last-child{border-bottom:0}.gma-rank{font-weight:700}.gma-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
@@ -0,0 +1,241 @@
1
+ /**
2
+ * Games admin - what the Quiz and Crossword screens share: the admin's helpers
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).
6
+ *
7
+ * Served from /plugins/_lib/admin/games/kit.js; its stylesheet is games.css
8
+ * beside it, listed in each Tool's manifest `admin.css`.
9
+ *
10
+ * @module _lib/admin/games/kit
11
+ */
12
+
13
+ import {fmtDateTime} from '/plugins/_lib/admin/ui/dates.js';
14
+ import {fmtDuration, ordinal} from '/plugins/_lib/public/games/format.js';
15
+
16
+ export {fmtDateTime, fmtDuration, ordinal};
17
+
18
+ let kit = null;
19
+ /** tool-kit, plugin-chrome and loading, once, with stand-ins for an older admin. */
20
+ export function loadKit() {
21
+ kit ??= Promise.all([
22
+ import('/admin/js/lib/tool-kit.js').catch(() => ({})),
23
+ import('/admin/js/lib/plugin-chrome.js').catch(() => ({})),
24
+ import('/admin/js/lib/loading.js').catch(() => ({}))
25
+ ]).then(([tk, chrome, loading]) => ({
26
+ viewLifetime: tk.viewLifetime || (() => ({add() {}, reset() {}})),
27
+ addBannerAction: chrome.addBannerAction || (() => null),
28
+ whileLoading: loading.whileLoading || ((slot, p) => (typeof p === 'function' ? p() : p)),
29
+ SHAPES: loading.SHAPES || {}
30
+ }));
31
+ return kit;
32
+ }
33
+
34
+ let requester = null;
35
+ /**
36
+ * A call to the API through the admin's apiRequest (it attaches the token and
37
+ * refreshes it on a 401, and keeps `err.status` / `err.body` on an error); H is
38
+ * the stand-in on an older admin.
39
+ */
40
+ export function request(method, path, body) {
41
+ requester ??= import('/admin/js/api.js').then(m => m.apiRequest).catch(() => null);
42
+ return requester.then(fn => {
43
+ if (fn) return fn(path, {method, ...(body !== undefined ? {body: JSON.stringify(body)} : {})});
44
+ const url = `/api${path}`;
45
+ return method === 'GET' ? H.get(url) : method === 'DELETE' ? H.delete(url) : H[method.toLowerCase()](url, body);
46
+ });
47
+ }
48
+
49
+ export const errText = (err) => err?.body?.error || err?.message || String(err);
50
+
51
+ /** E.confirm and toasts render HTML: user text goes in escaped. */
52
+ export const esc = (s) => String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
53
+
54
+ /**
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.
58
+ */
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
+ }
76
+ return el;
77
+ }
78
+
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'});
81
+
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);
90
+ }
91
+
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);
98
+ }
99
+
100
+ export async function copyText(text, done = 'Copied') {
101
+ try {
102
+ if (typeof _ !== 'undefined' && typeof _.copyToClipboard === 'function') await _.copyToClipboard(text);
103
+ else await navigator.clipboard.writeText(text);
104
+ E.toast(done, {type: 'success'});
105
+ } catch {
106
+ E.toast('Could not copy. Select the text and copy it yourself.', {type: 'warning'});
107
+ }
108
+ }
109
+
110
+ /** The sidebar re-asks a Tool's badge. */
111
+ export function refreshBadge(url) {
112
+ try { window.dispatchEvent(new CustomEvent('dm:sidebar-count', {detail: {url}})); } catch { /* no sidebar */ }
113
+ }
114
+
115
+ /** "Quiz › Capitals" in the plugin banner. */
116
+ export function bannerTrail(container, parts) {
117
+ const name = container.querySelector('.dm-plugin-banner-name');
118
+ 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
+ });
124
+ }
125
+
126
+ const STATUS_LABEL = {playing: 'Playing', finished: 'Finished', abandoned: 'Left unfinished', timeout: 'Out of time'};
127
+
128
+ /**
129
+ * The Plays screen for one game.
130
+ *
131
+ * @param {HTMLElement} root
132
+ * @param {{api: string, gameId: string, title: string, order: 'score'|'time', canManage: boolean, back: string, refreshBadge?: () => void}} opts
133
+ * `api` is the Tool's base under /api ('/plugins/quiz'), `back` the list's address.
134
+ */
135
+ export async function playsScreen(root, {api, gameId, title, order, canManage = true, back, base = '/quizzes', badge = () => {}}) {
136
+ const k = await loadKit();
137
+ root.innerHTML = '';
138
+ const slot = h('div', {className: 'gma-note dm-loading-slot', text: 'Loading…'});
139
+ root.append(slot);
140
+ let data;
141
+ try {
142
+ data = await k.whileLoading(slot, request('GET', `${api}${base}/${encodeURIComponent(gameId)}/plays`), k.SHAPES.table);
143
+ } catch (err) {
144
+ root.innerHTML = '';
145
+ root.append(h('p', {className: 'gma-error', text: `The plays could not load: ${errText(err)}`}));
146
+ return;
147
+ }
148
+ root.innerHTML = '';
149
+
150
+ const showScore = order === 'score';
151
+ 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);
192
+ };
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}));
195
+
196
+ async function reload() {
197
+ data = await request('GET', `${api}${base}/${encodeURIComponent(gameId)}/plays`);
198
+ draw();
199
+ badge();
200
+ }
201
+
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
+ }
209
+
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
+ }
217
+
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
+ }
226
+
227
+ draw();
228
+ }
229
+
230
+ /** A datetime-local value ('YYYY-MM-DDTHH:mm', the browser's time) from ISO, and back. */
231
+ export const toLocalInput = (iso) => {
232
+ const t = Date.parse(iso || '');
233
+ if (!Number.isFinite(t)) return '';
234
+ const d = new Date(t);
235
+ const p = (n) => String(n).padStart(2, '0');
236
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}T${p(d.getHours())}:${p(d.getMinutes())}`;
237
+ };
238
+ export const fromLocalInput = (v) => {
239
+ const t = Date.parse(v || '');
240
+ return Number.isFinite(t) ? new Date(t).toISOString() : '';
241
+ };
@@ -0,0 +1,82 @@
1
+ /**
2
+ * Games - what the Quiz and Crossword routes share: the play's key from the
3
+ * request, the rate limits, the honeypot and the admin's view of plays.
4
+ *
5
+ * @module _lib/games/http
6
+ */
7
+
8
+ import {cleanPeriod} from '../public/games/format.js';
9
+ import {PlayStore} from './plays.js';
10
+
11
+ /** The play's key: the `X-Play-Token` header (kept out of addresses and logs). */
12
+ export const tokenOf = (request) => String(request.headers['x-play-token'] || request.body?.t || '').slice(0, 100);
13
+
14
+ export const fail = (reply, status, error, extra = {}) => reply.code(status).send({error, ...extra});
15
+
16
+ /** Route options for the public side: a rate limit per visitor's address. */
17
+ export const limited = (max, minutes = 1) => ({config: {rateLimit: {max, timeWindow: `${minutes} minute${minutes === 1 ? '' : 's'}`}}});
18
+
19
+ /**
20
+ * A field people never see. A bot that fills it gets nothing (a 400 with the
21
+ * ordinary words, so it learns nothing either).
22
+ */
23
+ export const isBot = (body) => Boolean(String(body?.website ?? '').trim());
24
+
25
+ /** `?period=&limit=` for a board, the limit within the game's own size. */
26
+ export function boardQuery(query, size = 10) {
27
+ const max = Math.max(1, Math.min(100, Number(size) || 10));
28
+ const limit = Math.max(1, Math.min(max, Math.round(Number(query?.limit) || max)));
29
+ return {period: cleanPeriod(query?.period), limit};
30
+ }
31
+
32
+ /**
33
+ * The admin's routes for one game's plays, under `base` (e.g. '/quizzes'):
34
+ * GET <base>/:id/plays every play of it (no keys), newest first, plus its board
35
+ * DELETE <base>/:id/plays a fresh board: every play gone
36
+ * PUT /plays/:playId {hidden} - off or back on the board
37
+ * DELETE /plays/:playId one play gone
38
+ */
39
+ export function mountPlayAdmin(fastify, {store, base, can, exists}) {
40
+ fastify.get(`${base}/:id/plays`, can('read'), async (request, reply) => {
41
+ if (!await exists(request.params.id)) return fail(reply, 404, 'Not found.');
42
+ const all = await store.forGame(request.params.id);
43
+ const list = all.map(PlayStore.adminView).sort((a, b) => String(b.startedAt).localeCompare(String(a.startedAt)));
44
+ return {plays: list, board: await store.board(request.params.id, {period: 'all', limit: 100})};
45
+ });
46
+
47
+ fastify.delete(`${base}/:id/plays`, can('manage'), async (request, reply) => {
48
+ if (!await exists(request.params.id)) return fail(reply, 404, 'Not found.');
49
+ return {removed: await store.reset(request.params.id)};
50
+ });
51
+
52
+ fastify.put('/plays/:playId', can('manage'), async (request, reply) => {
53
+ const saved = await store.setHidden(request.params.playId, request.body?.hidden === true);
54
+ if (!saved) return fail(reply, 404, 'Not found.');
55
+ return PlayStore.adminView(saved);
56
+ });
57
+
58
+ fastify.delete('/plays/:playId', can('manage'), async (request, reply) => {
59
+ if (!await store.get(request.params.playId)) return fail(reply, 404, 'Not found.');
60
+ await store.remove(request.params.playId);
61
+ return {ok: true};
62
+ });
63
+ }
64
+
65
+ /** The sidebar badge: plays finished today across every game of the Tool. */
66
+ export async function todayBadge(store, noun = 'play') {
67
+ const start = new Date();
68
+ start.setHours(0, 0, 0, 0);
69
+ const today = (await store.all()).filter(p => p.status === 'finished' && Date.parse(p.finishedAt) >= start.getTime());
70
+ return {
71
+ count: today.length,
72
+ label: `${today.length} ${noun}${today.length === 1 ? '' : 's'} finished today`,
73
+ tone: 'info',
74
+ inventory: true,
75
+ details: {
76
+ title: 'Finished today',
77
+ items: today.sort((a, b) => String(b.finishedAt).localeCompare(String(a.finishedAt))).slice(0, 8)
78
+ .map(p => ({text: p.name, meta: p.score != null && store.order === 'score' ? `${p.score} points` : ''})),
79
+ empty: `No ${noun}s finished today yet.`
80
+ }
81
+ };
82
+ }
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Games - the name a player puts on the leaderboard. PURE.
3
+ *
4
+ * Names are typed by strangers and shown to everyone, so they are cleaned
5
+ * hard: letters (any script), digits, spaces and . ' - _ only, 2 to 24
6
+ * characters, and a short list of words that never go on a public board.
7
+ * A name is compared by its key (lower case, accents and spacing folded), so
8
+ * "Sam", "sam " and "SÁM" are one player for attempts and best-per-name.
9
+ *
10
+ * @module _lib/games/names
11
+ */
12
+
13
+ export const NAME_MIN = 2;
14
+ export const NAME_MAX = 24;
15
+
16
+ /** Folded to a-z first, so leetspeak and accents do not slip past. Kept short on purpose. */
17
+ const BLOCKED = ['fuck', 'shit', 'cunt', 'wank', 'twat', 'bitch', 'bastard', 'nigg', 'fag', 'slut', 'whore', 'rape', 'nazi', 'hitler', 'penis', 'vagina', 'dick', 'cock', 'pussy', 'arse', 'asshole', 'admin', 'moderator'];
18
+
19
+ const LEET = {0: 'o', 1: 'i', 3: 'e', 4: 'a', 5: 's', 7: 't', '@': 'a', $: 's'};
20
+
21
+ /** Lower case, accents gone, spacing collapsed - the identity of a name. */
22
+ export function nameKey(name) {
23
+ return String(name ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase().replace(/\s+/g, ' ').trim();
24
+ }
25
+
26
+ function blocked(name) {
27
+ const folded = nameKey(name).replace(/[013457@$]/g, c => LEET[c] || c).replace(/[^a-z]/g, '');
28
+ return BLOCKED.some(w => folded.includes(w));
29
+ }
30
+
31
+ /**
32
+ * @param {*} raw - what the player typed
33
+ * @returns {{name: string, error: string}} name is '' whenever error is set
34
+ */
35
+ export function cleanPlayerName(raw) {
36
+ const name = String(raw ?? '').normalize('NFC').replace(/\s+/g, ' ').trim();
37
+ if (name.length < NAME_MIN) return {name: '', error: `Please type a name of at least ${NAME_MIN} characters.`};
38
+ if (name.length > NAME_MAX) return {name: '', error: `Please keep your name to ${NAME_MAX} characters or fewer.`};
39
+ if (!/^[\p{L}\p{N}][\p{L}\p{N} .'_-]*$/u.test(name)) return {name: '', error: 'Letters, numbers, spaces and . \' - _ only, please.'};
40
+ if (blocked(name)) return {name: '', error: 'Please choose another name.'};
41
+ return {name, error: ''};
42
+ }
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Games - the plays of one game Tool, in a collection of its own
3
+ * (`quiz-plays`, `crossword-plays`).
4
+ *
5
+ * A play is one person's go at one game: who (the name they typed), when it
6
+ * started and finished BY THE SERVER'S CLOCK, the score, and the game's own
7
+ * state (`state` - which question they are on, penalties...). Its `token` is
8
+ * the player's key to it: handed over once, at the start, and asked for on
9
+ * every move, so nobody can answer for someone else's play. The token never
10
+ * leaves the server again - not to the board, not to the admin.
11
+ *
12
+ * Writes go one at a time (`withLock`), so two answers sent at once cannot
13
+ * both be marked.
14
+ *
15
+ * @module _lib/games/plays
16
+ */
17
+
18
+ import {randomBytes, timingSafeEqual} from 'node:crypto';
19
+ import {createEntry, deleteEntry, getEntry, listEntries, updateEntry} from '../../../server/services/collections.js';
20
+ import {nameKey} from './names.js';
21
+ import {placeOf, rankPlays} from './ranking.js';
22
+ import {ABANDON_MS} from './timing.js';
23
+
24
+ const flat = (e) => (e ? {id: e.id, ...e.data} : null);
25
+
26
+ export class PlayStore {
27
+ /**
28
+ * @param {string} slug - the plays collection
29
+ * @param {{order?: 'score'|'time'}} [opts] - how the board is ordered
30
+ */
31
+ constructor(slug, {order = 'score'} = {}) {
32
+ this.slug = slug;
33
+ this.order = order;
34
+ this.queue = Promise.resolve();
35
+ this.cache = null;
36
+ }
37
+
38
+ /** Run `fn` after every write before it has finished. */
39
+ withLock(fn) {
40
+ const run = this.queue.then(fn, fn);
41
+ this.queue = run.catch(() => {});
42
+ return run;
43
+ }
44
+
45
+ async all() {
46
+ if (!this.cache) {
47
+ this.cache = listEntries(this.slug, {limit: 100000})
48
+ .then(r => (r.entries || []).map(flat))
49
+ .catch(() => []);
50
+ }
51
+ return this.cache;
52
+ }
53
+
54
+ forget() { this.cache = null; }
55
+
56
+ async forGame(gameId) {
57
+ return (await this.all()).filter(p => p.gameId === gameId);
58
+ }
59
+
60
+ async get(id) {
61
+ return flat(await getEntry(this.slug, String(id)).catch(() => null));
62
+ }
63
+
64
+ /** The play, if `token` is its key; else null. */
65
+ async open(id, token) {
66
+ const play = await this.get(id);
67
+ if (!play || !PlayStore.tokenOk(play.token, token)) return null;
68
+ return play;
69
+ }
70
+
71
+ static tokenOk(stored, given) {
72
+ const a = Buffer.from(String(stored || ''));
73
+ const b = Buffer.from(String(given || ''));
74
+ return a.length > 0 && a.length === b.length && timingSafeEqual(a, b);
75
+ }
76
+
77
+ /** How many goes this name has had at this game (any state but hidden ones the admin removed). */
78
+ async attemptsBy(gameId, name) {
79
+ const key = nameKey(name);
80
+ return (await this.forGame(gameId)).filter(p => p.nameKey === key).length;
81
+ }
82
+
83
+ /**
84
+ * @param {{gameId: string, name: string, maxScore?: number, state?: object, userId?: string}} play
85
+ * @returns {Promise<object>} the play, token included - for the player, once
86
+ */
87
+ async start({gameId, name, maxScore = 0, state = {}, userId = ''}) {
88
+ return this.withLock(async () => {
89
+ const now = new Date().toISOString();
90
+ const saved = await createEntry(this.slug, {
91
+ gameId, name, nameKey: nameKey(name), userId,
92
+ status: 'playing', startedAt: now, finishedAt: '', timeMs: 0, score: 0, maxScore,
93
+ hidden: false, token: randomBytes(24).toString('base64url'), state
94
+ }, {source: 'public'});
95
+ this.forget();
96
+ return flat(saved);
97
+ });
98
+ }
99
+
100
+ /** Re-read the play inside the lock, let `fn` change it, write it back. `fn` returning null writes nothing. */
101
+ async change(id, fn) {
102
+ return this.withLock(async () => {
103
+ const current = await this.get(id);
104
+ if (!current) return null;
105
+ const next = await fn(structuredClone(current));
106
+ if (!next) return current;
107
+ const {id: _id, ...data} = next;
108
+ const saved = flat(await updateEntry(this.slug, id, data));
109
+ this.forget();
110
+ return saved;
111
+ });
112
+ }
113
+
114
+ /** Finished now: the time is the server's, plus any penalty the game added. */
115
+ static finish(play, {score = play.score, penaltyMs = 0, status = 'finished', now = Date.now()} = {}) {
116
+ play.status = status;
117
+ play.finishedAt = new Date(now).toISOString();
118
+ play.score = score;
119
+ play.timeMs = Math.max(0, now - Date.parse(play.startedAt)) + Math.max(0, penaltyMs);
120
+ return play;
121
+ }
122
+
123
+ async setHidden(id, hidden) {
124
+ return this.change(id, (p) => { p.hidden = hidden === true; return p; });
125
+ }
126
+
127
+ async remove(id) {
128
+ await this.withLock(() => deleteEntry(this.slug, String(id)));
129
+ this.forget();
130
+ }
131
+
132
+ /** Every play of a game gone: a fresh board. */
133
+ async reset(gameId) {
134
+ const mine = await this.forGame(gameId);
135
+ await this.withLock(async () => { for (const p of mine) await deleteEntry(this.slug, p.id).catch(() => {}); });
136
+ this.forget();
137
+ return mine.length;
138
+ }
139
+
140
+ async board(gameId, {period = 'all', limit = 10} = {}) {
141
+ return rankPlays(await this.forGame(gameId), {order: this.order, period, limit});
142
+ }
143
+
144
+ async placeOf(play, period = 'all') {
145
+ return placeOf(play, await this.forGame(play.gameId), {order: this.order, period});
146
+ }
147
+
148
+ /** A play as the admin sees it: everything but the key and the game's inner state. */
149
+ static adminView(p) {
150
+ if (!p) return p;
151
+ const {token, state, ...rest} = p;
152
+ const open = rest.status === 'playing' && Date.now() - Date.parse(rest.startedAt) > ABANDON_MS;
153
+ return {...rest, status: open ? 'abandoned' : rest.status};
154
+ }
155
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Games - who is top. PURE: plays in, leaderboard rows out.
3
+ *
4
+ * Only finished, visible plays count. A player (by name key) appears once,
5
+ * with their best play, so one person replaying cannot fill the board. Order:
6
+ *
7
+ * 'score' higher score first, then the faster time, then whoever got there first (Quiz)
8
+ * 'time' the faster time first, then whoever got there first (Crossword - every finisher solved it)
9
+ *
10
+ * Periods are calendar ones in the server's own day: today, this week (from
11
+ * Monday), this month, all time.
12
+ *
13
+ * @module _lib/games/ranking
14
+ */
15
+
16
+ import {cleanPeriod} from '../public/games/format.js';
17
+
18
+ /** The instant a period starts, or 0 for all time. */
19
+ export function periodStart(period, now = Date.now()) {
20
+ const d = new Date(now);
21
+ d.setHours(0, 0, 0, 0);
22
+ switch (cleanPeriod(period)) {
23
+ case 'today': return d.getTime();
24
+ case 'week': d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); return d.getTime();
25
+ case 'month': d.setDate(1); return d.getTime();
26
+ default: return 0;
27
+ }
28
+ }
29
+
30
+ /** a before b? - the board's order. */
31
+ export function comparePlays(a, b, order = 'score') {
32
+ if (order === 'score' && (b.score ?? 0) !== (a.score ?? 0)) return (b.score ?? 0) - (a.score ?? 0);
33
+ if ((a.timeMs ?? Infinity) !== (b.timeMs ?? Infinity)) return (a.timeMs ?? Infinity) - (b.timeMs ?? Infinity);
34
+ return String(a.finishedAt).localeCompare(String(b.finishedAt));
35
+ }
36
+
37
+ /**
38
+ * @param {object[]} plays - {id, name, nameKey, status, hidden, score, timeMs, finishedAt}
39
+ * @param {{order?: 'score'|'time', period?: string, now?: number, limit?: number, bestPerName?: boolean}} [opts]
40
+ * @returns {{id: string, rank: number, name: string, score: number, timeMs: number, finishedAt: string}[]}
41
+ */
42
+ export function rankPlays(plays, {order = 'score', period = 'all', now = Date.now(), limit = 10, bestPerName = true} = {}) {
43
+ const from = periodStart(period, now);
44
+ const counted = (plays || []).filter(p => p && p.status === 'finished' && p.hidden !== true
45
+ && Date.parse(p.finishedAt) >= from).sort((a, b) => comparePlays(a, b, order));
46
+ const seen = new Set();
47
+ const rows = [];
48
+ for (const p of counted) {
49
+ const key = p.nameKey || String(p.name).toLowerCase();
50
+ if (bestPerName && seen.has(key)) continue;
51
+ seen.add(key);
52
+ rows.push({id: p.id, rank: 0, name: p.name, score: p.score ?? 0, timeMs: p.timeMs ?? 0, finishedAt: p.finishedAt});
53
+ }
54
+ // Equal score and time share a rank ("=2nd"); the next is placed after them.
55
+ rows.forEach((r, i) => {
56
+ const prev = rows[i - 1];
57
+ r.rank = prev && (order === 'time' || prev.score === r.score) && prev.timeMs === r.timeMs ? prev.rank : i + 1;
58
+ });
59
+ return Math.max(0, Number(limit) || 0) ? rows.slice(0, Math.max(1, Math.min(100, Number(limit)))) : rows;
60
+ }
61
+
62
+ /** Where one play stands on its board for a period, counting it even when it is not the player's best. */
63
+ export function placeOf(play, plays, {order = 'score', period = 'all', now = Date.now()} = {}) {
64
+ if (!play || play.status !== 'finished') return 0;
65
+ const others = (plays || []).filter(p => p.id !== play.id);
66
+ const board = rankPlays([...others, play], {order, period, now, limit: 0, bestPerName: false});
67
+ return board.find(r => r.id === play.id)?.rank || 0;
68
+ }
@@ -0,0 +1,27 @@
1
+ /**
2
+ * Games - how the server keeps time. PURE.
3
+ *
4
+ * Every clock that decides anything is the server's: when a play started, when
5
+ * a question was put in front of the player, when the answer arrived. The
6
+ * browser's countdown is only a picture of these. A little grace covers the
7
+ * network, so an answer sent in the last second still counts.
8
+ *
9
+ * @module _lib/games/timing
10
+ */
11
+
12
+ /** A late answer is still on time within this much, for the network. */
13
+ export const GRACE_MS = 3000;
14
+
15
+ /** A play still open this long after it started counts as abandoned. */
16
+ export const ABANDON_MS = 24 * 3600 * 1000;
17
+
18
+ /** Milliseconds left before `startIso + limitMs`, never below 0; null when there is no limit. */
19
+ export function leftOf(startIso, limitMs, now = Date.now()) {
20
+ if (!(limitMs > 0)) return null;
21
+ return Math.max(0, Date.parse(startIso) + limitMs - now);
22
+ }
23
+
24
+ /** Past `startIso + limitMs` by more than the grace? */
25
+ export function overdue(startIso, limitMs, now = Date.now()) {
26
+ return limitMs > 0 && now > Date.parse(startIso) + limitMs + GRACE_MS;
27
+ }