domma-cms 1.8.32 → 1.8.34
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +9 -0
- package/docs/DommaFeatures.md +13 -1
- package/package.json +1 -1
- package/plugins/_lib/admin/games/games.css +1 -1
- package/plugins/_lib/admin/games/kit.js +329 -124
- package/plugins/_lib/games/http.js +11 -1
- package/plugins/_lib/games/plays.js +28 -7
- package/plugins/crossword/CLAUDE.md +37 -0
- package/plugins/crossword/admin/css/index.css +18 -1
- package/plugins/crossword/admin/views/crossword.js +337 -205
- package/plugins/crossword/docs/guide.md +53 -0
- package/plugins/crossword/inject/body.html +1 -1
- package/plugins/crossword/plugin.js +38 -5
- package/plugins/crossword/plugin.json +4 -3
- package/plugins/crossword/public/crossword.css +36 -1
- package/plugins/crossword/public/crossword.js +468 -6
- package/plugins/docs/CLAUDE.md +318 -0
- package/plugins/docs/admin/css/docs.css +783 -0
- package/plugins/docs/admin/lib/api.js +340 -0
- package/plugins/docs/admin/lib/convert.js +482 -0
- package/plugins/docs/admin/lib/drafts.js +115 -0
- package/plugins/docs/admin/lib/editor.js +851 -0
- package/plugins/docs/admin/lib/find-replace.js +426 -0
- package/plugins/docs/admin/lib/folders.js +203 -0
- package/plugins/docs/admin/lib/panel-guard.js +73 -0
- package/plugins/docs/admin/lib/pdf-view.js +126 -0
- package/plugins/docs/admin/lib/send.js +156 -0
- package/plugins/docs/admin/lib/settings.js +142 -0
- package/plugins/docs/admin/lib/share.js +323 -0
- package/plugins/docs/admin/lib/shortcuts.js +44 -0
- package/plugins/docs/admin/lib/templates.js +194 -0
- package/plugins/docs/admin/lib/versions.js +192 -0
- package/plugins/docs/admin/templates/docs.html +92 -0
- package/plugins/docs/admin/templates/editor.html +86 -0
- package/plugins/docs/admin/views/docs.js +777 -0
- package/plugins/docs/admin/views/editor.js +978 -0
- package/plugins/docs/config.js +24 -0
- package/plugins/docs/docs/guide.md +270 -0
- package/plugins/docs/lib/extensions.js +213 -0
- package/plugins/docs/lib/importer.js +72 -0
- package/plugins/docs/lib/pdf-worker.js +48 -0
- package/plugins/docs/lib/pdf.js +222 -0
- package/plugins/docs/lib/store.js +748 -0
- package/plugins/docs/plugin.js +1108 -0
- package/plugins/docs/plugin.json +89 -0
- package/plugins/docs/plugin.public.js +114 -0
- package/plugins/docs/templates/public-doc.html +36 -0
- package/plugins/docs/tests/_env.js +10 -0
- package/plugins/docs/tests/_pdf.js +73 -0
- package/plugins/docs/tests/convert.test.js +95 -0
- package/plugins/docs/tests/drafts.test.js +82 -0
- package/plugins/docs/tests/pdf.test.js +86 -0
- package/plugins/docs/tests/plugin.test.js +816 -0
- package/plugins/docs/tests/shortcuts.test.js +30 -0
- package/plugins/free-tier.lock.json +147 -1
- package/plugins/garage/CLAUDE.md +51 -0
- package/plugins/garage/admin/css/garage.css +73 -0
- package/plugins/garage/admin/lib/garage.js +236 -0
- package/plugins/garage/admin/templates/garage.html +86 -0
- package/plugins/garage/admin/views/garage.js +581 -0
- package/plugins/garage/collections/garage-vehicles/schema.json +101 -0
- package/plugins/garage/config.js +18 -0
- package/plugins/garage/docs/guide.md +118 -0
- package/plugins/garage/plugin.js +471 -0
- package/plugins/garage/plugin.json +68 -0
- package/plugins/garage/tests/access.test.js +134 -0
- package/plugins/garage/tests/garage.test.js +106 -0
- package/plugins/messages/CLAUDE.md +230 -0
- package/plugins/messages/admin/css/messages.css +117 -0
- package/plugins/messages/admin/lib/messages.js +452 -0
- package/plugins/messages/admin/templates/messages.html +76 -0
- package/plugins/messages/admin/views/messages.js +1219 -0
- package/plugins/messages/collections/user-message-threads/schema.json +22 -0
- package/plugins/messages/collections/user-messages/schema.json +23 -0
- package/plugins/messages/config.js +14 -0
- package/plugins/messages/docs/guide.md +176 -0
- package/plugins/messages/lib/call.js +138 -0
- package/plugins/messages/plugin.js +994 -0
- package/plugins/messages/plugin.json +59 -0
- package/plugins/messages/plugin.public.js +52 -0
- package/plugins/messages/templates/call.html +6 -0
- package/plugins/messages/tests/after-send.test.js +22 -0
- package/plugins/messages/tests/api.test.js +187 -0
- package/plugins/messages/tests/call.test.js +497 -0
- package/plugins/messages/tests/drafts.test.js +25 -0
- package/plugins/messages/tests/messages.test.js +167 -0
- package/plugins/quiz/CLAUDE.md +43 -0
- package/plugins/quiz/admin/css/index.css +13 -1
- package/plugins/quiz/admin/views/quiz.js +423 -245
- package/plugins/quiz/docs/guide.md +61 -0
- package/plugins/quiz/inject/body.html +1 -1
- package/plugins/quiz/plugin.js +39 -5
- package/plugins/quiz/plugin.json +4 -3
- package/plugins/quiz/public/quiz.css +36 -1
- package/plugins/quiz/public/quiz.js +298 -6
- package/plugins/theme-roller/CLAUDE.md +65 -0
- package/plugins/theme-roller/admin/css/theme-roller.css +43 -0
- package/plugins/theme-roller/admin/lib/prompt.js +22 -0
- package/plugins/theme-roller/admin/lib/themes.js +196 -0
- package/plugins/theme-roller/admin/lib/view.js +88 -0
- package/plugins/theme-roller/admin/templates/theme-roller.html +73 -0
- package/plugins/theme-roller/admin/views/theme-roller-view.js +639 -0
- package/plugins/theme-roller/config.js +1 -0
- package/plugins/theme-roller/docs/guide.md +104 -0
- package/plugins/theme-roller/plugin.js +263 -0
- package/plugins/theme-roller/plugin.json +42 -0
- package/plugins/theme-roller/tests/api.test.js +161 -0
- package/plugins/theme-roller/tests/prompt-values.test.js +22 -0
- package/plugins/theme-roller/tests/themes.test.js +77 -0
- package/plugins/theme-roller/tests/view.test.js +55 -0
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Quiz - the Tool. One view,
|
|
2
|
+
* Quiz - the Tool. One view, four screens by address:
|
|
3
3
|
*
|
|
4
4
|
* #/plugins/quiz every quiz
|
|
5
5
|
* #/plugins/quiz/new a new quiz
|
|
6
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
|
|
7
8
|
* #/plugins/quiz/plays/<id> who played it, and its leaderboard
|
|
8
9
|
*
|
|
9
|
-
* Route params are read from the hash (Domma's router passes none).
|
|
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 {{ }}.
|
|
10
14
|
*/
|
|
11
15
|
|
|
12
|
-
import {bannerTrail, copyText, errText, esc,
|
|
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';
|
|
13
17
|
|
|
14
18
|
const API = '/plugins/quiz';
|
|
15
19
|
const SIDEBAR = '#/plugins/quiz';
|
|
16
20
|
const ROUTE = /^#\/plugins\/quiz(\/|\?|$)/;
|
|
21
|
+
const PLAYER = '/plugins/quiz/public/quiz.js?v=1.1.0';
|
|
17
22
|
|
|
18
23
|
const KIND_LABEL = {single: 'One right answer', multiple: 'More than one right answer', truefalse: 'True or false', text: 'Type the answer'};
|
|
19
24
|
const STATUS_LABEL = {draft: 'Draft', open: 'Open', closed: 'Closed'};
|
|
@@ -22,6 +27,8 @@ const SHOW_LABEL = {end: 'At the end', 'after-each': 'After each question', neve
|
|
|
22
27
|
|
|
23
28
|
let lifetime = null;
|
|
24
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;
|
|
25
32
|
|
|
26
33
|
function where() {
|
|
27
34
|
const m = location.hash.split('?')[0].match(/^#\/plugins\/quiz(?:\/([a-z]+))?(?:\/([^/?#]+))?/);
|
|
@@ -30,6 +37,8 @@ function where() {
|
|
|
30
37
|
|
|
31
38
|
const badge = () => refreshBadge(SIDEBAR);
|
|
32
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('');
|
|
33
42
|
|
|
34
43
|
export const quizView = {
|
|
35
44
|
templateUrl: '/plugins/quiz/admin/templates/quiz.html',
|
|
@@ -39,74 +48,93 @@ export const quizView = {
|
|
|
39
48
|
lifetime ??= kit.viewLifetime(ROUTE);
|
|
40
49
|
if (!ROUTE.test(location.hash)) return;
|
|
41
50
|
lifetime.reset();
|
|
51
|
+
const n = ++mountSeq;
|
|
52
|
+
const alive = () => n === mountSeq && ROUTE.test(location.hash);
|
|
42
53
|
if (leaveGuard) { window.removeEventListener('beforeunload', leaveGuard); leaveGuard = null; }
|
|
43
54
|
const container = $container.get(0);
|
|
44
55
|
const root = container.querySelector('[data-quiz-admin]') || container;
|
|
45
56
|
const {screen, id} = where();
|
|
46
57
|
|
|
47
|
-
if (screen === 'plays' && id) {
|
|
48
|
-
let
|
|
49
|
-
try {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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});
|
|
53
73
|
}
|
|
54
|
-
if (screen === 'new' || (screen === 'edit' && id)) return editor(root, container, $container, kit, screen === 'new' ? '' : id);
|
|
55
|
-
return list(root, container, $container, kit);
|
|
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);
|
|
56
76
|
}
|
|
57
77
|
};
|
|
58
78
|
|
|
59
79
|
// ---- The list ----------------------------------------------------------------------
|
|
60
80
|
|
|
61
|
-
|
|
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) {
|
|
62
109
|
bannerTrail(container, [{text: 'Quiz'}]);
|
|
63
110
|
kit.addBannerAction($container, {id: 'quiz-new', icon: 'plus', title: 'New quiz', onClick: () => { location.hash = '#/plugins/quiz/new'; }});
|
|
64
|
-
|
|
65
|
-
const slot = h('div', {className: 'gma-note dm-loading-slot', text: 'Loading…'});
|
|
66
|
-
root.append(slot);
|
|
67
|
-
let quizzes;
|
|
111
|
+
let got;
|
|
68
112
|
try {
|
|
69
|
-
|
|
113
|
+
got = await loading(root, request('GET', `${API}/quizzes`), kit);
|
|
70
114
|
} catch (err) {
|
|
71
|
-
|
|
72
|
-
root
|
|
73
|
-
return;
|
|
115
|
+
if (!alive()) return;
|
|
116
|
+
return failed(root, `Quiz could not load: ${errText(err)}`);
|
|
74
117
|
}
|
|
75
118
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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
|
-
};
|
|
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);
|
|
103
132
|
|
|
104
133
|
async function duplicate(q) {
|
|
105
134
|
try {
|
|
106
135
|
const copy = await request('POST', `${API}/quizzes/${encodeURIComponent(q.id)}/copy`);
|
|
107
136
|
E.toast(`Copied as "${copy.title}" (a draft).`, {type: 'success'});
|
|
108
|
-
quizzes = (await request('GET', `${API}/quizzes`)).quizzes || [];
|
|
109
|
-
draw();
|
|
137
|
+
quizzes.value = (await request('GET', `${API}/quizzes`)).quizzes || [];
|
|
110
138
|
} catch (err) { E.toast(errText(err), {type: 'error'}); }
|
|
111
139
|
}
|
|
112
140
|
|
|
@@ -114,14 +142,33 @@ async function list(root, container, $container, kit) {
|
|
|
114
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;
|
|
115
143
|
try {
|
|
116
144
|
await request('DELETE', `${API}/quizzes/${encodeURIComponent(q.id)}`);
|
|
117
|
-
quizzes = quizzes.filter(x => x.id !== q.id);
|
|
118
|
-
draw();
|
|
145
|
+
quizzes.value = quizzes.peek().filter(x => x.id !== q.id);
|
|
119
146
|
badge();
|
|
120
147
|
E.toast(`${q.title} deleted.`, {type: 'success'});
|
|
121
148
|
} catch (err) { E.toast(errText(err), {type: 'error'}); }
|
|
122
149
|
}
|
|
123
150
|
|
|
124
|
-
|
|
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
|
+
});
|
|
125
172
|
}
|
|
126
173
|
|
|
127
174
|
// ---- The editor --------------------------------------------------------------------
|
|
@@ -132,221 +179,352 @@ const blankQuiz = () => ({
|
|
|
132
179
|
questions: []
|
|
133
180
|
});
|
|
134
181
|
const qid = () => Math.random().toString(36).slice(2, 10);
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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()}))
|
|
139
212
|
});
|
|
140
213
|
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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();
|
|
144
339
|
if (id) {
|
|
145
|
-
const slot = h('div', {className: 'gma-note dm-loading-slot', text: 'Loading…'});
|
|
146
|
-
root.append(slot);
|
|
147
340
|
try {
|
|
148
|
-
|
|
341
|
+
start = {...blankQuiz(), ...await loading(root, request('GET', `${API}/quizzes/${encodeURIComponent(id)}`), kit)};
|
|
149
342
|
} catch (err) {
|
|
150
|
-
|
|
151
|
-
root
|
|
152
|
-
return;
|
|
343
|
+
if (!alive()) return;
|
|
344
|
+
return failed(root, `That quiz could not load: ${errText(err)}`, {href: SIDEBAR, text: 'Back to Quiz'});
|
|
153
345
|
}
|
|
154
346
|
}
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
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();
|
|
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); }});
|
|
239
352
|
}
|
|
240
353
|
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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.')));
|
|
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++;
|
|
309
396
|
}
|
|
310
397
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
const total =
|
|
316
|
-
qCount.
|
|
317
|
-
|
|
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;
|
|
318
405
|
}
|
|
406
|
+
const changed = () => { touched(); tick.value++; };
|
|
319
407
|
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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; });
|
|
323
411
|
|
|
412
|
+
/** Saves; answers the quiz's id, or '' when it did not save. */
|
|
324
413
|
async function save() {
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
414
|
+
if (saving.peek()) return '';
|
|
415
|
+
problem.value = '';
|
|
416
|
+
saving.value = true;
|
|
328
417
|
try {
|
|
329
|
-
const out = id ? await request('PUT', `${API}/quizzes/${encodeURIComponent(id)}`, body) : await request('POST', `${API}/quizzes`, body);
|
|
330
|
-
|
|
331
|
-
saved =
|
|
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());
|
|
332
421
|
touched();
|
|
333
|
-
showShortcode();
|
|
334
422
|
E.toast(`${out.title} saved.`, {type: 'success'});
|
|
335
|
-
if (!id)
|
|
423
|
+
if (!id) return out.id;
|
|
336
424
|
bannerTrail(container, [{text: 'Quiz', href: SIDEBAR}, {text: out.title}]);
|
|
337
|
-
|
|
425
|
+
return out.id;
|
|
338
426
|
} catch (err) {
|
|
339
|
-
|
|
340
|
-
|
|
427
|
+
problem.value = errText(err);
|
|
428
|
+
return '';
|
|
341
429
|
} finally {
|
|
342
|
-
|
|
430
|
+
saving.value = false;
|
|
343
431
|
}
|
|
344
432
|
}
|
|
345
433
|
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
}
|
|
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);
|
|
352
527
|
|
|
528
|
+
touched();
|
|
529
|
+
if (!id) el.querySelector('#qz-title')?.focus();
|
|
530
|
+
}
|