domma-cms 1.8.45 → 1.9.0

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 (52) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/admin/js/lib/code-format.js +50 -40
  3. package/admin/js/templates/docs/usage-users-roles.html +4 -0
  4. package/admin/js/views/page-editor.js +10 -8
  5. package/bin/lib/left-free-tier.js +83 -0
  6. package/bin/update.js +24 -0
  7. package/docs/DommaFeatures.md +47 -7
  8. package/docs/configuration.md +19 -7
  9. package/docs/plugin-development.md +7 -0
  10. package/package.json +1 -1
  11. package/plugins/_lib/games/names.js +6 -0
  12. package/plugins/_lib/public/games/motion.css +1 -0
  13. package/plugins/_lib/public/games/motion.js +28 -0
  14. package/plugins/free-tier.lock.json +1 -43
  15. package/server/routes/api/api-endpoints.js +5 -1
  16. package/server/routes/api/collections.js +4 -3
  17. package/server/routes/api/forms.js +22 -0
  18. package/server/routes/api/views.js +18 -0
  19. package/server/services/collectionAccess.js +97 -15
  20. package/server/services/permissionRegistry.js +39 -11
  21. package/plugins/crossword/CLAUDE.md +0 -37
  22. package/plugins/crossword/admin/css/index.css +0 -18
  23. package/plugins/crossword/admin/templates/crossword.html +0 -1
  24. package/plugins/crossword/admin/views/crossword.js +0 -449
  25. package/plugins/crossword/collections/crossword-plays.json +0 -20
  26. package/plugins/crossword/collections/crossword-puzzles.json +0 -17
  27. package/plugins/crossword/config.js +0 -6
  28. package/plugins/crossword/docs/guide.md +0 -53
  29. package/plugins/crossword/inject/body.html +0 -1
  30. package/plugins/crossword/lib/crossword.js +0 -170
  31. package/plugins/crossword/lib/store.js +0 -56
  32. package/plugins/crossword/plugin.js +0 -274
  33. package/plugins/crossword/plugin.json +0 -79
  34. package/plugins/crossword/public/crossword.css +0 -36
  35. package/plugins/crossword/public/crossword.js +0 -519
  36. package/plugins/crossword/public/lib/layout.js +0 -199
  37. package/plugins/quiz/CLAUDE.md +0 -43
  38. package/plugins/quiz/admin/css/index.css +0 -13
  39. package/plugins/quiz/admin/templates/quiz.html +0 -1
  40. package/plugins/quiz/admin/views/quiz.js +0 -530
  41. package/plugins/quiz/collections/quiz-plays.json +0 -20
  42. package/plugins/quiz/collections/quiz-quizzes.json +0 -17
  43. package/plugins/quiz/config.js +0 -6
  44. package/plugins/quiz/docs/guide.md +0 -61
  45. package/plugins/quiz/inject/body.html +0 -1
  46. package/plugins/quiz/lib/engine.js +0 -115
  47. package/plugins/quiz/lib/quiz.js +0 -225
  48. package/plugins/quiz/lib/store.js +0 -56
  49. package/plugins/quiz/plugin.js +0 -275
  50. package/plugins/quiz/plugin.json +0 -79
  51. package/plugins/quiz/public/quiz.css +0 -36
  52. package/plugins/quiz/public/quiz.js +0 -372
@@ -1,372 +0,0 @@
1
- /**
2
- * Quiz - the player, in the browser.
3
- *
4
- * Mounts on every `[data-quiz]` placeholder the [quiz] shortcode wrote, and
5
- * every `[data-quiz-board]` the [quiz-leaderboard] one wrote. The quiz runs on
6
- * the SERVER: this asks for one question at a time (never with its answer),
7
- * sends each answer to be marked, and shows the clock the server keeps. A play
8
- * in progress is remembered for the tab's next load, so a reload carries on
9
- * where it was - its clock never stopped.
10
- *
11
- * domma-reactive (window.M): one template per mount, one M.applyBindings, and
12
- * text from the server only through bindings.
13
- *
14
- * `mount(el, {test})` is the same player for an admin testing the quiz from
15
- * its Tool: `test.info()` and `test.start()` go through the admin's own API,
16
- * the play is a test one (never on a board), and nothing is remembered.
17
- */
18
-
19
- import {Countdown, addStyles, celebrate, configOf, fmtDuration, memory, mountBoards, mountLeaderboard, ordinal, request, uid} from '/plugins/_lib/public/games/games.js';
20
-
21
- const M = window.M;
22
- const API = '/api/plugins/quiz/public';
23
- const NAME_KEY = 'games.name';
24
- const playKey = (slug) => `quiz.play.${slug}`;
25
-
26
- const TEMPLATE = `
27
- <p class="gm-muted qz-live" role="status" aria-live="polite" data-bind-text="live.value"></p>
28
-
29
- <section class="gm-card" hidden data-bind-hidden="screen.value !== 'loading'"><p class="gm-muted">Loading the quiz…</p></section>
30
-
31
- <section class="gm-card" hidden data-bind-hidden="screen.value !== 'error'">
32
- <p class="gm-error" data-bind-text="problem.value"></p>
33
- </section>
34
-
35
- <section class="gm-card qz-intro" hidden data-bind-hidden="screen.value !== 'intro'">
36
- <h2 class="gm-title" data-bind-text="info.value.title"></h2>
37
- <p class="gm-intro" hidden data-bind-hidden="!info.value.intro" data-bind-text="info.value.intro"></p>
38
- <ul class="gm-facts" data-each="facts.value key=k"><li data-bind-text="k"></li></ul>
39
- <p class="gm-note" hidden data-bind-hidden="info.value.open" data-bind-text="info.value.reason"></p>
40
- <form class="gm-start" hidden data-bind-hidden="!info.value.open" data-on-submit="start" novalidate>
41
- <p class="gm-note gm-test-note" hidden data-bind-hidden="!testing">A test go: it plays exactly as your players see it, and it is not saved to the leaderboard.</p>
42
- <div class="gm-field" hidden data-bind-hidden="testing">
43
- <label for="%U%-name">Your name</label>
44
- <input id="%U%-name" class="gm-input" maxlength="24" autocomplete="nickname" data-model="name.value"
45
- data-bind-aria-invalid="problem.value ? 'true' : false" aria-describedby="%U%-err">
46
- </div>
47
- <div class="gm-hp" aria-hidden="true"><label>Website <input tabindex="-1" autocomplete="off" data-model="website.value"></label></div>
48
- <button type="submit" class="btn btn-primary" data-bind-disabled="busy.value" data-bind-text="testing ? 'Start the test' : 'Start the quiz'">Start the quiz</button>
49
- </form>
50
- <p class="gm-error" id="%U%-err" hidden data-bind-hidden="!problem.value" data-bind-text="problem.value"></p>
51
- </section>
52
-
53
- <section class="gm-card qz-question" hidden data-bind-hidden="screen.value !== 'question'" aria-labelledby="%U%-q">
54
- <div class="gm-bar">
55
- <span class="gm-progress" data-bind-text="progress.value"></span>
56
- <span class="gm-clock" hidden data-bind-hidden="!clockShown.value" data-bind-class="clockLow.value && 'is-low'"
57
- role="timer" data-bind-aria-label="'Time left ' + clockText.value" data-bind-text="clockText.value"></span>
58
- </div>
59
- <div class="gm-track" aria-hidden="true"><span data-bind-style-width="trackWidth.value"></span></div>
60
- <h2 class="qz-q" id="%U%-q" tabindex="-1" data-bind-text="q.value.text"></h2>
61
- <img class="qz-image" alt="" hidden data-bind-hidden="!q.value.image" data-bind-src="q.value.image || false">
62
- <p class="gm-muted qz-hint" hidden data-bind-hidden="q.value.kind !== 'multiple'">Choose every right answer, then press Answer.</p>
63
- <div class="qz-options" hidden data-bind-hidden="q.value.kind === 'text'" data-each="options.value key=id">
64
- <button type="button" class="qz-option" data-bind-class="picked && 'is-picked'" data-bind-aria-pressed="picked ? 'true' : 'false'"
65
- data-bind-disabled="$root.busy.value" data-on-click="$root.choose($data)">
66
- <span class="qz-letter" data-bind-text="letter"></span><span data-bind-text="text"></span>
67
- </button>
68
- </div>
69
- <form class="qz-typed" hidden data-bind-hidden="q.value.kind !== 'text'" data-on-submit="sendTyped" novalidate>
70
- <label class="gm-muted" for="%U%-typed">Your answer</label>
71
- <input id="%U%-typed" class="gm-input" maxlength="200" autocomplete="off" data-model="typed.value">
72
- </form>
73
- <div class="gm-actions" hidden data-bind-hidden="q.value.kind !== 'multiple' && q.value.kind !== 'text'">
74
- <button type="button" class="btn btn-primary" data-bind-disabled="busy.value" data-on-click="sendPicked">Answer</button>
75
- </div>
76
- </section>
77
-
78
- <section class="gm-card qz-feedback" hidden data-bind-hidden="screen.value !== 'feedback'" aria-labelledby="%U%-fb">
79
- <h2 class="qz-verdict" id="%U%-fb" tabindex="-1" data-bind-class="fb.value.correct ? 'is-right' : 'is-wrong'" data-bind-text="fb.value.title"></h2>
80
- <p hidden data-bind-hidden="fb.value.correct || !fb.value.rightAnswer">The answer was <strong data-bind-text="fb.value.rightAnswer"></strong>.</p>
81
- <p class="gm-muted" hidden data-bind-hidden="!fb.value.explanation" data-bind-text="fb.value.explanation"></p>
82
- <div class="gm-actions"><button type="button" class="btn btn-primary" data-on-click="next" data-bind-text="fb.value.nextLabel"></button></div>
83
- </section>
84
-
85
- <section class="gm-card qz-result" hidden data-bind-hidden="screen.value !== 'result'" aria-labelledby="%U%-res">
86
- <h2 class="gm-title" id="%U%-res" tabindex="-1" data-bind-text="res.value.title"></h2>
87
- <p class="gm-result-score" data-bind-text="res.value.scoreText"></p>
88
- <p class="gm-muted" data-bind-text="res.value.detail"></p>
89
- <p class="gm-result-place" hidden data-bind-hidden="!res.value.placeText" data-bind-text="res.value.placeText"></p>
90
- <div class="gm-actions">
91
- <button type="button" class="btn btn-primary" data-on-click="again" data-bind-text="testing ? 'Test again' : 'Play again'">Play again</button>
92
- </div>
93
- <div class="qz-review" hidden data-bind-hidden="!review.value.length">
94
- <h3>Your answers</h3>
95
- <ol class="qz-review-list" data-each="review.value key=k">
96
- <li data-bind-class="correct ? 'is-right' : 'is-wrong'">
97
- <p class="qz-review-q" data-bind-text="text"></p>
98
- <p class="qz-review-a"><span data-bind-text="mark"></span> <span data-bind-text="yoursText"></span></p>
99
- <p class="qz-review-a" hidden data-bind-hidden="correct">Answer: <strong data-bind-text="right"></strong></p>
100
- <p class="gm-muted" hidden data-bind-hidden="!explanation" data-bind-text="explanation"></p>
101
- </li>
102
- </ol>
103
- </div>
104
- </section>
105
-
106
- <div class="qz-board-slot"></div>`;
107
-
108
- const LETTERS = 'ABCDEFGH';
109
-
110
- /** The server's words: the public request's Error, or the admin's (its reply is on `err.body`). */
111
- const errText = (err) => err?.body?.error || err?.message || String(err);
112
-
113
- function facts(info) {
114
- const out = [`${info.questions} question${info.questions === 1 ? '' : 's'}`];
115
- if (info.timeLimit) out.push(`${fmtDuration(info.timeLimit * 1000)} to finish`);
116
- if (info.questionSeconds) out.push(`${info.questionSeconds} seconds a question`);
117
- else if (info.perQuestionTimes) out.push('Some questions are timed');
118
- if (info.attemptsPerName === 1) out.push('One go each');
119
- else if (info.attemptsPerName > 1) out.push(`${info.attemptsPerName} goes each`);
120
- if (info.leaderboard) out.push('Fastest top score wins');
121
- return out.map(k => ({k}));
122
- }
123
-
124
- /**
125
- * @param {HTMLElement} el
126
- * @param {{test?: {info: () => Promise<object>, start: () => Promise<{playId: string, token: string, name: string, info: object}>}}} [opts]
127
- */
128
- export function mount(el, {test = null} = {}) {
129
- const cfg = test ? {slug: 'test'} : configOf(el, 'data-quiz');
130
- if (!cfg?.slug || !M) return;
131
- addStyles(['/plugins/quiz/public/quiz.css?v=1.1.0']);
132
- const U = uid('qz');
133
- el.innerHTML = TEMPLATE.replaceAll('%U%', U);
134
-
135
- const s = {
136
- testing: Boolean(test),
137
- screen: M.observable('loading'),
138
- live: M.observable(''),
139
- problem: M.observable(''),
140
- busy: M.observable(false),
141
- info: M.observable({title: '', intro: '', open: false, reason: ''}),
142
- facts: M.observable([]),
143
- name: M.observable(String(memory.get(NAME_KEY) || '')),
144
- website: M.observable(''),
145
- progress: M.observable(''),
146
- q: M.observable({text: '', kind: 'single', image: ''}),
147
- options: M.observable([]),
148
- typed: M.observable(''),
149
- clockText: M.observable(''),
150
- clockShown: M.observable(false),
151
- clockLow: M.observable(false),
152
- trackWidth: M.observable('0%'),
153
- fb: M.observable({}),
154
- res: M.observable({}),
155
- review: M.observable([])
156
- };
157
-
158
- let play = test ? null : memory.get(playKey(cfg.slug)) || null; // {playId, token}
159
- let board = null;
160
- let current = null; // the play's last view from the server
161
- let qClock = null;
162
- let totalClock = null;
163
-
164
- const focus = (id) => setTimeout(() => el.querySelector(`#${U}-${id}`)?.focus({preventScroll: false}), 30);
165
- const say = (t) => { s.live.value = ''; setTimeout(() => { s.live.value = t; }, 20); };
166
-
167
- function showClock() {
168
- const qLeft = qClock?.left.peek() ?? 0;
169
- const tLeft = totalClock?.left.peek() ?? 0;
170
- const useQ = current?.questionLeftMs != null && (current?.totalLeftMs == null || qLeft <= tLeft);
171
- const left = useQ ? qLeft : tLeft;
172
- const whole = useQ ? (current?.question?.seconds || 0) * 1000 : (s.info.peek().timeLimit || 0) * 1000;
173
- s.clockShown.value = current?.questionLeftMs != null || current?.totalLeftMs != null;
174
- s.clockText.value = fmtDuration(left);
175
- s.clockLow.value = left > 0 && left <= 5000;
176
- s.trackWidth.value = whole ? `${Math.max(0, Math.min(100, (left / whole) * 100)).toFixed(1)}%` : `${((current?.index || 0) / (current?.total || 1)) * 100}%`;
177
- }
178
-
179
- function stopClocks() {
180
- qClock?.stop();
181
- totalClock?.stop();
182
- }
183
-
184
- qClock = new Countdown(() => { if (s.screen.peek() === 'question') send(null, {timeout: true}); });
185
- totalClock = new Countdown(() => { if (['question', 'feedback'].includes(s.screen.peek())) setTimeout(load, 600); });
186
- const stopTicks = M.effect(() => { qClock.left.value; totalClock.left.value; showClock(); });
187
-
188
- function forget() {
189
- if (!test) memory.remove(playKey(cfg.slug));
190
- play = null;
191
- }
192
-
193
- async function loadInfo() {
194
- const info = test ? await test.info() : await request(`${API}/quiz/${encodeURIComponent(cfg.slug)}`);
195
- s.info.value = info;
196
- s.facts.value = facts(info);
197
- if (info.leaderboard && !board && !test) {
198
- board = mountLeaderboard(el.querySelector('.qz-board-slot'), {url: `${API}/quiz/${encodeURIComponent(cfg.slug)}/leaderboard`, limit: info.leaderboardSize, title: 'Leaderboard'});
199
- }
200
- return info;
201
- }
202
-
203
- /** Where the play is now, from the server; draws the screen that goes with it. */
204
- async function load() {
205
- if (!play) { s.screen.value = 'intro'; return; }
206
- try {
207
- const v = await request(`${API}/play/${encodeURIComponent(play.playId)}`, {token: play.token});
208
- current = v;
209
- if (v.status !== 'playing') { stopClocks(); return showResult(); }
210
- drawQuestion(v);
211
- } catch (err) {
212
- if (err.code === 'gone' || err.status === 404) { forget(); s.screen.value = 'intro'; return; }
213
- s.problem.value = err.message;
214
- s.screen.value = 'error';
215
- }
216
- }
217
-
218
- function drawQuestion(v) {
219
- const q = v.question;
220
- s.q.value = {text: q.text, kind: q.kind, image: q.image || ''};
221
- s.options.value = q.options.map((o, i) => ({id: o.id, text: o.text, letter: LETTERS[i] || '', picked: false}));
222
- s.typed.value = '';
223
- s.progress.value = `Question ${v.index + 1} of ${v.total}`;
224
- s.busy.value = false;
225
- s.screen.value = 'question';
226
- qClock.start(v.questionLeftMs ?? 0);
227
- totalClock.start(v.totalLeftMs ?? 0);
228
- showClock();
229
- if (q.kind === 'text') focus('typed');
230
- else focus('q');
231
- say(`Question ${v.index + 1} of ${v.total}. ${q.text}`);
232
- }
233
-
234
- async function send(answer, {timeout = false} = {}) {
235
- if (s.busy.peek() || !current?.question) return;
236
- s.busy.value = true;
237
- qClock.stop();
238
- try {
239
- const r = await request(`${API}/play/${encodeURIComponent(play.playId)}/answer`, {
240
- method: 'POST', token: play.token, body: {q: current.question.id, answer}
241
- });
242
- current = r.play;
243
- const done = r.play.status !== 'playing';
244
- if (r.feedback) {
245
- s.fb.value = {
246
- correct: r.correct,
247
- title: r.correct ? 'Right!' : timeout || r.late ? 'Out of time' : 'Not quite',
248
- rightAnswer: r.feedback.rightAnswer, explanation: r.feedback.explanation,
249
- nextLabel: done ? 'See your score' : 'Next question'
250
- };
251
- s.screen.value = 'feedback';
252
- say(s.fb.peek().title);
253
- focus('fb');
254
- return;
255
- }
256
- if (done) { stopClocks(); return showResult(); }
257
- // The next question's clock starts when the server hands it over, so ask for it.
258
- await load();
259
- } catch (err) {
260
- if (err.code === 'not-current' || err.code === 'finished') return load();
261
- s.busy.value = false;
262
- s.live.value = err.message;
263
- }
264
- }
265
-
266
- async function showResult() {
267
- stopClocks();
268
- try {
269
- const r = await request(`${API}/play/${encodeURIComponent(play.playId)}/result`, {token: play.token});
270
- const placeText = r.test
271
- ? `A test go, so it is not on the leaderboard.${r.wouldPlace ? ` It would have come ${ordinal(r.wouldPlace)} of all time.` : ''}`
272
- : r.place ? `You came ${ordinal(r.place)} of all time${r.placeToday && r.placeToday !== r.place ? `, ${ordinal(r.placeToday)} today` : ''}.` : '';
273
- s.res.value = {
274
- title: r.timeUp ? `Time's up, ${r.name}!` : `Well done, ${r.name}!`,
275
- scoreText: `${r.score} / ${r.maxScore}`,
276
- detail: `${r.correct} of ${r.total} right in ${fmtDuration(r.timeMs)}.`,
277
- placeText
278
- };
279
- s.review.value = (r.review || []).map((x, i) => ({
280
- k: `r${i}`, text: `${i + 1}. ${x.text}`, correct: x.correct, right: x.right, explanation: x.explanation,
281
- mark: x.correct ? '✓' : '✗', yoursText: x.yours ? `You said: ${x.yours}` : x.late ? 'Out of time' : 'No answer'
282
- }));
283
- s.screen.value = 'result';
284
- say(`${s.res.peek().title} ${s.res.peek().detail} ${placeText}`);
285
- focus('res');
286
- board?.refresh(play.playId);
287
- if (r.place === 1 && !r.test) celebrate();
288
- } catch (err) {
289
- if (err.status === 404) { forget(); s.screen.value = 'intro'; return; }
290
- s.problem.value = err.message;
291
- s.screen.value = 'error';
292
- }
293
- }
294
-
295
- async function begin() {
296
- if (s.busy.peek()) return;
297
- s.problem.value = '';
298
- s.busy.value = true;
299
- try {
300
- const r = test ? await test.start() : await request(`${API}/quiz/${encodeURIComponent(cfg.slug)}/start`, {
301
- method: 'POST', body: {name: s.name.peek(), website: s.website.peek()}
302
- });
303
- play = {playId: r.playId, token: r.token};
304
- if (test) {
305
- if (r.info) { s.info.value = r.info; s.facts.value = facts(r.info); }
306
- } else {
307
- memory.set(NAME_KEY, r.name);
308
- memory.set(playKey(cfg.slug), play);
309
- }
310
- await load();
311
- } catch (err) {
312
- s.problem.value = errText(err);
313
- s.busy.value = false;
314
- if (!test) focus('name');
315
- }
316
- }
317
-
318
- const methods = {
319
- // Form handlers answer false at once (no native submit, no reload); the work runs after.
320
- start() { begin(); return false; },
321
- choose(opt) {
322
- if (s.busy.peek()) return;
323
- if (s.q.peek().kind === 'multiple') {
324
- s.options.value = s.options.peek().map(o => (o.id === opt.id ? {...o, picked: !o.picked} : o));
325
- return;
326
- }
327
- send(opt.id);
328
- },
329
- sendPicked() {
330
- if (s.q.peek().kind === 'text') return send(s.typed.peek());
331
- send(s.options.peek().filter(o => o.picked).map(o => o.id));
332
- },
333
- sendTyped() { send(s.typed.peek()); return false; },
334
- next() {
335
- if (current?.status !== 'playing') return showResult();
336
- load();
337
- },
338
- async again() {
339
- forget();
340
- s.problem.value = '';
341
- s.busy.value = false;
342
- await loadInfo().catch(() => {});
343
- s.screen.value = 'intro';
344
- focus('name');
345
- }
346
- };
347
-
348
- const bindings = M.applyBindings({...s, ...methods}, el, {methods});
349
-
350
- loadInfo().then(load).catch((err) => {
351
- s.problem.value = err.status === 404 ? 'This quiz is not available.' : errText(err);
352
- s.screen.value = 'error';
353
- });
354
-
355
- // Gone from the page (an SPA swap): stop the clocks.
356
- const dispose = () => { stopClocks(); stopTicks?.(); board?.dispose(); bindings?.dispose?.(); watch.disconnect(); };
357
- const watch = new MutationObserver(() => { if (!el.isConnected) dispose(); });
358
- watch.observe(document.body, {childList: true, subtree: true});
359
- return {dispose};
360
- }
361
-
362
- function mountAll() {
363
- for (const el of document.querySelectorAll('[data-quiz]')) {
364
- if (el.dataset.gmMounted) continue;
365
- el.dataset.gmMounted = '1';
366
- mount(el);
367
- }
368
- mountBoards('data-quiz-board', (slug) => `${API}/quiz/${encodeURIComponent(slug)}/leaderboard`);
369
- }
370
-
371
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountAll);
372
- else mountAll();