domma-cms 1.8.32 → 1.8.34

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 (110) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/docs/DommaFeatures.md +13 -1
  3. package/package.json +1 -1
  4. package/plugins/_lib/admin/games/games.css +1 -1
  5. package/plugins/_lib/admin/games/kit.js +329 -124
  6. package/plugins/_lib/games/http.js +11 -1
  7. package/plugins/_lib/games/plays.js +28 -7
  8. package/plugins/crossword/CLAUDE.md +37 -0
  9. package/plugins/crossword/admin/css/index.css +18 -1
  10. package/plugins/crossword/admin/views/crossword.js +337 -205
  11. package/plugins/crossword/docs/guide.md +53 -0
  12. package/plugins/crossword/inject/body.html +1 -1
  13. package/plugins/crossword/plugin.js +38 -5
  14. package/plugins/crossword/plugin.json +4 -3
  15. package/plugins/crossword/public/crossword.css +36 -1
  16. package/plugins/crossword/public/crossword.js +468 -6
  17. package/plugins/docs/CLAUDE.md +318 -0
  18. package/plugins/docs/admin/css/docs.css +783 -0
  19. package/plugins/docs/admin/lib/api.js +340 -0
  20. package/plugins/docs/admin/lib/convert.js +482 -0
  21. package/plugins/docs/admin/lib/drafts.js +115 -0
  22. package/plugins/docs/admin/lib/editor.js +851 -0
  23. package/plugins/docs/admin/lib/find-replace.js +426 -0
  24. package/plugins/docs/admin/lib/folders.js +203 -0
  25. package/plugins/docs/admin/lib/panel-guard.js +73 -0
  26. package/plugins/docs/admin/lib/pdf-view.js +126 -0
  27. package/plugins/docs/admin/lib/send.js +156 -0
  28. package/plugins/docs/admin/lib/settings.js +142 -0
  29. package/plugins/docs/admin/lib/share.js +323 -0
  30. package/plugins/docs/admin/lib/shortcuts.js +44 -0
  31. package/plugins/docs/admin/lib/templates.js +194 -0
  32. package/plugins/docs/admin/lib/versions.js +192 -0
  33. package/plugins/docs/admin/templates/docs.html +92 -0
  34. package/plugins/docs/admin/templates/editor.html +86 -0
  35. package/plugins/docs/admin/views/docs.js +777 -0
  36. package/plugins/docs/admin/views/editor.js +978 -0
  37. package/plugins/docs/config.js +24 -0
  38. package/plugins/docs/docs/guide.md +270 -0
  39. package/plugins/docs/lib/extensions.js +213 -0
  40. package/plugins/docs/lib/importer.js +72 -0
  41. package/plugins/docs/lib/pdf-worker.js +48 -0
  42. package/plugins/docs/lib/pdf.js +222 -0
  43. package/plugins/docs/lib/store.js +748 -0
  44. package/plugins/docs/plugin.js +1108 -0
  45. package/plugins/docs/plugin.json +89 -0
  46. package/plugins/docs/plugin.public.js +114 -0
  47. package/plugins/docs/templates/public-doc.html +36 -0
  48. package/plugins/docs/tests/_env.js +10 -0
  49. package/plugins/docs/tests/_pdf.js +73 -0
  50. package/plugins/docs/tests/convert.test.js +95 -0
  51. package/plugins/docs/tests/drafts.test.js +82 -0
  52. package/plugins/docs/tests/pdf.test.js +86 -0
  53. package/plugins/docs/tests/plugin.test.js +816 -0
  54. package/plugins/docs/tests/shortcuts.test.js +30 -0
  55. package/plugins/free-tier.lock.json +147 -1
  56. package/plugins/garage/CLAUDE.md +51 -0
  57. package/plugins/garage/admin/css/garage.css +73 -0
  58. package/plugins/garage/admin/lib/garage.js +236 -0
  59. package/plugins/garage/admin/templates/garage.html +86 -0
  60. package/plugins/garage/admin/views/garage.js +581 -0
  61. package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
  62. package/plugins/garage/config.js +18 -0
  63. package/plugins/garage/docs/guide.md +118 -0
  64. package/plugins/garage/plugin.js +471 -0
  65. package/plugins/garage/plugin.json +68 -0
  66. package/plugins/garage/tests/access.test.js +134 -0
  67. package/plugins/garage/tests/garage.test.js +106 -0
  68. package/plugins/messages/CLAUDE.md +230 -0
  69. package/plugins/messages/admin/css/messages.css +117 -0
  70. package/plugins/messages/admin/lib/messages.js +452 -0
  71. package/plugins/messages/admin/templates/messages.html +76 -0
  72. package/plugins/messages/admin/views/messages.js +1219 -0
  73. package/plugins/messages/collections/user-message-threads/schema.json +22 -0
  74. package/plugins/messages/collections/user-messages/schema.json +23 -0
  75. package/plugins/messages/config.js +14 -0
  76. package/plugins/messages/docs/guide.md +176 -0
  77. package/plugins/messages/lib/call.js +138 -0
  78. package/plugins/messages/plugin.js +994 -0
  79. package/plugins/messages/plugin.json +59 -0
  80. package/plugins/messages/plugin.public.js +52 -0
  81. package/plugins/messages/templates/call.html +6 -0
  82. package/plugins/messages/tests/after-send.test.js +22 -0
  83. package/plugins/messages/tests/api.test.js +187 -0
  84. package/plugins/messages/tests/call.test.js +497 -0
  85. package/plugins/messages/tests/drafts.test.js +25 -0
  86. package/plugins/messages/tests/messages.test.js +167 -0
  87. package/plugins/quiz/CLAUDE.md +43 -0
  88. package/plugins/quiz/admin/css/index.css +13 -1
  89. package/plugins/quiz/admin/views/quiz.js +423 -245
  90. package/plugins/quiz/docs/guide.md +61 -0
  91. package/plugins/quiz/inject/body.html +1 -1
  92. package/plugins/quiz/plugin.js +39 -5
  93. package/plugins/quiz/plugin.json +4 -3
  94. package/plugins/quiz/public/quiz.css +36 -1
  95. package/plugins/quiz/public/quiz.js +298 -6
  96. package/plugins/theme-roller/CLAUDE.md +65 -0
  97. package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
  98. package/plugins/theme-roller/admin/lib/prompt.js +22 -0
  99. package/plugins/theme-roller/admin/lib/themes.js +196 -0
  100. package/plugins/theme-roller/admin/lib/view.js +88 -0
  101. package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
  102. package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
  103. package/plugins/theme-roller/config.js +1 -0
  104. package/plugins/theme-roller/docs/guide.md +104 -0
  105. package/plugins/theme-roller/plugin.js +263 -0
  106. package/plugins/theme-roller/plugin.json +42 -0
  107. package/plugins/theme-roller/tests/api.test.js +161 -0
  108. package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
  109. package/plugins/theme-roller/tests/themes.test.js +77 -0
  110. package/plugins/theme-roller/tests/view.test.js +55 -0
@@ -1,27 +1,32 @@
1
1
  /**
2
- * Crossword - the Tool. One view, three screens by address:
2
+ * Crossword - the Tool. One view, four screens by address:
3
3
  *
4
4
  * #/plugins/crossword every crossword
5
5
  * #/plugins/crossword/new a new crossword
6
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
7
8
  * #/plugins/crossword/plays/<id> who solved it, and its leaderboard
8
9
  *
9
10
  * The preview runs the same layout function the server saves with
10
11
  * (public/lib/layout.js), from the same words and seed, so what you see is
11
- * what is saved.
12
+ * what is saved. Every screen is domma-reactive (observables + one
13
+ * M.applyBindings, through the admin kit's mountScreen).
12
14
  */
13
15
 
14
- import {bannerTrail, copyText, errText, esc, field, fmtDuration, fromLocalInput, h, icon, loadKit, playsScreen, refreshBadge, request, toggle, toLocalInput} from '/plugins/_lib/admin/games/kit.js';
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';
15
17
  import {MAX_WORDS, cleanAnswer, generate, solutionGrid} from '/plugins/crossword/public/lib/layout.js';
16
18
 
17
19
  const API = '/plugins/crossword';
18
20
  const SIDEBAR = '#/plugins/crossword';
19
21
  const ROUTE = /^#\/plugins\/crossword(\/|\?|$)/;
22
+ const PLAYER = '/plugins/crossword/public/crossword.js?v=1.1.0';
20
23
  const STATUS_LABEL = {draft: 'Draft', open: 'Open', closed: 'Closed'};
21
24
  const STATUS_TONE = {draft: '', open: 'is-success', closed: 'is-warning'};
22
25
 
23
26
  let lifetime = null;
24
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;
25
30
 
26
31
  function where() {
27
32
  const m = location.hash.split('?')[0].match(/^#\/plugins\/crossword(?:\/([a-z]+))?(?:\/([^/?#]+))?/);
@@ -30,6 +35,7 @@ function where() {
30
35
 
31
36
  const badge = () => refreshBadge(SIDEBAR);
32
37
  const shortcodeOf = (slug) => `[crossword id="${slug}" /]`;
38
+ const href = (screen, id) => `#/plugins/crossword/${screen}/${encodeURIComponent(id)}`;
33
39
 
34
40
  export const crosswordView = {
35
41
  templateUrl: '/plugins/crossword/admin/templates/crossword.html',
@@ -39,74 +45,93 @@ export const crosswordView = {
39
45
  lifetime ??= kit.viewLifetime(ROUTE);
40
46
  if (!ROUTE.test(location.hash)) return;
41
47
  lifetime.reset();
48
+ const n = ++mountSeq;
49
+ const alive = () => n === mountSeq && ROUTE.test(location.hash);
42
50
  if (leaveGuard) { window.removeEventListener('beforeunload', leaveGuard); leaveGuard = null; }
43
51
  const container = $container.get(0);
44
52
  const root = container.querySelector('[data-crossword-admin]') || container;
45
53
  const {screen, id} = where();
46
54
 
47
- if (screen === 'plays' && id) {
48
- let title = 'Plays';
49
- try { title = (await request('GET', `${API}/puzzles/${encodeURIComponent(id)}`)).title; } catch { /* the screen says so */ }
50
- bannerTrail(container, [{text: 'Crossword', href: SIDEBAR}, {text: `${title}: plays`}]);
51
- kit.addBannerAction($container, {id: 'crossword-edit', icon: 'edit', title: 'Edit this crossword', onClick: () => { location.hash = `#/plugins/crossword/edit/${encodeURIComponent(id)}`; }});
52
- return playsScreen(root, {api: API, gameId: id, title, order: 'time', back: SIDEBAR, base: '/puzzles', badge});
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});
53
70
  }
54
- if (screen === 'new' || (screen === 'edit' && id)) return editor(root, container, $container, kit, screen === 'new' ? '' : id);
55
- return list(root, container, $container, kit);
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);
56
73
  }
57
74
  };
58
75
 
59
76
  // ---- The list ----------------------------------------------------------------------
60
77
 
61
- async function list(root, container, $container, kit) {
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) {
62
106
  bannerTrail(container, [{text: 'Crossword'}]);
63
107
  kit.addBannerAction($container, {id: 'crossword-new', icon: 'plus', title: 'New crossword', onClick: () => { location.hash = '#/plugins/crossword/new'; }});
64
- root.innerHTML = '';
65
- const slot = h('div', {className: 'gma-note dm-loading-slot', text: 'Loading…'});
66
- root.append(slot);
67
- let puzzles;
108
+ let got;
68
109
  try {
69
- puzzles = (await kit.whileLoading(slot, request('GET', `${API}/puzzles`), kit.SHAPES.rows)).puzzles || [];
110
+ got = await loading(root, request('GET', `${API}/puzzles`), kit);
70
111
  } catch (err) {
71
- root.innerHTML = '';
72
- root.append(h('p', {className: 'gma-error', text: `Crossword could not load: ${errText(err)}`}));
73
- return;
112
+ if (!alive()) return;
113
+ return failed(root, `Crossword could not load: ${errText(err)}`);
74
114
  }
75
115
 
76
- const draw = () => {
77
- root.innerHTML = '';
78
- root.append(h('div', {className: 'gma-bar'},
79
- h('a', {className: 'btn btn-sm btn-primary', href: '#/plugins/crossword/new'}, icon('plus'), ' New crossword'),
80
- h('span', {className: 'gma-spacer'}),
81
- h('span', {className: 'gma-note', text: 'Put a crossword on any page with its shortcode.'})));
82
- if (!puzzles.length) {
83
- root.append(h('div', {className: 'gma-empty'}, icon('grid', 30),
84
- h('p', {}, h('strong', {text: 'No crosswords yet.'}), h('br'), 'Type the answers and clues; the grid lays itself out.'),
85
- h('a', {className: 'btn btn-primary', href: '#/plugins/crossword/new'}, 'Make a crossword')));
86
- I.scan(root);
87
- return;
88
- }
89
- root.append(h('div', {className: 'gma-list'}, puzzles.map(p => h('div', {className: 'gma-row'},
90
- h('div', {className: 'gma-row-main'},
91
- h('div', {}, h('a', {href: `#/plugins/crossword/edit/${encodeURIComponent(p.id)}`, text: p.title}),
92
- h('span', {className: `gma-chip ${STATUS_TONE[p.status] || ''}`, text: STATUS_LABEL[p.status] || p.status})),
93
- h('small', {text: [`${p.words} answers, ${p.size}`, `${p.finished} solved of ${p.plays} play${p.plays === 1 ? '' : 's'}`,
94
- p.finished ? `fastest ${fmtDuration(p.fastest)}` : '', shortcodeOf(p.slug)].filter(Boolean).join(' · ')})),
95
- h('div', {className: 'gma-row-actions'},
96
- h('a', {className: 'btn btn-sm btn-ghost', href: `#/plugins/crossword/edit/${encodeURIComponent(p.id)}`}, icon('edit'), ' Edit'),
97
- h('a', {className: 'btn btn-sm btn-ghost', href: `#/plugins/crossword/plays/${encodeURIComponent(p.id)}`}, icon('award'), ' Plays'),
98
- h('button', {type: 'button', className: 'btn btn-sm btn-ghost', title: 'Copy the shortcode', onClick: () => copyText(shortcodeOf(p.slug), 'Shortcode copied')}, icon('copy')),
99
- h('button', {type: 'button', className: 'btn btn-sm btn-ghost', title: 'Make a copy', onClick: () => duplicate(p)}, icon('layers')),
100
- h('button', {type: 'button', className: 'btn btn-sm btn-ghost gma-danger-text', title: 'Delete', onClick: () => remove(p)}, icon('trash')))))));
101
- I.scan(root);
102
- };
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);
103
129
 
104
130
  async function duplicate(p) {
105
131
  try {
106
132
  const copy = await request('POST', `${API}/puzzles/${encodeURIComponent(p.id)}/copy`);
107
133
  E.toast(`Copied as "${copy.title}" (a draft).`, {type: 'success'});
108
- puzzles = (await request('GET', `${API}/puzzles`)).puzzles || [];
109
- draw();
134
+ puzzles.value = (await request('GET', `${API}/puzzles`)).puzzles || [];
110
135
  } catch (err) { E.toast(errText(err), {type: 'error'}); }
111
136
  }
112
137
 
@@ -114,14 +139,33 @@ async function list(root, container, $container, kit) {
114
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;
115
140
  try {
116
141
  await request('DELETE', `${API}/puzzles/${encodeURIComponent(p.id)}`);
117
- puzzles = puzzles.filter(x => x.id !== p.id);
118
- draw();
142
+ puzzles.value = puzzles.peek().filter(x => x.id !== p.id);
119
143
  badge();
120
144
  E.toast(`${p.title} deleted.`, {type: 'success'});
121
145
  } catch (err) { E.toast(errText(err), {type: 'error'}); }
122
146
  }
123
147
 
124
- draw();
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
+ });
125
169
  }
126
170
 
127
171
  // ---- The editor --------------------------------------------------------------------
@@ -131,187 +175,275 @@ const blankPuzzle = () => ({
131
175
  leaderboard: true, leaderboardSize: 10, attemptsPerName: 0, seed: 1,
132
176
  words: [{answer: '', clue: ''}, {answer: '', clue: ''}, {answer: '', clue: ''}]
133
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
+ }
134
187
 
135
- async function editor(root, container, $container, kit, id) {
136
- root.innerHTML = '';
137
- let puzzle = blankPuzzle();
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();
138
279
  if (id) {
139
- const slot = h('div', {className: 'gma-note dm-loading-slot', text: 'Loading…'});
140
- root.append(slot);
141
280
  try {
142
- const got = await kit.whileLoading(slot, request('GET', `${API}/puzzles/${encodeURIComponent(id)}`), kit.SHAPES.rows);
143
- const {layout, ...rest} = got;
144
- puzzle = {...blankPuzzle(), ...rest};
281
+ const {layout, ...rest} = await loading(root, request('GET', `${API}/puzzles/${encodeURIComponent(id)}`), kit);
282
+ start = {...blankPuzzle(), ...rest};
145
283
  } catch (err) {
146
- root.innerHTML = '';
147
- root.append(h('p', {className: 'gma-error', text: `That crossword could not load: ${errText(err)}`}), h('a', {href: SIDEBAR, text: 'Back to Crossword'}));
148
- return;
284
+ if (!alive()) return;
285
+ return failed(root, `That crossword could not load: ${errText(err)}`, {href: SIDEBAR, text: 'Back to Crossword'});
149
286
  }
150
287
  }
151
- bannerTrail(container, [{text: 'Crossword', href: SIDEBAR}, {text: id ? puzzle.title : 'New crossword'}]);
152
- if (id) kit.addBannerAction($container, {id: 'crossword-plays', icon: 'award', title: 'Plays and leaderboard', onClick: () => { location.hash = `#/plugins/crossword/plays/${encodeURIComponent(id)}`; }});
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
+ }
153
295
 
154
- const fields = (p) => {
155
- const {id: _i, createdAt, updatedAt, unplaced, ...rest} = p;
156
- return rest;
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)
157
301
  };
158
- const snapshot = () => JSON.stringify(fields(puzzle));
159
- let saved = snapshot();
160
- const dirty = () => snapshot() !== saved;
161
- leaveGuard = (e) => { if (dirty() && ROUTE.test(location.hash)) { e.preventDefault(); e.returnValue = ''; } };
162
- window.addEventListener('beforeunload', leaveGuard);
163
- lifetime.add(() => { window.removeEventListener('beforeunload', leaveGuard); leaveGuard = null; });
164
-
165
- const dirtyNote = h('span', {className: 'is-dirty', hidden: true, text: 'Unsaved changes'});
166
- const errorBox = h('p', {className: 'gma-error', hidden: true, role: 'alert'});
167
- const touched = () => { dirtyNote.hidden = !dirty(); };
168
-
169
- const input = (key, attrs = {}) => h('input', {className: 'form-input', value: puzzle[key] ?? '', ...attrs,
170
- onInput: (e) => { puzzle[key] = attrs.type === 'number' ? Number(e.target.value) || 0 : e.target.value; touched(); }});
171
- const status = h('select', {className: 'form-select', onChange: (e) => { puzzle.status = e.target.value; touched(); }},
172
- Object.entries(STATUS_LABEL).map(([v, label]) => h('option', {value: v, text: label})));
173
- status.value = puzzle.status;
174
-
175
- const shortcode = h('code');
176
- const showShortcode = () => { shortcode.textContent = puzzle.slug ? shortcodeOf(puzzle.slug) : 'Saved crosswords get a shortcode'; };
177
- showShortcode();
178
-
179
- const minutes = h('input', {className: 'form-input', type: 'number', min: 0, step: 1, value: puzzle.timeLimit ? Math.round(puzzle.timeLimit / 60) : 0,
180
- onInput: (e) => { puzzle.timeLimit = Math.round((Number(e.target.value) || 0) * 60); touched(); }});
181
- const intro = h('textarea', {className: 'form-input', rows: 3, onInput: (e) => { puzzle.intro = e.target.value; touched(); }}, puzzle.intro);
182
-
183
- const details = h('section', {className: 'gma-card'},
184
- h('h3', {className: 'gma-h', text: 'The crossword'}),
185
- h('div', {className: 'gma-grid'},
186
- field('Title', input('title', {maxlength: 120, placeholder: 'Friday teaser'})),
187
- field('Short name', input('slug', {maxlength: 60, placeholder: 'made from the title'}), 'Used in the shortcode.'),
188
- field('Status', status, 'Only an open crossword can be played.')),
189
- field('Introduction', intro, 'Shown above the grid.'),
190
- h('div', {className: 'gma-grid'},
191
- field('Opens', h('input', {className: 'form-input', type: 'datetime-local', value: toLocalInput(puzzle.opensAt), onInput: (e) => { puzzle.opensAt = fromLocalInput(e.target.value); touched(); }}), 'Optional.'),
192
- field('Closes', h('input', {className: 'form-input', type: 'datetime-local', value: toLocalInput(puzzle.closesAt), onInput: (e) => { puzzle.closesAt = fromLocalInput(e.target.value); touched(); }}), 'Optional.')),
193
- h('div', {className: 'gma-shortcode'}, h('span', {className: 'gma-muted', text: 'Shortcode:'}), shortcode,
194
- h('button', {type: 'button', className: 'btn btn-xs btn-ghost', onClick: () => puzzle.slug && copyText(shortcodeOf(puzzle.slug), 'Shortcode copied')}, icon('copy'), ' Copy')));
195
-
196
- const rules = h('section', {className: 'gma-card'},
197
- h('h3', {className: 'gma-h', text: 'Rules and leaderboard'}),
198
- h('div', {className: 'gma-grid'},
199
- field('Time limit (minutes)', minutes, '0 = no limit. Out of time means off the leaderboard.'),
200
- field('Seconds added per check', input('checkPenalty', {type: 'number', min: 0, max: 600})),
201
- field('Seconds added for a wrong full grid', input('wrongPenalty', {type: 'number', min: 0, max: 600}), 'Stops people guessing their way to the top.'),
202
- field('Goes each (per name)', input('attemptsPerName', {type: 'number', min: 0, max: 100}), '0 = as many as they like.'),
203
- field('Leaderboard size', input('leaderboardSize', {type: 'number', min: 3, max: 100}))),
204
- h('div', {className: 'gma-grid'},
205
- toggle('Let players check their letters', puzzle.checks, (v) => { puzzle.checks = v; touched(); }, 'Shows which squares are wrong, at a cost in time.'),
206
- toggle('Show a leaderboard', puzzle.leaderboard, (v) => { puzzle.leaderboard = v; touched(); }, 'Fastest time wins, penalties included.')));
207
-
208
- // Words and the live grid.
209
- const wordList = h('div', {className: 'cwa-words'});
210
- const wordCount = h('span', {className: 'gma-muted'});
211
- const preview = h('div', {className: 'cwa-preview'});
212
- const previewNote = h('p', {className: 'gma-note'});
213
- const unplacedNote = h('p', {className: 'cwa-unplaced', hidden: true, role: 'status'});
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
+ }
214
345
 
346
+ /** The live grid, from the same layout function the server saves with. */
215
347
  function drawPreview() {
216
- const ready = puzzle.words.filter(w => cleanAnswer(w.answer).letters.length >= 2 && w.clue.trim());
217
- const layout = generate(ready, {seed: puzzle.seed});
218
- preview.innerHTML = '';
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()});
219
351
  if (!layout.entries.length) {
220
- preview.append(h('p', {className: 'gma-muted', text: 'Type at least two answers with clues and the grid appears here.'}));
221
- previewNote.textContent = '';
352
+ cells.value = [];
353
+ previewNote.value = '';
222
354
  } else {
223
355
  const grid = solutionGrid(layout);
224
356
  const numbers = new Map(layout.entries.map(e => [`${e.row},${e.col}`, e.number]));
225
- const table = h('div', {className: 'cwa-grid', style: `grid-template-columns: repeat(${layout.width}, 1.7rem)`});
226
- grid.forEach((row, r) => row.forEach((ch, c) => table.append(h('div', {className: ch ? 'cwa-cell' : 'cwa-cell is-block'},
227
- numbers.has(`${r},${c}`) ? h('span', {className: 'cwa-num', text: numbers.get(`${r},${c}`)}) : null, ch || ''))));
228
- preview.append(table);
229
- previewNote.textContent = `${layout.width} × ${layout.height}, ${layout.entries.length} answers. Layout ${puzzle.seed}.`;
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()}.`;
230
360
  }
231
- unplacedNote.hidden = !layout.unplaced.length;
232
- unplacedNote.textContent = layout.unplaced.length
361
+ unplaced.value = layout.unplaced.length
233
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.`
234
363
  : '';
235
- const shown = puzzle.words.filter(w => w.answer || w.clue).length;
236
- wordCount.textContent = `${shown} of ${MAX_WORDS}`;
237
- }
238
- const redraw = _.debounce ? _.debounce(drawPreview, 250) : drawPreview;
239
-
240
- function wordRow(w, i) {
241
- const enumeration = h('span', {className: 'cwa-enum'});
242
- const showEnum = () => { const c = cleanAnswer(w.answer); enumeration.textContent = c.letters ? `(${c.enumeration})` : ''; };
243
- showEnum();
244
- return h('div', {className: 'cwa-word'},
245
- h('span', {className: 'cwa-n', text: `${i + 1}`}),
246
- h('input', {className: 'form-input cwa-answer', value: w.answer, maxlength: 40, placeholder: 'Answer', 'aria-label': `Answer ${i + 1}`,
247
- onInput: (e) => { w.answer = e.target.value; showEnum(); touched(); redraw(); }}),
248
- enumeration,
249
- h('input', {className: 'form-input cwa-clue', value: w.clue, maxlength: 300, placeholder: 'Clue', 'aria-label': `Clue ${i + 1}`,
250
- onInput: (e) => { w.clue = e.target.value; touched(); redraw(); },
251
- onKeydown: (e) => { if (e.key === 'Enter' && i === puzzle.words.length - 1) { e.preventDefault(); addWord(); } }}),
252
- h('button', {type: 'button', className: 'btn btn-xs btn-ghost', title: 'Remove', onClick: () => { puzzle.words.splice(i, 1); drawWords(); touched(); }}, icon('x')));
253
- }
254
-
255
- function drawWords() {
256
- wordList.innerHTML = '';
257
- puzzle.words.forEach((w, i) => wordList.append(wordRow(w, i)));
258
- I.scan(wordList);
259
- drawPreview();
260
364
  }
365
+ const redraw = typeof _ !== 'undefined' && _.debounce ? _.debounce(drawPreview, 250) : drawPreview;
261
366
 
262
- function addWord() {
263
- if (puzzle.words.length >= MAX_WORDS) return;
264
- puzzle.words.push({answer: '', clue: ''});
265
- drawWords();
266
- touched();
267
- wordList.lastElementChild?.querySelector('.cwa-answer')?.focus();
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;
268
372
  }
373
+ const changed = () => { touched(); drawPreview(); tick.value++; };
269
374
 
270
- const words = h('section', {className: 'gma-card'},
271
- h('div', {className: 'gma-bar'}, h('h3', {className: 'gma-h', text: 'Answers and clues'}), wordCount),
272
- h('p', {className: 'gma-note', text: '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.'}),
273
- wordList,
274
- h('div', {className: 'gma-bar'}, h('button', {type: 'button', className: 'btn btn-sm btn-outline', onClick: addWord}, icon('plus'), ' Add an answer')));
275
-
276
- const grid = h('section', {className: 'gma-card cwa-side'},
277
- h('div', {className: 'gma-bar'}, h('h3', {className: 'gma-h', text: 'The grid'}), h('span', {className: 'gma-spacer'}),
278
- h('button', {type: 'button', className: 'btn btn-sm btn-outline', title: 'Try a different arrangement of the same words',
279
- onClick: () => { puzzle.seed = (puzzle.seed % 2147483646) + 1; touched(); drawPreview(); }}, icon('refresh-cw'), ' Another layout')),
280
- preview, previewNote, unplacedNote,
281
- h('p', {className: 'gma-note', text: 'Players see the shape and the numbers, never the letters.'}));
282
-
283
- const saveBtn = h('button', {type: 'button', className: 'btn btn-primary', onClick: () => save()}, icon('check'), ' Save');
284
- const bar = h('div', {className: 'gma-save'}, saveBtn,
285
- h('a', {className: 'btn btn-ghost', href: SIDEBAR, text: 'Back to the list'}), dirtyNote, h('span', {className: 'gma-spacer'}), errorBox);
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; });
286
378
 
379
+ /** Saves; answers the crossword's id, or '' when it did not save. */
287
380
  async function save() {
288
- errorBox.hidden = true;
289
- saveBtn.disabled = true;
381
+ if (saving.peek()) return '';
382
+ problem.value = '';
383
+ saving.value = true;
290
384
  try {
291
- const body = fields(puzzle);
292
- const out = id ? await request('PUT', `${API}/puzzles/${encodeURIComponent(id)}`, body) : await request('POST', `${API}/puzzles`, body);
293
- const {layout, unplaced, ...rest} = out;
294
- puzzle = {...blankPuzzle(), ...rest};
295
- if (!puzzle.words.length) puzzle.words.push({answer: '', clue: ''});
296
- saved = snapshot();
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());
297
388
  touched();
298
- showShortcode();
299
- E.toast(unplaced?.length ? `${out.title} saved. ${unplaced.length} answer${unplaced.length === 1 ? '' : 's'} did not fit the grid.` : `${out.title} saved.`,
300
- {type: unplaced?.length ? 'warning' : 'success'});
301
- if (!id) { location.hash = `#/plugins/crossword/edit/${encodeURIComponent(out.id)}`; return; }
302
- bannerTrail(container, [{text: 'Crossword', href: SIDEBAR}, {text: out.title}]);
303
- drawWords();
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;
304
394
  } catch (err) {
305
- errorBox.textContent = errText(err);
306
- errorBox.hidden = false;
395
+ problem.value = errText(err);
396
+ return '';
307
397
  } finally {
308
- saveBtn.disabled = false;
398
+ saving.value = false;
309
399
  }
310
400
  }
311
401
 
312
- root.innerHTML = '';
313
- root.append(details, h('div', {className: 'gma-two'}, words, grid), rules, bar);
314
- drawWords();
315
- I.scan(root);
316
- if (!id) details.querySelector('input')?.focus();
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();
317
449
  }