domma-cms 1.8.31 → 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 +7 -0
- package/package.json +1 -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,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Games - words and numbers shared by the server and the browser (Quiz,
|
|
3
|
+
* Crossword). Served from /plugins/_lib/public/games/format.js and imported by
|
|
4
|
+
* path on the server, so it must stay free of I/O and of DOM.
|
|
5
|
+
*
|
|
6
|
+
* @module _lib/public/games/format
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** "0:42", "3:05", "1:02:09" from milliseconds. Tenths only under a minute, when asked. */
|
|
10
|
+
export function fmtDuration(ms, {tenths = false} = {}) {
|
|
11
|
+
const n = Math.max(0, Number(ms) || 0);
|
|
12
|
+
if (tenths && n < 60000) return `${(Math.floor(n / 100) / 10).toFixed(1)}s`;
|
|
13
|
+
const total = Math.floor(n / 1000);
|
|
14
|
+
const h = Math.floor(total / 3600);
|
|
15
|
+
const m = Math.floor((total % 3600) / 60);
|
|
16
|
+
const s = String(total % 60).padStart(2, '0');
|
|
17
|
+
return h ? `${h}:${String(m).padStart(2, '0')}:${s}` : `${m}:${s}`;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** "1st", "2nd", "3rd", "11th". */
|
|
21
|
+
export function ordinal(n) {
|
|
22
|
+
const v = Math.abs(Math.round(Number(n) || 0));
|
|
23
|
+
const tail = v % 100 >= 11 && v % 100 <= 13 ? 'th' : ({1: 'st', 2: 'nd', 3: 'rd'}[v % 10] || 'th');
|
|
24
|
+
return `${v}${tail}`;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The leaderboard periods, in the order the switcher shows them. */
|
|
28
|
+
export const PERIODS = [
|
|
29
|
+
{key: 'today', label: 'Today'},
|
|
30
|
+
{key: 'week', label: 'This week'},
|
|
31
|
+
{key: 'month', label: 'This month'},
|
|
32
|
+
{key: 'all', label: 'All time'}
|
|
33
|
+
];
|
|
34
|
+
|
|
35
|
+
export const PERIOD_KEYS = PERIODS.map(p => p.key);
|
|
36
|
+
|
|
37
|
+
/** A period key, or 'all'. */
|
|
38
|
+
export const cleanPeriod = (p) => (PERIOD_KEYS.includes(String(p)) ? String(p) : 'all');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.gm-app{--gm-gap: 1rem;--gm-radius: var(--dm-radius, 10px);--gm-line: var(--dm-border, rgba(127, 127, 127, .3));--gm-soft: var(--dm-surface, rgba(127, 127, 127, .08));--gm-accent: var(--dm-primary, #3b82f6);--gm-good: var(--dm-success, #16a34a);--gm-bad: var(--dm-danger, #dc2626);--gm-warn: var(--dm-warning, #d97706);max-width:760px;margin:1.5rem auto;color:var(--dm-text, inherit)}.gm-app [hidden],.gm-board [hidden]{display:none!important}.gm-card{border:1px solid var(--gm-line);border-radius:var(--gm-radius);background:var(--dm-surface-raised, var(--dm-background, transparent));padding:1.25rem 1.5rem;margin-bottom:var(--gm-gap)}.gm-title{margin:0 0 .5rem;font-size:1.6rem;line-height:1.2}.gm-intro{margin:0 0 1rem;white-space:pre-line}.gm-muted{color:var(--dm-text-muted, #6b7280);margin:.25rem 0}.gm-facts{display:flex;flex-wrap:wrap;gap:.5rem 1.25rem;margin:0 0 1rem;padding:0;list-style:none;color:var(--dm-text-muted, #6b7280)}.gm-facts li:before{content:"\2022 "}.gm-facts li:first-child:before{content:""}.gm-start{display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end}.gm-field{display:flex;flex-direction:column;gap:.3rem;flex:1 1 220px}.gm-field label{font-weight:600}.gm-input{font:inherit;padding:.6rem .75rem;border:1px solid var(--gm-line);border-radius:8px;background:var(--dm-input-bg, var(--dm-background, #fff));color:inherit;width:100%;box-sizing:border-box}.gm-input:focus{outline:2px solid var(--gm-accent);outline-offset:1px}.gm-hp{position:absolute!important;left:-9999px!important;width:1px;height:1px;overflow:hidden}.gm-error{color:var(--gm-bad);margin:.5rem 0 0;font-weight:600}.gm-note{margin:.5rem 0 0}.gm-bar{display:flex;flex-wrap:wrap;justify-content:space-between;align-items:center;gap:.5rem;margin-bottom:1rem}.gm-progress{font-weight:600}.gm-clock{font-variant-numeric:tabular-nums;font-weight:700;padding:.2rem .65rem;border-radius:999px;background:var(--gm-soft);min-width:4.5rem;text-align:center}.gm-clock.is-low{background:var(--gm-bad);color:#fff}.gm-track{height:6px;border-radius:999px;background:var(--gm-soft);overflow:hidden;margin-bottom:1rem}.gm-track>span{display:block;height:100%;background:var(--gm-accent);transition:width .2s linear}.gm-actions{display:flex;flex-wrap:wrap;gap:.5rem;margin-top:1rem}.gm-result-score{font-size:2.4rem;font-weight:800;margin:.25rem 0}.gm-result-place{font-size:1.15rem;margin:0 0 .75rem}.gm-board{border:1px solid var(--gm-line, rgba(127, 127, 127, .3));border-radius:var(--gm-radius, 10px);padding:1rem 1.25rem;margin:0 0 1rem}.gm-board-head{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:.5rem;margin-bottom:.5rem}.gm-board-title{margin:0;font-size:1.2rem}.gm-periods{display:flex;flex-wrap:wrap;gap:.25rem}.gm-period{font:inherit;font-size:.85rem;padding:.2rem .6rem;border-radius:999px;cursor:pointer;border:1px solid var(--gm-line, rgba(127, 127, 127, .3));background:transparent;color:inherit}.gm-period.is-on{background:var(--dm-primary, #3b82f6);border-color:var(--dm-primary, #3b82f6);color:#fff}.gm-board-list{list-style:none;margin:0;padding:0}.gm-board-row{display:grid;grid-template-columns:3.2rem 1fr auto auto;gap:.75rem;align-items:center;padding:.45rem .5rem;border-bottom:1px solid var(--gm-line, rgba(127, 127, 127, .2))}.gm-board-row:last-child{border-bottom:0}.gm-board-row:nth-child(1) .gm-rank{color:#c9a227}.gm-board-row:nth-child(2) .gm-rank{color:#9aa3ad}.gm-board-row:nth-child(3) .gm-rank{color:#b0703a}.gm-board-row.is-mine{background:color-mix(in srgb,var(--dm-primary, #3b82f6) 14%,transparent);border-radius:6px;font-weight:700}.gm-rank{font-weight:800}.gm-name{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.gm-score,.gm-time{font-variant-numeric:tabular-nums;text-align:right}.gm-score{font-weight:700}@media(max-width:520px){.gm-card{padding:1rem}.gm-board-row{grid-template-columns:2.6rem 1fr auto auto;gap:.5rem}}
|
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Games - what the Quiz and Crossword pages share in the browser: talking to
|
|
3
|
+
* the server, the leaderboard, the countdown, the player's remembered name,
|
|
4
|
+
* the stylesheet and a celebration for a top place.
|
|
5
|
+
*
|
|
6
|
+
* Served from /plugins/_lib/public/games/games.js. Domma is on every public
|
|
7
|
+
* page, so the screens are domma-reactive (window.M): observables, one
|
|
8
|
+
* M.applyBindings per screen, and text from the server reaches the page only
|
|
9
|
+
* through bindings (textContent), never as HTML.
|
|
10
|
+
*
|
|
11
|
+
* @module _lib/public/games/games
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import {fmtDuration, ordinal, PERIODS} from './format.js';
|
|
15
|
+
|
|
16
|
+
export {fmtDuration, ordinal, PERIODS};
|
|
17
|
+
|
|
18
|
+
const M = window.M;
|
|
19
|
+
|
|
20
|
+
/** JSON in and out (`token`: the play's key); a failure is an Error carrying the server's words, `status` and `code`. */
|
|
21
|
+
export async function request(url, {method = 'GET', body, token} = {}) {
|
|
22
|
+
const headers = {Accept: 'application/json'};
|
|
23
|
+
if (body) headers['Content-Type'] = 'application/json';
|
|
24
|
+
// A play's key travels in a header, so it stays out of addresses and server logs.
|
|
25
|
+
if (token) headers['X-Play-Token'] = token;
|
|
26
|
+
const res = await fetch(url, {
|
|
27
|
+
method,
|
|
28
|
+
headers,
|
|
29
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
30
|
+
credentials: 'same-origin'
|
|
31
|
+
});
|
|
32
|
+
let data = null;
|
|
33
|
+
try { data = await res.json(); } catch { /* not JSON */ }
|
|
34
|
+
if (!res.ok) {
|
|
35
|
+
const err = new Error(data?.error || (res.status === 429 ? 'Too many tries just now. Please wait a minute.' : 'Something went wrong. Please try again.'));
|
|
36
|
+
err.status = res.status;
|
|
37
|
+
err.code = data?.code || '';
|
|
38
|
+
throw err;
|
|
39
|
+
}
|
|
40
|
+
return data;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Remembered conveniences (the name last played under, a play in progress). Storage may be blocked. */
|
|
44
|
+
export const memory = {
|
|
45
|
+
get(k) { try { return window.S ? S.get(k) : null; } catch { return null; } },
|
|
46
|
+
set(k, v) { try { window.S?.set(k, v); } catch { /* a convenience */ } },
|
|
47
|
+
remove(k) { try { window.S?.remove(k); } catch { /* a convenience */ } }
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
let stylesAdded = false;
|
|
51
|
+
/** The shared stylesheet, once per page. */
|
|
52
|
+
export function addStyles(extra = []) {
|
|
53
|
+
const want = stylesAdded ? extra : ['/plugins/_lib/public/games/games.css', ...extra];
|
|
54
|
+
stylesAdded = true;
|
|
55
|
+
for (const href of want) {
|
|
56
|
+
if (document.querySelector(`link[href="${href}"]`)) continue;
|
|
57
|
+
const link = document.createElement('link');
|
|
58
|
+
link.rel = 'stylesheet';
|
|
59
|
+
link.href = href;
|
|
60
|
+
document.head.appendChild(link);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The JSON config a shortcode wrote on its placeholder, or null. */
|
|
65
|
+
export function configOf(el, attr) {
|
|
66
|
+
try { return JSON.parse(el.getAttribute(attr) || 'null'); } catch { return null; }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** A short-lived unique prefix for ids inside one mount. */
|
|
70
|
+
let mounts = 0;
|
|
71
|
+
export const uid = (prefix) => `${prefix}${++mounts}${Math.random().toString(36).slice(2, 6)}`;
|
|
72
|
+
|
|
73
|
+
/** Ticker tape for a top place - skipped when the visitor asked for less motion. */
|
|
74
|
+
export function celebrate() {
|
|
75
|
+
try {
|
|
76
|
+
if (window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) return;
|
|
77
|
+
const fx = window.Domma?.effects;
|
|
78
|
+
if (typeof fx?.tickerTape === 'function') fx.tickerTape(null, {burst: true, burstCount: 160});
|
|
79
|
+
} catch { /* a celebration never breaks the page */ }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* A countdown to a deadline the SERVER set: the browser only shows it. `left`
|
|
84
|
+
* is an observable of milliseconds; `onEnd` runs once when it reaches 0.
|
|
85
|
+
*/
|
|
86
|
+
export class Countdown {
|
|
87
|
+
constructor(onEnd = () => {}) {
|
|
88
|
+
this.left = M.observable(0);
|
|
89
|
+
this.onEnd = onEnd;
|
|
90
|
+
this.timer = null;
|
|
91
|
+
this.deadline = 0;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `ms` from now; 0 or less stops it (no limit). */
|
|
95
|
+
start(ms) {
|
|
96
|
+
this.stop();
|
|
97
|
+
if (!(ms > 0)) { this.left.value = 0; return; }
|
|
98
|
+
this.deadline = performance.now() + ms;
|
|
99
|
+
const tick = () => {
|
|
100
|
+
const left = Math.max(0, this.deadline - performance.now());
|
|
101
|
+
this.left.value = left;
|
|
102
|
+
if (left <= 0) { this.stop(); this.onEnd(); }
|
|
103
|
+
};
|
|
104
|
+
tick();
|
|
105
|
+
this.timer = setInterval(tick, 200);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
stop() {
|
|
109
|
+
if (this.timer) clearInterval(this.timer);
|
|
110
|
+
this.timer = null;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** A running clock from a start the server gave (milliseconds already gone). */
|
|
115
|
+
export class Stopwatch {
|
|
116
|
+
constructor() {
|
|
117
|
+
this.shown = M.observable('0:00');
|
|
118
|
+
this.timer = null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
start(elapsedMs = 0) {
|
|
122
|
+
this.stop();
|
|
123
|
+
const origin = performance.now() - Math.max(0, elapsedMs);
|
|
124
|
+
const tick = () => { this.shown.value = fmtDuration(performance.now() - origin); };
|
|
125
|
+
tick();
|
|
126
|
+
this.timer = setInterval(tick, 500);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
stop() {
|
|
130
|
+
if (this.timer) clearInterval(this.timer);
|
|
131
|
+
this.timer = null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const BOARD = `
|
|
136
|
+
<div class="gm-board-head">
|
|
137
|
+
<h3 class="gm-board-title" data-bind-text="title.value"></h3>
|
|
138
|
+
<div class="gm-periods" role="group" aria-label="Period" data-each="periods key=key">
|
|
139
|
+
<button type="button" class="gm-period" data-bind-class="$root.period.value === key && 'is-on'"
|
|
140
|
+
data-bind-aria-pressed="$root.period.value === key ? 'true' : 'false'" data-on-click="$root.pick($data)" data-bind-text="label"></button>
|
|
141
|
+
</div>
|
|
142
|
+
</div>
|
|
143
|
+
<p class="gm-muted" hidden data-bind-hidden="!loading.value">Loading…</p>
|
|
144
|
+
<p class="gm-muted" hidden data-bind-hidden="loading.value || rows.value.length">No scores yet. Be the first!</p>
|
|
145
|
+
<ol class="gm-board-list" hidden data-bind-hidden="!rows.value.length" data-each="rows.value key=id">
|
|
146
|
+
<li class="gm-board-row" data-bind-class="mine && 'is-mine'">
|
|
147
|
+
<span class="gm-rank" data-bind-text="place"></span>
|
|
148
|
+
<span class="gm-name" data-bind-text="name"></span>
|
|
149
|
+
<span class="gm-score" data-if="showScore" data-bind-text="scoreText"></span>
|
|
150
|
+
<span class="gm-time" data-bind-text="timeText"></span>
|
|
151
|
+
</li>
|
|
152
|
+
</ol>`;
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* A live leaderboard on `el`, fetched from `url` (?period=&limit=).
|
|
156
|
+
*
|
|
157
|
+
* @param {HTMLElement} el
|
|
158
|
+
* @param {{url: string, period?: string, limit?: number, showScore?: boolean, title?: string}} opts
|
|
159
|
+
* @returns {{refresh: (mineId?: string) => Promise<void>, dispose: () => void}}
|
|
160
|
+
*/
|
|
161
|
+
export function mountLeaderboard(el, {url, period = 'all', limit = 10, showScore = true, title = 'Leaderboard'}) {
|
|
162
|
+
if (!M) return {refresh: async () => {}, dispose() {}};
|
|
163
|
+
addStyles();
|
|
164
|
+
el.classList.add('gm-board');
|
|
165
|
+
el.innerHTML = BOARD;
|
|
166
|
+
const state = {
|
|
167
|
+
title: M.observable(title),
|
|
168
|
+
period: M.observable(PERIODS.some(p => p.key === period) ? period : 'all'),
|
|
169
|
+
periods: PERIODS,
|
|
170
|
+
rows: M.observable([]),
|
|
171
|
+
loading: M.observable(true),
|
|
172
|
+
pick: (p) => { state.period.value = p.key; refresh(); }
|
|
173
|
+
};
|
|
174
|
+
let mine = '';
|
|
175
|
+
async function refresh(mineId) {
|
|
176
|
+
if (mineId) mine = mineId;
|
|
177
|
+
state.loading.value = !state.rows.peek().length;
|
|
178
|
+
try {
|
|
179
|
+
const res = await request(`${url}?period=${encodeURIComponent(state.period.peek())}&limit=${encodeURIComponent(limit)}`);
|
|
180
|
+
state.rows.value = (res.rows || []).map(r => ({
|
|
181
|
+
id: r.id, name: r.name, mine: r.id === mine, showScore,
|
|
182
|
+
place: ordinal(r.rank), scoreText: `${r.score}`, timeText: fmtDuration(r.timeMs)
|
|
183
|
+
}));
|
|
184
|
+
} catch {
|
|
185
|
+
state.rows.value = [];
|
|
186
|
+
} finally {
|
|
187
|
+
state.loading.value = false;
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
const handle = M.applyBindings(state, el);
|
|
191
|
+
refresh();
|
|
192
|
+
return {refresh, dispose: () => handle.dispose()};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/** Every `[data-<kind>-board]` placeholder on the page (the leaderboard shortcodes). */
|
|
196
|
+
export function mountBoards(attr, urlFor, {showScore = true} = {}) {
|
|
197
|
+
for (const el of document.querySelectorAll(`[${attr}]`)) {
|
|
198
|
+
if (el.dataset.gmMounted) continue;
|
|
199
|
+
el.dataset.gmMounted = '1';
|
|
200
|
+
const cfg = configOf(el, attr);
|
|
201
|
+
if (!cfg?.id) continue;
|
|
202
|
+
mountLeaderboard(el, {url: urlFor(cfg.id), period: cfg.period, limit: cfg.limit, showScore, title: cfg.title || 'Leaderboard'});
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.cwa-words{display:flex;flex-direction:column;gap:.4rem}.cwa-word{display:grid;grid-template-columns:1.6rem minmax(7rem,12rem) 3.5rem minmax(0,1fr) auto;gap:.4rem;align-items:center}.cwa-n{color:var(--dm-text-muted);font-size:var(--dm-text-sm);text-align:right}.cwa-answer{text-transform:uppercase;font-family:var(--dm-font-mono, monospace);letter-spacing:.04em}.cwa-enum{color:var(--dm-text-muted);font-size:var(--dm-text-xs);white-space:nowrap}@media(max-width:700px){.cwa-word{grid-template-columns:1.4rem 1fr auto}.cwa-enum{display:none}.cwa-clue{grid-column:2 / 4}}.cwa-side{position:sticky;top:.5rem}.cwa-preview{overflow:auto;max-height:70vh}.cwa-grid{display:grid;gap:0;width:max-content;border:2px solid var(--dm-text)}.cwa-cell{position:relative;width:1.7rem;height:1.7rem;display:grid;place-items:center;font-weight:700;font-size:.85rem;background:var(--dm-background, #fff);color:var(--dm-text);border:1px solid color-mix(in srgb,var(--dm-text) 35%,transparent);box-sizing:border-box}.cwa-cell.is-block{background:var(--dm-text);border-color:var(--dm-text)}.cwa-num{position:absolute;top:1px;left:2px;font-size:.5rem;font-weight:600;line-height:1;color:var(--dm-text-muted)}.cwa-unplaced{margin:0;padding:.5rem .75rem;border-radius:6px;font-size:var(--dm-text-sm);background:color-mix(in srgb,var(--dm-warning) 15%,transparent);color:color-mix(in srgb,var(--dm-warning) 55%,var(--dm-text))}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<div class="gma cwa" data-crossword-admin></div>
|
|
@@ -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>
|