domma-cms 1.8.31 → 1.8.33

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 (46) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/docs/DommaFeatures.md +13 -1
  3. package/package.json +1 -1
  4. package/plugins/_lib/admin/games/games.css +1 -0
  5. package/plugins/_lib/admin/games/kit.js +446 -0
  6. package/plugins/_lib/games/http.js +92 -0
  7. package/plugins/_lib/games/names.js +42 -0
  8. package/plugins/_lib/games/plays.js +176 -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/CLAUDE.md +37 -0
  15. package/plugins/crossword/admin/css/index.css +18 -0
  16. package/plugins/crossword/admin/templates/crossword.html +1 -0
  17. package/plugins/crossword/admin/views/crossword.js +449 -0
  18. package/plugins/crossword/collections/crossword-plays.json +20 -0
  19. package/plugins/crossword/collections/crossword-puzzles.json +17 -0
  20. package/plugins/crossword/config.js +6 -0
  21. package/plugins/crossword/docs/guide.md +53 -0
  22. package/plugins/crossword/inject/body.html +1 -0
  23. package/plugins/crossword/lib/crossword.js +170 -0
  24. package/plugins/crossword/lib/store.js +56 -0
  25. package/plugins/crossword/plugin.js +274 -0
  26. package/plugins/crossword/plugin.json +79 -0
  27. package/plugins/crossword/public/crossword.css +36 -0
  28. package/plugins/crossword/public/crossword.js +519 -0
  29. package/plugins/crossword/public/lib/layout.js +199 -0
  30. package/plugins/free-tier.lock.json +43 -1
  31. package/plugins/quiz/CLAUDE.md +43 -0
  32. package/plugins/quiz/admin/css/index.css +13 -0
  33. package/plugins/quiz/admin/templates/quiz.html +1 -0
  34. package/plugins/quiz/admin/views/quiz.js +530 -0
  35. package/plugins/quiz/collections/quiz-plays.json +20 -0
  36. package/plugins/quiz/collections/quiz-quizzes.json +17 -0
  37. package/plugins/quiz/config.js +6 -0
  38. package/plugins/quiz/docs/guide.md +61 -0
  39. package/plugins/quiz/inject/body.html +1 -0
  40. package/plugins/quiz/lib/engine.js +115 -0
  41. package/plugins/quiz/lib/quiz.js +225 -0
  42. package/plugins/quiz/lib/store.js +56 -0
  43. package/plugins/quiz/plugin.js +275 -0
  44. package/plugins/quiz/plugin.json +79 -0
  45. package/plugins/quiz/public/quiz.css +36 -0
  46. package/plugins/quiz/public/quiz.js +372 -0
@@ -0,0 +1,449 @@
1
+ /**
2
+ * Crossword - the Tool. One view, four screens by address:
3
+ *
4
+ * #/plugins/crossword every crossword
5
+ * #/plugins/crossword/new a new crossword
6
+ * #/plugins/crossword/edit/<id> its words, clues and settings, with the grid previewed live
7
+ * #/plugins/crossword/test/<id> solve it as a player would - a test go, never on the leaderboard
8
+ * #/plugins/crossword/plays/<id> who solved it, and its leaderboard
9
+ *
10
+ * The preview runs the same layout function the server saves with
11
+ * (public/lib/layout.js), from the same words and seed, so what you see is
12
+ * what is saved. Every screen is domma-reactive (observables + one
13
+ * M.applyBindings, through the admin kit's mountScreen).
14
+ */
15
+
16
+ import {bannerTrail, copyText, errText, esc, failed, fmtDuration, fromLocalInput, ico, loadKit, loading, mountScreen, playsScreen, refreshBadge, request, saveKey, testScreen, toLocalInput, wireRows} from '/plugins/_lib/admin/games/kit.js';
17
+ import {MAX_WORDS, cleanAnswer, generate, solutionGrid} from '/plugins/crossword/public/lib/layout.js';
18
+
19
+ const API = '/plugins/crossword';
20
+ const SIDEBAR = '#/plugins/crossword';
21
+ const ROUTE = /^#\/plugins\/crossword(\/|\?|$)/;
22
+ const PLAYER = '/plugins/crossword/public/crossword.js?v=1.1.0';
23
+ const STATUS_LABEL = {draft: 'Draft', open: 'Open', closed: 'Closed'};
24
+ const STATUS_TONE = {draft: '', open: 'is-success', closed: 'is-warning'};
25
+
26
+ let lifetime = null;
27
+ let leaveGuard = null;
28
+ // Each mount's number: a screen whose data arrives after the admin has moved on draws nothing.
29
+ let mountSeq = 0;
30
+
31
+ function where() {
32
+ const m = location.hash.split('?')[0].match(/^#\/plugins\/crossword(?:\/([a-z]+))?(?:\/([^/?#]+))?/);
33
+ return {screen: m?.[1] || 'list', id: m?.[2] ? decodeURIComponent(m[2]) : ''};
34
+ }
35
+
36
+ const badge = () => refreshBadge(SIDEBAR);
37
+ const shortcodeOf = (slug) => `[crossword id="${slug}" /]`;
38
+ const href = (screen, id) => `#/plugins/crossword/${screen}/${encodeURIComponent(id)}`;
39
+
40
+ export const crosswordView = {
41
+ templateUrl: '/plugins/crossword/admin/templates/crossword.html',
42
+
43
+ async onMount($container) {
44
+ const kit = await loadKit();
45
+ lifetime ??= kit.viewLifetime(ROUTE);
46
+ if (!ROUTE.test(location.hash)) return;
47
+ lifetime.reset();
48
+ const n = ++mountSeq;
49
+ const alive = () => n === mountSeq && ROUTE.test(location.hash);
50
+ if (leaveGuard) { window.removeEventListener('beforeunload', leaveGuard); leaveGuard = null; }
51
+ const container = $container.get(0);
52
+ const root = container.querySelector('[data-crossword-admin]') || container;
53
+ const {screen, id} = where();
54
+
55
+ if ((screen === 'plays' || screen === 'test') && id) {
56
+ let puzzle = null;
57
+ try { puzzle = await request('GET', `${API}/puzzles/${encodeURIComponent(id)}`); } catch { /* the screen says so */ }
58
+ if (!alive()) return;
59
+ const title = puzzle?.title || 'Crossword';
60
+ bannerTrail(container, [{text: 'Crossword', href: SIDEBAR}, {text: `${title}: ${screen === 'test' ? 'test' : 'plays'}`}]);
61
+ kit.addBannerAction($container, {id: 'crossword-edit', icon: 'edit', title: 'Edit this crossword', onClick: () => { location.hash = href('edit', id); }});
62
+ if (screen === 'plays') {
63
+ kit.addBannerAction($container, {id: 'crossword-test', icon: 'play', title: 'Test it', onClick: () => { location.hash = href('test', id); }});
64
+ return playsScreen(root, {api: API, gameId: id, title, order: 'time', back: SIDEBAR, base: '/puzzles', badge, lifetime, alive, testHref: href('test', id)});
65
+ }
66
+ kit.addBannerAction($container, {id: 'crossword-plays', icon: 'award', title: 'Plays and leaderboard', onClick: () => { location.hash = href('plays', id); }});
67
+ if (!puzzle) return failed(root, 'That crossword is not here any more.', {href: SIDEBAR, text: 'Back to Crossword'});
68
+ return testScreen(root, {api: API, base: '/puzzles', gameId: id, player: PLAYER, appClass: 'cw-app',
69
+ editHref: href('edit', id), playsHref: href('plays', id), statusText: STATUS_LABEL[puzzle.status] || '', lifetime, alive});
70
+ }
71
+ if (screen === 'new' || (screen === 'edit' && id)) return editor(root, container, $container, kit, screen === 'new' ? '' : id, alive);
72
+ return list(root, container, $container, kit, alive);
73
+ }
74
+ };
75
+
76
+ // ---- The list ----------------------------------------------------------------------
77
+
78
+ const LIST_HTML = `
79
+ <div class="gma-bar">
80
+ <a class="btn btn-sm btn-primary" href="#/plugins/crossword/new">${ico('plus')} New crossword</a>
81
+ <span class="gma-spacer"></span>
82
+ <span class="gma-note">Put a crossword on any page with its shortcode.</span>
83
+ <span data-help-title="Crossword" data-help="Type the answers and clues and the grid lays itself out. Test it - you solve it exactly as your players will, and a test go never reaches the leaderboard. When it is ready, set it to Open and put its shortcode on a page. Right-click a crossword (or its ⋮) for everything else."></span>
84
+ </div>
85
+ <div class="gma-empty" hidden data-bind-hidden="rows.value.length">
86
+ ${ico('grid', 30)}
87
+ <p><strong>No crosswords yet.</strong><br>Type the answers and clues; the grid lays itself out.</p>
88
+ <a class="btn btn-primary" href="#/plugins/crossword/new">Make a crossword</a>
89
+ </div>
90
+ <div class="gma-list" data-part="list" data-each="rows.value key=id"></div>`;
91
+
92
+ const LIST_ROW = `
93
+ <div class="gma-row" tabindex="0" data-id="{{id}}">
94
+ <div class="gma-row-main">
95
+ <div><a data-bind-href="editHref" data-bind-text="title"></a><span class="gma-chip" data-bind-class="tone" data-bind-text="statusText"></span></div>
96
+ <small data-bind-text="meta"></small>
97
+ </div>
98
+ <div class="gma-row-actions">
99
+ <a class="btn btn-sm btn-ghost" data-bind-href="testHref" title="Solve it as your players will - not saved to the leaderboard">${ico('play')} Test</a>
100
+ <a class="btn btn-sm btn-ghost" data-bind-href="playsHref">${ico('award')} Plays</a>
101
+ <button type="button" class="gma-kebab" aria-label="Actions" title="Actions">${ico('more-vertical', 16)}</button>
102
+ </div>
103
+ </div>`;
104
+
105
+ async function list(root, container, $container, kit, alive) {
106
+ bannerTrail(container, [{text: 'Crossword'}]);
107
+ kit.addBannerAction($container, {id: 'crossword-new', icon: 'plus', title: 'New crossword', onClick: () => { location.hash = '#/plugins/crossword/new'; }});
108
+ let got;
109
+ try {
110
+ got = await loading(root, request('GET', `${API}/puzzles`), kit);
111
+ } catch (err) {
112
+ if (!alive()) return;
113
+ return failed(root, `Crossword could not load: ${errText(err)}`);
114
+ }
115
+
116
+ if (!alive()) return;
117
+ const puzzles = M.observable(got.puzzles || []);
118
+ const rows = M.observable([]);
119
+ lifetime.add(M.effect(() => {
120
+ rows.value = puzzles.value.map(p => ({
121
+ id: p.id, title: p.title, statusText: STATUS_LABEL[p.status] || p.status, tone: STATUS_TONE[p.status] || '',
122
+ editHref: href('edit', p.id), testHref: href('test', p.id), playsHref: href('plays', p.id),
123
+ meta: [`${p.words} answers, ${p.size}`, `${p.finished} solved of ${p.plays} play${p.plays === 1 ? '' : 's'}`,
124
+ p.finished ? `fastest ${fmtDuration(p.fastest)}` : '', shortcodeOf(p.slug)].filter(Boolean).join(' · ')
125
+ }));
126
+ }));
127
+
128
+ const byId = (id) => puzzles.peek().find(p => p.id === id);
129
+
130
+ async function duplicate(p) {
131
+ try {
132
+ const copy = await request('POST', `${API}/puzzles/${encodeURIComponent(p.id)}/copy`);
133
+ E.toast(`Copied as "${copy.title}" (a draft).`, {type: 'success'});
134
+ puzzles.value = (await request('GET', `${API}/puzzles`)).puzzles || [];
135
+ } catch (err) { E.toast(errText(err), {type: 'error'}); }
136
+ }
137
+
138
+ async function remove(p) {
139
+ if (!await E.confirm(`Delete the crossword <strong>${esc(p.title)}</strong>? Its ${p.plays} play${p.plays === 1 ? '' : 's'} and leaderboard go with it.`)) return;
140
+ try {
141
+ await request('DELETE', `${API}/puzzles/${encodeURIComponent(p.id)}`);
142
+ puzzles.value = puzzles.peek().filter(x => x.id !== p.id);
143
+ badge();
144
+ E.toast(`${p.title} deleted.`, {type: 'success'});
145
+ } catch (err) { E.toast(errText(err), {type: 'error'}); }
146
+ }
147
+
148
+ const el = mountScreen(root, {
149
+ html: LIST_HTML, model: {rows}, lifetime, k: kit, className: 'gma-crossword-list', rescan: [rows],
150
+ before: (screen) => { screen.querySelector('[data-part="list"]').innerHTML = LIST_ROW; }
151
+ });
152
+ wireRows(el.querySelector('[data-part="list"]'), {
153
+ k: kit, lifetime,
154
+ main: (id) => { location.hash = href('edit', id); },
155
+ items: (id) => {
156
+ const p = byId(id);
157
+ if (!p) return [];
158
+ return [
159
+ {label: 'Edit', icon: 'edit', action: () => { location.hash = href('edit', id); }},
160
+ {label: 'Test it', icon: 'play', action: () => { location.hash = href('test', id); }},
161
+ {label: 'Plays and leaderboard', icon: 'award', action: () => { location.hash = href('plays', id); }},
162
+ {label: 'Copy the shortcode', icon: 'copy', action: () => copyText(shortcodeOf(p.slug), 'Shortcode copied')},
163
+ {label: 'Make a copy', icon: 'layers', action: () => duplicate(p)},
164
+ {type: 'divider'},
165
+ {label: 'Delete…', icon: 'trash', action: () => remove(p)}
166
+ ];
167
+ }
168
+ });
169
+ }
170
+
171
+ // ---- The editor --------------------------------------------------------------------
172
+
173
+ const blankPuzzle = () => ({
174
+ title: '', slug: '', intro: '', status: 'draft', opensAt: '', closesAt: '', timeLimit: 0, checks: true, checkPenalty: 30, wrongPenalty: 10,
175
+ leaderboard: true, leaderboardSize: 10, attemptsPerName: 0, seed: 1,
176
+ words: [{answer: '', clue: ''}, {answer: '', clue: ''}, {answer: '', clue: ''}]
177
+ });
178
+ const wid = () => Math.random().toString(36).slice(2, 10);
179
+ const num = (v) => Number(v) || 0;
180
+
181
+ /** One answer and its clue, as the editor binds it. */
182
+ function wordModel(w = {}) {
183
+ const answer = M.observable(w.answer || '');
184
+ return {id: wid(), n: M.observable(''), answer, clue: M.observable(w.clue || ''),
185
+ enumeration: M.computed(() => { const c = cleanAnswer(answer.value); return c.letters ? `(${c.enumeration})` : ''; })};
186
+ }
187
+
188
+ const field = (id, label, control, {hint = '', help = ''} = {}) => `
189
+ <div class="form-group gma-field">
190
+ <label class="form-label" for="${id}">${esc(label)}${help ? ` <span data-help-title="${esc(label)}" data-help="${esc(help)}"></span>` : ''}</label>
191
+ ${control}
192
+ ${hint ? `<p class="form-hint">${esc(hint)}</p>` : ''}
193
+ </div>`;
194
+ const check = (bool, label, hint = '') => `
195
+ <div class="form-group gma-field">
196
+ <label class="gma-toggle"><input type="checkbox" data-bool="${bool}"> <span>${esc(label)}</span></label>
197
+ ${hint ? `<p class="form-hint">${esc(hint)}</p>` : ''}
198
+ </div>`;
199
+
200
+ const EDITOR_HTML = `
201
+ <section class="gma-card">
202
+ <h3 class="gma-h">The crossword</h3>
203
+ <div class="gma-grid">
204
+ ${field('cw-title', 'Title', '<input id="cw-title" class="form-input" maxlength="120" placeholder="Friday teaser" data-model="f.title.value">')}
205
+ ${field('cw-slug', 'Short name', '<input id="cw-slug" class="form-input" maxlength="60" placeholder="made from the title" data-model="f.slug.value">',
206
+ {help: 'Used in the shortcode. Letters, numbers and hyphens; made from the title when empty.'})}
207
+ ${field('cw-status', 'Status', `<select id="cw-status" class="form-select" data-model="f.status.value">${Object.entries(STATUS_LABEL).map(([v, t]) => `<option value="${v}">${t}</option>`).join('')}</select>`,
208
+ {help: 'Only an open crossword can be played on a page. A draft is not shown at all; a closed one shows its leaderboard. Test works whatever the status.'})}
209
+ </div>
210
+ ${field('cw-intro', 'Introduction', '<textarea id="cw-intro" class="form-input" rows="3" data-model="f.intro.value"></textarea>', {hint: 'Shown above the grid.'})}
211
+ <div class="gma-grid">
212
+ ${field('cw-opens', 'Opens', '<input id="cw-opens" class="form-input" type="datetime-local" data-model="f.opensAt.value">', {hint: 'Optional.'})}
213
+ ${field('cw-closes', 'Closes', '<input id="cw-closes" class="form-input" type="datetime-local" data-model="f.closesAt.value">', {hint: 'Optional.'})}
214
+ </div>
215
+ <div class="gma-shortcode"><span class="gma-muted">Shortcode:</span><code data-bind-text="shortcode.value"></code>
216
+ <button type="button" class="btn btn-xs btn-ghost" data-on-click="copyShortcode" data-bind-disabled="!f.slug.value">${ico('copy')} Copy</button>
217
+ <span data-help-title="Shortcode" data-help="Paste it into any page to put the crossword there. [crossword-leaderboard id=&quot;...&quot; period=&quot;week&quot; /] shows its fastest solvers on their own."></span></div>
218
+ </section>
219
+
220
+ <div class="gma-two">
221
+ <section class="gma-card">
222
+ <div class="gma-bar"><h3 class="gma-h">Answers and clues</h3><span class="gma-muted" data-bind-text="wordCount.value"></span>
223
+ <span data-help-title="Answers and clues" data-help="Spaces and hyphens in an answer show in the clue as word lengths, e.g. New York (3,4). Answers that share letters cross more. Enter in the last clue adds another answer."></span></div>
224
+ <div class="cwa-words" data-part="words" data-each="words key=id"></div>
225
+ <div class="gma-bar"><button type="button" class="btn btn-sm btn-outline" data-on-click="addWord" data-bind-disabled="words.length >= max">${ico('plus')} Add an answer</button></div>
226
+ </section>
227
+ <section class="gma-card cwa-side">
228
+ <div class="gma-bar"><h3 class="gma-h">The grid</h3><span class="gma-spacer"></span>
229
+ <button type="button" class="btn btn-sm btn-outline" title="Try a different arrangement of the same words" data-on-click="anotherLayout">${ico('refresh-cw')} Another layout</button></div>
230
+ <div class="cwa-preview">
231
+ <p class="gma-muted" hidden data-bind-hidden="cells.value.length">Type at least two answers with clues and the grid appears here.</p>
232
+ <div class="cwa-grid" hidden data-bind-hidden="!cells.value.length" data-bind-style-grid-template-columns="columns.value" data-part="grid" data-each="cells.value key=id"></div>
233
+ </div>
234
+ <p class="gma-note" data-bind-text="previewNote.value"></p>
235
+ <p class="cwa-unplaced" role="status" hidden data-bind-hidden="!unplaced.value" data-bind-text="unplaced.value"></p>
236
+ <p class="gma-note">Players see the shape and the numbers, never the letters.</p>
237
+ </section>
238
+ </div>
239
+
240
+ <section class="gma-card">
241
+ <h3 class="gma-h">Rules and leaderboard</h3>
242
+ <div class="gma-grid">
243
+ ${field('cw-minutes', 'Time limit (minutes)', '<input id="cw-minutes" class="form-input" type="number" min="0" step="0.5" data-model="f.minutes.value">', {hint: '0 = no limit. Out of time means off the leaderboard.'})}
244
+ ${field('cw-check', 'Seconds added per check', '<input id="cw-check" class="form-input" type="number" min="0" max="600" data-model="f.checkPenalty.value">')}
245
+ ${field('cw-wrong', 'Seconds added for a wrong full grid', '<input id="cw-wrong" class="form-input" type="number" min="0" max="600" data-model="f.wrongPenalty.value">', {hint: 'Stops people guessing their way to the top.'})}
246
+ ${field('cw-goes', 'Goes each (per name)', '<input id="cw-goes" class="form-input" type="number" min="0" max="100" data-model="f.attemptsPerName.value">', {hint: '0 = as many as they like. Test goes do not count.'})}
247
+ ${field('cw-size', 'Leaderboard size', '<input id="cw-size" class="form-input" type="number" min="3" max="100" data-model="f.leaderboardSize.value">')}
248
+ </div>
249
+ <div class="gma-grid">
250
+ ${check('checks', 'Let players check their letters', 'Shows which squares are wrong, at a cost in time.')}
251
+ ${check('leaderboard', 'Show a leaderboard', 'Fastest time wins, penalties included.')}
252
+ </div>
253
+ </section>
254
+
255
+ <div class="gma-save">
256
+ <button type="button" class="btn btn-primary" data-on-click="save" data-bind-disabled="saving.value">${ico('check')} Save</button>
257
+ <button type="button" class="btn btn-outline" data-on-click="test" data-bind-disabled="saving.value"
258
+ title="Solve it as your players will. A test go is not saved to the leaderboard.">${ico('play')} <span data-bind-text="testLabel.value"></span></button>
259
+ <a class="btn btn-ghost" href="#/plugins/crossword">Back to the list</a>
260
+ <span class="is-dirty" hidden data-bind-hidden="!dirty.value">Unsaved changes</span>
261
+ <span class="gma-spacer"></span>
262
+ <p class="gma-error" role="alert" hidden data-bind-hidden="!problem.value" data-bind-text="problem.value"></p>
263
+ </div>`;
264
+
265
+ const WORD = `
266
+ <div class="cwa-word">
267
+ <span class="cwa-n" data-bind-text="n.value"></span>
268
+ <input class="form-input cwa-answer" maxlength="40" placeholder="Answer" data-bind-aria-label="'Answer ' + n.value" data-model="answer.value">
269
+ <span class="cwa-enum" data-bind-text="enumeration.value"></span>
270
+ <input class="form-input cwa-clue" maxlength="300" placeholder="Clue" data-bind-aria-label="'Clue ' + n.value" data-model="clue.value"
271
+ data-on-keydown="$root.clueKey($data)">
272
+ <button type="button" class="btn btn-xs btn-ghost gma-quiet" title="Remove" aria-label="Remove this answer" data-on-click="$root.removeWord($data)">${ico('x')}</button>
273
+ </div>`;
274
+
275
+ const CELL = `<div class="cwa-cell" data-bind-class="block && 'is-block'"><span class="cwa-num" hidden data-bind-hidden="!num" data-bind-text="num"></span><span data-bind-text="ch"></span></div>`;
276
+
277
+ async function editor(root, container, $container, kit, id, alive) {
278
+ let start = blankPuzzle();
279
+ if (id) {
280
+ try {
281
+ const {layout, ...rest} = await loading(root, request('GET', `${API}/puzzles/${encodeURIComponent(id)}`), kit);
282
+ start = {...blankPuzzle(), ...rest};
283
+ } catch (err) {
284
+ if (!alive()) return;
285
+ return failed(root, `That crossword could not load: ${errText(err)}`, {href: SIDEBAR, text: 'Back to Crossword'});
286
+ }
287
+ }
288
+ if (!alive()) return;
289
+ if (!start.words?.length) start.words = [{answer: '', clue: ''}];
290
+ bannerTrail(container, [{text: 'Crossword', href: SIDEBAR}, {text: id ? start.title : 'New crossword'}]);
291
+ if (id) {
292
+ kit.addBannerAction($container, {id: 'crossword-test', icon: 'play', title: 'Test it', onClick: () => model.test()});
293
+ kit.addBannerAction($container, {id: 'crossword-plays', icon: 'award', title: 'Plays and leaderboard', onClick: () => { location.hash = href('plays', id); }});
294
+ }
295
+
296
+ const f = {
297
+ title: M.observable(start.title), slug: M.observable(start.slug), intro: M.observable(start.intro), status: M.observable(start.status),
298
+ opensAt: M.observable(toLocalInput(start.opensAt)), closesAt: M.observable(toLocalInput(start.closesAt)),
299
+ minutes: M.observable(start.timeLimit ? start.timeLimit / 60 : 0), checkPenalty: M.observable(start.checkPenalty),
300
+ wrongPenalty: M.observable(start.wrongPenalty), attemptsPerName: M.observable(start.attemptsPerName), leaderboardSize: M.observable(start.leaderboardSize)
301
+ };
302
+ const bools = {checks: M.observable(start.checks), leaderboard: M.observable(start.leaderboard)};
303
+ const seed = M.observable(start.seed || 1);
304
+ const words = M.observableArray(start.words.map(wordModel));
305
+ const saving = M.observable(false);
306
+ const problem = M.observable('');
307
+ const dirty = M.observable(false);
308
+ const wordCount = M.observable('');
309
+ const cells = M.observable([]);
310
+ const columns = M.observable('');
311
+ const previewNote = M.observable('');
312
+ const unplaced = M.observable('');
313
+ const tick = M.observable(0);
314
+ const shortcode = M.computed(() => (f.slug.value ? shortcodeOf(f.slug.value) : 'Saved crosswords get a shortcode'));
315
+ const testLabel = M.computed(() => (dirty.value || !id ? 'Save and test' : 'Test'));
316
+
317
+ const body = () => ({
318
+ title: f.title.peek(), slug: f.slug.peek(), intro: f.intro.peek(), status: f.status.peek(),
319
+ opensAt: fromLocalInput(f.opensAt.peek()), closesAt: fromLocalInput(f.closesAt.peek()),
320
+ timeLimit: Math.round(num(f.minutes.peek()) * 60), checkPenalty: num(f.checkPenalty.peek()), wrongPenalty: num(f.wrongPenalty.peek()),
321
+ attemptsPerName: num(f.attemptsPerName.peek()), leaderboardSize: num(f.leaderboardSize.peek()) || 10,
322
+ checks: bools.checks.peek(), leaderboard: bools.leaderboard.peek(), seed: seed.peek(),
323
+ words: words.peek().map(w => ({answer: w.answer.peek(), clue: w.clue.peek()}))
324
+ });
325
+ let saved = JSON.stringify(body());
326
+
327
+ /**
328
+ * What the server saved, back into the editor: it cleans what it is given (numbers clamped, blank
329
+ * answers dropped), so the editor must show what was stored. The words are rebuilt only when they
330
+ * changed, so a save does not throw away focus.
331
+ */
332
+ function load(p) {
333
+ f.title.value = p.title; f.slug.value = p.slug; f.intro.value = p.intro; f.status.value = p.status;
334
+ f.opensAt.value = toLocalInput(p.opensAt); f.closesAt.value = toLocalInput(p.closesAt);
335
+ f.minutes.value = p.timeLimit ? p.timeLimit / 60 : 0; f.checkPenalty.value = p.checkPenalty; f.wrongPenalty.value = p.wrongPenalty;
336
+ f.attemptsPerName.value = p.attemptsPerName; f.leaderboardSize.value = p.leaderboardSize; seed.value = p.seed || 1;
337
+ for (const k of Object.keys(bools)) bools[k].value = p[k] === true;
338
+ for (const box of el.querySelectorAll('[data-bool]')) box.checked = bools[box.dataset.bool].peek();
339
+ const incoming = p.words?.length ? p.words : [{answer: '', clue: ''}];
340
+ const now = words.peek().map(w => ({answer: w.answer.peek(), clue: w.clue.peek()}));
341
+ if (JSON.stringify(incoming.map(w => ({answer: w.answer, clue: w.clue}))) !== JSON.stringify(now)) words.set(incoming.map(wordModel));
342
+ drawPreview();
343
+ tick.value++;
344
+ }
345
+
346
+ /** The live grid, from the same layout function the server saves with. */
347
+ function drawPreview() {
348
+ const ready = words.peek().map(w => ({answer: w.answer.peek(), clue: w.clue.peek()}))
349
+ .filter(w => cleanAnswer(w.answer).letters.length >= 2 && w.clue.trim());
350
+ const layout = generate(ready, {seed: seed.peek()});
351
+ if (!layout.entries.length) {
352
+ cells.value = [];
353
+ previewNote.value = '';
354
+ } else {
355
+ const grid = solutionGrid(layout);
356
+ const numbers = new Map(layout.entries.map(e => [`${e.row},${e.col}`, e.number]));
357
+ cells.value = grid.flatMap((row, r) => row.map((ch, c) => ({id: `${r},${c}`, block: !ch, ch: ch || '', num: numbers.get(`${r},${c}`) || ''})));
358
+ columns.value = `repeat(${layout.width}, 1.7rem)`;
359
+ previewNote.value = `${layout.width} × ${layout.height}, ${layout.entries.length} answers. Layout ${seed.peek()}.`;
360
+ }
361
+ unplaced.value = layout.unplaced.length
362
+ ? `Did not fit: ${layout.unplaced.map(u => u.answer).join(', ')}. Try another layout, or change those answers so they share letters with the others.`
363
+ : '';
364
+ }
365
+ const redraw = typeof _ !== 'undefined' && _.debounce ? _.debounce(drawPreview, 250) : drawPreview;
366
+
367
+ function touched() {
368
+ const list = words.peek();
369
+ list.forEach((w, i) => { w.n.value = `${i + 1}`; });
370
+ wordCount.value = `${list.filter(w => w.answer.peek() || w.clue.peek()).length} of ${MAX_WORDS}`;
371
+ dirty.value = JSON.stringify(body()) !== saved;
372
+ }
373
+ const changed = () => { touched(); drawPreview(); tick.value++; };
374
+
375
+ leaveGuard = (e) => { if (dirty.peek() && ROUTE.test(location.hash)) { e.preventDefault(); e.returnValue = ''; } };
376
+ window.addEventListener('beforeunload', leaveGuard);
377
+ lifetime.add(() => { window.removeEventListener('beforeunload', leaveGuard); leaveGuard = null; });
378
+
379
+ /** Saves; answers the crossword's id, or '' when it did not save. */
380
+ async function save() {
381
+ if (saving.peek()) return '';
382
+ problem.value = '';
383
+ saving.value = true;
384
+ try {
385
+ const out = id ? await request('PUT', `${API}/puzzles/${encodeURIComponent(id)}`, body()) : await request('POST', `${API}/puzzles`, body());
386
+ load({...blankPuzzle(), ...out});
387
+ saved = JSON.stringify(body());
388
+ touched();
389
+ const missed = out.unplaced?.length || 0;
390
+ E.toast(missed ? `${out.title} saved. ${missed} answer${missed === 1 ? '' : 's'} did not fit the grid.` : `${out.title} saved.`,
391
+ {type: missed ? 'warning' : 'success'});
392
+ if (id) bannerTrail(container, [{text: 'Crossword', href: SIDEBAR}, {text: out.title}]);
393
+ return out.id;
394
+ } catch (err) {
395
+ problem.value = errText(err);
396
+ return '';
397
+ } finally {
398
+ saving.value = false;
399
+ }
400
+ }
401
+
402
+ const model = {
403
+ f, words, max: MAX_WORDS, saving, problem, dirty, wordCount, cells, columns, previewNote, unplaced, shortcode, testLabel,
404
+ async save() {
405
+ const savedId = await save();
406
+ if (savedId && !id) location.hash = href('edit', savedId);
407
+ },
408
+ async test() {
409
+ const savedId = dirty.peek() || !id ? await save() : id;
410
+ if (savedId) location.hash = href('test', savedId);
411
+ },
412
+ copyShortcode() { if (f.slug.peek()) copyText(shortcodeOf(f.slug.peek()), 'Shortcode copied'); },
413
+ anotherLayout() { seed.value = (seed.peek() % 2147483646) + 1; changed(); },
414
+ addWord() {
415
+ if (words.peek().length >= MAX_WORDS) return;
416
+ words.push(wordModel());
417
+ changed();
418
+ setTimeout(() => el.querySelector('.cwa-word:last-of-type .cwa-answer')?.focus(), 30);
419
+ },
420
+ removeWord(w) { words.remove(w); changed(); },
421
+ clueKey(w, e) {
422
+ if (e.key !== 'Enter' || words.peek().at(-1) !== w) return;
423
+ e.preventDefault();
424
+ model.addWord();
425
+ }
426
+ };
427
+
428
+ const el = mountScreen(root, {
429
+ html: EDITOR_HTML, model, lifetime, k: kit, className: 'gma-crossword-editor', rescan: [words, tick],
430
+ before: (screen) => {
431
+ screen.querySelector('[data-part="words"]').innerHTML = WORD;
432
+ screen.querySelector('[data-part="grid"]').innerHTML = CELL;
433
+ for (const box of screen.querySelectorAll('[data-bool]')) {
434
+ box.checked = Boolean(bools[box.dataset.bool].peek());
435
+ box.addEventListener('change', () => { bools[box.dataset.bool].value = box.checked; touched(); });
436
+ }
437
+ }
438
+ });
439
+ // Typing anywhere: the bindings write first (their listeners were added first), then this sees it.
440
+ const onEdit = (e) => { touched(); if (e.target.closest?.('.cwa-word')) redraw(); };
441
+ el.addEventListener('input', onEdit);
442
+ el.addEventListener('change', onEdit);
443
+ lifetime.add(() => { el.removeEventListener('input', onEdit); el.removeEventListener('change', onEdit); });
444
+ saveKey(() => model.save(), () => ROUTE.test(location.hash) && el.isConnected, lifetime);
445
+
446
+ touched();
447
+ drawPreview();
448
+ if (!id) el.querySelector('#cw-title')?.focus();
449
+ }
@@ -0,0 +1,20 @@
1
+ {
2
+ "slug": "crossword-plays",
3
+ "title": "Crossword - Plays",
4
+ "description": "Every go at a crossword: the name and the time. Look at them in Crossword, not here.",
5
+ "plugin": "crossword",
6
+ "fields": [
7
+ {"name": "gameId", "label": "Crossword", "type": "text"},
8
+ {"name": "name", "label": "Name", "type": "text"},
9
+ {"name": "status", "label": "Status", "type": "text"},
10
+ {"name": "score", "label": "Score", "type": "number"},
11
+ {"name": "timeMs", "label": "Time (ms)", "type": "number"},
12
+ {"name": "hidden", "label": "Hidden from the leaderboard", "type": "boolean"}
13
+ ],
14
+ "api": {
15
+ "create": {"enabled": false, "access": "admin"},
16
+ "read": {"enabled": false, "access": "admin"},
17
+ "update": {"enabled": false, "access": "admin"},
18
+ "delete": {"enabled": false, "access": "admin"}
19
+ }
20
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "slug": "crossword-puzzles",
3
+ "title": "Crossword - Puzzles",
4
+ "description": "The crosswords, answers included. Edit them in Crossword, not here.",
5
+ "plugin": "crossword",
6
+ "fields": [
7
+ {"name": "title", "label": "Title", "type": "text"},
8
+ {"name": "slug", "label": "Short name", "type": "text"},
9
+ {"name": "status", "label": "Status", "type": "text"}
10
+ ],
11
+ "api": {
12
+ "create": {"enabled": false, "access": "admin"},
13
+ "read": {"enabled": false, "access": "admin"},
14
+ "update": {"enabled": false, "access": "admin"},
15
+ "delete": {"enabled": false, "access": "admin"}
16
+ }
17
+ }
@@ -0,0 +1,6 @@
1
+ /**
2
+ * Crossword - default configuration. Every crossword carries its own settings
3
+ * (time limit, checking, penalties, the leaderboard...), edited with the
4
+ * crossword; nothing here needs changing.
5
+ */
6
+ export default {};
@@ -0,0 +1,53 @@
1
+ ---
2
+ title: Guide
3
+ order: 1
4
+ ---
5
+
6
+ Put crosswords on your website. You type the answers and clues, the grid is laid out for you, and visitors race the clock to a leaderboard. Crossword is free.
7
+
8
+ ## What it does
9
+
10
+ - **You write words and clues; it builds the grid.** The preview updates as you type. Not happy with the shape? **Another layout** tries a different arrangement of the same words. Any word that will not cross the others is listed so you can change it.
11
+ - **Phrases are fine.** "New York" shows as (3,4) and "well-being" as (4-5) in the clue.
12
+ - **Timing kept by the server**, from the moment the crossword is started to the moment it is solved.
13
+ - **Check** marks the wrong squares, at a time penalty you choose (30 seconds unless you change it). You can switch checking off.
14
+ - **Submitting a wrong grid** costs a smaller penalty (10 seconds unless you change it), so guessing is not free. Empty squares are pointed out and cost nothing.
15
+ - **Leaderboards** of the fastest solvers for today, this week, this month and all time, best go per name.
16
+ - The answers never reach the visitor's browser until they have solved it.
17
+ - Optional overall time limit, opening and closing times, and a limit on goes per name.
18
+
19
+ ## Getting started
20
+
21
+ 1. Switch Crossword on in **Tools**.
22
+ 2. Open **Tools > Games > Crossword** and choose **New crossword**.
23
+ 3. Add at least two answers with their clues. Ten to twenty words make a good grid.
24
+ 4. Press **Test** and solve it yourself (see below).
25
+ 5. Set the status to **Open** and save.
26
+ 6. Put it on any page:
27
+
28
+ ```
29
+ [crossword id="sea-life" /]
30
+ ```
31
+
32
+ ## Testing a crossword
33
+
34
+ **Test** (in the editor, on each crossword in the list, and on its Plays screen) opens the crossword right there in the admin, exactly as your players will see it: the same grid, clock, checks and penalties.
35
+
36
+ - It plays the version you last saved. With unsaved changes the button says **Save and test**, and saves first.
37
+ - It works whatever the status, so you can try a draft before anyone can see it.
38
+ - A test go is never saved to the leaderboard. It is not in the Plays list, it does not count towards the goes each name is allowed, and it does not raise a notification. When you solve it, it tells you where you would have come.
39
+ - Each new test clears away the one before.
40
+
41
+ ## A leaderboard on its own
42
+
43
+ ```
44
+ [crossword-leaderboard id="sea-life" period="all" limit="10" title="Fastest solvers" /]
45
+ ```
46
+
47
+ ## Playing
48
+
49
+ Click a square, or a clue, and type. Typing moves along the word. Arrow keys move around, clicking the same square again or pressing Space turns between across and down, Tab jumps to the next clue, and Backspace clears. Letters are remembered if the page is reloaded.
50
+
51
+ ## Looking after the board
52
+
53
+ Open a crossword's **Plays** to see every go (your own test goes are not shown). You can hide a play from the leaderboard, delete it, or reset the leaderboard.
@@ -0,0 +1 @@
1
+ <script type="module" src="/plugins/crossword/public/crossword.js?v=1.1.0"></script>