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.
- package/CHANGELOG.md +9 -0
- package/docs/DommaFeatures.md +13 -1
- package/package.json +1 -1
- package/plugins/_lib/admin/games/games.css +1 -1
- package/plugins/_lib/admin/games/kit.js +329 -124
- package/plugins/_lib/games/http.js +11 -1
- package/plugins/_lib/games/plays.js +28 -7
- package/plugins/crossword/CLAUDE.md +37 -0
- package/plugins/crossword/admin/css/index.css +18 -1
- package/plugins/crossword/admin/views/crossword.js +337 -205
- package/plugins/crossword/docs/guide.md +53 -0
- package/plugins/crossword/inject/body.html +1 -1
- package/plugins/crossword/plugin.js +38 -5
- package/plugins/crossword/plugin.json +4 -3
- package/plugins/crossword/public/crossword.css +36 -1
- package/plugins/crossword/public/crossword.js +468 -6
- package/plugins/docs/CLAUDE.md +318 -0
- package/plugins/docs/admin/css/docs.css +783 -0
- package/plugins/docs/admin/lib/api.js +340 -0
- package/plugins/docs/admin/lib/convert.js +482 -0
- package/plugins/docs/admin/lib/drafts.js +115 -0
- package/plugins/docs/admin/lib/editor.js +851 -0
- package/plugins/docs/admin/lib/find-replace.js +426 -0
- package/plugins/docs/admin/lib/folders.js +203 -0
- package/plugins/docs/admin/lib/panel-guard.js +73 -0
- package/plugins/docs/admin/lib/pdf-view.js +126 -0
- package/plugins/docs/admin/lib/send.js +156 -0
- package/plugins/docs/admin/lib/settings.js +142 -0
- package/plugins/docs/admin/lib/share.js +323 -0
- package/plugins/docs/admin/lib/shortcuts.js +44 -0
- package/plugins/docs/admin/lib/templates.js +194 -0
- package/plugins/docs/admin/lib/versions.js +192 -0
- package/plugins/docs/admin/templates/docs.html +92 -0
- package/plugins/docs/admin/templates/editor.html +86 -0
- package/plugins/docs/admin/views/docs.js +777 -0
- package/plugins/docs/admin/views/editor.js +978 -0
- package/plugins/docs/config.js +24 -0
- package/plugins/docs/docs/guide.md +270 -0
- package/plugins/docs/lib/extensions.js +213 -0
- package/plugins/docs/lib/importer.js +72 -0
- package/plugins/docs/lib/pdf-worker.js +48 -0
- package/plugins/docs/lib/pdf.js +222 -0
- package/plugins/docs/lib/store.js +748 -0
- package/plugins/docs/plugin.js +1108 -0
- package/plugins/docs/plugin.json +89 -0
- package/plugins/docs/plugin.public.js +114 -0
- package/plugins/docs/templates/public-doc.html +36 -0
- package/plugins/docs/tests/_env.js +10 -0
- package/plugins/docs/tests/_pdf.js +73 -0
- package/plugins/docs/tests/convert.test.js +95 -0
- package/plugins/docs/tests/drafts.test.js +82 -0
- package/plugins/docs/tests/pdf.test.js +86 -0
- package/plugins/docs/tests/plugin.test.js +816 -0
- package/plugins/docs/tests/shortcuts.test.js +30 -0
- package/plugins/free-tier.lock.json +147 -1
- package/plugins/garage/CLAUDE.md +51 -0
- package/plugins/garage/admin/css/garage.css +73 -0
- package/plugins/garage/admin/lib/garage.js +236 -0
- package/plugins/garage/admin/templates/garage.html +86 -0
- package/plugins/garage/admin/views/garage.js +581 -0
- package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
- package/plugins/garage/config.js +18 -0
- package/plugins/garage/docs/guide.md +118 -0
- package/plugins/garage/plugin.js +471 -0
- package/plugins/garage/plugin.json +68 -0
- package/plugins/garage/tests/access.test.js +134 -0
- package/plugins/garage/tests/garage.test.js +106 -0
- package/plugins/messages/CLAUDE.md +230 -0
- package/plugins/messages/admin/css/messages.css +117 -0
- package/plugins/messages/admin/lib/messages.js +452 -0
- package/plugins/messages/admin/templates/messages.html +76 -0
- package/plugins/messages/admin/views/messages.js +1219 -0
- package/plugins/messages/collections/user-message-threads/schema.json +22 -0
- package/plugins/messages/collections/user-messages/schema.json +23 -0
- package/plugins/messages/config.js +14 -0
- package/plugins/messages/docs/guide.md +176 -0
- package/plugins/messages/lib/call.js +138 -0
- package/plugins/messages/plugin.js +994 -0
- package/plugins/messages/plugin.json +59 -0
- package/plugins/messages/plugin.public.js +52 -0
- package/plugins/messages/templates/call.html +6 -0
- package/plugins/messages/tests/after-send.test.js +22 -0
- package/plugins/messages/tests/api.test.js +187 -0
- package/plugins/messages/tests/call.test.js +497 -0
- package/plugins/messages/tests/drafts.test.js +25 -0
- package/plugins/messages/tests/messages.test.js +167 -0
- package/plugins/quiz/CLAUDE.md +43 -0
- package/plugins/quiz/admin/css/index.css +13 -1
- package/plugins/quiz/admin/views/quiz.js +423 -245
- package/plugins/quiz/docs/guide.md +61 -0
- package/plugins/quiz/inject/body.html +1 -1
- package/plugins/quiz/plugin.js +39 -5
- package/plugins/quiz/plugin.json +4 -3
- package/plugins/quiz/public/quiz.css +36 -1
- package/plugins/quiz/public/quiz.js +298 -6
- package/plugins/theme-roller/CLAUDE.md +65 -0
- package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
- package/plugins/theme-roller/admin/lib/prompt.js +22 -0
- package/plugins/theme-roller/admin/lib/themes.js +196 -0
- package/plugins/theme-roller/admin/lib/view.js +88 -0
- package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
- package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
- package/plugins/theme-roller/config.js +1 -0
- package/plugins/theme-roller/docs/guide.md +104 -0
- package/plugins/theme-roller/plugin.js +263 -0
- package/plugins/theme-roller/plugin.json +42 -0
- package/plugins/theme-roller/tests/api.test.js +161 -0
- package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
- package/plugins/theme-roller/tests/themes.test.js +77 -0
- package/plugins/theme-roller/tests/view.test.js +55 -0
|
@@ -1,27 +1,32 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Crossword - the Tool. One view,
|
|
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,
|
|
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
|
|
49
|
-
try {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
110
|
+
got = await loading(root, request('GET', `${API}/puzzles`), kit);
|
|
70
111
|
} catch (err) {
|
|
71
|
-
|
|
72
|
-
root
|
|
73
|
-
return;
|
|
112
|
+
if (!alive()) return;
|
|
113
|
+
return failed(root, `Crossword could not load: ${errText(err)}`);
|
|
74
114
|
}
|
|
75
115
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
|
|
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
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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="..." period="week" /] 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
|
|
143
|
-
|
|
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
|
-
|
|
147
|
-
root
|
|
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
|
-
|
|
152
|
-
if (
|
|
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
|
|
155
|
-
|
|
156
|
-
|
|
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
|
|
159
|
-
|
|
160
|
-
const
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
const
|
|
166
|
-
const
|
|
167
|
-
const
|
|
168
|
-
|
|
169
|
-
const
|
|
170
|
-
|
|
171
|
-
const
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
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 =
|
|
217
|
-
|
|
218
|
-
|
|
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
|
-
|
|
221
|
-
previewNote.
|
|
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
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
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
|
-
|
|
289
|
-
|
|
381
|
+
if (saving.peek()) return '';
|
|
382
|
+
problem.value = '';
|
|
383
|
+
saving.value = true;
|
|
290
384
|
try {
|
|
291
|
-
const
|
|
292
|
-
|
|
293
|
-
|
|
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
|
-
|
|
299
|
-
E.toast(
|
|
300
|
-
{type:
|
|
301
|
-
if (
|
|
302
|
-
|
|
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
|
-
|
|
306
|
-
|
|
395
|
+
problem.value = errText(err);
|
|
396
|
+
return '';
|
|
307
397
|
} finally {
|
|
308
|
-
|
|
398
|
+
saving.value = false;
|
|
309
399
|
}
|
|
310
400
|
}
|
|
311
401
|
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
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
|
}
|