domma-cms 1.8.46 → 1.9.1

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 (53) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/admin/js/lib/shortcode-registry.js +1 -1
  3. package/admin/js/templates/docs/usage-shortcodes.html +1 -1
  4. package/admin/js/templates/docs/usage-users-roles.html +4 -0
  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/markdown-shortcodes.md +4 -0
  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/markdown.js +7 -1
  21. package/server/services/permissionRegistry.js +39 -11
  22. package/plugins/crossword/CLAUDE.md +0 -37
  23. package/plugins/crossword/admin/css/index.css +0 -18
  24. package/plugins/crossword/admin/templates/crossword.html +0 -1
  25. package/plugins/crossword/admin/views/crossword.js +0 -449
  26. package/plugins/crossword/collections/crossword-plays.json +0 -20
  27. package/plugins/crossword/collections/crossword-puzzles.json +0 -17
  28. package/plugins/crossword/config.js +0 -6
  29. package/plugins/crossword/docs/guide.md +0 -53
  30. package/plugins/crossword/inject/body.html +0 -1
  31. package/plugins/crossword/lib/crossword.js +0 -170
  32. package/plugins/crossword/lib/store.js +0 -56
  33. package/plugins/crossword/plugin.js +0 -274
  34. package/plugins/crossword/plugin.json +0 -79
  35. package/plugins/crossword/public/crossword.css +0 -36
  36. package/plugins/crossword/public/crossword.js +0 -519
  37. package/plugins/crossword/public/lib/layout.js +0 -199
  38. package/plugins/quiz/CLAUDE.md +0 -43
  39. package/plugins/quiz/admin/css/index.css +0 -13
  40. package/plugins/quiz/admin/templates/quiz.html +0 -1
  41. package/plugins/quiz/admin/views/quiz.js +0 -530
  42. package/plugins/quiz/collections/quiz-plays.json +0 -20
  43. package/plugins/quiz/collections/quiz-quizzes.json +0 -17
  44. package/plugins/quiz/config.js +0 -6
  45. package/plugins/quiz/docs/guide.md +0 -61
  46. package/plugins/quiz/inject/body.html +0 -1
  47. package/plugins/quiz/lib/engine.js +0 -115
  48. package/plugins/quiz/lib/quiz.js +0 -225
  49. package/plugins/quiz/lib/store.js +0 -56
  50. package/plugins/quiz/plugin.js +0 -275
  51. package/plugins/quiz/plugin.json +0 -79
  52. package/plugins/quiz/public/quiz.css +0 -36
  53. package/plugins/quiz/public/quiz.js +0 -372
@@ -1,519 +0,0 @@
1
- /**
2
- * Crossword - the player, in the browser.
3
- *
4
- * Mounts on every `[data-crossword]` placeholder the [crossword] shortcode
5
- * wrote, and every `[data-crossword-board]` the leaderboard one wrote. The
6
- * server holds the answers: this gets the grid's shape and the clues when a
7
- * play starts, sends the letters to be checked or judged, and shows the clock
8
- * the server keeps. Letters typed so far are remembered per play, so a reload
9
- * keeps them (the clock never stopped).
10
- *
11
- * The screens (start, playing, result) are domma-reactive bindings. The grid
12
- * itself is built once per play as plain inputs, because it is all focus and
13
- * keystrokes: typing moves along the word, arrows move, clicking a square
14
- * twice turns the direction, Tab goes to the next clue.
15
- *
16
- * `mount(el, {test})` is the same player for an admin testing the crossword
17
- * from its Tool: `test.info()` and `test.start()` go through the admin's own
18
- * API, the play is a test one (never on a board), and nothing is remembered.
19
- */
20
-
21
- import {Stopwatch, Countdown, addStyles, celebrate, configOf, fmtDuration, memory, mountBoards, mountLeaderboard, ordinal, request, uid} from '/plugins/_lib/public/games/games.js';
22
-
23
- const M = window.M;
24
- const API = '/api/plugins/crossword/public';
25
- const NAME_KEY = 'games.name';
26
- const playKey = (slug) => `crossword.play.${slug}`;
27
- const cellsKey = (playId) => `crossword.cells.${playId}`;
28
-
29
- const TEMPLATE = `
30
- <p class="cw-live" role="status" aria-live="polite" data-bind-text="live.value"></p>
31
-
32
- <section class="gm-card" hidden data-bind-hidden="screen.value !== 'loading'"><p class="gm-muted">Loading the crossword…</p></section>
33
- <section class="gm-card" hidden data-bind-hidden="screen.value !== 'error'"><p class="gm-error" data-bind-text="problem.value"></p></section>
34
-
35
- <section class="gm-card" 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" aria-describedby="%U%-err">
45
- </div>
46
- <div class="gm-hp" aria-hidden="true"><label>Website <input tabindex="-1" autocomplete="off" data-model="website.value"></label></div>
47
- <button type="submit" class="btn btn-primary" data-bind-disabled="busy.value" data-bind-text="testing ? 'Start the test' : 'Start the clock'">Start the clock</button>
48
- </form>
49
- <p class="gm-error" id="%U%-err" hidden data-bind-hidden="!problem.value" data-bind-text="problem.value"></p>
50
- </section>
51
-
52
- <section class="gm-card cw-play" hidden data-bind-hidden="screen.value !== 'play' && screen.value !== 'result'">
53
- <div class="gm-bar" hidden data-bind-hidden="screen.value !== 'play'">
54
- <strong data-bind-text="info.value.title"></strong>
55
- <span class="cw-clocks">
56
- <span class="gm-muted" hidden data-bind-hidden="!penaltyText.value" data-bind-text="penaltyText.value"></span>
57
- <span class="gm-clock" role="timer" aria-label="Time so far" data-bind-text="clock.shown.value"></span>
58
- <span class="gm-clock" hidden data-bind-hidden="!leftShown.value" data-bind-class="leftLow.value && 'is-low'"
59
- data-bind-aria-label="'Time left ' + leftText.value" data-bind-text="leftText.value"></span>
60
- </span>
61
- </div>
62
- <div class="cw-result" hidden data-bind-hidden="screen.value !== 'result'">
63
- <h2 class="gm-title" id="%U%-res" tabindex="-1" data-bind-text="res.value.title"></h2>
64
- <p class="gm-result-score" hidden data-bind-hidden="!res.value.solved" data-bind-text="res.value.timeText"></p>
65
- <p class="gm-muted" data-bind-text="res.value.detail"></p>
66
- <p class="gm-result-place" hidden data-bind-hidden="!res.value.placeText" data-bind-text="res.value.placeText"></p>
67
- <div class="gm-actions"><button type="button" class="btn btn-primary" data-on-click="again" data-bind-text="testing ? 'Test again' : 'Play again'">Play again</button></div>
68
- </div>
69
- <p class="cw-current" aria-hidden="true" hidden data-bind-hidden="screen.value !== 'play' || !currentClue.value" data-bind-text="currentClue.value"></p>
70
- <div class="cw-layout">
71
- <div class="cw-grid-wrap"><div class="cw-grid" role="grid" aria-label="Crossword grid"></div></div>
72
- <div class="cw-clues">
73
- <div><h3>Across</h3><ol class="cw-list" data-dir="across"></ol></div>
74
- <div><h3>Down</h3><ol class="cw-list" data-dir="down"></ol></div>
75
- </div>
76
- </div>
77
- <p class="gm-note cw-message" hidden data-bind-hidden="!message.value" data-bind-class="messageTone.value" data-bind-text="message.value"></p>
78
- <div class="gm-actions" hidden data-bind-hidden="screen.value !== 'play'">
79
- <button type="button" class="btn btn-primary" data-bind-disabled="busy.value" data-on-click="submit">I've finished</button>
80
- <button type="button" class="btn btn-outline" hidden data-bind-hidden="!info.value.checks" data-bind-disabled="busy.value" data-on-click="check"
81
- data-bind-text="checkLabel.value"></button>
82
- <button type="button" class="btn btn-ghost" data-on-click="clearWord">Clear this word</button>
83
- </div>
84
- </section>
85
-
86
- <div class="cw-board-slot"></div>`;
87
-
88
- function facts(info) {
89
- const out = [`${info.answers} answers`, `${info.width} × ${info.height} grid`];
90
- if (info.timeLimit) out.push(`${fmtDuration(info.timeLimit * 1000)} to solve it`);
91
- if (info.checks) out.push(info.checkPenalty ? `Checking costs ${info.checkPenalty} seconds` : 'You can check your letters');
92
- if (info.attemptsPerName === 1) out.push('One go each');
93
- if (info.leaderboard) out.push('Fastest solve wins');
94
- return out.map(k => ({k}));
95
- }
96
-
97
- /** The grid as a player works it: letters, the cursor, the direction. Plain DOM. */
98
- class Board {
99
- constructor(el, clueLists, puzzle, {onChange, onClue}) {
100
- this.el = el;
101
- this.lists = clueLists;
102
- this.p = puzzle;
103
- this.onChange = onChange;
104
- this.onClue = onClue;
105
- this.letters = puzzle.shape.map(row => [...row].map(() => ''));
106
- this.inputs = [];
107
- this.dir = 'across';
108
- this.pos = null;
109
- this.entries = [...puzzle.across, ...puzzle.down];
110
- this.build();
111
- }
112
-
113
- open(r, c) { return r >= 0 && c >= 0 && r < this.p.height && c < this.p.width && this.p.shape[r][c] === '.'; }
114
-
115
- /** The entry through (r, c) in a direction, or null. */
116
- entryAt(r, c, dir) {
117
- return this.entries.find(e => e.dir === dir && (dir === 'across'
118
- ? e.row === r && c >= e.col && c < e.col + e.length
119
- : e.col === c && r >= e.row && r < e.row + e.length)) || null;
120
- }
121
-
122
- cellsOf(e) {
123
- return Array.from({length: e.length}, (_, i) => (e.dir === 'across' ? [e.row, e.col + i] : [e.row + i, e.col]));
124
- }
125
-
126
- build() {
127
- const {width, height, shape} = this.p;
128
- const numbers = new Map(this.entries.map(e => [`${e.row},${e.col}`, e.number]));
129
- this.el.innerHTML = '';
130
- this.el.style.setProperty('--cw-cols', width);
131
- for (let r = 0; r < height; r++) {
132
- const rowEl = document.createElement('div');
133
- rowEl.className = 'cw-row';
134
- rowEl.setAttribute('role', 'row');
135
- this.inputs[r] = [];
136
- for (let c = 0; c < width; c++) {
137
- const cell = document.createElement('div');
138
- cell.className = shape[r][c] === '.' ? 'cw-cell' : 'cw-cell is-block';
139
- cell.setAttribute('role', 'gridcell');
140
- if (shape[r][c] === '.') {
141
- if (numbers.has(`${r},${c}`)) {
142
- const n = document.createElement('span');
143
- n.className = 'cw-num';
144
- n.textContent = numbers.get(`${r},${c}`);
145
- cell.append(n);
146
- }
147
- const input = document.createElement('input');
148
- input.className = 'cw-input';
149
- input.maxLength = 2;
150
- input.autocomplete = 'off';
151
- input.setAttribute('autocapitalize', 'characters');
152
- input.spellcheck = false;
153
- input.setAttribute('aria-label', this.labelFor(r, c));
154
- input.addEventListener('focus', () => this.focusCell(r, c, {fromFocus: true}));
155
- input.addEventListener('mousedown', (e) => {
156
- if (this.pos && this.pos[0] === r && this.pos[1] === c) { e.preventDefault(); this.turn(); }
157
- });
158
- input.addEventListener('keydown', (e) => this.key(e, r, c));
159
- input.addEventListener('input', (e) => this.typed(e, r, c));
160
- cell.append(input);
161
- this.inputs[r][c] = input;
162
- }
163
- rowEl.append(cell);
164
- }
165
- this.el.append(rowEl);
166
- }
167
- for (const list of this.lists) {
168
- list.innerHTML = '';
169
- for (const e of this.entries.filter(x => x.dir === list.dataset.dir)) {
170
- const li = document.createElement('li');
171
- li.dataset.key = `${e.dir}${e.number}`;
172
- li.value = e.number;
173
- const b = document.createElement('button');
174
- b.type = 'button';
175
- b.className = 'cw-clue';
176
- const num = document.createElement('strong');
177
- num.textContent = `${e.number} `;
178
- b.append(num, document.createTextNode(`${e.clue} (${e.enumeration})`));
179
- b.addEventListener('click', () => { this.dir = e.dir; this.focusCell(e.row, e.col); });
180
- li.append(b);
181
- list.append(li);
182
- }
183
- }
184
- }
185
-
186
- labelFor(r, c) {
187
- const parts = ['across', 'down'].map(d => this.entryAt(r, c, d)).filter(Boolean).map(e => `${e.number} ${e.dir}`);
188
- return `Square in ${parts.join(' and ')}`;
189
- }
190
-
191
- focusCell(r, c, {fromFocus = false} = {}) {
192
- if (!this.open(r, c)) return;
193
- if (!this.entryAt(r, c, this.dir)) this.dir = this.dir === 'across' ? 'down' : 'across';
194
- this.pos = [r, c];
195
- if (!fromFocus) this.inputs[r][c].focus();
196
- this.paint();
197
- }
198
-
199
- turn() {
200
- const other = this.dir === 'across' ? 'down' : 'across';
201
- if (this.pos && this.entryAt(...this.pos, other)) { this.dir = other; this.paint(); }
202
- }
203
-
204
- current() { return this.pos ? this.entryAt(...this.pos, this.dir) : null; }
205
-
206
- paint() {
207
- const e = this.current();
208
- const inWord = new Set(e ? this.cellsOf(e).map(([r, c]) => `${r},${c}`) : []);
209
- this.inputs.forEach((row, r) => row.forEach((input, c) => {
210
- if (!input) return;
211
- const cell = input.parentElement;
212
- cell.classList.toggle('is-word', inWord.has(`${r},${c}`));
213
- cell.classList.toggle('is-cursor', Boolean(this.pos) && this.pos[0] === r && this.pos[1] === c);
214
- }));
215
- for (const list of this.lists) {
216
- for (const li of list.children) {
217
- const on = e && li.dataset.key === `${e.dir}${e.number}`;
218
- li.classList.toggle('is-on', Boolean(on));
219
- if (on && typeof li.scrollIntoView === 'function' && list.scrollHeight > list.clientHeight) li.scrollIntoView({block: 'nearest'});
220
- }
221
- }
222
- this.onClue(e ? `${e.number} ${e.dir}: ${e.clue} (${e.enumeration})` : '');
223
- }
224
-
225
- set(r, c, ch) {
226
- this.letters[r][c] = ch;
227
- this.inputs[r][c].value = ch;
228
- this.inputs[r][c].parentElement.classList.remove('is-wrong');
229
- this.onChange();
230
- }
231
-
232
- /** The next open square along the word (or the same one at its end). */
233
- step(r, c, by) {
234
- const [dr, dc] = this.dir === 'across' ? [0, by] : [by, 0];
235
- return this.open(r + dr, c + dc) ? [r + dr, c + dc] : null;
236
- }
237
-
238
- typed(e, r, c) {
239
- const ch = (e.target.value.toUpperCase().match(/[A-Z]/g) || []).pop() || '';
240
- this.set(r, c, ch);
241
- if (ch) { const next = this.step(r, c, 1); if (next) this.focusCell(...next); }
242
- }
243
-
244
- key(e, r, c) {
245
- const moves = {ArrowUp: [-1, 0, 'down'], ArrowDown: [1, 0, 'down'], ArrowLeft: [0, -1, 'across'], ArrowRight: [0, 1, 'across']};
246
- if (moves[e.key]) {
247
- e.preventDefault();
248
- const [dr, dc, dir] = moves[e.key];
249
- if (this.dir !== dir && this.entryAt(r, c, dir)) { this.dir = dir; this.paint(); return; }
250
- let [rr, cc] = [r + dr, c + dc];
251
- while (rr >= 0 && cc >= 0 && rr < this.p.height && cc < this.p.width && !this.open(rr, cc)) { rr += dr; cc += dc; }
252
- if (this.open(rr, cc)) this.focusCell(rr, cc);
253
- } else if (e.key === 'Backspace') {
254
- e.preventDefault();
255
- if (this.letters[r][c]) this.set(r, c, '');
256
- else { const prev = this.step(r, c, -1); if (prev) { this.focusCell(...prev); this.set(...prev, ''); } }
257
- } else if (e.key === 'Delete') {
258
- e.preventDefault();
259
- this.set(r, c, '');
260
- } else if (e.key === ' ') {
261
- e.preventDefault();
262
- this.turn();
263
- } else if (e.key === 'Tab') {
264
- const cur = this.current();
265
- const i = this.entries.indexOf(cur);
266
- const next = this.entries[(i + (e.shiftKey ? -1 : 1) + this.entries.length) % this.entries.length];
267
- if (next) { e.preventDefault(); this.dir = next.dir; this.focusCell(next.row, next.col); }
268
- } else if (/^[a-z]$/i.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey) {
269
- // Overtype: a letter replaces what is there, without waiting for the input event's quirks.
270
- e.preventDefault();
271
- this.set(r, c, e.key.toUpperCase());
272
- const next = this.step(r, c, 1);
273
- if (next) this.focusCell(...next);
274
- }
275
- }
276
-
277
- clearCurrent() {
278
- const e = this.current();
279
- if (!e) return;
280
- for (const [r, c] of this.cellsOf(e)) this.set(r, c, '');
281
- this.focusCell(e.row, e.col);
282
- }
283
-
284
- /** Rows of letters, a space for an empty square - what the server reads. */
285
- rows() {
286
- return this.letters.map((row, r) => row.map((ch, c) => (this.open(r, c) ? (ch || ' ') : ' ')).join(''));
287
- }
288
-
289
- load(rows) {
290
- if (!Array.isArray(rows) || rows.length !== this.p.height) return;
291
- rows.forEach((row, r) => [...String(row)].forEach((ch, c) => {
292
- if (this.open(r, c) && /[A-Z]/.test(ch)) { this.letters[r][c] = ch; this.inputs[r][c].value = ch; }
293
- }));
294
- }
295
-
296
- markWrong(list) {
297
- for (const [r, c] of list) this.inputs[r]?.[c]?.parentElement.classList.add('is-wrong');
298
- }
299
-
300
- reveal(rows, {lock = true} = {}) {
301
- rows.forEach((row, r) => [...row].forEach((ch, c) => {
302
- const input = this.inputs[r]?.[c];
303
- if (!input) return;
304
- input.value = ch.trim();
305
- if (lock) input.readOnly = true;
306
- input.parentElement.classList.remove('is-wrong', 'is-word', 'is-cursor');
307
- }));
308
- this.pos = null;
309
- for (const list of this.lists) for (const li of list.children) li.classList.remove('is-on');
310
- }
311
- }
312
-
313
- /** The server's words: the public request's Error, or the admin's (its reply is on `err.body`). */
314
- const errText = (err) => err?.body?.error || err?.message || String(err);
315
-
316
- /**
317
- * @param {HTMLElement} el
318
- * @param {{test?: {info: () => Promise<object>, start: () => Promise<{playId: string, token: string, name: string, info: object}>}}} [opts]
319
- */
320
- export function mount(el, {test = null} = {}) {
321
- const cfg = test ? {slug: 'test'} : configOf(el, 'data-crossword');
322
- if (!cfg?.slug || !M) return;
323
- addStyles(['/plugins/crossword/public/crossword.css?v=1.1.0']);
324
- const U = uid('cw');
325
- el.innerHTML = TEMPLATE.replaceAll('%U%', U);
326
-
327
- const s = {
328
- testing: Boolean(test),
329
- screen: M.observable('loading'), live: M.observable(''), problem: M.observable(''), busy: M.observable(false),
330
- info: M.observable({title: '', intro: '', open: false, reason: '', checks: false}), facts: M.observable([]),
331
- name: M.observable(String(memory.get(NAME_KEY) || '')), website: M.observable(''),
332
- clock: new Stopwatch(), leftText: M.observable(''), leftShown: M.observable(false), leftLow: M.observable(false),
333
- penaltyText: M.observable(''), currentClue: M.observable(''), message: M.observable(''), messageTone: M.observable(''),
334
- checkLabel: M.observable('Check my letters'), res: M.observable({})
335
- };
336
-
337
- let play = test ? null : memory.get(playKey(cfg.slug)) || null;
338
- let board = null;
339
- let grid = null;
340
- let penaltyMs = 0;
341
- const left = new Countdown(() => { if (s.screen.peek() === 'play') setTimeout(load, 3500); });
342
- const stopLeft = M.effect(() => {
343
- const ms = left.left.value;
344
- s.leftText.value = `${fmtDuration(ms)} left`;
345
- s.leftLow.value = ms > 0 && ms <= 60000;
346
- });
347
-
348
- const say = (t) => { s.live.value = ''; setTimeout(() => { s.live.value = t; }, 20); };
349
- const tell = (text, tone = '') => { s.message.value = text; s.messageTone.value = tone ? `is-${tone}` : ''; say(text); };
350
- const forget = () => { if (!test) memory.remove(playKey(cfg.slug)); play = null; };
351
- const saveCells = () => { if (play && grid && !test) memory.set(cellsKey(play.playId), grid.rows()); };
352
-
353
- function setClock(v) {
354
- penaltyMs = v.penaltyMs || 0;
355
- s.penaltyText.value = penaltyMs ? `+${Math.round(penaltyMs / 1000)}s` : '';
356
- s.clock.start((v.elapsedMs || 0) + penaltyMs);
357
- s.leftShown.value = v.totalLeftMs != null;
358
- if (v.totalLeftMs != null) left.start(v.totalLeftMs); else left.stop();
359
- }
360
-
361
- async function loadInfo() {
362
- const info = test ? await test.info() : await request(`${API}/crossword/${encodeURIComponent(cfg.slug)}`);
363
- showInfo(info);
364
- }
365
-
366
- function showInfo(info) {
367
- s.info.value = info;
368
- s.facts.value = facts(info);
369
- s.checkLabel.value = info.checkPenalty ? `Check my letters (+${info.checkPenalty}s)` : 'Check my letters';
370
- if (info.leaderboard && !board && !test) {
371
- board = mountLeaderboard(el.querySelector('.cw-board-slot'), {url: `${API}/crossword/${encodeURIComponent(cfg.slug)}/leaderboard`,
372
- limit: info.leaderboardSize, showScore: false, title: 'Fastest solvers'});
373
- }
374
- }
375
-
376
- async function load() {
377
- if (!play) { s.screen.value = 'intro'; return; }
378
- try {
379
- const v = await request(`${API}/play/${encodeURIComponent(play.playId)}`, {token: play.token});
380
- if (v.status !== 'playing') return showResult();
381
- if (!grid) {
382
- grid = new Board(el.querySelector('.cw-grid'), [...el.querySelectorAll('.cw-list')], v.puzzle, {
383
- onChange: () => { saveCells(); s.message.value = ''; },
384
- onClue: (t) => { s.currentClue.value = t; }
385
- });
386
- if (!test) grid.load(memory.get(cellsKey(play.playId)));
387
- }
388
- setClock(v);
389
- s.busy.value = false;
390
- s.screen.value = 'play';
391
- const first = v.puzzle.across[0] || v.puzzle.down[0];
392
- if (first && !grid.pos) setTimeout(() => { grid.dir = first.dir; grid.focusCell(first.row, first.col); }, 50);
393
- } catch (err) {
394
- if (err.code === 'gone' || err.status === 404) { forget(); s.screen.value = 'intro'; return; }
395
- s.problem.value = err.message;
396
- s.screen.value = 'error';
397
- }
398
- }
399
-
400
- async function showResult() {
401
- s.clock.stop();
402
- left.stop();
403
- try {
404
- const r = await request(`${API}/play/${encodeURIComponent(play.playId)}/result`, {token: play.token});
405
- const placeText = r.test
406
- ? `A test go, so it is not on the leaderboard.${r.wouldPlace ? ` It would have come ${ordinal(r.wouldPlace)} of all time.` : ''}`
407
- : r.place ? `You came ${ordinal(r.place)} of all time${r.placeToday && r.placeToday !== r.place ? `, ${ordinal(r.placeToday)} today` : ''}.` : '';
408
- s.res.value = {
409
- solved: r.solved,
410
- title: r.solved ? `Solved, ${r.name}!` : `Time's up, ${r.name}.`,
411
- timeText: fmtDuration(r.timeMs),
412
- detail: r.solved
413
- ? (r.penaltyMs ? `Including ${Math.round(r.penaltyMs / 1000)} seconds of penalties${r.checks ? ` (${r.checks} check${r.checks === 1 ? '' : 's'})` : ''}.` : 'No penalties. Clean solve!')
414
- : 'The clock ran out before the grid was finished.',
415
- placeText
416
- };
417
- s.message.value = '';
418
- s.currentClue.value = '';
419
- if (r.solution && grid) grid.reveal(r.solution);
420
- else if (r.solution && !grid) { /* a finished play from an earlier visit: no grid to show */ }
421
- s.screen.value = 'result';
422
- say(`${s.res.peek().title} ${r.solved ? fmtDuration(r.timeMs) : ''} ${placeText}`);
423
- setTimeout(() => el.querySelector(`#${U}-res`)?.focus(), 30);
424
- board?.refresh(play.playId);
425
- if (r.place === 1 && !r.test) celebrate();
426
- memory.remove(cellsKey(play.playId));
427
- } catch (err) {
428
- if (err.status === 404) { forget(); s.screen.value = 'intro'; return; }
429
- s.problem.value = err.message;
430
- s.screen.value = 'error';
431
- }
432
- }
433
-
434
- async function begin() {
435
- if (s.busy.peek()) return;
436
- s.problem.value = '';
437
- s.busy.value = true;
438
- try {
439
- const r = test ? await test.start() : await request(`${API}/crossword/${encodeURIComponent(cfg.slug)}/start`, {
440
- method: 'POST', body: {name: s.name.peek(), website: s.website.peek()}
441
- });
442
- play = {playId: r.playId, token: r.token};
443
- if (test) {
444
- if (r.info) showInfo(r.info);
445
- } else {
446
- memory.set(NAME_KEY, r.name);
447
- memory.set(playKey(cfg.slug), play);
448
- }
449
- await load();
450
- } catch (err) {
451
- s.problem.value = errText(err);
452
- s.busy.value = false;
453
- if (!test) el.querySelector(`#${U}-name`)?.focus();
454
- }
455
- }
456
-
457
- async function judge(path) {
458
- if (s.busy.peek() || !grid) return;
459
- s.busy.value = true;
460
- try {
461
- const r = await request(`${API}/play/${encodeURIComponent(play.playId)}/${path}`, {method: 'POST', token: play.token, body: {cells: grid.rows()}});
462
- setClock(r.play);
463
- if (path === 'check') {
464
- grid.markWrong(r.wrong);
465
- tell(r.wrong.length ? `${r.wrong.length} square${r.wrong.length === 1 ? ' is' : 's are'} wrong (marked in red).`
466
- : r.empty ? `Everything so far is right. ${r.empty} to go.` : 'Everything is right. Press "I\'ve finished"!', r.wrong.length ? 'bad' : 'good');
467
- } else if (r.solved) {
468
- return showResult();
469
- } else {
470
- tell(r.message, 'bad');
471
- }
472
- } catch (err) {
473
- if (err.code === 'finished' || err.code === 'gone') return load();
474
- tell(err.message, 'bad');
475
- } finally {
476
- s.busy.value = false;
477
- }
478
- }
479
-
480
- const methods = {
481
- start() { begin(); return false; },
482
- check() { judge('check'); },
483
- submit() { judge('submit'); },
484
- clearWord() { grid?.clearCurrent(); },
485
- async again() {
486
- forget();
487
- grid = null;
488
- el.querySelector('.cw-grid').innerHTML = '';
489
- el.querySelectorAll('.cw-list').forEach(l => { l.innerHTML = ''; });
490
- s.busy.value = false;
491
- await loadInfo().catch(() => {});
492
- s.screen.value = 'intro';
493
- }
494
- };
495
-
496
- const bindings = M.applyBindings({...s, ...methods}, el, {methods});
497
-
498
- loadInfo().then(load).catch((err) => {
499
- s.problem.value = err.status === 404 ? 'This crossword is not available.' : errText(err);
500
- s.screen.value = 'error';
501
- });
502
-
503
- const dispose = () => { s.clock.stop(); left.stop(); stopLeft?.(); board?.dispose(); bindings?.dispose?.(); watch.disconnect(); };
504
- const watch = new MutationObserver(() => { if (!el.isConnected) dispose(); });
505
- watch.observe(document.body, {childList: true, subtree: true});
506
- return {dispose};
507
- }
508
-
509
- function mountAll() {
510
- for (const el of document.querySelectorAll('[data-crossword]')) {
511
- if (el.dataset.gmMounted) continue;
512
- el.dataset.gmMounted = '1';
513
- mount(el);
514
- }
515
- mountBoards('data-crossword-board', (slug) => `${API}/crossword/${encodeURIComponent(slug)}/leaderboard`, {showScore: false});
516
- }
517
-
518
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountAll);
519
- else mountAll();