domma-cms 1.8.30 → 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 (41) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/docs/DommaFeatures.md +5 -4
  3. package/package.json +3 -1
  4. package/plugins/_lib/admin/games/games.css +1 -0
  5. package/plugins/_lib/admin/games/kit.js +241 -0
  6. package/plugins/_lib/games/http.js +82 -0
  7. package/plugins/_lib/games/names.js +42 -0
  8. package/plugins/_lib/games/plays.js +155 -0
  9. package/plugins/_lib/games/ranking.js +68 -0
  10. package/plugins/_lib/games/timing.js +27 -0
  11. package/plugins/_lib/public/games/format.js +38 -0
  12. package/plugins/_lib/public/games/games.css +1 -0
  13. package/plugins/_lib/public/games/games.js +204 -0
  14. package/plugins/crossword/admin/css/index.css +1 -0
  15. package/plugins/crossword/admin/templates/crossword.html +1 -0
  16. package/plugins/crossword/admin/views/crossword.js +317 -0
  17. package/plugins/crossword/collections/crossword-plays.json +20 -0
  18. package/plugins/crossword/collections/crossword-puzzles.json +17 -0
  19. package/plugins/crossword/config.js +6 -0
  20. package/plugins/crossword/inject/body.html +1 -0
  21. package/plugins/crossword/lib/crossword.js +170 -0
  22. package/plugins/crossword/lib/store.js +56 -0
  23. package/plugins/crossword/plugin.js +241 -0
  24. package/plugins/crossword/plugin.json +78 -0
  25. package/plugins/crossword/public/crossword.css +1 -0
  26. package/plugins/crossword/public/crossword.js +57 -0
  27. package/plugins/crossword/public/lib/layout.js +199 -0
  28. package/plugins/quiz/admin/css/index.css +1 -0
  29. package/plugins/quiz/admin/templates/quiz.html +1 -0
  30. package/plugins/quiz/admin/views/quiz.js +352 -0
  31. package/plugins/quiz/collections/quiz-plays.json +20 -0
  32. package/plugins/quiz/collections/quiz-quizzes.json +17 -0
  33. package/plugins/quiz/config.js +6 -0
  34. package/plugins/quiz/inject/body.html +1 -0
  35. package/plugins/quiz/lib/engine.js +115 -0
  36. package/plugins/quiz/lib/quiz.js +225 -0
  37. package/plugins/quiz/lib/store.js +56 -0
  38. package/plugins/quiz/plugin.js +241 -0
  39. package/plugins/quiz/plugin.json +78 -0
  40. package/plugins/quiz/public/quiz.css +1 -0
  41. package/plugins/quiz/public/quiz.js +80 -0
package/CHANGELOG.md CHANGED
@@ -1,5 +1,16 @@
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
+
10
+ ## [1.8.31] - 2026-10-20
11
+
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.
13
+
3
14
  ## [1.8.30] - 2026-10-20
4
15
 
5
16
  - Redraw the St George's Day castle and 26 seasonal details across the patron saints, Halloween and Valentine's Day celebrations. The CMS renderer uses the same shaded flowers, grouped plants, heritage props and characters as [domma-celebrate 1.5.0](https://github.com/pinpointzero73/domma-celebrate/releases/tag/v1.5.0).
@@ -39,8 +39,8 @@ Code was treated as the truth. Where docs disagree with it, the disagreement is
39
39
  | `campaigns-pro` | paid | 1.0.2 | Marketing campaigns, one paid Tool: compose a message, send it to an audience imported from Contacts (shared with Surveys), schedule sends, attach an offer that closes by a date or a claim limit. Opens and clicks (cookieless), links tagged for Analytics, a linked Shop discount code, send a survey as a campaign, follow-up sequences, resend to non-openers, A/B subject tests, and a version-1 stats feed other Tools read. |
40
40
  | `contacts-pro` | paid | 0.12.0 | Contacts, with more to them: your own custom fields, birthdays and ages with the ones coming up and a reminder in the sidebar, and groups with their own emblem and colours. Share a contact, several, or a whole group with people or roles - to view, or to edit. Call a contact now on a video call on your site. Works over the same contacts as the built-in Tool, so nothing moves and nothing is lost. |
41
41
  | `data-transfer` | paid | 1.3.3 | Move collections between file and MongoDB storage, back them up and restore them (all or some, merged or replaced) - keeping every entry's id - and clone them (cloned entries get new ids, so references stay unambiguous). Every restore and transfer takes a safety snapshot first. |
42
- | `docs` | paid | 2.8.1 | Document editor: Home and editor screens, sharing with Viewers/Editors, public links, import/export, send to page/blog/email, edit lock, Trash, version history, templates. |
43
- | `docs-pro` | paid | 0.8.1 | Domma Spaces (publish a Docs folder at `/spaces` with a Kindle-style reader), Cloud copies (Google Drive, OneDrive, Dropbox, S3), Signatures (emailed signing links, typed or drawn, signed copy with a certificate page) and protected public links (expiry date, password). Adds to Docs. |
42
+ | `docs` | paid | 2.13.1 | Document editor: Home and editor screens, sharing with Viewers/Editors, public links, import/export, send to page/blog/email, edit lock, Trash, version history, templates. PDF documents (2.13): a PDF kept as it is, shown in the browser's viewer, searchable inside, versioned on replace. |
43
+ | `docs-pro` | paid | 0.11.0 | Domma Spaces (publish a Docs folder at `/spaces` with a Kindle-style reader), Cloud copies (Google Drive, OneDrive, Dropbox, S3), Signatures (emailed signing links, typed or drawn, signed copy with a certificate page; 0.10: boxes placed on a PDF document's pages, the signed PDF stamped with pdf-lib), PDF tools (0.11: fill the PDF's own form, annotate, pages) and protected public links (expiry date, password). Adds to Docs. |
44
44
  | `email-pro` | paid | 0.9.0 | Full mail: read, write, send and search, over a synchronised store. Search, threads, rules, scheduled sends, snooze and vacation replies need MongoDB; without it, reading, filing and sending still work. |
45
45
  | `feedback` | free | 1.1.4 | Tell Domma what is broken, missing or good - straight from your admin - and see what became of it: seen, planned, in progress, done, with Domma's reply. |
46
46
  | `garage` | paid | 1.4.3 | UK vehicle management with DVLA API lookup, save vehicles, and search history. |
@@ -1842,7 +1842,7 @@ A safe way to move collections between file storage and MongoDB, take backups, r
1842
1842
 
1843
1843
  ### Docs (`docs`)
1844
1844
 
1845
- Tier: paid (closed source) · Version: 2.6.0 · Requires: no MongoDB (documents are JSON files under the plugin's `data/`); no `minCmsVersion` declared · Uses: `blog` (Send to a new blog post)
1845
+ Tier: paid (closed source) · Version: 2.13.1 · Requires: no MongoDB (documents are JSON files under the plugin's `data/`, PDF files beside them); optional `pdfjs-dist` (CMS optionalDependency) to read the text inside PDFs; no `minCmsVersion` declared · Uses: `blog` (Send to a new blog post)
1846
1846
 
1847
1847
  A document editor inside the admin. Documents are private until shared: share with people as Viewers or Editors, or publish a read-only public link. Import and export Markdown, HTML and text, print to PDF, and send a document on as a draft page, a draft blog post or an email. One person edits at a time; everyone else reads and sees who is editing.
1848
1848
 
@@ -1867,6 +1867,7 @@ A document editor inside the admin. Documents are private until shared: share wi
1867
1867
  - Saving a document accepts up to 5 MB (2.4.0) - documents with built-in pictures were refused at Fastify's 1 MB default.
1868
1868
  - **Suggester** share role (2.5.0, with Docs Pro): reads and suggests changes; without Docs Pro a Suggester is a Viewer. Pending suggestions are kept out of the public link, downloads, print, email and Send to by Docs itself; editors may take over editing from a Suggester.
1869
1869
  - Ready-made templates from an add-on (2.6.0) are listed in Templates after your own, marked with their category, read only.
1870
+ - **PDF documents** (2.13.0): New > Upload a PDF, Import or drop - up to 20 MB, kept whole (never rewritten) and shown in the browser's own PDF viewer; the text inside is read (pdfjs-dist, in a worker with a time budget) for search and the card excerpt; a PDF with JavaScript, Launch actions, attached files, rich media or XFA is refused with the reason, looking inside its compressed streams too. Shared, starred, filed, linked (the public page frames the PDF, Download PDF), emailed (attached), copied and trashed like any document; **Replace the PDF…** keeps the old file in Version history (download or restore). Add-ons declare the document kinds they understand (`kinds`, default HTML only).
1870
1871
  - `contentPaths: ["data"]` - documents are carried across plugin updates.
1871
1872
 
1872
1873
  **Where:** Admin sidebar "Docs" (book-open icon) -> `#/plugins/docs`; documents at `#/plugins/docs/d/<id>`; public links at `/docs/s/<token>`; settings under the banner cog; guide under **Documentation > Plugins > Docs**.
@@ -1891,7 +1892,7 @@ Tier: paid (closed source) · Version: 0.8.0 · Requires: `docs` 2.6+ (2.8+ for
1891
1892
 
1892
1893
  - **Cloud copies** (0.2.0): copies of your own documents (one folder and its folders, or all; never Trash) in **Google Drive** (scope `drive.file` - only its own files), **OneDrive** (its own app folder, `Files.ReadWrite.AppFolder`), **Dropbox** (App folder) or **S3 / S3-compatible** (Amazon S3, Cloudflare R2, Backblaze B2, Wasabi, MinIO; own SigV4 signing, no SDK) as Markdown, HTML, text and/or PDF, in folders matching your Docs folders under a folder you name. **Every save** (90 s after you stop editing), **Nightly** (after 2am, what changed) or **By hand**; unchanged documents are not sent again; copies stay when a document is trashed or deleted. Each customer makes their own app with the provider - a step-by-step guide per provider in the form, with the site's exact redirect address and a Copy button; Connect opens a sign-in window. Secrets encrypted at rest (AES-256-GCM keyed from JWT_SECRET), never sent back (Saved / last 4 of a key). Cards with state, what/as/when/where, Copy now, Test, See why; menu: Copy everything again, Connect again, Settings, Switch off, Remove. Editor chip ("Copied to Drive 10:42" / failed) and **Copy to the cloud now**. Failures notify the owner (source `docs-pro:cloud`), resolved when copying works again. Permission `cloud.use`, granted to `admin`.
1893
1894
 
1894
- - **Signatures** (0.3.0): **Request signatures…** on a document's menu - people (name + email, up to 20), optional signing order, message, deadline (7-90 days). The document is frozen with a SHA-256 fingerprint. Each signer is emailed their own link (in turn when ordered) to `/docs/sign/<token>`: the document, consent to sign electronically, typed or drawn signature, or decline with a reason. When everyone has signed, the signed copy - document, signatures and a certificate page (who, when, IP, browser, fingerprints, the full record) - is emailed to everyone as a PDF (web page when the server has no Chrome). **Signatures** place: each request's state, signers, Remind (once an hour each), Cancel, downloads, record. Editor chip ("Signed by 1 of 2"). Notifications to the sender (source `docs-pro:signing`). Needs the site's SMTP; a simple electronic signature (not for deeds/wills). Permission `signing.send`, granted to `admin`.
1895
+ - **Signatures** (0.3.0): **Request signatures…** on a document's menu - people (name + email, up to 20), optional signing order, message, deadline (7-90 days). The document is frozen with a SHA-256 fingerprint. Each signer is emailed their own link (in turn when ordered) to `/docs/sign/<token>`: the document, consent to sign electronically, typed or drawn signature, or decline with a reason. When everyone has signed, the signed copy - document, signatures and a certificate page (who, when, IP, browser, fingerprints, the full record) - is emailed to everyone as a PDF (web page when the server has no Chrome). **Signatures** place: each request's state, signers, Remind (once an hour each), Cancel, downloads, record. Editor chip ("Signed by 1 of 2"). Notifications to the sender (source `docs-pro:signing`). Needs the site's SMTP; a simple electronic signature (not for deeds/wills). Permission `signing.send`, granted to `admin`. **Signing a PDF** (0.10.0, Docs 2.13): the sender places Signature / Initials / Date / Name / Text boxes on the pages (a modal drawing them with pdf.js, served from the CMS's own copy at `/docs-pro/pdfjs/`), the file is frozen beside the request, the signer's page draws the pages with their boxes and their mark goes into every box; the signed PDF is the file with every box drawn in by pdf-lib (optional CMS dependency) plus a certificate page - no web-page copy. Spaces, Word, comments, suggestions and merge fields do not apply to PDF documents; cloud copies send the file. **PDF tools** (0.11.0): in a PDF document's menu - Fill in the form (the PDF's own AcroForm: text, ticks, choices; from a contact; Save and lock flattens), Annotate (text, date, tick, cross, highlight, stamp, picture, drawing placed on the pages), Pages (turn, remove, reorder), Take pages out (a new document), Add another PDF (merge) - every change a new version in Docs' History.
1895
1896
  - **Protected links** (0.3.0): an expiry date (the whole day counts) and/or a password (scrypt) on a document's public link, set in its Share panel. Expired -> a clear 410 page; password -> a form, then a 12-hour cookie for that link only; a new password asks everyone again; downloads are gated too. Kept with the document across new link addresses.
1896
1897
  - **Word files** (0.4.0): `.docx` in New > Import and drag-and-drop (15 MB) - headings, alignment, bold/italic/underline/strike, super/subscript, highlight, links, nested lists with numbering that carries on, quotes, code, tables with merged cells, pictures (PNG/JPEG/GIF to the Media library as `doc-…`, or up to 2 MB built in without Media rights); tracked changes accepted. **Download as Word (.docx)** on Home's and the editor's document menu. Own zip and XML code (no dependencies); hostile files capped per part, by node count and one import at a time; output validated against the Office 2019 schema.
1897
1898
  - **Merge fields** (0.4.0): **Insert a field** (contact name, first name, company, job title, email, phone, address, website, reference, today's date, your own `{{field}}`) and **Fill in the fields…** - search your Contacts or type values; makes a filled-in copy of your own (the original keeps its fields); values escaped, fields in the title too. A template with fields asks for them as soon as it is used and fills the new document in place.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "domma-cms",
3
- "version": "1.8.30",
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",
@@ -98,6 +98,8 @@
98
98
  },
99
99
  "optionalDependencies": {
100
100
  "mongodb": "^6.21.0",
101
+ "pdf-lib": "^1.17.1",
102
+ "pdfjs-dist": "^6.4.299",
101
103
  "puppeteer-core": "^25.11.0"
102
104
  },
103
105
  "devDependencies": {
@@ -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
+ }