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,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quiz - the Tool. One view, three screens by address:
|
|
3
|
+
*
|
|
4
|
+
* #/plugins/quiz every quiz
|
|
5
|
+
* #/plugins/quiz/new a new quiz
|
|
6
|
+
* #/plugins/quiz/edit/<id> a quiz and its questions
|
|
7
|
+
* #/plugins/quiz/plays/<id> who played it, and its leaderboard
|
|
8
|
+
*
|
|
9
|
+
* Route params are read from the hash (Domma's router passes none).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {bannerTrail, copyText, errText, esc, field, h, icon, loadKit, playsScreen, refreshBadge, request, toggle, toLocalInput, fromLocalInput} from '/plugins/_lib/admin/games/kit.js';
|
|
13
|
+
|
|
14
|
+
const API = '/plugins/quiz';
|
|
15
|
+
const SIDEBAR = '#/plugins/quiz';
|
|
16
|
+
const ROUTE = /^#\/plugins\/quiz(\/|\?|$)/;
|
|
17
|
+
|
|
18
|
+
const KIND_LABEL = {single: 'One right answer', multiple: 'More than one right answer', truefalse: 'True or false', text: 'Type the answer'};
|
|
19
|
+
const STATUS_LABEL = {draft: 'Draft', open: 'Open', closed: 'Closed'};
|
|
20
|
+
const STATUS_TONE = {draft: '', open: 'is-success', closed: 'is-warning'};
|
|
21
|
+
const SHOW_LABEL = {end: 'At the end', 'after-each': 'After each question', never: 'Never'};
|
|
22
|
+
|
|
23
|
+
let lifetime = null;
|
|
24
|
+
let leaveGuard = null;
|
|
25
|
+
|
|
26
|
+
function where() {
|
|
27
|
+
const m = location.hash.split('?')[0].match(/^#\/plugins\/quiz(?:\/([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) => `[quiz id="${slug}" /]`;
|
|
33
|
+
|
|
34
|
+
export const quizView = {
|
|
35
|
+
templateUrl: '/plugins/quiz/admin/templates/quiz.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-quiz-admin]') || container;
|
|
45
|
+
const {screen, id} = where();
|
|
46
|
+
|
|
47
|
+
if (screen === 'plays' && id) {
|
|
48
|
+
let title = 'Plays';
|
|
49
|
+
try { title = (await request('GET', `${API}/quizzes/${encodeURIComponent(id)}`)).title; } catch { /* the screen says so */ }
|
|
50
|
+
bannerTrail(container, [{text: 'Quiz', href: SIDEBAR}, {text: `${title}: plays`}]);
|
|
51
|
+
kit.addBannerAction($container, {id: 'quiz-edit', icon: 'edit', title: 'Edit this quiz', onClick: () => { location.hash = `#/plugins/quiz/edit/${encodeURIComponent(id)}`; }});
|
|
52
|
+
return playsScreen(root, {api: API, gameId: id, title, order: 'score', back: SIDEBAR, base: '/quizzes', 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: 'Quiz'}]);
|
|
63
|
+
kit.addBannerAction($container, {id: 'quiz-new', icon: 'plus', title: 'New quiz', onClick: () => { location.hash = '#/plugins/quiz/new'; }});
|
|
64
|
+
root.innerHTML = '';
|
|
65
|
+
const slot = h('div', {className: 'gma-note dm-loading-slot', text: 'Loading…'});
|
|
66
|
+
root.append(slot);
|
|
67
|
+
let quizzes;
|
|
68
|
+
try {
|
|
69
|
+
quizzes = (await kit.whileLoading(slot, request('GET', `${API}/quizzes`), kit.SHAPES.rows)).quizzes || [];
|
|
70
|
+
} catch (err) {
|
|
71
|
+
root.innerHTML = '';
|
|
72
|
+
root.append(h('p', {className: 'gma-error', text: `Quiz 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/quiz/new'}, icon('plus'), ' New quiz'),
|
|
80
|
+
h('span', {className: 'gma-spacer'}),
|
|
81
|
+
h('span', {className: 'gma-note', text: 'Put a quiz on any page with its shortcode. The leaderboard can go on its own too.'})));
|
|
82
|
+
if (!quizzes.length) {
|
|
83
|
+
root.append(h('div', {className: 'gma-empty'}, icon('help-circle', 30),
|
|
84
|
+
h('p', {}, h('strong', {text: 'No quizzes yet.'}), h('br'), 'Make one, add questions, open it, then put its shortcode on a page.'),
|
|
85
|
+
h('a', {className: 'btn btn-primary', href: '#/plugins/quiz/new'}, 'Make a quiz')));
|
|
86
|
+
I.scan(root);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
root.append(h('div', {className: 'gma-list'}, quizzes.map(q => h('div', {className: 'gma-row'},
|
|
90
|
+
h('div', {className: 'gma-row-main'},
|
|
91
|
+
h('div', {}, h('a', {href: `#/plugins/quiz/edit/${encodeURIComponent(q.id)}`, text: q.title}),
|
|
92
|
+
h('span', {className: `gma-chip ${STATUS_TONE[q.status] || ''}`, text: STATUS_LABEL[q.status] || q.status})),
|
|
93
|
+
h('small', {text: [`${q.questions} question${q.questions === 1 ? '' : 's'}`, `${q.finished} finished of ${q.plays} play${q.plays === 1 ? '' : 's'}`,
|
|
94
|
+
q.finished ? `best ${q.best}` : '', shortcodeOf(q.slug)].filter(Boolean).join(' · ')})),
|
|
95
|
+
h('div', {className: 'gma-row-actions'},
|
|
96
|
+
h('a', {className: 'btn btn-sm btn-ghost', href: `#/plugins/quiz/edit/${encodeURIComponent(q.id)}`}, icon('edit'), ' Edit'),
|
|
97
|
+
h('a', {className: 'btn btn-sm btn-ghost', href: `#/plugins/quiz/plays/${encodeURIComponent(q.id)}`}, icon('award'), ' Plays'),
|
|
98
|
+
h('button', {type: 'button', className: 'btn btn-sm btn-ghost', title: 'Copy the shortcode', onClick: () => copyText(shortcodeOf(q.slug), 'Shortcode copied')}, icon('copy')),
|
|
99
|
+
h('button', {type: 'button', className: 'btn btn-sm btn-ghost', title: 'Make a copy', onClick: () => duplicate(q)}, icon('layers')),
|
|
100
|
+
h('button', {type: 'button', className: 'btn btn-sm btn-ghost gma-danger-text', title: 'Delete', onClick: () => remove(q)}, icon('trash')))))));
|
|
101
|
+
I.scan(root);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
async function duplicate(q) {
|
|
105
|
+
try {
|
|
106
|
+
const copy = await request('POST', `${API}/quizzes/${encodeURIComponent(q.id)}/copy`);
|
|
107
|
+
E.toast(`Copied as "${copy.title}" (a draft).`, {type: 'success'});
|
|
108
|
+
quizzes = (await request('GET', `${API}/quizzes`)).quizzes || [];
|
|
109
|
+
draw();
|
|
110
|
+
} catch (err) { E.toast(errText(err), {type: 'error'}); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function remove(q) {
|
|
114
|
+
if (!await E.confirm(`Delete the quiz <strong>${esc(q.title)}</strong>? Its ${q.plays} play${q.plays === 1 ? '' : 's'} and leaderboard go with it. Pages that show it will say it is not available.`)) return;
|
|
115
|
+
try {
|
|
116
|
+
await request('DELETE', `${API}/quizzes/${encodeURIComponent(q.id)}`);
|
|
117
|
+
quizzes = quizzes.filter(x => x.id !== q.id);
|
|
118
|
+
draw();
|
|
119
|
+
badge();
|
|
120
|
+
E.toast(`${q.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 blankQuiz = () => ({
|
|
130
|
+
title: '', slug: '', intro: '', status: 'draft', opensAt: '', closesAt: '', timeLimit: 0, questionSeconds: 0,
|
|
131
|
+
shuffleQuestions: false, shuffleOptions: true, draw: 0, showAnswers: 'end', leaderboard: true, leaderboardSize: 10, attemptsPerName: 0,
|
|
132
|
+
questions: []
|
|
133
|
+
});
|
|
134
|
+
const qid = () => Math.random().toString(36).slice(2, 10);
|
|
135
|
+
const blankQuestion = (kind = 'single') => ({
|
|
136
|
+
id: qid(), kind, text: '', image: '', points: 1, seconds: 0, explanation: '', accepted: [],
|
|
137
|
+
options: kind === 'truefalse' ? [{id: 'true', text: 'True', correct: true}, {id: 'false', text: 'False', correct: false}]
|
|
138
|
+
: kind === 'text' ? [] : [{id: qid(), text: '', correct: true}, {id: qid(), text: '', correct: false}]
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
async function editor(root, container, $container, kit, id) {
|
|
142
|
+
root.innerHTML = '';
|
|
143
|
+
let quiz = blankQuiz();
|
|
144
|
+
if (id) {
|
|
145
|
+
const slot = h('div', {className: 'gma-note dm-loading-slot', text: 'Loading…'});
|
|
146
|
+
root.append(slot);
|
|
147
|
+
try {
|
|
148
|
+
quiz = {...blankQuiz(), ...await kit.whileLoading(slot, request('GET', `${API}/quizzes/${encodeURIComponent(id)}`), kit.SHAPES.rows)};
|
|
149
|
+
} catch (err) {
|
|
150
|
+
root.innerHTML = '';
|
|
151
|
+
root.append(h('p', {className: 'gma-error', text: `That quiz could not load: ${errText(err)}`}), h('a', {href: SIDEBAR, text: 'Back to Quiz'}));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
bannerTrail(container, [{text: 'Quiz', href: SIDEBAR}, {text: id ? quiz.title : 'New quiz'}]);
|
|
156
|
+
if (id) kit.addBannerAction($container, {id: 'quiz-plays', icon: 'award', title: 'Plays and leaderboard', onClick: () => { location.hash = `#/plugins/quiz/plays/${encodeURIComponent(id)}`; }});
|
|
157
|
+
|
|
158
|
+
const snapshot = () => JSON.stringify(quiz);
|
|
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
|
+
// Text inputs write straight into `quiz`; numbers as numbers.
|
|
170
|
+
const input = (key, attrs = {}, obj = quiz) => h('input', {className: 'form-input', value: obj[key] ?? '', ...attrs,
|
|
171
|
+
onInput: (e) => { obj[key] = attrs.type === 'number' ? Number(e.target.value) || 0 : e.target.value; touched(); }});
|
|
172
|
+
const textarea = (key, rows = 3, obj = quiz, attrs = {}) => h('textarea', {className: 'form-input', rows, ...attrs,
|
|
173
|
+
onInput: (e) => { obj[key] = e.target.value; touched(); }}, obj[key] || '');
|
|
174
|
+
const select = (key, options, obj = quiz, onChange = null) => {
|
|
175
|
+
const el = h('select', {className: 'form-select', onChange: (e) => { obj[key] = e.target.value; touched(); onChange?.(e.target.value); }},
|
|
176
|
+
Object.entries(options).map(([v, label]) => h('option', {value: v, text: label})));
|
|
177
|
+
el.value = obj[key];
|
|
178
|
+
return el;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const shortcode = h('code');
|
|
182
|
+
const showShortcode = () => { shortcode.textContent = quiz.slug ? shortcodeOf(quiz.slug) : 'Saved quizzes get a shortcode'; };
|
|
183
|
+
showShortcode();
|
|
184
|
+
|
|
185
|
+
const minutes = h('input', {className: 'form-input', type: 'number', min: 0, step: 0.5, value: quiz.timeLimit ? quiz.timeLimit / 60 : 0,
|
|
186
|
+
onInput: (e) => { quiz.timeLimit = Math.round((Number(e.target.value) || 0) * 60); touched(); }});
|
|
187
|
+
const opens = h('input', {className: 'form-input', type: 'datetime-local', value: toLocalInput(quiz.opensAt), onInput: (e) => { quiz.opensAt = fromLocalInput(e.target.value); touched(); }});
|
|
188
|
+
const closes = h('input', {className: 'form-input', type: 'datetime-local', value: toLocalInput(quiz.closesAt), onInput: (e) => { quiz.closesAt = fromLocalInput(e.target.value); touched(); }});
|
|
189
|
+
|
|
190
|
+
const details = h('section', {className: 'gma-card'},
|
|
191
|
+
h('h3', {className: 'gma-h', text: 'The quiz'}),
|
|
192
|
+
h('div', {className: 'gma-grid'},
|
|
193
|
+
field('Title', input('title', {maxlength: 120, placeholder: 'Capital cities'})),
|
|
194
|
+
field('Short name', input('slug', {maxlength: 60, placeholder: 'made from the title'}), 'Used in the shortcode. Letters, numbers and hyphens.'),
|
|
195
|
+
field('Status', select('status', STATUS_LABEL), 'Only an open quiz can be played. A draft is not shown at all.')),
|
|
196
|
+
field('Introduction', textarea('intro', 3), 'Shown above the Start button.'),
|
|
197
|
+
h('div', {className: 'gma-grid'},
|
|
198
|
+
field('Opens', opens, 'Optional. Open quizzes can be played from this time.'),
|
|
199
|
+
field('Closes', closes, 'Optional. After this, it shows the leaderboard but cannot be played.')),
|
|
200
|
+
h('div', {className: 'gma-shortcode'}, h('span', {className: 'gma-muted', text: 'Shortcode:'}), shortcode,
|
|
201
|
+
h('button', {type: 'button', className: 'btn btn-xs btn-ghost', onClick: () => quiz.slug && copyText(shortcodeOf(quiz.slug), 'Shortcode copied')}, icon('copy'), ' Copy')));
|
|
202
|
+
|
|
203
|
+
const rules = h('section', {className: 'gma-card'},
|
|
204
|
+
h('h3', {className: 'gma-h', text: 'Rules and leaderboard'}),
|
|
205
|
+
h('div', {className: 'gma-grid'},
|
|
206
|
+
field('Time for the whole quiz (minutes)', minutes, '0 = no limit. When it runs out the quiz ends with what they have.'),
|
|
207
|
+
field('Seconds for each question', input('questionSeconds', {type: 'number', min: 0, max: 3600}), '0 = no limit. A question can set its own.'),
|
|
208
|
+
field('Ask this many questions', input('draw', {type: 'number', min: 0}), '0 = all of them. Fewer picks a random set for each player.'),
|
|
209
|
+
field('Goes each (per name)', input('attemptsPerName', {type: 'number', min: 0, max: 100}), '0 = as many as they like.'),
|
|
210
|
+
field('Show the right answers', select('showAnswers', SHOW_LABEL)),
|
|
211
|
+
field('Leaderboard size', input('leaderboardSize', {type: 'number', min: 3, max: 100}))),
|
|
212
|
+
h('div', {className: 'gma-grid'},
|
|
213
|
+
toggle('Shuffle the questions', quiz.shuffleQuestions, (v) => { quiz.shuffleQuestions = v; touched(); }),
|
|
214
|
+
toggle('Shuffle the answers', quiz.shuffleOptions, (v) => { quiz.shuffleOptions = v; touched(); }),
|
|
215
|
+
toggle('Show a leaderboard', quiz.leaderboard, (v) => { quiz.leaderboard = v; touched(); }, 'Highest score wins; the faster time breaks a tie.')));
|
|
216
|
+
|
|
217
|
+
const qList = h('div', {className: 'qza-questions'});
|
|
218
|
+
const qCount = h('span', {className: 'gma-muted'});
|
|
219
|
+
const questions = h('section', {className: 'gma-card'},
|
|
220
|
+
h('div', {className: 'gma-bar'}, h('h3', {className: 'gma-h', text: 'Questions'}), qCount, h('span', {className: 'gma-spacer'}),
|
|
221
|
+
h('button', {type: 'button', className: 'btn btn-sm btn-primary', onClick: () => addQuestion('single')}, icon('plus'), ' Add a question')),
|
|
222
|
+
qList,
|
|
223
|
+
h('div', {className: 'gma-bar'},
|
|
224
|
+
h('button', {type: 'button', className: 'btn btn-sm btn-outline', onClick: () => addQuestion('single')}, icon('plus'), ' Add a question')));
|
|
225
|
+
|
|
226
|
+
function addQuestion(kind) {
|
|
227
|
+
quiz.questions.push(blankQuestion(kind));
|
|
228
|
+
drawQuestions();
|
|
229
|
+
touched();
|
|
230
|
+
qList.lastElementChild?.querySelector('textarea')?.focus();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function move(i, by) {
|
|
234
|
+
const j = i + by;
|
|
235
|
+
if (j < 0 || j >= quiz.questions.length) return;
|
|
236
|
+
[quiz.questions[i], quiz.questions[j]] = [quiz.questions[j], quiz.questions[i]];
|
|
237
|
+
drawQuestions();
|
|
238
|
+
touched();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function questionCard(q, i) {
|
|
242
|
+
const n = quiz.questions.length;
|
|
243
|
+
const head = h('div', {className: 'gma-bar'},
|
|
244
|
+
h('strong', {text: `Question ${i + 1}`}),
|
|
245
|
+
select('kind', KIND_LABEL, q, (kind) => {
|
|
246
|
+
// A change of kind keeps what still makes sense.
|
|
247
|
+
if (kind === 'truefalse') q.options = blankQuestion('truefalse').options;
|
|
248
|
+
else if (kind === 'text') { q.accepted = q.accepted?.length ? q.accepted : q.options.filter(o => o.correct && o.text).map(o => o.text); q.options = []; }
|
|
249
|
+
else if (!q.options.length || q.options[0].id === 'true') q.options = blankQuestion('single').options;
|
|
250
|
+
if (kind === 'single' && q.options.filter(o => o.correct).length > 1) q.options.forEach((o, k) => { o.correct = k === q.options.findIndex(x => x.correct); });
|
|
251
|
+
drawQuestions();
|
|
252
|
+
}),
|
|
253
|
+
h('span', {className: 'gma-spacer'}),
|
|
254
|
+
h('button', {type: 'button', className: 'btn btn-xs btn-ghost', title: 'Move up', disabled: i === 0, onClick: () => move(i, -1)}, icon('arrow-up')),
|
|
255
|
+
h('button', {type: 'button', className: 'btn btn-xs btn-ghost', title: 'Move down', disabled: i === n - 1, onClick: () => move(i, 1)}, icon('arrow-down')),
|
|
256
|
+
h('button', {type: 'button', className: 'btn btn-xs btn-ghost', title: 'Copy this question', onClick: () => {
|
|
257
|
+
quiz.questions.splice(i + 1, 0, {...structuredClone(q), id: qid(), options: q.options.map(o => ({...o, id: o.id === 'true' || o.id === 'false' ? o.id : qid()}))});
|
|
258
|
+
drawQuestions(); touched();
|
|
259
|
+
}}, icon('copy')),
|
|
260
|
+
h('button', {type: 'button', className: 'btn btn-xs btn-ghost gma-danger-text', title: 'Delete this question', onClick: async () => {
|
|
261
|
+
if (q.text && !await E.confirm(`Delete question ${i + 1}?`)) return;
|
|
262
|
+
quiz.questions.splice(i, 1);
|
|
263
|
+
drawQuestions(); touched();
|
|
264
|
+
}}, icon('trash')));
|
|
265
|
+
|
|
266
|
+
const answers = h('div', {className: 'qza-answers'});
|
|
267
|
+
if (q.kind === 'text') {
|
|
268
|
+
const acc = h('textarea', {className: 'form-input', rows: 3, placeholder: 'Tokyo',
|
|
269
|
+
onInput: (e) => { q.accepted = e.target.value.split('\n'); touched(); }}, (q.accepted || []).join('\n'));
|
|
270
|
+
answers.append(field('Accepted answers, one a line', acc, 'Capitals, accents, spaces, punctuation and a leading "the" do not matter.'));
|
|
271
|
+
} else {
|
|
272
|
+
const group = `qz-${q.id}`;
|
|
273
|
+
answers.append(h('p', {className: 'gma-note', text: q.kind === 'multiple' ? 'Tick every right answer.' : 'Choose the right answer.'}));
|
|
274
|
+
q.options.forEach((o, k) => {
|
|
275
|
+
const mark = h('input', {type: q.kind === 'multiple' ? 'checkbox' : 'radio', name: group, checked: o.correct, title: 'Right answer',
|
|
276
|
+
onChange: (e) => {
|
|
277
|
+
if (q.kind === 'multiple') o.correct = e.target.checked;
|
|
278
|
+
else q.options.forEach(x => { x.correct = x === o; });
|
|
279
|
+
touched();
|
|
280
|
+
}});
|
|
281
|
+
const text = q.kind === 'truefalse'
|
|
282
|
+
? h('span', {className: 'qza-fixed', text: o.text})
|
|
283
|
+
: h('input', {className: 'form-input', value: o.text, maxlength: 200, placeholder: `Answer ${k + 1}`, onInput: (e) => { o.text = e.target.value; touched(); }});
|
|
284
|
+
answers.append(h('div', {className: `qza-answer${o.correct ? ' is-right' : ''}`},
|
|
285
|
+
h('label', {className: 'qza-mark'}, mark, h('span', {className: 'sr-only', text: 'Right answer'})), text,
|
|
286
|
+
q.kind !== 'truefalse' ? h('button', {type: 'button', className: 'btn btn-xs btn-ghost', title: 'Remove this answer', disabled: q.options.length <= 2,
|
|
287
|
+
onClick: () => { q.options.splice(k, 1); drawQuestions(); touched(); }}, icon('x')) : null));
|
|
288
|
+
mark.addEventListener('change', () => answers.querySelectorAll('.qza-answer').forEach((row, r) => row.classList.toggle('is-right', q.options[r]?.correct)));
|
|
289
|
+
});
|
|
290
|
+
if (q.kind !== 'truefalse' && q.options.length < 8) {
|
|
291
|
+
answers.append(h('button', {type: 'button', className: 'btn btn-xs btn-ghost', onClick: () => {
|
|
292
|
+
q.options.push({id: qid(), text: '', correct: false}); drawQuestions(); touched();
|
|
293
|
+
qList.children[i]?.querySelectorAll('.qza-answer input.form-input')[q.options.length - 1]?.focus();
|
|
294
|
+
}}, icon('plus'), ' Add an answer'));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return h('div', {className: 'qza-question', dataset: {id: q.id}},
|
|
299
|
+
head,
|
|
300
|
+
field('Question', textarea('text', 2, q, {maxlength: 1000, placeholder: 'What is the capital of Japan?'})),
|
|
301
|
+
answers,
|
|
302
|
+
h('details', {className: 'qza-more', open: Boolean(q.image || q.explanation || q.seconds || q.points !== 1)},
|
|
303
|
+
h('summary', {text: 'Points, time, picture and explanation'}),
|
|
304
|
+
h('div', {className: 'gma-grid'},
|
|
305
|
+
field('Points', input('points', {type: 'number', min: 0, max: 100}, q)),
|
|
306
|
+
field('Seconds for this question', input('seconds', {type: 'number', min: 0, max: 3600}, q), '0 = the quiz\'s own setting.'),
|
|
307
|
+
field('Picture (address)', input('image', {placeholder: '/media/…', maxlength: 500}, q), 'A picture from Media, or an https:// address.')),
|
|
308
|
+
field('Explanation', textarea('explanation', 2, q, {maxlength: 1000}), 'Shown with the right answer, when the quiz shows answers.')));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function drawQuestions() {
|
|
312
|
+
qList.innerHTML = '';
|
|
313
|
+
quiz.questions.forEach((q, i) => qList.append(questionCard(q, i)));
|
|
314
|
+
if (!quiz.questions.length) qList.append(h('p', {className: 'gma-muted', text: 'No questions yet.'}));
|
|
315
|
+
const total = quiz.questions.reduce((s, q) => s + (Number(q.points) || 0), 0);
|
|
316
|
+
qCount.textContent = quiz.questions.length ? `${quiz.questions.length} · ${total} point${total === 1 ? '' : 's'}` : '';
|
|
317
|
+
I.scan(qList);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const saveBtn = h('button', {type: 'button', className: 'btn btn-primary', onClick: () => save()}, icon('check'), ' Save');
|
|
321
|
+
const bar = h('div', {className: 'gma-save'}, saveBtn,
|
|
322
|
+
h('a', {className: 'btn btn-ghost', href: SIDEBAR, text: 'Back to the list'}), dirtyNote, h('span', {className: 'gma-spacer'}), errorBox);
|
|
323
|
+
|
|
324
|
+
async function save() {
|
|
325
|
+
errorBox.hidden = true;
|
|
326
|
+
saveBtn.disabled = true;
|
|
327
|
+
const body = {...quiz, questions: quiz.questions.map(q => ({...q, accepted: (q.accepted || []).map(a => a.trim()).filter(Boolean)}))};
|
|
328
|
+
try {
|
|
329
|
+
const out = id ? await request('PUT', `${API}/quizzes/${encodeURIComponent(id)}`, body) : await request('POST', `${API}/quizzes`, body);
|
|
330
|
+
quiz = {...blankQuiz(), ...out};
|
|
331
|
+
saved = snapshot();
|
|
332
|
+
touched();
|
|
333
|
+
showShortcode();
|
|
334
|
+
E.toast(`${out.title} saved.`, {type: 'success'});
|
|
335
|
+
if (!id) { location.hash = `#/plugins/quiz/edit/${encodeURIComponent(out.id)}`; return; }
|
|
336
|
+
bannerTrail(container, [{text: 'Quiz', href: SIDEBAR}, {text: out.title}]);
|
|
337
|
+
drawQuestions();
|
|
338
|
+
} catch (err) {
|
|
339
|
+
errorBox.textContent = errText(err);
|
|
340
|
+
errorBox.hidden = false;
|
|
341
|
+
} finally {
|
|
342
|
+
saveBtn.disabled = false;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
root.innerHTML = '';
|
|
347
|
+
root.append(details, rules, questions, bar);
|
|
348
|
+
drawQuestions();
|
|
349
|
+
I.scan(root);
|
|
350
|
+
if (!id) details.querySelector('input')?.focus();
|
|
351
|
+
}
|
|
352
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"slug": "quiz-plays",
|
|
3
|
+
"title": "Quiz - Plays",
|
|
4
|
+
"description": "Every go at a quiz: the name, the score and the time. Look at them in Quiz, not here.",
|
|
5
|
+
"plugin": "quiz",
|
|
6
|
+
"fields": [
|
|
7
|
+
{"name": "gameId", "label": "Quiz", "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": "quiz-quizzes",
|
|
3
|
+
"title": "Quiz - Quizzes",
|
|
4
|
+
"description": "The quizzes and their questions, answers included. Edit them in Quiz, not here.",
|
|
5
|
+
"plugin": "quiz",
|
|
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/quiz/public/quiz.js?v=1.0.0"></script>
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quiz - one play moving forward, by the server's clock. PURE: each function
|
|
3
|
+
* takes the quiz, the play and `now`, changes the play in place and says
|
|
4
|
+
* whether it changed (the route then saves it).
|
|
5
|
+
*
|
|
6
|
+
* catchUp(quiz, play, now) time that has run out while nobody asked: a question
|
|
7
|
+
* past its limit is marked as not answered; past the
|
|
8
|
+
* quiz's limit, the play ends
|
|
9
|
+
* serve(quiz, play, now) the current question is now in front of the player
|
|
10
|
+
* answer(quiz, play, qid, given, now) one answer, marked; the play moves on (or ends)
|
|
11
|
+
* view(quiz, play, now) what the player may know of their play now
|
|
12
|
+
*
|
|
13
|
+
* @module quiz/lib/engine
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import {GRACE_MS, leftOf, overdue} from '../../_lib/games/timing.js';
|
|
17
|
+
import {mark, publicQuestion, questionLimitMs, rightText} from './quiz.js';
|
|
18
|
+
|
|
19
|
+
const questionOf = (quiz, play) => {
|
|
20
|
+
const slot = play.state.order[play.state.index];
|
|
21
|
+
return slot ? quiz.questions.find(q => q.id === slot.q) || null : null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const scoreOf = (play) => play.state.answers.reduce((s, a) => s + (a.points || 0), 0);
|
|
25
|
+
|
|
26
|
+
/** Ends the play. Out of time: the time counted is the limit itself, never more. */
|
|
27
|
+
export function end(quiz, play, now, {timeUp = false} = {}) {
|
|
28
|
+
const limit = (quiz.timeLimit || 0) * 1000;
|
|
29
|
+
play.status = 'finished';
|
|
30
|
+
play.finishedAt = new Date(now).toISOString();
|
|
31
|
+
play.score = scoreOf(play);
|
|
32
|
+
const spent = Math.max(0, now - Date.parse(play.startedAt));
|
|
33
|
+
play.timeMs = limit ? Math.min(spent, limit) : spent;
|
|
34
|
+
play.state.timeUp = timeUp;
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function skip(quiz, play, now, why) {
|
|
39
|
+
const q = questionOf(quiz, play);
|
|
40
|
+
play.state.answers.push({q: q?.id || '', given: null, correct: false, points: 0, late: true, why, at: new Date(now).toISOString()});
|
|
41
|
+
play.state.index += 1;
|
|
42
|
+
if (play.state.index >= play.state.order.length) end(quiz, play, now);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Bring the play up to date with the clock. */
|
|
46
|
+
export function catchUp(quiz, play, now = Date.now()) {
|
|
47
|
+
if (play.status !== 'playing') return false;
|
|
48
|
+
let changed = false;
|
|
49
|
+
if (overdue(play.startedAt, (quiz.timeLimit || 0) * 1000, now)) return end(quiz, play, now, {timeUp: true});
|
|
50
|
+
// Only a question that was SHOWN can run out: one never fetched has not started its clock.
|
|
51
|
+
while (play.status === 'playing') {
|
|
52
|
+
const q = questionOf(quiz, play);
|
|
53
|
+
if (!q) { end(quiz, play, now); return true; }
|
|
54
|
+
const served = play.state.servedAt[play.state.index];
|
|
55
|
+
if (!served || !overdue(served, questionLimitMs(quiz, q), now)) break;
|
|
56
|
+
skip(quiz, play, now, 'time');
|
|
57
|
+
changed = true;
|
|
58
|
+
}
|
|
59
|
+
return changed;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** The current question is on the player's screen from now - the first time only, so a reload does not reset its clock. */
|
|
63
|
+
export function serve(quiz, play, now = Date.now()) {
|
|
64
|
+
if (play.status !== 'playing' || play.state.servedAt[play.state.index]) return false;
|
|
65
|
+
play.state.servedAt[play.state.index] = new Date(now).toISOString();
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* An answer to question `qid`. Refused (`error`) when it is not the question
|
|
71
|
+
* they are on - an old tab, a replayed request. Late is marked wrong.
|
|
72
|
+
*
|
|
73
|
+
* @returns {{error?: string, correct?: boolean, late?: boolean, points?: number, q?: object}}
|
|
74
|
+
*/
|
|
75
|
+
export function answer(quiz, play, qid, given, now = Date.now()) {
|
|
76
|
+
if (play.status !== 'playing') return {error: 'This play has finished.'};
|
|
77
|
+
const q = questionOf(quiz, play);
|
|
78
|
+
if (!q || q.id !== String(qid)) return {error: 'That is not the question you are on. Carry on from where you are.', code: 'not-current'};
|
|
79
|
+
const served = play.state.servedAt[play.state.index];
|
|
80
|
+
if (!served) return {error: 'That question has not been shown yet.', code: 'not-current'};
|
|
81
|
+
const late = overdue(served, questionLimitMs(quiz, q), now) || overdue(play.startedAt, (quiz.timeLimit || 0) * 1000, now);
|
|
82
|
+
const result = late ? {correct: false, given: null} : mark(q, given);
|
|
83
|
+
const points = result.correct ? q.points : 0;
|
|
84
|
+
play.state.answers.push({q: q.id, given: result.given, correct: result.correct, points, late, at: new Date(now).toISOString()});
|
|
85
|
+
play.state.index += 1;
|
|
86
|
+
play.score = scoreOf(play);
|
|
87
|
+
if (play.state.index >= play.state.order.length) end(quiz, play, now);
|
|
88
|
+
else if (overdue(play.startedAt, (quiz.timeLimit || 0) * 1000, now)) end(quiz, play, now, {timeUp: true});
|
|
89
|
+
return {correct: result.correct, late, points, q};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Feedback straight after an answer, when the quiz shows answers as you go. */
|
|
93
|
+
export function feedbackFor(quiz, q, outcome) {
|
|
94
|
+
if (quiz.showAnswers !== 'after-each' || !q) return null;
|
|
95
|
+
return {correct: outcome.correct, late: outcome.late, rightAnswer: rightText(q), explanation: q.explanation || ''};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** What the player may know of their play right now. */
|
|
99
|
+
export function view(quiz, play, now = Date.now()) {
|
|
100
|
+
const total = play.state.order.length;
|
|
101
|
+
const base = {
|
|
102
|
+
status: play.status, index: Math.min(play.state.index, total), total,
|
|
103
|
+
elapsedMs: Math.max(0, (play.status === 'playing' ? now : Date.parse(play.finishedAt)) - Date.parse(play.startedAt)),
|
|
104
|
+
score: play.score || 0, grace: GRACE_MS
|
|
105
|
+
};
|
|
106
|
+
if (play.status !== 'playing') return base;
|
|
107
|
+
const q = publicQuestion(quiz, play.state);
|
|
108
|
+
const served = play.state.servedAt[play.state.index];
|
|
109
|
+
return {
|
|
110
|
+
...base,
|
|
111
|
+
question: q,
|
|
112
|
+
questionLeftMs: served ? leftOf(served, questionLimitMs(quiz, questionOf(quiz, play)), now) : null,
|
|
113
|
+
totalLeftMs: leftOf(play.startedAt, (quiz.timeLimit || 0) * 1000, now)
|
|
114
|
+
};
|
|
115
|
+
}
|