domma-cms 1.8.30 → 1.8.32
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 +11 -0
- package/docs/DommaFeatures.md +5 -4
- package/package.json +3 -1
- package/plugins/_lib/admin/games/games.css +1 -0
- package/plugins/_lib/admin/games/kit.js +241 -0
- package/plugins/_lib/games/http.js +82 -0
- package/plugins/_lib/games/names.js +42 -0
- package/plugins/_lib/games/plays.js +155 -0
- package/plugins/_lib/games/ranking.js +68 -0
- package/plugins/_lib/games/timing.js +27 -0
- package/plugins/_lib/public/games/format.js +38 -0
- package/plugins/_lib/public/games/games.css +1 -0
- package/plugins/_lib/public/games/games.js +204 -0
- package/plugins/crossword/admin/css/index.css +1 -0
- package/plugins/crossword/admin/templates/crossword.html +1 -0
- package/plugins/crossword/admin/views/crossword.js +317 -0
- package/plugins/crossword/collections/crossword-plays.json +20 -0
- package/plugins/crossword/collections/crossword-puzzles.json +17 -0
- package/plugins/crossword/config.js +6 -0
- package/plugins/crossword/inject/body.html +1 -0
- package/plugins/crossword/lib/crossword.js +170 -0
- package/plugins/crossword/lib/store.js +56 -0
- package/plugins/crossword/plugin.js +241 -0
- package/plugins/crossword/plugin.json +78 -0
- package/plugins/crossword/public/crossword.css +1 -0
- package/plugins/crossword/public/crossword.js +57 -0
- package/plugins/crossword/public/lib/layout.js +199 -0
- package/plugins/quiz/admin/css/index.css +1 -0
- package/plugins/quiz/admin/templates/quiz.html +1 -0
- package/plugins/quiz/admin/views/quiz.js +352 -0
- package/plugins/quiz/collections/quiz-plays.json +20 -0
- package/plugins/quiz/collections/quiz-quizzes.json +17 -0
- package/plugins/quiz/config.js +6 -0
- package/plugins/quiz/inject/body.html +1 -0
- package/plugins/quiz/lib/engine.js +115 -0
- package/plugins/quiz/lib/quiz.js +225 -0
- package/plugins/quiz/lib/store.js +56 -0
- package/plugins/quiz/plugin.js +241 -0
- package/plugins/quiz/plugin.json +78 -0
- package/plugins/quiz/public/quiz.css +1 -0
- package/plugins/quiz/public/quiz.js +80 -0
|
@@ -0,0 +1,317 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crossword - the Tool. One view, three screens by address:
|
|
3
|
+
*
|
|
4
|
+
* #/plugins/crossword every crossword
|
|
5
|
+
* #/plugins/crossword/new a new crossword
|
|
6
|
+
* #/plugins/crossword/edit/<id> its words, clues and settings, with the grid previewed live
|
|
7
|
+
* #/plugins/crossword/plays/<id> who solved it, and its leaderboard
|
|
8
|
+
*
|
|
9
|
+
* The preview runs the same layout function the server saves with
|
|
10
|
+
* (public/lib/layout.js), from the same words and seed, so what you see is
|
|
11
|
+
* what is saved.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {bannerTrail, copyText, errText, esc, field, fmtDuration, fromLocalInput, h, icon, loadKit, playsScreen, refreshBadge, request, toggle, toLocalInput} from '/plugins/_lib/admin/games/kit.js';
|
|
15
|
+
import {MAX_WORDS, cleanAnswer, generate, solutionGrid} from '/plugins/crossword/public/lib/layout.js';
|
|
16
|
+
|
|
17
|
+
const API = '/plugins/crossword';
|
|
18
|
+
const SIDEBAR = '#/plugins/crossword';
|
|
19
|
+
const ROUTE = /^#\/plugins\/crossword(\/|\?|$)/;
|
|
20
|
+
const STATUS_LABEL = {draft: 'Draft', open: 'Open', closed: 'Closed'};
|
|
21
|
+
const STATUS_TONE = {draft: '', open: 'is-success', closed: 'is-warning'};
|
|
22
|
+
|
|
23
|
+
let lifetime = null;
|
|
24
|
+
let leaveGuard = null;
|
|
25
|
+
|
|
26
|
+
function where() {
|
|
27
|
+
const m = location.hash.split('?')[0].match(/^#\/plugins\/crossword(?:\/([a-z]+))?(?:\/([^/?#]+))?/);
|
|
28
|
+
return {screen: m?.[1] || 'list', id: m?.[2] ? decodeURIComponent(m[2]) : ''};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const badge = () => refreshBadge(SIDEBAR);
|
|
32
|
+
const shortcodeOf = (slug) => `[crossword id="${slug}" /]`;
|
|
33
|
+
|
|
34
|
+
export const crosswordView = {
|
|
35
|
+
templateUrl: '/plugins/crossword/admin/templates/crossword.html',
|
|
36
|
+
|
|
37
|
+
async onMount($container) {
|
|
38
|
+
const kit = await loadKit();
|
|
39
|
+
lifetime ??= kit.viewLifetime(ROUTE);
|
|
40
|
+
if (!ROUTE.test(location.hash)) return;
|
|
41
|
+
lifetime.reset();
|
|
42
|
+
if (leaveGuard) { window.removeEventListener('beforeunload', leaveGuard); leaveGuard = null; }
|
|
43
|
+
const container = $container.get(0);
|
|
44
|
+
const root = container.querySelector('[data-crossword-admin]') || container;
|
|
45
|
+
const {screen, id} = where();
|
|
46
|
+
|
|
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});
|
|
53
|
+
}
|
|
54
|
+
if (screen === 'new' || (screen === 'edit' && id)) return editor(root, container, $container, kit, screen === 'new' ? '' : id);
|
|
55
|
+
return list(root, container, $container, kit);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// ---- The list ----------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
async function list(root, container, $container, kit) {
|
|
62
|
+
bannerTrail(container, [{text: 'Crossword'}]);
|
|
63
|
+
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;
|
|
68
|
+
try {
|
|
69
|
+
puzzles = (await kit.whileLoading(slot, request('GET', `${API}/puzzles`), kit.SHAPES.rows)).puzzles || [];
|
|
70
|
+
} catch (err) {
|
|
71
|
+
root.innerHTML = '';
|
|
72
|
+
root.append(h('p', {className: 'gma-error', text: `Crossword could not load: ${errText(err)}`}));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
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
|
+
};
|
|
103
|
+
|
|
104
|
+
async function duplicate(p) {
|
|
105
|
+
try {
|
|
106
|
+
const copy = await request('POST', `${API}/puzzles/${encodeURIComponent(p.id)}/copy`);
|
|
107
|
+
E.toast(`Copied as "${copy.title}" (a draft).`, {type: 'success'});
|
|
108
|
+
puzzles = (await request('GET', `${API}/puzzles`)).puzzles || [];
|
|
109
|
+
draw();
|
|
110
|
+
} catch (err) { E.toast(errText(err), {type: 'error'}); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function remove(p) {
|
|
114
|
+
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
|
+
try {
|
|
116
|
+
await request('DELETE', `${API}/puzzles/${encodeURIComponent(p.id)}`);
|
|
117
|
+
puzzles = puzzles.filter(x => x.id !== p.id);
|
|
118
|
+
draw();
|
|
119
|
+
badge();
|
|
120
|
+
E.toast(`${p.title} deleted.`, {type: 'success'});
|
|
121
|
+
} catch (err) { E.toast(errText(err), {type: 'error'}); }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
draw();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- The editor --------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
const blankPuzzle = () => ({
|
|
130
|
+
title: '', slug: '', intro: '', status: 'draft', opensAt: '', closesAt: '', timeLimit: 0, checks: true, checkPenalty: 30, wrongPenalty: 10,
|
|
131
|
+
leaderboard: true, leaderboardSize: 10, attemptsPerName: 0, seed: 1,
|
|
132
|
+
words: [{answer: '', clue: ''}, {answer: '', clue: ''}, {answer: '', clue: ''}]
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
async function editor(root, container, $container, kit, id) {
|
|
136
|
+
root.innerHTML = '';
|
|
137
|
+
let puzzle = blankPuzzle();
|
|
138
|
+
if (id) {
|
|
139
|
+
const slot = h('div', {className: 'gma-note dm-loading-slot', text: 'Loading…'});
|
|
140
|
+
root.append(slot);
|
|
141
|
+
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};
|
|
145
|
+
} 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;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
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)}`; }});
|
|
153
|
+
|
|
154
|
+
const fields = (p) => {
|
|
155
|
+
const {id: _i, createdAt, updatedAt, unplaced, ...rest} = p;
|
|
156
|
+
return rest;
|
|
157
|
+
};
|
|
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'});
|
|
214
|
+
|
|
215
|
+
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 = '';
|
|
219
|
+
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 = '';
|
|
222
|
+
} else {
|
|
223
|
+
const grid = solutionGrid(layout);
|
|
224
|
+
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}.`;
|
|
230
|
+
}
|
|
231
|
+
unplacedNote.hidden = !layout.unplaced.length;
|
|
232
|
+
unplacedNote.textContent = layout.unplaced.length
|
|
233
|
+
? `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
|
+
: '';
|
|
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
|
+
}
|
|
261
|
+
|
|
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();
|
|
268
|
+
}
|
|
269
|
+
|
|
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);
|
|
286
|
+
|
|
287
|
+
async function save() {
|
|
288
|
+
errorBox.hidden = true;
|
|
289
|
+
saveBtn.disabled = true;
|
|
290
|
+
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();
|
|
297
|
+
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();
|
|
304
|
+
} catch (err) {
|
|
305
|
+
errorBox.textContent = errText(err);
|
|
306
|
+
errorBox.hidden = false;
|
|
307
|
+
} finally {
|
|
308
|
+
saveBtn.disabled = false;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
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();
|
|
317
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"slug": "crossword-plays",
|
|
3
|
+
"title": "Crossword - Plays",
|
|
4
|
+
"description": "Every go at a crossword: the name and the time. Look at them in Crossword, not here.",
|
|
5
|
+
"plugin": "crossword",
|
|
6
|
+
"fields": [
|
|
7
|
+
{"name": "gameId", "label": "Crossword", "type": "text"},
|
|
8
|
+
{"name": "name", "label": "Name", "type": "text"},
|
|
9
|
+
{"name": "status", "label": "Status", "type": "text"},
|
|
10
|
+
{"name": "score", "label": "Score", "type": "number"},
|
|
11
|
+
{"name": "timeMs", "label": "Time (ms)", "type": "number"},
|
|
12
|
+
{"name": "hidden", "label": "Hidden from the leaderboard", "type": "boolean"}
|
|
13
|
+
],
|
|
14
|
+
"api": {
|
|
15
|
+
"create": {"enabled": false, "access": "admin"},
|
|
16
|
+
"read": {"enabled": false, "access": "admin"},
|
|
17
|
+
"update": {"enabled": false, "access": "admin"},
|
|
18
|
+
"delete": {"enabled": false, "access": "admin"}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"slug": "crossword-puzzles",
|
|
3
|
+
"title": "Crossword - Puzzles",
|
|
4
|
+
"description": "The crosswords, answers included. Edit them in Crossword, not here.",
|
|
5
|
+
"plugin": "crossword",
|
|
6
|
+
"fields": [
|
|
7
|
+
{"name": "title", "label": "Title", "type": "text"},
|
|
8
|
+
{"name": "slug", "label": "Short name", "type": "text"},
|
|
9
|
+
{"name": "status", "label": "Status", "type": "text"}
|
|
10
|
+
],
|
|
11
|
+
"api": {
|
|
12
|
+
"create": {"enabled": false, "access": "admin"},
|
|
13
|
+
"read": {"enabled": false, "access": "admin"},
|
|
14
|
+
"update": {"enabled": false, "access": "admin"},
|
|
15
|
+
"delete": {"enabled": false, "access": "admin"}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<script type="module" src="/plugins/crossword/public/crossword.js?v=1.0.0"></script>
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crossword - the rules. PURE: no I/O, so the routes and the tests share them.
|
|
3
|
+
*
|
|
4
|
+
* cleanPuzzle(input) -> {puzzle, errors, unplaced} what the admin saved, laid out by the server
|
|
5
|
+
* isOpen(puzzle, now) -> {open, reason}
|
|
6
|
+
* publicPuzzle(puzzle) -> the grid's shape and the clues - never a letter of the answers
|
|
7
|
+
* readCells(raw, puzzle) -> the player's letters as rows, or null when malformed
|
|
8
|
+
* compare(cells, solution) -> {wrong: [[r, c]], empty: n}
|
|
9
|
+
* catchUp / finish -> the play, by the server's clock
|
|
10
|
+
*
|
|
11
|
+
* @module crossword/lib/crossword
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {GRACE_MS, leftOf, overdue} from '../../_lib/games/timing.js';
|
|
15
|
+
import {MAX_WORDS, generate, shapeOf, solutionGrid} from '../public/lib/layout.js';
|
|
16
|
+
|
|
17
|
+
export const STATUSES = ['draft', 'open', 'closed'];
|
|
18
|
+
export const LIMITS = {clue: 300, answer: 40, timeLimit: 6 * 3600, penalty: 600};
|
|
19
|
+
|
|
20
|
+
const str = (v, max) => String(v ?? '').replace(/\r\n?/g, '\n').trim().slice(0, max);
|
|
21
|
+
const int = (v, min, max, dflt = 0) => {
|
|
22
|
+
const n = Math.round(Number(v));
|
|
23
|
+
return Number.isFinite(n) ? Math.max(min, Math.min(max, n)) : dflt;
|
|
24
|
+
};
|
|
25
|
+
const iso = (v) => {
|
|
26
|
+
const t = Date.parse(String(v || ''));
|
|
27
|
+
return Number.isFinite(t) ? new Date(t).toISOString() : '';
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export function slugify(s) {
|
|
31
|
+
return String(s || '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toLowerCase()
|
|
32
|
+
.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 60);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The puzzle as the admin saved it. The grid is laid out HERE from the words
|
|
37
|
+
* and the seed (the same function the builder previews with), so a layout is
|
|
38
|
+
* never taken from the browser.
|
|
39
|
+
*
|
|
40
|
+
* @returns {{puzzle: object, errors: Object<string, string>, unplaced: object[]}}
|
|
41
|
+
*/
|
|
42
|
+
export function cleanPuzzle(input = {}) {
|
|
43
|
+
const errors = {};
|
|
44
|
+
const words = (Array.isArray(input.words) ? input.words : []).slice(0, MAX_WORDS)
|
|
45
|
+
.map(w => ({answer: str(w?.answer, LIMITS.answer), clue: str(w?.clue, LIMITS.clue)}))
|
|
46
|
+
.filter(w => w.answer || w.clue);
|
|
47
|
+
const puzzle = {
|
|
48
|
+
title: str(input.title, 120),
|
|
49
|
+
slug: slugify(input.slug || input.title),
|
|
50
|
+
intro: str(input.intro, 2000),
|
|
51
|
+
status: STATUSES.includes(input.status) ? input.status : 'draft',
|
|
52
|
+
opensAt: iso(input.opensAt),
|
|
53
|
+
closesAt: iso(input.closesAt),
|
|
54
|
+
timeLimit: int(input.timeLimit, 0, LIMITS.timeLimit),
|
|
55
|
+
checks: input.checks !== false,
|
|
56
|
+
checkPenalty: int(input.checkPenalty ?? 30, 0, LIMITS.penalty, 30),
|
|
57
|
+
wrongPenalty: int(input.wrongPenalty ?? 10, 0, LIMITS.penalty, 10),
|
|
58
|
+
leaderboard: input.leaderboard !== false,
|
|
59
|
+
leaderboardSize: int(input.leaderboardSize ?? 10, 3, 100, 10),
|
|
60
|
+
attemptsPerName: int(input.attemptsPerName, 0, 100),
|
|
61
|
+
seed: int(input.seed ?? 1, 1, 2 ** 31 - 1, 1),
|
|
62
|
+
words,
|
|
63
|
+
layout: {width: 0, height: 0, entries: []}
|
|
64
|
+
};
|
|
65
|
+
if (!puzzle.title) errors.title = 'Give the crossword a title.';
|
|
66
|
+
if (!puzzle.slug) errors.slug = 'Give the crossword a short name for its address (letters and numbers).';
|
|
67
|
+
if (puzzle.opensAt && puzzle.closesAt && puzzle.closesAt <= puzzle.opensAt) errors.closesAt = 'It has to close after it opens.';
|
|
68
|
+
words.forEach((w, i) => {
|
|
69
|
+
if (!w.answer) errors[`w${i + 1}`] ??= `Word ${i + 1} has a clue but no answer.`;
|
|
70
|
+
else if (!w.clue) errors[`w${i + 1}`] ??= `"${w.answer}" needs a clue.`;
|
|
71
|
+
});
|
|
72
|
+
const laid = generate(words.filter(w => w.answer && w.clue), {seed: puzzle.seed});
|
|
73
|
+
puzzle.layout = {width: laid.width, height: laid.height, entries: laid.entries};
|
|
74
|
+
if (puzzle.status === 'open' && laid.entries.length < 2) errors.words = 'Add at least two answers that cross before opening the crossword.';
|
|
75
|
+
return {puzzle, errors, unplaced: laid.unplaced};
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function isOpen(puzzle, now = Date.now()) {
|
|
79
|
+
if (!puzzle || puzzle.status === 'draft') return {open: false, reason: 'This crossword is not open yet.', state: 'draft'};
|
|
80
|
+
if (puzzle.status === 'closed') return {open: false, reason: 'This crossword has closed.', state: 'closed'};
|
|
81
|
+
if (puzzle.opensAt && Date.parse(puzzle.opensAt) > now) return {open: false, reason: 'This crossword opens soon.', state: 'scheduled', opensAt: puzzle.opensAt};
|
|
82
|
+
if (puzzle.closesAt && Date.parse(puzzle.closesAt) <= now) return {open: false, reason: 'This crossword has closed.', state: 'ended'};
|
|
83
|
+
if ((puzzle.layout?.entries?.length || 0) < 1) return {open: false, reason: 'This crossword is not ready yet.', state: 'empty'};
|
|
84
|
+
return {open: true, reason: '', state: 'open'};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** What anyone may see: the shape, the numbers and the clues - no letters. */
|
|
88
|
+
export function publicPuzzle(puzzle) {
|
|
89
|
+
const {width, height, entries} = puzzle.layout;
|
|
90
|
+
const grid = solutionGrid(puzzle.layout);
|
|
91
|
+
const clue = (e) => ({number: e.number, dir: e.dir, clue: e.clue, enumeration: e.enumeration, row: e.row, col: e.col, length: e.letters.length});
|
|
92
|
+
return {
|
|
93
|
+
width, height, shape: shapeOf(grid),
|
|
94
|
+
across: entries.filter(e => e.dir === 'across').map(clue),
|
|
95
|
+
down: entries.filter(e => e.dir === 'down').map(clue)
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* The player's grid: `height` strings of `width` characters, a letter or a
|
|
101
|
+
* space. Anything else (a black square they typed into, a wrong size) is null.
|
|
102
|
+
*/
|
|
103
|
+
export function readCells(raw, puzzle) {
|
|
104
|
+
const {width, height} = puzzle.layout;
|
|
105
|
+
if (!Array.isArray(raw) || raw.length !== height) return null;
|
|
106
|
+
const rows = [];
|
|
107
|
+
for (const r of raw) {
|
|
108
|
+
const row = String(r ?? '').toUpperCase();
|
|
109
|
+
if (row.length !== width || !/^[A-Z ]*$/.test(row)) return null;
|
|
110
|
+
rows.push(row);
|
|
111
|
+
}
|
|
112
|
+
return rows;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Which squares are wrong (a letter that is not the answer's) and how many are still empty. */
|
|
116
|
+
export function compare(cells, solution) {
|
|
117
|
+
const wrong = [];
|
|
118
|
+
let empty = 0;
|
|
119
|
+
solution.forEach((row, r) => row.forEach((ch, c) => {
|
|
120
|
+
if (!ch) return;
|
|
121
|
+
const given = cells[r][c];
|
|
122
|
+
if (given === ' ') empty++;
|
|
123
|
+
else if (given !== ch) wrong.push([r, c]);
|
|
124
|
+
}));
|
|
125
|
+
return {wrong, empty};
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const limitMs = (puzzle) => (puzzle.timeLimit || 0) * 1000;
|
|
129
|
+
|
|
130
|
+
/** Out of time while nobody asked: the play ends, off the board. */
|
|
131
|
+
export function catchUp(puzzle, play, now = Date.now()) {
|
|
132
|
+
if (play.status !== 'playing' || !overdue(play.startedAt, limitMs(puzzle), now)) return false;
|
|
133
|
+
play.status = 'timeout';
|
|
134
|
+
play.finishedAt = new Date(now).toISOString();
|
|
135
|
+
play.timeMs = limitMs(puzzle);
|
|
136
|
+
return true;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** Solved: the time is the server's, plus every penalty. */
|
|
140
|
+
export function finish(play, now = Date.now()) {
|
|
141
|
+
play.status = 'finished';
|
|
142
|
+
play.finishedAt = new Date(now).toISOString();
|
|
143
|
+
play.score = 1;
|
|
144
|
+
play.timeMs = Math.max(0, now - Date.parse(play.startedAt)) + (play.state.penaltyMs || 0);
|
|
145
|
+
return play;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** What the player may know of their play right now. */
|
|
149
|
+
export function view(puzzle, play, now = Date.now()) {
|
|
150
|
+
const end = play.status === 'playing' ? now : Date.parse(play.finishedAt);
|
|
151
|
+
return {
|
|
152
|
+
status: play.status,
|
|
153
|
+
elapsedMs: Math.max(0, end - Date.parse(play.startedAt)),
|
|
154
|
+
penaltyMs: play.state.penaltyMs || 0,
|
|
155
|
+
checks: play.state.checks || 0,
|
|
156
|
+
totalLeftMs: play.status === 'playing' ? leftOf(play.startedAt, limitMs(puzzle), now) : null,
|
|
157
|
+
grace: GRACE_MS
|
|
158
|
+
};
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** The puzzle as a list row in the admin. */
|
|
162
|
+
export function summary(puzzle, plays = []) {
|
|
163
|
+
const finished = plays.filter(p => p.status === 'finished');
|
|
164
|
+
return {
|
|
165
|
+
id: puzzle.id, title: puzzle.title, slug: puzzle.slug, status: puzzle.status,
|
|
166
|
+
words: puzzle.layout?.entries?.length || 0, size: `${puzzle.layout?.width || 0}×${puzzle.layout?.height || 0}`,
|
|
167
|
+
plays: plays.length, finished: finished.length,
|
|
168
|
+
fastest: finished.length ? Math.min(...finished.map(p => p.timeMs || 0)) : 0, updatedAt: puzzle.updatedAt || ''
|
|
169
|
+
};
|
|
170
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crossword - where puzzles and plays are kept: the collections `crossword-puzzles` and
|
|
3
|
+
* `crossword-plays` (made at every boot when missing; API access all off - a puzzle
|
|
4
|
+
* holds its answers, a play the player's name).
|
|
5
|
+
*
|
|
6
|
+
* @module crossword/lib/store
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from 'node:fs/promises';
|
|
10
|
+
import {createCollection, createEntry, deleteEntry, getCollection, getEntry, listEntries, updateEntry} from '../../../server/services/collections.js';
|
|
11
|
+
import {PlayStore} from '../../_lib/games/plays.js';
|
|
12
|
+
|
|
13
|
+
export const PUZZLES = 'crossword-puzzles';
|
|
14
|
+
export const PLAYS = 'crossword-plays';
|
|
15
|
+
|
|
16
|
+
export const plays = new PlayStore(PLAYS, {order: 'time'});
|
|
17
|
+
|
|
18
|
+
export async function ensureCollections(log = console) {
|
|
19
|
+
for (const slug of [PUZZLES, PLAYS]) {
|
|
20
|
+
if (await getCollection(slug)) continue;
|
|
21
|
+
const schema = JSON.parse(await fs.readFile(new URL(`../collections/${slug}.json`, import.meta.url), 'utf8'));
|
|
22
|
+
await createCollection(schema);
|
|
23
|
+
log.info?.(`[crossword] Created collection: ${slug}`);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const flat = (e) => (e ? {id: e.id, ...e.data, createdAt: e.meta?.createdAt, updatedAt: e.meta?.updatedAt} : null);
|
|
28
|
+
|
|
29
|
+
let cache = null;
|
|
30
|
+
export async function allPuzzles() {
|
|
31
|
+
cache ??= listEntries(PUZZLES, {limit: 100000}).then(r => (r.entries || []).map(flat)).catch(() => []);
|
|
32
|
+
return cache;
|
|
33
|
+
}
|
|
34
|
+
const forget = () => { cache = null; };
|
|
35
|
+
|
|
36
|
+
export const getPuzzle = async (id) => flat(await getEntry(PUZZLES, String(id)).catch(() => null));
|
|
37
|
+
export const puzzleBySlug = async (slug) => (await allPuzzles()).find(q => q.slug === String(slug)) || null;
|
|
38
|
+
|
|
39
|
+
/** A short name no other crossword has. */
|
|
40
|
+
export async function uniqueSlug(slug, selfId = '') {
|
|
41
|
+
const taken = new Set((await allPuzzles()).filter(q => q.id !== selfId).map(q => q.slug));
|
|
42
|
+
if (!taken.has(slug)) return slug;
|
|
43
|
+
for (let n = 2; ; n++) if (!taken.has(`${slug}-${n}`)) return `${slug}-${n}`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function savePuzzle(id, data) {
|
|
47
|
+
const saved = id ? await updateEntry(PUZZLES, id, data) : await createEntry(PUZZLES, data);
|
|
48
|
+
forget();
|
|
49
|
+
return flat(saved);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export async function removePuzzle(id) {
|
|
53
|
+
await deleteEntry(PUZZLES, String(id));
|
|
54
|
+
forget();
|
|
55
|
+
return plays.reset(String(id));
|
|
56
|
+
}
|