domma-cms 1.8.46 → 1.9.0
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/admin/js/templates/docs/usage-users-roles.html +4 -0
- package/bin/lib/left-free-tier.js +83 -0
- package/bin/update.js +24 -0
- package/docs/DommaFeatures.md +47 -7
- package/docs/plugin-development.md +7 -0
- package/package.json +1 -1
- package/plugins/_lib/games/names.js +6 -0
- package/plugins/_lib/public/games/motion.css +1 -0
- package/plugins/_lib/public/games/motion.js +28 -0
- package/plugins/free-tier.lock.json +1 -43
- package/server/routes/api/api-endpoints.js +5 -1
- package/server/routes/api/collections.js +4 -3
- package/server/routes/api/forms.js +22 -0
- package/server/routes/api/views.js +18 -0
- package/server/services/collectionAccess.js +97 -15
- package/server/services/permissionRegistry.js +39 -11
- package/plugins/crossword/CLAUDE.md +0 -37
- package/plugins/crossword/admin/css/index.css +0 -18
- package/plugins/crossword/admin/templates/crossword.html +0 -1
- package/plugins/crossword/admin/views/crossword.js +0 -449
- package/plugins/crossword/collections/crossword-plays.json +0 -20
- package/plugins/crossword/collections/crossword-puzzles.json +0 -17
- package/plugins/crossword/config.js +0 -6
- package/plugins/crossword/docs/guide.md +0 -53
- package/plugins/crossword/inject/body.html +0 -1
- package/plugins/crossword/lib/crossword.js +0 -170
- package/plugins/crossword/lib/store.js +0 -56
- package/plugins/crossword/plugin.js +0 -274
- package/plugins/crossword/plugin.json +0 -79
- package/plugins/crossword/public/crossword.css +0 -36
- package/plugins/crossword/public/crossword.js +0 -519
- package/plugins/crossword/public/lib/layout.js +0 -199
- package/plugins/quiz/CLAUDE.md +0 -43
- package/plugins/quiz/admin/css/index.css +0 -13
- package/plugins/quiz/admin/templates/quiz.html +0 -1
- package/plugins/quiz/admin/views/quiz.js +0 -530
- package/plugins/quiz/collections/quiz-plays.json +0 -20
- package/plugins/quiz/collections/quiz-quizzes.json +0 -17
- package/plugins/quiz/config.js +0 -6
- package/plugins/quiz/docs/guide.md +0 -61
- package/plugins/quiz/inject/body.html +0 -1
- package/plugins/quiz/lib/engine.js +0 -115
- package/plugins/quiz/lib/quiz.js +0 -225
- package/plugins/quiz/lib/store.js +0 -56
- package/plugins/quiz/plugin.js +0 -275
- package/plugins/quiz/plugin.json +0 -79
- package/plugins/quiz/public/quiz.css +0 -36
- package/plugins/quiz/public/quiz.js +0 -372
|
@@ -1,530 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Quiz - the Tool. One view, four 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/test/<id> play it as a player would - a test go, never on the leaderboard
|
|
8
|
-
* #/plugins/quiz/plays/<id> who played it, and its leaderboard
|
|
9
|
-
*
|
|
10
|
-
* Route params are read from the hash (Domma's router passes none). Every
|
|
11
|
-
* screen is domma-reactive (observables + one M.applyBindings, through the
|
|
12
|
-
* admin kit's mountScreen); templates live here, not in the templateUrl file,
|
|
13
|
-
* because that one goes through _.render, which eats a data-each row's {{ }}.
|
|
14
|
-
*/
|
|
15
|
-
|
|
16
|
-
import {bannerTrail, copyText, errText, esc, failed, fromLocalInput, ico, loadKit, loading, mountScreen, playsScreen, refreshBadge, request, saveKey, sortableRows, testScreen, toLocalInput, wireRows} from '/plugins/_lib/admin/games/kit.js';
|
|
17
|
-
|
|
18
|
-
const API = '/plugins/quiz';
|
|
19
|
-
const SIDEBAR = '#/plugins/quiz';
|
|
20
|
-
const ROUTE = /^#\/plugins\/quiz(\/|\?|$)/;
|
|
21
|
-
const PLAYER = '/plugins/quiz/public/quiz.js?v=1.1.0';
|
|
22
|
-
|
|
23
|
-
const KIND_LABEL = {single: 'One right answer', multiple: 'More than one right answer', truefalse: 'True or false', text: 'Type the answer'};
|
|
24
|
-
const STATUS_LABEL = {draft: 'Draft', open: 'Open', closed: 'Closed'};
|
|
25
|
-
const STATUS_TONE = {draft: '', open: 'is-success', closed: 'is-warning'};
|
|
26
|
-
const SHOW_LABEL = {end: 'At the end', 'after-each': 'After each question', never: 'Never'};
|
|
27
|
-
|
|
28
|
-
let lifetime = null;
|
|
29
|
-
let leaveGuard = null;
|
|
30
|
-
// Each mount's number: a screen whose data arrives after the admin has moved on draws nothing.
|
|
31
|
-
let mountSeq = 0;
|
|
32
|
-
|
|
33
|
-
function where() {
|
|
34
|
-
const m = location.hash.split('?')[0].match(/^#\/plugins\/quiz(?:\/([a-z]+))?(?:\/([^/?#]+))?/);
|
|
35
|
-
return {screen: m?.[1] || 'list', id: m?.[2] ? decodeURIComponent(m[2]) : ''};
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
const badge = () => refreshBadge(SIDEBAR);
|
|
39
|
-
const shortcodeOf = (slug) => `[quiz id="${slug}" /]`;
|
|
40
|
-
const href = (screen, id) => `#/plugins/quiz/${screen}/${encodeURIComponent(id)}`;
|
|
41
|
-
const options = (labels) => Object.entries(labels).map(([v, t]) => `<option value="${v}">${esc(t)}</option>`).join('');
|
|
42
|
-
|
|
43
|
-
export const quizView = {
|
|
44
|
-
templateUrl: '/plugins/quiz/admin/templates/quiz.html',
|
|
45
|
-
|
|
46
|
-
async onMount($container) {
|
|
47
|
-
const kit = await loadKit();
|
|
48
|
-
lifetime ??= kit.viewLifetime(ROUTE);
|
|
49
|
-
if (!ROUTE.test(location.hash)) return;
|
|
50
|
-
lifetime.reset();
|
|
51
|
-
const n = ++mountSeq;
|
|
52
|
-
const alive = () => n === mountSeq && ROUTE.test(location.hash);
|
|
53
|
-
if (leaveGuard) { window.removeEventListener('beforeunload', leaveGuard); leaveGuard = null; }
|
|
54
|
-
const container = $container.get(0);
|
|
55
|
-
const root = container.querySelector('[data-quiz-admin]') || container;
|
|
56
|
-
const {screen, id} = where();
|
|
57
|
-
|
|
58
|
-
if ((screen === 'plays' || screen === 'test') && id) {
|
|
59
|
-
let quiz = null;
|
|
60
|
-
try { quiz = await request('GET', `${API}/quizzes/${encodeURIComponent(id)}`); } catch { /* the screen says so */ }
|
|
61
|
-
if (!alive()) return;
|
|
62
|
-
const title = quiz?.title || 'Quiz';
|
|
63
|
-
bannerTrail(container, [{text: 'Quiz', href: SIDEBAR}, {text: `${title}: ${screen === 'test' ? 'test' : 'plays'}`}]);
|
|
64
|
-
kit.addBannerAction($container, {id: 'quiz-edit', icon: 'edit', title: 'Edit this quiz', onClick: () => { location.hash = href('edit', id); }});
|
|
65
|
-
if (screen === 'plays') {
|
|
66
|
-
kit.addBannerAction($container, {id: 'quiz-test', icon: 'play', title: 'Test it', onClick: () => { location.hash = href('test', id); }});
|
|
67
|
-
return playsScreen(root, {api: API, gameId: id, title, order: 'score', back: SIDEBAR, base: '/quizzes', badge, lifetime, alive, testHref: href('test', id)});
|
|
68
|
-
}
|
|
69
|
-
kit.addBannerAction($container, {id: 'quiz-plays', icon: 'award', title: 'Plays and leaderboard', onClick: () => { location.hash = href('plays', id); }});
|
|
70
|
-
if (!quiz) return failed(root, 'That quiz is not here any more.', {href: SIDEBAR, text: 'Back to Quiz'});
|
|
71
|
-
return testScreen(root, {api: API, base: '/quizzes', gameId: id, player: PLAYER, appClass: 'qz-app',
|
|
72
|
-
editHref: href('edit', id), playsHref: href('plays', id), statusText: STATUS_LABEL[quiz.status] || '', lifetime, alive});
|
|
73
|
-
}
|
|
74
|
-
if (screen === 'new' || (screen === 'edit' && id)) return editor(root, container, $container, kit, screen === 'new' ? '' : id, alive);
|
|
75
|
-
return list(root, container, $container, kit, alive);
|
|
76
|
-
}
|
|
77
|
-
};
|
|
78
|
-
|
|
79
|
-
// ---- The list ----------------------------------------------------------------------
|
|
80
|
-
|
|
81
|
-
const LIST_HTML = `
|
|
82
|
-
<div class="gma-bar">
|
|
83
|
-
<a class="btn btn-sm btn-primary" href="#/plugins/quiz/new">${ico('plus')} New quiz</a>
|
|
84
|
-
<span class="gma-spacer"></span>
|
|
85
|
-
<span class="gma-note">Put a quiz on any page with its shortcode. The leaderboard can go on its own too.</span>
|
|
86
|
-
<span data-help-title="Quiz" data-help="Make a quiz, add its questions, and Test it - you play it exactly as your players will, and a test go never reaches the leaderboard. When it is ready, set it to Open and put its shortcode on a page. Right-click a quiz (or its ⋮) for everything else."></span>
|
|
87
|
-
</div>
|
|
88
|
-
<div class="gma-empty" hidden data-bind-hidden="rows.value.length">
|
|
89
|
-
${ico('help-circle', 30)}
|
|
90
|
-
<p><strong>No quizzes yet.</strong><br>Make one, add questions, test it, open it, then put its shortcode on a page.</p>
|
|
91
|
-
<a class="btn btn-primary" href="#/plugins/quiz/new">Make a quiz</a>
|
|
92
|
-
</div>
|
|
93
|
-
<div class="gma-list" data-part="list" data-each="rows.value key=id"></div>`;
|
|
94
|
-
|
|
95
|
-
const LIST_ROW = `
|
|
96
|
-
<div class="gma-row" tabindex="0" data-id="{{id}}">
|
|
97
|
-
<div class="gma-row-main">
|
|
98
|
-
<div><a data-bind-href="editHref" data-bind-text="title"></a><span class="gma-chip" data-bind-class="tone" data-bind-text="statusText"></span></div>
|
|
99
|
-
<small data-bind-text="meta"></small>
|
|
100
|
-
</div>
|
|
101
|
-
<div class="gma-row-actions">
|
|
102
|
-
<a class="btn btn-sm btn-ghost" data-bind-href="testHref" title="Play it as your players will - not saved to the leaderboard">${ico('play')} Test</a>
|
|
103
|
-
<a class="btn btn-sm btn-ghost" data-bind-href="playsHref">${ico('award')} Plays</a>
|
|
104
|
-
<button type="button" class="gma-kebab" aria-label="Actions" title="Actions">${ico('more-vertical', 16)}</button>
|
|
105
|
-
</div>
|
|
106
|
-
</div>`;
|
|
107
|
-
|
|
108
|
-
async function list(root, container, $container, kit, alive) {
|
|
109
|
-
bannerTrail(container, [{text: 'Quiz'}]);
|
|
110
|
-
kit.addBannerAction($container, {id: 'quiz-new', icon: 'plus', title: 'New quiz', onClick: () => { location.hash = '#/plugins/quiz/new'; }});
|
|
111
|
-
let got;
|
|
112
|
-
try {
|
|
113
|
-
got = await loading(root, request('GET', `${API}/quizzes`), kit);
|
|
114
|
-
} catch (err) {
|
|
115
|
-
if (!alive()) return;
|
|
116
|
-
return failed(root, `Quiz could not load: ${errText(err)}`);
|
|
117
|
-
}
|
|
118
|
-
|
|
119
|
-
if (!alive()) return;
|
|
120
|
-
const quizzes = M.observable(got.quizzes || []);
|
|
121
|
-
const rows = M.observable([]);
|
|
122
|
-
lifetime.add(M.effect(() => {
|
|
123
|
-
rows.value = quizzes.value.map(q => ({
|
|
124
|
-
id: q.id, title: q.title, statusText: STATUS_LABEL[q.status] || q.status, tone: STATUS_TONE[q.status] || '',
|
|
125
|
-
editHref: href('edit', q.id), testHref: href('test', q.id), playsHref: href('plays', q.id),
|
|
126
|
-
meta: [`${q.questions} question${q.questions === 1 ? '' : 's'}`, `${q.finished} finished of ${q.plays} play${q.plays === 1 ? '' : 's'}`,
|
|
127
|
-
q.finished ? `best ${q.best}` : '', shortcodeOf(q.slug)].filter(Boolean).join(' · ')
|
|
128
|
-
}));
|
|
129
|
-
}));
|
|
130
|
-
|
|
131
|
-
const byId = (id) => quizzes.peek().find(q => q.id === id);
|
|
132
|
-
|
|
133
|
-
async function duplicate(q) {
|
|
134
|
-
try {
|
|
135
|
-
const copy = await request('POST', `${API}/quizzes/${encodeURIComponent(q.id)}/copy`);
|
|
136
|
-
E.toast(`Copied as "${copy.title}" (a draft).`, {type: 'success'});
|
|
137
|
-
quizzes.value = (await request('GET', `${API}/quizzes`)).quizzes || [];
|
|
138
|
-
} catch (err) { E.toast(errText(err), {type: 'error'}); }
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
async function remove(q) {
|
|
142
|
-
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;
|
|
143
|
-
try {
|
|
144
|
-
await request('DELETE', `${API}/quizzes/${encodeURIComponent(q.id)}`);
|
|
145
|
-
quizzes.value = quizzes.peek().filter(x => x.id !== q.id);
|
|
146
|
-
badge();
|
|
147
|
-
E.toast(`${q.title} deleted.`, {type: 'success'});
|
|
148
|
-
} catch (err) { E.toast(errText(err), {type: 'error'}); }
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
const el = mountScreen(root, {
|
|
152
|
-
html: LIST_HTML, model: {rows}, lifetime, k: kit, className: 'gma-quiz-list', rescan: [rows],
|
|
153
|
-
before: (screen) => { screen.querySelector('[data-part="list"]').innerHTML = LIST_ROW; }
|
|
154
|
-
});
|
|
155
|
-
wireRows(el.querySelector('[data-part="list"]'), {
|
|
156
|
-
k: kit, lifetime,
|
|
157
|
-
main: (id) => { location.hash = href('edit', id); },
|
|
158
|
-
items: (id) => {
|
|
159
|
-
const q = byId(id);
|
|
160
|
-
if (!q) return [];
|
|
161
|
-
return [
|
|
162
|
-
{label: 'Edit', icon: 'edit', action: () => { location.hash = href('edit', id); }},
|
|
163
|
-
{label: 'Test it', icon: 'play', action: () => { location.hash = href('test', id); }},
|
|
164
|
-
{label: 'Plays and leaderboard', icon: 'award', action: () => { location.hash = href('plays', id); }},
|
|
165
|
-
{label: 'Copy the shortcode', icon: 'copy', action: () => copyText(shortcodeOf(q.slug), 'Shortcode copied')},
|
|
166
|
-
{label: 'Make a copy', icon: 'layers', action: () => duplicate(q)},
|
|
167
|
-
{type: 'divider'},
|
|
168
|
-
{label: 'Delete…', icon: 'trash', action: () => remove(q)}
|
|
169
|
-
];
|
|
170
|
-
}
|
|
171
|
-
});
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
// ---- The editor --------------------------------------------------------------------
|
|
175
|
-
|
|
176
|
-
const blankQuiz = () => ({
|
|
177
|
-
title: '', slug: '', intro: '', status: 'draft', opensAt: '', closesAt: '', timeLimit: 0, questionSeconds: 0,
|
|
178
|
-
shuffleQuestions: false, shuffleOptions: true, draw: 0, showAnswers: 'end', leaderboard: true, leaderboardSize: 10, attemptsPerName: 0,
|
|
179
|
-
questions: []
|
|
180
|
-
});
|
|
181
|
-
const qid = () => Math.random().toString(36).slice(2, 10);
|
|
182
|
-
const TRUE_FALSE = () => [{id: 'true', text: 'True', correct: true}, {id: 'false', text: 'False', correct: false}];
|
|
183
|
-
const TWO_BLANK = () => [{id: qid(), text: '', correct: true}, {id: qid(), text: '', correct: false}];
|
|
184
|
-
|
|
185
|
-
/** One answer of a question, as the editor binds it. */
|
|
186
|
-
const optionModel = (o) => ({id: o.id || qid(), text: M.observable(o.text || ''), correct: M.observable(o.correct === true)});
|
|
187
|
-
|
|
188
|
-
/** One question, as the editor binds it: every field an observable, the answers an observableArray. */
|
|
189
|
-
function questionModel(q = {}) {
|
|
190
|
-
const kind = q.kind || 'single';
|
|
191
|
-
return {
|
|
192
|
-
id: q.id || qid(),
|
|
193
|
-
label: M.observable(''),
|
|
194
|
-
kind: M.observable(kind),
|
|
195
|
-
text: M.observable(q.text || ''),
|
|
196
|
-
image: M.observable(q.image || ''),
|
|
197
|
-
points: M.observable(q.points ?? 1),
|
|
198
|
-
seconds: M.observable(q.seconds || 0),
|
|
199
|
-
explanation: M.observable(q.explanation || ''),
|
|
200
|
-
accepted: M.observable((q.accepted || []).join('\n')),
|
|
201
|
-
options: M.observableArray((q.options?.length ? q.options : kind === 'truefalse' ? TRUE_FALSE() : kind === 'text' ? [] : TWO_BLANK()).map(optionModel)),
|
|
202
|
-
moreOpen: Boolean(q.image || q.explanation || q.seconds || (q.points ?? 1) !== 1)
|
|
203
|
-
};
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
const num = (v) => Number(v) || 0;
|
|
207
|
-
const plainQuestion = (q) => ({
|
|
208
|
-
id: q.id, kind: q.kind.peek(), text: q.text.peek(), image: q.image.peek(), points: num(q.points.peek()), seconds: num(q.seconds.peek()),
|
|
209
|
-
explanation: q.explanation.peek(),
|
|
210
|
-
accepted: q.kind.peek() === 'text' ? q.accepted.peek().split('\n').map(a => a.trim()).filter(Boolean) : [],
|
|
211
|
-
options: q.kind.peek() === 'text' ? [] : q.options.peek().map(o => ({id: o.id, text: o.text.peek(), correct: o.correct.peek()}))
|
|
212
|
-
});
|
|
213
|
-
|
|
214
|
-
const field = (id, label, control, {hint = '', help = ''} = {}) => `
|
|
215
|
-
<div class="form-group gma-field">
|
|
216
|
-
<label class="form-label" for="${id}">${esc(label)}${help ? ` <span data-help-title="${esc(label)}" data-help="${esc(help)}"></span>` : ''}</label>
|
|
217
|
-
${control}
|
|
218
|
-
${hint ? `<p class="form-hint">${esc(hint)}</p>` : ''}
|
|
219
|
-
</div>`;
|
|
220
|
-
const check = (bool, label, hint = '') => `
|
|
221
|
-
<div class="form-group gma-field">
|
|
222
|
-
<label class="gma-toggle"><input type="checkbox" data-bool="${bool}"> <span>${esc(label)}</span></label>
|
|
223
|
-
${hint ? `<p class="form-hint">${esc(hint)}</p>` : ''}
|
|
224
|
-
</div>`;
|
|
225
|
-
|
|
226
|
-
const EDITOR_HTML = `
|
|
227
|
-
<section class="gma-card">
|
|
228
|
-
<h3 class="gma-h">The quiz</h3>
|
|
229
|
-
<div class="gma-grid">
|
|
230
|
-
${field('qz-title', 'Title', '<input id="qz-title" class="form-input" maxlength="120" placeholder="Capital cities" data-model="f.title.value">')}
|
|
231
|
-
${field('qz-slug', 'Short name', '<input id="qz-slug" class="form-input" maxlength="60" placeholder="made from the title" data-model="f.slug.value">',
|
|
232
|
-
{help: 'Used in the shortcode. Letters, numbers and hyphens; made from the title when empty.'})}
|
|
233
|
-
${field('qz-status', 'Status', `<select id="qz-status" class="form-select" data-model="f.status.value">${options(STATUS_LABEL)}</select>`,
|
|
234
|
-
{help: 'Only an open quiz can be played on a page. A draft is not shown at all; a closed one shows its leaderboard. Test works whatever the status.'})}
|
|
235
|
-
</div>
|
|
236
|
-
${field('qz-intro', 'Introduction', '<textarea id="qz-intro" class="form-input" rows="3" data-model="f.intro.value"></textarea>', {hint: 'Shown above the Start button.'})}
|
|
237
|
-
<div class="gma-grid">
|
|
238
|
-
${field('qz-opens', 'Opens', '<input id="qz-opens" class="form-input" type="datetime-local" data-model="f.opensAt.value">', {hint: 'Optional. Open quizzes can be played from this time.'})}
|
|
239
|
-
${field('qz-closes', 'Closes', '<input id="qz-closes" class="form-input" type="datetime-local" data-model="f.closesAt.value">', {hint: 'Optional. After this, it shows the leaderboard but cannot be played.'})}
|
|
240
|
-
</div>
|
|
241
|
-
<div class="gma-shortcode"><span class="gma-muted">Shortcode:</span><code data-bind-text="shortcode.value"></code>
|
|
242
|
-
<button type="button" class="btn btn-xs btn-ghost" data-on-click="copyShortcode" data-bind-disabled="!f.slug.value">${ico('copy')} Copy</button>
|
|
243
|
-
<span data-help-title="Shortcode" data-help="Paste it into any page to put the quiz there. [quiz-leaderboard id="..." period="week" /] shows its leaderboard on its own."></span></div>
|
|
244
|
-
</section>
|
|
245
|
-
|
|
246
|
-
<section class="gma-card">
|
|
247
|
-
<h3 class="gma-h">Rules and leaderboard</h3>
|
|
248
|
-
<div class="gma-grid">
|
|
249
|
-
${field('qz-minutes', 'Time for the whole quiz (minutes)', '<input id="qz-minutes" class="form-input" type="number" min="0" step="0.5" data-model="f.minutes.value">', {hint: '0 = no limit. When it runs out the quiz ends with what they have.'})}
|
|
250
|
-
${field('qz-qsecs', 'Seconds for each question', '<input id="qz-qsecs" class="form-input" type="number" min="0" max="3600" data-model="f.questionSeconds.value">', {hint: '0 = no limit. A question can set its own.'})}
|
|
251
|
-
${field('qz-draw', 'Ask this many questions', '<input id="qz-draw" class="form-input" type="number" min="0" data-model="f.draw.value">', {hint: '0 = all of them. Fewer picks a random set for each player.'})}
|
|
252
|
-
${field('qz-goes', 'Goes each (per name)', '<input id="qz-goes" class="form-input" type="number" min="0" max="100" data-model="f.attemptsPerName.value">', {hint: '0 = as many as they like. Test goes do not count.'})}
|
|
253
|
-
${field('qz-show', 'Show the right answers', `<select id="qz-show" class="form-select" data-model="f.showAnswers.value">${options(SHOW_LABEL)}</select>`)}
|
|
254
|
-
${field('qz-size', 'Leaderboard size', '<input id="qz-size" class="form-input" type="number" min="3" max="100" data-model="f.leaderboardSize.value">')}
|
|
255
|
-
</div>
|
|
256
|
-
<div class="gma-grid">
|
|
257
|
-
${check('shuffleQuestions', 'Shuffle the questions')}
|
|
258
|
-
${check('shuffleOptions', 'Shuffle the answers')}
|
|
259
|
-
${check('leaderboard', 'Show a leaderboard', 'Highest score wins; the faster time breaks a tie.')}
|
|
260
|
-
</div>
|
|
261
|
-
</section>
|
|
262
|
-
|
|
263
|
-
<section class="gma-card">
|
|
264
|
-
<div class="gma-bar"><h3 class="gma-h">Questions</h3><span class="gma-muted" data-bind-text="qCount.value"></span>
|
|
265
|
-
<span data-help-title="Questions" data-help="Drag a question by its grip to move it. Points, a time of its own, a picture and an explanation are under each question's More."></span>
|
|
266
|
-
<span class="gma-spacer"></span>
|
|
267
|
-
<button type="button" class="btn btn-sm btn-primary" data-on-click="addQuestion">${ico('plus')} Add a question</button></div>
|
|
268
|
-
<p class="gma-muted" hidden data-bind-hidden="questions.length">No questions yet.</p>
|
|
269
|
-
<div class="qza-questions" data-part="questions" data-each="questions key=id"></div>
|
|
270
|
-
<div class="gma-bar" hidden data-bind-hidden="!questions.length">
|
|
271
|
-
<button type="button" class="btn btn-sm btn-outline" data-on-click="addQuestion">${ico('plus')} Add a question</button></div>
|
|
272
|
-
</section>
|
|
273
|
-
|
|
274
|
-
<div class="gma-save">
|
|
275
|
-
<button type="button" class="btn btn-primary" data-on-click="save" data-bind-disabled="saving.value">${ico('check')} Save</button>
|
|
276
|
-
<button type="button" class="btn btn-outline" data-on-click="test" data-bind-disabled="saving.value"
|
|
277
|
-
title="Play it as your players will. A test go is not saved to the leaderboard.">${ico('play')} <span data-bind-text="testLabel.value"></span></button>
|
|
278
|
-
<a class="btn btn-ghost" href="#/plugins/quiz">Back to the list</a>
|
|
279
|
-
<span class="is-dirty" hidden data-bind-hidden="!dirty.value">Unsaved changes</span>
|
|
280
|
-
<span class="gma-spacer"></span>
|
|
281
|
-
<p class="gma-error" role="alert" hidden data-bind-hidden="!problem.value" data-bind-text="problem.value"></p>
|
|
282
|
-
</div>`;
|
|
283
|
-
|
|
284
|
-
const QUESTION = `
|
|
285
|
-
<div class="qza-question" data-id="{{id}}">
|
|
286
|
-
<div class="gma-bar">
|
|
287
|
-
<span class="gma-grip" title="Drag to move" aria-hidden="true">${ico('grip-vertical')}</span>
|
|
288
|
-
<strong data-bind-text="label.value"></strong>
|
|
289
|
-
<select class="form-select" aria-label="Kind of question" data-bind-value="kind.value" data-on-change="$root.setKind($data)">${options(KIND_LABEL)}</select>
|
|
290
|
-
<span class="gma-spacer"></span>
|
|
291
|
-
<button type="button" class="btn btn-xs btn-ghost" title="Copy this question" aria-label="Copy this question" data-on-click="$root.copyQuestion($data)">${ico('copy')}</button>
|
|
292
|
-
<button type="button" class="btn btn-xs btn-ghost gma-quiet" title="Delete this question" aria-label="Delete this question" data-on-click="$root.removeQuestion($data)">${ico('x')}</button>
|
|
293
|
-
</div>
|
|
294
|
-
<div class="form-group gma-field">
|
|
295
|
-
<label class="form-label">Question</label>
|
|
296
|
-
<textarea class="form-input" rows="2" maxlength="1000" placeholder="What is the capital of Japan?" data-model="text.value"></textarea>
|
|
297
|
-
</div>
|
|
298
|
-
<div class="qza-answers" hidden data-bind-hidden="kind.value !== 'text'">
|
|
299
|
-
<label class="form-label">Accepted answers, one a line</label>
|
|
300
|
-
<textarea class="form-input" rows="3" placeholder="Tokyo" data-model="accepted.value"></textarea>
|
|
301
|
-
<p class="form-hint">Capitals, accents, spaces, punctuation and a leading "the" do not matter.</p>
|
|
302
|
-
</div>
|
|
303
|
-
<div class="qza-answers" hidden data-bind-hidden="kind.value === 'text'">
|
|
304
|
-
<p class="gma-note" data-bind-text="kind.value === 'multiple' ? 'Tick every right answer.' : 'Choose the right answer.'"></p>
|
|
305
|
-
{{#each options key=id}}
|
|
306
|
-
<div class="qza-answer" data-bind-class="correct.value && 'is-right'">
|
|
307
|
-
<!-- No name on the radios: one right answer is kept by markRight, not by a browser radio group
|
|
308
|
-
(a group would also uncheck a hidden radio behind a ticked checkbox). -->
|
|
309
|
-
<label class="qza-mark" title="Right answer">
|
|
310
|
-
<input type="radio" hidden data-bind-hidden="$parent.kind.value === 'multiple'"
|
|
311
|
-
data-bind-checked="correct.value" data-on-change="$root.markRight($parent, $data)">
|
|
312
|
-
<input type="checkbox" hidden data-bind-hidden="$parent.kind.value !== 'multiple'"
|
|
313
|
-
data-bind-checked="correct.value" data-on-change="$root.markRight($parent, $data)"><span class="sr-only">Right answer</span></label>
|
|
314
|
-
<input class="form-input" maxlength="200" placeholder="An answer" data-model="text.value" data-bind-readonly="$parent.kind.value === 'truefalse'">
|
|
315
|
-
<button type="button" class="btn btn-xs btn-ghost gma-quiet" title="Remove this answer" aria-label="Remove this answer"
|
|
316
|
-
hidden data-bind-hidden="$parent.kind.value === 'truefalse'" data-bind-disabled="$parent.options.length <= 2"
|
|
317
|
-
data-on-click="$root.removeOption($parent, $data)">${ico('x')}</button>
|
|
318
|
-
</div>
|
|
319
|
-
{{/each}}
|
|
320
|
-
<div><button type="button" class="btn btn-xs btn-ghost qza-add" hidden data-bind-hidden="kind.value === 'truefalse' || options.length >= 8"
|
|
321
|
-
data-on-click="$root.addOption($data)">${ico('plus')} Add an answer</button></div>
|
|
322
|
-
</div>
|
|
323
|
-
<details class="qza-more" data-bind-open="moreOpen">
|
|
324
|
-
<summary>More: points, time, picture and explanation</summary>
|
|
325
|
-
<div class="gma-grid">
|
|
326
|
-
<div class="form-group gma-field"><label class="form-label">Points</label><input class="form-input" type="number" min="0" max="100" data-model="points.value"></div>
|
|
327
|
-
<div class="form-group gma-field"><label class="form-label">Seconds for this question</label><input class="form-input" type="number" min="0" max="3600" data-model="seconds.value">
|
|
328
|
-
<p class="form-hint">0 = the quiz's own setting.</p></div>
|
|
329
|
-
<div class="form-group gma-field"><label class="form-label">Picture (address)</label><input class="form-input" maxlength="500" placeholder="/media/…" data-model="image.value">
|
|
330
|
-
<p class="form-hint">A picture from Media, or an https:// address.</p></div>
|
|
331
|
-
</div>
|
|
332
|
-
<div class="form-group gma-field"><label class="form-label">Explanation</label><textarea class="form-input" rows="2" maxlength="1000" data-model="explanation.value"></textarea>
|
|
333
|
-
<p class="form-hint">Shown with the right answer, when the quiz shows answers.</p></div>
|
|
334
|
-
</details>
|
|
335
|
-
</div>`;
|
|
336
|
-
|
|
337
|
-
async function editor(root, container, $container, kit, id, alive) {
|
|
338
|
-
let start = blankQuiz();
|
|
339
|
-
if (id) {
|
|
340
|
-
try {
|
|
341
|
-
start = {...blankQuiz(), ...await loading(root, request('GET', `${API}/quizzes/${encodeURIComponent(id)}`), kit)};
|
|
342
|
-
} catch (err) {
|
|
343
|
-
if (!alive()) return;
|
|
344
|
-
return failed(root, `That quiz could not load: ${errText(err)}`, {href: SIDEBAR, text: 'Back to Quiz'});
|
|
345
|
-
}
|
|
346
|
-
}
|
|
347
|
-
if (!alive()) return;
|
|
348
|
-
bannerTrail(container, [{text: 'Quiz', href: SIDEBAR}, {text: id ? start.title : 'New quiz'}]);
|
|
349
|
-
if (id) {
|
|
350
|
-
kit.addBannerAction($container, {id: 'quiz-test', icon: 'play', title: 'Test it', onClick: () => model.test()});
|
|
351
|
-
kit.addBannerAction($container, {id: 'quiz-plays', icon: 'award', title: 'Plays and leaderboard', onClick: () => { location.hash = href('plays', id); }});
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
const f = {
|
|
355
|
-
title: M.observable(start.title), slug: M.observable(start.slug), intro: M.observable(start.intro), status: M.observable(start.status),
|
|
356
|
-
opensAt: M.observable(toLocalInput(start.opensAt)), closesAt: M.observable(toLocalInput(start.closesAt)),
|
|
357
|
-
minutes: M.observable(start.timeLimit ? start.timeLimit / 60 : 0), questionSeconds: M.observable(start.questionSeconds),
|
|
358
|
-
draw: M.observable(start.draw), attemptsPerName: M.observable(start.attemptsPerName), showAnswers: M.observable(start.showAnswers),
|
|
359
|
-
leaderboardSize: M.observable(start.leaderboardSize)
|
|
360
|
-
};
|
|
361
|
-
const bools = {shuffleQuestions: M.observable(start.shuffleQuestions), shuffleOptions: M.observable(start.shuffleOptions), leaderboard: M.observable(start.leaderboard)};
|
|
362
|
-
const questions = M.observableArray(start.questions.map(questionModel));
|
|
363
|
-
const saving = M.observable(false);
|
|
364
|
-
const problem = M.observable('');
|
|
365
|
-
const dirty = M.observable(false);
|
|
366
|
-
const qCount = M.observable('');
|
|
367
|
-
const tick = M.observable(0); // a redraw of icons after rows change
|
|
368
|
-
const shortcode = M.computed(() => (f.slug.value ? shortcodeOf(f.slug.value) : 'Saved quizzes get a shortcode'));
|
|
369
|
-
const testLabel = M.computed(() => (dirty.value || !id ? 'Save and test' : 'Test'));
|
|
370
|
-
|
|
371
|
-
const body = () => ({
|
|
372
|
-
title: f.title.peek(), slug: f.slug.peek(), intro: f.intro.peek(), status: f.status.peek(),
|
|
373
|
-
opensAt: fromLocalInput(f.opensAt.peek()), closesAt: fromLocalInput(f.closesAt.peek()),
|
|
374
|
-
timeLimit: Math.round(num(f.minutes.peek()) * 60), questionSeconds: num(f.questionSeconds.peek()), draw: num(f.draw.peek()),
|
|
375
|
-
attemptsPerName: num(f.attemptsPerName.peek()), showAnswers: f.showAnswers.peek(), leaderboardSize: num(f.leaderboardSize.peek()) || 10,
|
|
376
|
-
shuffleQuestions: bools.shuffleQuestions.peek(), shuffleOptions: bools.shuffleOptions.peek(), leaderboard: bools.leaderboard.peek(),
|
|
377
|
-
questions: questions.peek().map(plainQuestion)
|
|
378
|
-
});
|
|
379
|
-
let saved = JSON.stringify(body());
|
|
380
|
-
|
|
381
|
-
/**
|
|
382
|
-
* What the server saved, back into the editor: it cleans what it is given (numbers clamped, a draw
|
|
383
|
-
* larger than the bank dropped, blank answers removed), so the editor must show what was stored.
|
|
384
|
-
* Questions are rebuilt only when they changed, so a save does not throw away focus and open panels.
|
|
385
|
-
*/
|
|
386
|
-
function load(q) {
|
|
387
|
-
f.title.value = q.title; f.slug.value = q.slug; f.intro.value = q.intro; f.status.value = q.status;
|
|
388
|
-
f.opensAt.value = toLocalInput(q.opensAt); f.closesAt.value = toLocalInput(q.closesAt);
|
|
389
|
-
f.minutes.value = q.timeLimit ? q.timeLimit / 60 : 0; f.questionSeconds.value = q.questionSeconds; f.draw.value = q.draw;
|
|
390
|
-
f.attemptsPerName.value = q.attemptsPerName; f.showAnswers.value = q.showAnswers; f.leaderboardSize.value = q.leaderboardSize;
|
|
391
|
-
for (const k of Object.keys(bools)) bools[k].value = q[k] === true;
|
|
392
|
-
for (const box of el.querySelectorAll('[data-bool]')) box.checked = bools[box.dataset.bool].peek();
|
|
393
|
-
const incoming = (q.questions || []).map(questionModel);
|
|
394
|
-
if (JSON.stringify(incoming.map(plainQuestion)) !== JSON.stringify(questions.peek().map(plainQuestion))) questions.set(incoming);
|
|
395
|
-
tick.value++;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
/** After any edit: the numbers, the points total, the dirty flag. */
|
|
399
|
-
function touched() {
|
|
400
|
-
const qs = questions.peek();
|
|
401
|
-
qs.forEach((q, i) => { q.label.value = `Question ${i + 1}`; });
|
|
402
|
-
const total = qs.reduce((s, q) => s + num(q.points.peek()), 0);
|
|
403
|
-
qCount.value = qs.length ? `${qs.length} · ${total} point${total === 1 ? '' : 's'}` : '';
|
|
404
|
-
dirty.value = JSON.stringify(body()) !== saved;
|
|
405
|
-
}
|
|
406
|
-
const changed = () => { touched(); tick.value++; };
|
|
407
|
-
|
|
408
|
-
leaveGuard = (e) => { if (dirty.peek() && ROUTE.test(location.hash)) { e.preventDefault(); e.returnValue = ''; } };
|
|
409
|
-
window.addEventListener('beforeunload', leaveGuard);
|
|
410
|
-
lifetime.add(() => { window.removeEventListener('beforeunload', leaveGuard); leaveGuard = null; });
|
|
411
|
-
|
|
412
|
-
/** Saves; answers the quiz's id, or '' when it did not save. */
|
|
413
|
-
async function save() {
|
|
414
|
-
if (saving.peek()) return '';
|
|
415
|
-
problem.value = '';
|
|
416
|
-
saving.value = true;
|
|
417
|
-
try {
|
|
418
|
-
const out = id ? await request('PUT', `${API}/quizzes/${encodeURIComponent(id)}`, body()) : await request('POST', `${API}/quizzes`, body());
|
|
419
|
-
load({...blankQuiz(), ...out});
|
|
420
|
-
saved = JSON.stringify(body());
|
|
421
|
-
touched();
|
|
422
|
-
E.toast(`${out.title} saved.`, {type: 'success'});
|
|
423
|
-
if (!id) return out.id;
|
|
424
|
-
bannerTrail(container, [{text: 'Quiz', href: SIDEBAR}, {text: out.title}]);
|
|
425
|
-
return out.id;
|
|
426
|
-
} catch (err) {
|
|
427
|
-
problem.value = errText(err);
|
|
428
|
-
return '';
|
|
429
|
-
} finally {
|
|
430
|
-
saving.value = false;
|
|
431
|
-
}
|
|
432
|
-
}
|
|
433
|
-
|
|
434
|
-
const model = {
|
|
435
|
-
f, questions, saving, problem, dirty, qCount, shortcode, testLabel,
|
|
436
|
-
async save() {
|
|
437
|
-
const savedId = await save();
|
|
438
|
-
if (savedId && !id) location.hash = href('edit', savedId);
|
|
439
|
-
},
|
|
440
|
-
async test() {
|
|
441
|
-
// Before saving: a new quiz saved here and then refused would be saved again (a second copy) next time.
|
|
442
|
-
if (!questions.peek().length) { problem.value = 'Add a question, then test it.'; return; }
|
|
443
|
-
const savedId = dirty.peek() || !id ? await save() : id;
|
|
444
|
-
if (savedId) location.hash = href('test', savedId);
|
|
445
|
-
},
|
|
446
|
-
copyShortcode() { if (f.slug.peek()) copyText(shortcodeOf(f.slug.peek()), 'Shortcode copied'); },
|
|
447
|
-
addQuestion() {
|
|
448
|
-
questions.push(questionModel());
|
|
449
|
-
changed();
|
|
450
|
-
setTimeout(() => el.querySelector('.qza-question:last-of-type textarea')?.focus(), 30);
|
|
451
|
-
},
|
|
452
|
-
copyQuestion(q) {
|
|
453
|
-
const plain = plainQuestion(q);
|
|
454
|
-
const copy = questionModel({...plain, id: qid(), accepted: plain.accepted,
|
|
455
|
-
options: plain.options.map(o => ({...o, id: o.id === 'true' || o.id === 'false' ? o.id : qid()}))});
|
|
456
|
-
questions.splice(questions.indexOf(q) + 1, 0, copy);
|
|
457
|
-
changed();
|
|
458
|
-
},
|
|
459
|
-
async removeQuestion(q) {
|
|
460
|
-
if (q.text.peek() && !await E.confirm(`Delete <strong>${esc(q.label.peek())}</strong>?`)) return;
|
|
461
|
-
questions.remove(q);
|
|
462
|
-
changed();
|
|
463
|
-
},
|
|
464
|
-
/** A change of kind keeps what still makes sense. */
|
|
465
|
-
setKind(q, e) {
|
|
466
|
-
const kind = e?.target?.value || q.kind.peek();
|
|
467
|
-
const was = q.kind.peek();
|
|
468
|
-
if (kind === was) return;
|
|
469
|
-
if (kind === 'truefalse') q.options.set(TRUE_FALSE().map(optionModel));
|
|
470
|
-
else if (kind === 'text') {
|
|
471
|
-
if (!q.accepted.peek().trim()) q.accepted.value = q.options.peek().filter(o => o.correct.peek() && o.text.peek()).map(o => o.text.peek()).join('\n');
|
|
472
|
-
} else if (was === 'truefalse' || !q.options.peek().length) {
|
|
473
|
-
q.options.set(TWO_BLANK().map(optionModel));
|
|
474
|
-
}
|
|
475
|
-
if (kind === 'single') {
|
|
476
|
-
const first = q.options.peek().findIndex(o => o.correct.peek());
|
|
477
|
-
q.options.peek().forEach((o, i) => { o.correct.value = i === Math.max(0, first); });
|
|
478
|
-
}
|
|
479
|
-
q.kind.value = kind;
|
|
480
|
-
changed();
|
|
481
|
-
},
|
|
482
|
-
markRight(q, o, e) {
|
|
483
|
-
if (q.kind.peek() === 'multiple') o.correct.value = Boolean(e?.target?.checked);
|
|
484
|
-
else q.options.peek().forEach(x => { x.correct.value = x === o; });
|
|
485
|
-
touched();
|
|
486
|
-
},
|
|
487
|
-
addOption(q) {
|
|
488
|
-
if (q.options.peek().length >= 8) return;
|
|
489
|
-
q.options.push(optionModel({text: '', correct: false}));
|
|
490
|
-
changed();
|
|
491
|
-
setTimeout(() => el.querySelector(`.qza-question[data-id="${q.id}"] .qza-answer:last-of-type input.form-input`)?.focus(), 30);
|
|
492
|
-
},
|
|
493
|
-
removeOption(q, o) {
|
|
494
|
-
if (q.options.peek().length <= 2) return;
|
|
495
|
-
const wasRight = o.correct.peek();
|
|
496
|
-
q.options.remove(o);
|
|
497
|
-
if (wasRight && q.kind.peek() === 'single') q.options.peek()[0].correct.value = true;
|
|
498
|
-
changed();
|
|
499
|
-
}
|
|
500
|
-
};
|
|
501
|
-
|
|
502
|
-
const el = mountScreen(root, {
|
|
503
|
-
html: EDITOR_HTML, model, lifetime, k: kit, className: 'gma-quiz-editor', rescan: [questions, tick],
|
|
504
|
-
before: (screen) => {
|
|
505
|
-
screen.querySelector('[data-part="questions"]').innerHTML = QUESTION;
|
|
506
|
-
for (const box of screen.querySelectorAll('[data-bool]')) {
|
|
507
|
-
box.checked = Boolean(bools[box.dataset.bool].peek());
|
|
508
|
-
box.addEventListener('change', () => { bools[box.dataset.bool].value = box.checked; touched(); });
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
});
|
|
512
|
-
// Typing anywhere: the bindings write first (their listeners were added first), then this sees it.
|
|
513
|
-
const onEdit = () => touched();
|
|
514
|
-
el.addEventListener('input', onEdit);
|
|
515
|
-
el.addEventListener('change', onEdit);
|
|
516
|
-
lifetime.add(() => { el.removeEventListener('input', onEdit); el.removeEventListener('change', onEdit); });
|
|
517
|
-
|
|
518
|
-
sortableRows(el.querySelector('[data-part="questions"]'), {
|
|
519
|
-
items: '.qza-question', handle: '.gma-grip', lifetime,
|
|
520
|
-
onMove: (from, to) => {
|
|
521
|
-
const [moved] = questions.splice(from, 1);
|
|
522
|
-
questions.splice(to, 0, moved);
|
|
523
|
-
changed();
|
|
524
|
-
}
|
|
525
|
-
});
|
|
526
|
-
saveKey(() => model.save(), () => ROUTE.test(location.hash) && el.isConnected, lifetime);
|
|
527
|
-
|
|
528
|
-
touched();
|
|
529
|
-
if (!id) el.querySelector('#qz-title')?.focus();
|
|
530
|
-
}
|
|
@@ -1,20 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
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
|
-
}
|
package/plugins/quiz/config.js
DELETED
|
@@ -1,61 +0,0 @@
|
|
|
1
|
-
---
|
|
2
|
-
title: Guide
|
|
3
|
-
order: 1
|
|
4
|
-
---
|
|
5
|
-
|
|
6
|
-
Run quizzes on your website. Visitors type their name, answer against the clock and land on a leaderboard. Quiz is free.
|
|
7
|
-
|
|
8
|
-
## What it does
|
|
9
|
-
|
|
10
|
-
- **Four kinds of question:** one right answer, more than one right answer, true or false, and a typed answer (case, accents, punctuation and a leading "the" do not matter, and you can accept more than one spelling).
|
|
11
|
-
- **Timing kept by the server.** A time limit for the whole quiz, a default per question, or a time on any single question. The clock starts when the question is shown, so a slow connection or a reload does not cost or gain anyone time.
|
|
12
|
-
- **Marking on the server.** The right answers never reach the visitor's browser until they have answered.
|
|
13
|
-
- **Leaderboards** for today, this week, this month and all time. Each name appears once, with its best go. Equal scores are split by time.
|
|
14
|
-
- **Your choice of when to show answers:** after each question, at the end, or never.
|
|
15
|
-
- **Shuffling:** questions, answers, or a random draw of so many questions from a bigger bank.
|
|
16
|
-
- **Opening and closing times**, and a limit on goes per name.
|
|
17
|
-
- The sidebar badge shows how many quizzes have been finished today, and you get a notification when someone takes the top spot.
|
|
18
|
-
|
|
19
|
-
## Getting started
|
|
20
|
-
|
|
21
|
-
1. Switch Quiz on in **Tools**.
|
|
22
|
-
2. Open **Tools > Games > Quiz** and choose **New quiz**.
|
|
23
|
-
3. Give it a title and add your questions. Tick the right answer (or answers) on each one.
|
|
24
|
-
4. Press **Test** and play it yourself (see below).
|
|
25
|
-
5. Set the status to **Open** and save.
|
|
26
|
-
6. Put it on any page:
|
|
27
|
-
|
|
28
|
-
```
|
|
29
|
-
[quiz id="capitals" /]
|
|
30
|
-
```
|
|
31
|
-
|
|
32
|
-
`id` is the quiz's short name, shown in the list and under the title in the editor.
|
|
33
|
-
|
|
34
|
-
## Testing a quiz
|
|
35
|
-
|
|
36
|
-
**Test** (in the editor, on each quiz in the list, and on its Plays screen) plays the quiz right there in the admin, exactly as your players will see it: the same clock, the same marking, the same screens, and the answers shown when the quiz shows them.
|
|
37
|
-
|
|
38
|
-
- It plays the version you last saved. With unsaved changes the button says **Save and test**, and saves first.
|
|
39
|
-
- It works whatever the status, so you can try a draft before anyone can see it, or a closed quiz.
|
|
40
|
-
- A test go is never saved to the leaderboard. It is not in the Plays list, it does not count towards the goes each name is allowed, and it does not raise a notification. At the end it tells you where it would have come.
|
|
41
|
-
- Each new test clears away the one before.
|
|
42
|
-
|
|
43
|
-
## A leaderboard on its own
|
|
44
|
-
|
|
45
|
-
The quiz shows its own leaderboard at the end. To show one somewhere else, for example on your home page:
|
|
46
|
-
|
|
47
|
-
```
|
|
48
|
-
[quiz-leaderboard id="capitals" period="week" limit="10" title="This week's best" /]
|
|
49
|
-
```
|
|
50
|
-
|
|
51
|
-
`period` is `today`, `week`, `month` or `all`. Visitors can switch period on the board.
|
|
52
|
-
|
|
53
|
-
## Players and fair play
|
|
54
|
-
|
|
55
|
-
- Players only give a name: 2 to 24 letters, numbers, spaces and `. ' - _`. Rude names are refused.
|
|
56
|
-
- A play belongs to the browser that started it. Reloading carries on where they were.
|
|
57
|
-
- Running out of time on a question marks it wrong and moves on. Running out of time on the whole quiz ends it, and the time counted is the limit.
|
|
58
|
-
|
|
59
|
-
## Looking after the board
|
|
60
|
-
|
|
61
|
-
Open a quiz's **Plays** to see every go (your own test goes are not shown). You can hide a play from the leaderboard (it stays in the list), delete it, or reset the whole leaderboard, for example before a new quiz night.
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
<script type="module" src="/plugins/quiz/public/quiz.js?v=1.1.0"></script>
|