domma-cms 1.8.31 → 1.8.32
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +7 -0
- package/package.json +1 -1
- package/plugins/_lib/admin/games/games.css +1 -0
- package/plugins/_lib/admin/games/kit.js +241 -0
- package/plugins/_lib/games/http.js +82 -0
- package/plugins/_lib/games/names.js +42 -0
- package/plugins/_lib/games/plays.js +155 -0
- package/plugins/_lib/games/ranking.js +68 -0
- package/plugins/_lib/games/timing.js +27 -0
- package/plugins/_lib/public/games/format.js +38 -0
- package/plugins/_lib/public/games/games.css +1 -0
- package/plugins/_lib/public/games/games.js +204 -0
- package/plugins/crossword/admin/css/index.css +1 -0
- package/plugins/crossword/admin/templates/crossword.html +1 -0
- package/plugins/crossword/admin/views/crossword.js +317 -0
- package/plugins/crossword/collections/crossword-plays.json +20 -0
- package/plugins/crossword/collections/crossword-puzzles.json +17 -0
- package/plugins/crossword/config.js +6 -0
- package/plugins/crossword/inject/body.html +1 -0
- package/plugins/crossword/lib/crossword.js +170 -0
- package/plugins/crossword/lib/store.js +56 -0
- package/plugins/crossword/plugin.js +241 -0
- package/plugins/crossword/plugin.json +78 -0
- package/plugins/crossword/public/crossword.css +1 -0
- package/plugins/crossword/public/crossword.js +57 -0
- package/plugins/crossword/public/lib/layout.js +199 -0
- package/plugins/quiz/admin/css/index.css +1 -0
- package/plugins/quiz/admin/templates/quiz.html +1 -0
- package/plugins/quiz/admin/views/quiz.js +352 -0
- package/plugins/quiz/collections/quiz-plays.json +20 -0
- package/plugins/quiz/collections/quiz-quizzes.json +17 -0
- package/plugins/quiz/config.js +6 -0
- package/plugins/quiz/inject/body.html +1 -0
- package/plugins/quiz/lib/engine.js +115 -0
- package/plugins/quiz/lib/quiz.js +225 -0
- package/plugins/quiz/lib/store.js +56 -0
- package/plugins/quiz/plugin.js +241 -0
- package/plugins/quiz/plugin.json +78 -0
- package/plugins/quiz/public/quiz.css +1 -0
- package/plugins/quiz/public/quiz.js +80 -0
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crossword - laying words out in a grid. PURE, and isomorphic: the admin
|
|
3
|
+
* builder runs it in the browser for a live preview, the server runs it again
|
|
4
|
+
* to check what it is given. It holds no puzzle, so it is safe to serve.
|
|
5
|
+
*
|
|
6
|
+
* cleanAnswer("New York") -> {letters: 'NEWYORK', enumeration: '3,4'}
|
|
7
|
+
* generate(words, {seed}) -> {width, height, entries, unplaced}
|
|
8
|
+
* solutionGrid(layout) -> rows of letters ('' = a black square), or throws if entries clash
|
|
9
|
+
* numberEntries(entries) -> entries numbered in reading order
|
|
10
|
+
*
|
|
11
|
+
* The generator is greedy: longest words first, each placed where it crosses
|
|
12
|
+
* the most letters already down without touching any other word side by side,
|
|
13
|
+
* keeping the grid as square as it can. It tries a number of shuffles from the
|
|
14
|
+
* seed and keeps the best, so the same seed always gives the same grid.
|
|
15
|
+
*
|
|
16
|
+
* @module crossword/public/lib/layout
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export const MAX_WORDS = 60;
|
|
20
|
+
export const MAX_LETTERS = 21;
|
|
21
|
+
export const MIN_LETTERS = 2;
|
|
22
|
+
|
|
23
|
+
/** Letters only, upper case, accents folded; the word lengths for the clue, e.g. "(3,4)". */
|
|
24
|
+
export function cleanAnswer(raw) {
|
|
25
|
+
const words = String(raw ?? '').normalize('NFKD').replace(/[̀-ͯ]/g, '').toUpperCase()
|
|
26
|
+
.split(/[\s-]+/).map(w => w.replace(/[^A-Z]/g, '')).filter(Boolean);
|
|
27
|
+
const letters = words.join('');
|
|
28
|
+
const hyphenated = /-/.test(String(raw ?? ''));
|
|
29
|
+
return {letters, enumeration: words.map(w => w.length).join(hyphenated ? '-' : ',')};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** A small seeded random (mulberry32), so a seed always lays out the same grid. */
|
|
33
|
+
export function seeded(seed) {
|
|
34
|
+
let a = (Number(seed) >>> 0) || 1;
|
|
35
|
+
return () => {
|
|
36
|
+
a = (a + 0x6D2B79F5) >>> 0;
|
|
37
|
+
let t = a;
|
|
38
|
+
t = Math.imul(t ^ (t >>> 15), t | 1);
|
|
39
|
+
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
|
|
40
|
+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const key = (r, c) => `${r},${c}`;
|
|
45
|
+
const DIRS = {across: [0, 1], down: [1, 0]};
|
|
46
|
+
|
|
47
|
+
/** One attempt: words in this order, placed greedily on an open board. */
|
|
48
|
+
function attempt(words) {
|
|
49
|
+
const cells = new Map(); // "r,c" -> {ch, across: bool, down: bool}
|
|
50
|
+
const placed = [];
|
|
51
|
+
const unplaced = [];
|
|
52
|
+
let box = {r0: 0, r1: -1, c0: 0, c1: -1};
|
|
53
|
+
|
|
54
|
+
const at = (r, c) => cells.get(key(r, c));
|
|
55
|
+
|
|
56
|
+
function fits(w, r, c, dir) {
|
|
57
|
+
const [dr, dc] = DIRS[dir];
|
|
58
|
+
// The squares just before and after the word must be empty.
|
|
59
|
+
if (at(r - dr, c - dc) || at(r + dr * w.length, c + dc * w.length)) return -1;
|
|
60
|
+
let crossings = 0;
|
|
61
|
+
for (let i = 0; i < w.length; i++) {
|
|
62
|
+
const rr = r + dr * i;
|
|
63
|
+
const cc = c + dc * i;
|
|
64
|
+
const cell = at(rr, cc);
|
|
65
|
+
if (cell) {
|
|
66
|
+
if (cell.ch !== w[i] || cell[dir]) return -1;
|
|
67
|
+
crossings++;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
// An empty square must not sit beside another word's letter (side by side, parallel).
|
|
71
|
+
if (at(rr + dc, cc + dr) || at(rr - dc, cc - dr)) return -1;
|
|
72
|
+
}
|
|
73
|
+
return crossings;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function put(w, r, c, dir, item) {
|
|
77
|
+
const [dr, dc] = DIRS[dir];
|
|
78
|
+
for (let i = 0; i < w.length; i++) {
|
|
79
|
+
const k = key(r + dr * i, c + dc * i);
|
|
80
|
+
const cell = cells.get(k) || {ch: w[i], across: false, down: false};
|
|
81
|
+
cell[dir] = true;
|
|
82
|
+
cells.set(k, cell);
|
|
83
|
+
}
|
|
84
|
+
const r1 = r + dr * (w.length - 1);
|
|
85
|
+
const c1 = c + dc * (w.length - 1);
|
|
86
|
+
box = placed.length
|
|
87
|
+
? {r0: Math.min(box.r0, r), r1: Math.max(box.r1, r1), c0: Math.min(box.c0, c), c1: Math.max(box.c1, c1)}
|
|
88
|
+
: {r0: r, r1, c0: c, c1};
|
|
89
|
+
placed.push({...item, row: r, col: c, dir});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
let queue = [...words];
|
|
93
|
+
let progress = true;
|
|
94
|
+
// Words that could not cross anything yet get another chance once more are down.
|
|
95
|
+
while (queue.length && progress) {
|
|
96
|
+
progress = false;
|
|
97
|
+
const left = [];
|
|
98
|
+
for (const item of queue) {
|
|
99
|
+
const w = item.letters;
|
|
100
|
+
if (!placed.length) { put(w, 0, 0, 'across', item); progress = true; continue; }
|
|
101
|
+
let best = null;
|
|
102
|
+
for (const [k, cell] of cells) {
|
|
103
|
+
const [cr, cc] = k.split(',').map(Number);
|
|
104
|
+
for (let i = 0; i < w.length; i++) {
|
|
105
|
+
if (w[i] !== cell.ch) continue;
|
|
106
|
+
for (const dir of ['across', 'down']) {
|
|
107
|
+
if (cell[dir]) continue;
|
|
108
|
+
const [dr, dc] = DIRS[dir];
|
|
109
|
+
const r = cr - dr * i;
|
|
110
|
+
const c = cc - dc * i;
|
|
111
|
+
const crossings = fits(w, r, c, dir);
|
|
112
|
+
if (crossings < 1) continue;
|
|
113
|
+
const r1 = r + dr * (w.length - 1);
|
|
114
|
+
const c1 = c + dc * (w.length - 1);
|
|
115
|
+
const h = Math.max(box.r1, r1) - Math.min(box.r0, r) + 1;
|
|
116
|
+
const wd = Math.max(box.c1, c1) - Math.min(box.c0, c) + 1;
|
|
117
|
+
// More crossings, then a smaller, squarer grid.
|
|
118
|
+
const score = crossings * 100 - (h * wd) / 4 - Math.abs(h - wd) * 2;
|
|
119
|
+
if (!best || score > best.score) best = {score, r, c, dir};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (best) { put(w, best.r, best.c, best.dir, item); progress = true; }
|
|
124
|
+
else left.push(item);
|
|
125
|
+
}
|
|
126
|
+
queue = left;
|
|
127
|
+
}
|
|
128
|
+
unplaced.push(...queue);
|
|
129
|
+
return {placed, unplaced, box, crossings: [...cells.values()].filter(c => c.across && c.down).length};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Entries numbered in reading order: a square that starts any entry gets the next number. */
|
|
133
|
+
export function numberEntries(entries) {
|
|
134
|
+
const starts = [...new Set(entries.map(e => key(e.row, e.col)))]
|
|
135
|
+
.map(k => k.split(',').map(Number)).sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
|
136
|
+
const num = new Map(starts.map(([r, c], i) => [key(r, c), i + 1]));
|
|
137
|
+
return entries.map(e => ({...e, number: num.get(key(e.row, e.col))}))
|
|
138
|
+
.sort((a, b) => (a.dir === b.dir ? a.number - b.number : a.dir === 'across' ? -1 : 1));
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* A layout from a list of {answer, clue}.
|
|
143
|
+
*
|
|
144
|
+
* @param {{answer: string, clue: string}[]} words
|
|
145
|
+
* @param {{seed?: number, tries?: number}} [opts]
|
|
146
|
+
* @returns {{width: number, height: number, entries: object[], unplaced: object[], seed: number}}
|
|
147
|
+
*/
|
|
148
|
+
export function generate(words, {seed = 1, tries = 40} = {}) {
|
|
149
|
+
const items = [];
|
|
150
|
+
const seen = new Set();
|
|
151
|
+
for (const w of (words || []).slice(0, MAX_WORDS)) {
|
|
152
|
+
const {letters, enumeration} = cleanAnswer(w?.answer);
|
|
153
|
+
if (letters.length < MIN_LETTERS || letters.length > MAX_LETTERS || seen.has(letters)) continue;
|
|
154
|
+
seen.add(letters);
|
|
155
|
+
items.push({answer: String(w.answer).trim(), letters, enumeration, clue: String(w?.clue ?? '').trim()});
|
|
156
|
+
}
|
|
157
|
+
if (!items.length) return {width: 0, height: 0, entries: [], unplaced: [], seed};
|
|
158
|
+
|
|
159
|
+
const rand = seeded(seed);
|
|
160
|
+
let best = null;
|
|
161
|
+
for (let t = 0; t < Math.max(1, tries); t++) {
|
|
162
|
+
// The first try is longest-first; the rest shuffle words of similar length.
|
|
163
|
+
const order = items.map(it => ({it, k: it.letters.length + (t ? rand() * 4 : 0)})).sort((a, b) => b.k - a.k).map(x => x.it);
|
|
164
|
+
const res = attempt(order);
|
|
165
|
+
const h = res.box.r1 - res.box.r0 + 1;
|
|
166
|
+
const w = res.box.c1 - res.box.c0 + 1;
|
|
167
|
+
const score = res.placed.length * 1000 + res.crossings * 10 - h * w / 2 - Math.abs(h - w) * 3;
|
|
168
|
+
if (!best || score > best.score) best = {...res, score, h, w};
|
|
169
|
+
}
|
|
170
|
+
const entries = numberEntries(best.placed.map(p => ({
|
|
171
|
+
answer: p.answer, letters: p.letters, enumeration: p.enumeration, clue: p.clue,
|
|
172
|
+
row: p.row - best.box.r0, col: p.col - best.box.c0, dir: p.dir
|
|
173
|
+
})));
|
|
174
|
+
return {width: best.w, height: best.h, entries, unplaced: best.unplaced.map(u => ({answer: u.answer, clue: u.clue})), seed};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The solution as rows of letters ('' = black). Throws when the entries do
|
|
179
|
+
* not fit the size or disagree about a square - a layout from anywhere but
|
|
180
|
+
* generate() is checked this way before it is saved.
|
|
181
|
+
*/
|
|
182
|
+
export function solutionGrid({width, height, entries}) {
|
|
183
|
+
const grid = Array.from({length: height}, () => Array(width).fill(''));
|
|
184
|
+
for (const e of entries) {
|
|
185
|
+
const [dr, dc] = DIRS[e.dir] || [];
|
|
186
|
+
if (dr === undefined) throw new Error('An entry has no direction.');
|
|
187
|
+
for (let i = 0; i < e.letters.length; i++) {
|
|
188
|
+
const r = e.row + dr * i;
|
|
189
|
+
const c = e.col + dc * i;
|
|
190
|
+
if (r < 0 || c < 0 || r >= height || c >= width) throw new Error(`"${e.answer}" runs off the grid.`);
|
|
191
|
+
if (grid[r][c] && grid[r][c] !== e.letters[i]) throw new Error(`"${e.answer}" clashes with another answer.`);
|
|
192
|
+
grid[r][c] = e.letters[i];
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
return grid;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The grid's shape for the player: '#' black, '.' a square to fill - no letters. */
|
|
199
|
+
export const shapeOf = (grid) => grid.map(row => row.map(ch => (ch ? '.' : '#')).join(''));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.qza-questions{display:flex;flex-direction:column;gap:.75rem}.qza-question{border:1px solid var(--gma-line);border-left:4px solid var(--dm-primary);border-radius:var(--dm-radius-md, 8px);padding:.85rem 1rem;display:flex;flex-direction:column;gap:.6rem;background:var(--dm-background, transparent)}.qza-question .form-select{width:auto}.qza-answers{display:flex;flex-direction:column;gap:.4rem}.qza-answer{display:flex;align-items:center;gap:.5rem;padding:.25rem .4rem;border-radius:6px}.qza-answer.is-right{background:color-mix(in srgb,var(--dm-success) 12%,transparent)}.qza-answer .form-input{flex:1;min-width:0}.qza-mark{display:inline-flex;align-items:center;cursor:pointer;padding:.2rem}.qza-mark input{width:1.1rem;height:1.1rem;accent-color:var(--dm-success)}.qza-fixed{flex:1;font-weight:500}.qza-more summary{cursor:pointer;color:var(--dm-text-muted);font-size:var(--dm-text-sm);margin-bottom:.5rem}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<div class="gma qza" data-quiz-admin></div>
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Quiz - the Tool. One view, three screens by address:
|
|
3
|
+
*
|
|
4
|
+
* #/plugins/quiz every quiz
|
|
5
|
+
* #/plugins/quiz/new a new quiz
|
|
6
|
+
* #/plugins/quiz/edit/<id> a quiz and its questions
|
|
7
|
+
* #/plugins/quiz/plays/<id> who played it, and its leaderboard
|
|
8
|
+
*
|
|
9
|
+
* Route params are read from the hash (Domma's router passes none).
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import {bannerTrail, copyText, errText, esc, field, h, icon, loadKit, playsScreen, refreshBadge, request, toggle, toLocalInput, fromLocalInput} from '/plugins/_lib/admin/games/kit.js';
|
|
13
|
+
|
|
14
|
+
const API = '/plugins/quiz';
|
|
15
|
+
const SIDEBAR = '#/plugins/quiz';
|
|
16
|
+
const ROUTE = /^#\/plugins\/quiz(\/|\?|$)/;
|
|
17
|
+
|
|
18
|
+
const KIND_LABEL = {single: 'One right answer', multiple: 'More than one right answer', truefalse: 'True or false', text: 'Type the answer'};
|
|
19
|
+
const STATUS_LABEL = {draft: 'Draft', open: 'Open', closed: 'Closed'};
|
|
20
|
+
const STATUS_TONE = {draft: '', open: 'is-success', closed: 'is-warning'};
|
|
21
|
+
const SHOW_LABEL = {end: 'At the end', 'after-each': 'After each question', never: 'Never'};
|
|
22
|
+
|
|
23
|
+
let lifetime = null;
|
|
24
|
+
let leaveGuard = null;
|
|
25
|
+
|
|
26
|
+
function where() {
|
|
27
|
+
const m = location.hash.split('?')[0].match(/^#\/plugins\/quiz(?:\/([a-z]+))?(?:\/([^/?#]+))?/);
|
|
28
|
+
return {screen: m?.[1] || 'list', id: m?.[2] ? decodeURIComponent(m[2]) : ''};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const badge = () => refreshBadge(SIDEBAR);
|
|
32
|
+
const shortcodeOf = (slug) => `[quiz id="${slug}" /]`;
|
|
33
|
+
|
|
34
|
+
export const quizView = {
|
|
35
|
+
templateUrl: '/plugins/quiz/admin/templates/quiz.html',
|
|
36
|
+
|
|
37
|
+
async onMount($container) {
|
|
38
|
+
const kit = await loadKit();
|
|
39
|
+
lifetime ??= kit.viewLifetime(ROUTE);
|
|
40
|
+
if (!ROUTE.test(location.hash)) return;
|
|
41
|
+
lifetime.reset();
|
|
42
|
+
if (leaveGuard) { window.removeEventListener('beforeunload', leaveGuard); leaveGuard = null; }
|
|
43
|
+
const container = $container.get(0);
|
|
44
|
+
const root = container.querySelector('[data-quiz-admin]') || container;
|
|
45
|
+
const {screen, id} = where();
|
|
46
|
+
|
|
47
|
+
if (screen === 'plays' && id) {
|
|
48
|
+
let title = 'Plays';
|
|
49
|
+
try { title = (await request('GET', `${API}/quizzes/${encodeURIComponent(id)}`)).title; } catch { /* the screen says so */ }
|
|
50
|
+
bannerTrail(container, [{text: 'Quiz', href: SIDEBAR}, {text: `${title}: plays`}]);
|
|
51
|
+
kit.addBannerAction($container, {id: 'quiz-edit', icon: 'edit', title: 'Edit this quiz', onClick: () => { location.hash = `#/plugins/quiz/edit/${encodeURIComponent(id)}`; }});
|
|
52
|
+
return playsScreen(root, {api: API, gameId: id, title, order: 'score', back: SIDEBAR, base: '/quizzes', badge});
|
|
53
|
+
}
|
|
54
|
+
if (screen === 'new' || (screen === 'edit' && id)) return editor(root, container, $container, kit, screen === 'new' ? '' : id);
|
|
55
|
+
return list(root, container, $container, kit);
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
// ---- The list ----------------------------------------------------------------------
|
|
60
|
+
|
|
61
|
+
async function list(root, container, $container, kit) {
|
|
62
|
+
bannerTrail(container, [{text: 'Quiz'}]);
|
|
63
|
+
kit.addBannerAction($container, {id: 'quiz-new', icon: 'plus', title: 'New quiz', onClick: () => { location.hash = '#/plugins/quiz/new'; }});
|
|
64
|
+
root.innerHTML = '';
|
|
65
|
+
const slot = h('div', {className: 'gma-note dm-loading-slot', text: 'Loading…'});
|
|
66
|
+
root.append(slot);
|
|
67
|
+
let quizzes;
|
|
68
|
+
try {
|
|
69
|
+
quizzes = (await kit.whileLoading(slot, request('GET', `${API}/quizzes`), kit.SHAPES.rows)).quizzes || [];
|
|
70
|
+
} catch (err) {
|
|
71
|
+
root.innerHTML = '';
|
|
72
|
+
root.append(h('p', {className: 'gma-error', text: `Quiz could not load: ${errText(err)}`}));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const draw = () => {
|
|
77
|
+
root.innerHTML = '';
|
|
78
|
+
root.append(h('div', {className: 'gma-bar'},
|
|
79
|
+
h('a', {className: 'btn btn-sm btn-primary', href: '#/plugins/quiz/new'}, icon('plus'), ' New quiz'),
|
|
80
|
+
h('span', {className: 'gma-spacer'}),
|
|
81
|
+
h('span', {className: 'gma-note', text: 'Put a quiz on any page with its shortcode. The leaderboard can go on its own too.'})));
|
|
82
|
+
if (!quizzes.length) {
|
|
83
|
+
root.append(h('div', {className: 'gma-empty'}, icon('help-circle', 30),
|
|
84
|
+
h('p', {}, h('strong', {text: 'No quizzes yet.'}), h('br'), 'Make one, add questions, open it, then put its shortcode on a page.'),
|
|
85
|
+
h('a', {className: 'btn btn-primary', href: '#/plugins/quiz/new'}, 'Make a quiz')));
|
|
86
|
+
I.scan(root);
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
root.append(h('div', {className: 'gma-list'}, quizzes.map(q => h('div', {className: 'gma-row'},
|
|
90
|
+
h('div', {className: 'gma-row-main'},
|
|
91
|
+
h('div', {}, h('a', {href: `#/plugins/quiz/edit/${encodeURIComponent(q.id)}`, text: q.title}),
|
|
92
|
+
h('span', {className: `gma-chip ${STATUS_TONE[q.status] || ''}`, text: STATUS_LABEL[q.status] || q.status})),
|
|
93
|
+
h('small', {text: [`${q.questions} question${q.questions === 1 ? '' : 's'}`, `${q.finished} finished of ${q.plays} play${q.plays === 1 ? '' : 's'}`,
|
|
94
|
+
q.finished ? `best ${q.best}` : '', shortcodeOf(q.slug)].filter(Boolean).join(' · ')})),
|
|
95
|
+
h('div', {className: 'gma-row-actions'},
|
|
96
|
+
h('a', {className: 'btn btn-sm btn-ghost', href: `#/plugins/quiz/edit/${encodeURIComponent(q.id)}`}, icon('edit'), ' Edit'),
|
|
97
|
+
h('a', {className: 'btn btn-sm btn-ghost', href: `#/plugins/quiz/plays/${encodeURIComponent(q.id)}`}, icon('award'), ' Plays'),
|
|
98
|
+
h('button', {type: 'button', className: 'btn btn-sm btn-ghost', title: 'Copy the shortcode', onClick: () => copyText(shortcodeOf(q.slug), 'Shortcode copied')}, icon('copy')),
|
|
99
|
+
h('button', {type: 'button', className: 'btn btn-sm btn-ghost', title: 'Make a copy', onClick: () => duplicate(q)}, icon('layers')),
|
|
100
|
+
h('button', {type: 'button', className: 'btn btn-sm btn-ghost gma-danger-text', title: 'Delete', onClick: () => remove(q)}, icon('trash')))))));
|
|
101
|
+
I.scan(root);
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
async function duplicate(q) {
|
|
105
|
+
try {
|
|
106
|
+
const copy = await request('POST', `${API}/quizzes/${encodeURIComponent(q.id)}/copy`);
|
|
107
|
+
E.toast(`Copied as "${copy.title}" (a draft).`, {type: 'success'});
|
|
108
|
+
quizzes = (await request('GET', `${API}/quizzes`)).quizzes || [];
|
|
109
|
+
draw();
|
|
110
|
+
} catch (err) { E.toast(errText(err), {type: 'error'}); }
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function remove(q) {
|
|
114
|
+
if (!await E.confirm(`Delete the quiz <strong>${esc(q.title)}</strong>? Its ${q.plays} play${q.plays === 1 ? '' : 's'} and leaderboard go with it. Pages that show it will say it is not available.`)) return;
|
|
115
|
+
try {
|
|
116
|
+
await request('DELETE', `${API}/quizzes/${encodeURIComponent(q.id)}`);
|
|
117
|
+
quizzes = quizzes.filter(x => x.id !== q.id);
|
|
118
|
+
draw();
|
|
119
|
+
badge();
|
|
120
|
+
E.toast(`${q.title} deleted.`, {type: 'success'});
|
|
121
|
+
} catch (err) { E.toast(errText(err), {type: 'error'}); }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
draw();
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ---- The editor --------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
const blankQuiz = () => ({
|
|
130
|
+
title: '', slug: '', intro: '', status: 'draft', opensAt: '', closesAt: '', timeLimit: 0, questionSeconds: 0,
|
|
131
|
+
shuffleQuestions: false, shuffleOptions: true, draw: 0, showAnswers: 'end', leaderboard: true, leaderboardSize: 10, attemptsPerName: 0,
|
|
132
|
+
questions: []
|
|
133
|
+
});
|
|
134
|
+
const qid = () => Math.random().toString(36).slice(2, 10);
|
|
135
|
+
const blankQuestion = (kind = 'single') => ({
|
|
136
|
+
id: qid(), kind, text: '', image: '', points: 1, seconds: 0, explanation: '', accepted: [],
|
|
137
|
+
options: kind === 'truefalse' ? [{id: 'true', text: 'True', correct: true}, {id: 'false', text: 'False', correct: false}]
|
|
138
|
+
: kind === 'text' ? [] : [{id: qid(), text: '', correct: true}, {id: qid(), text: '', correct: false}]
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
async function editor(root, container, $container, kit, id) {
|
|
142
|
+
root.innerHTML = '';
|
|
143
|
+
let quiz = blankQuiz();
|
|
144
|
+
if (id) {
|
|
145
|
+
const slot = h('div', {className: 'gma-note dm-loading-slot', text: 'Loading…'});
|
|
146
|
+
root.append(slot);
|
|
147
|
+
try {
|
|
148
|
+
quiz = {...blankQuiz(), ...await kit.whileLoading(slot, request('GET', `${API}/quizzes/${encodeURIComponent(id)}`), kit.SHAPES.rows)};
|
|
149
|
+
} catch (err) {
|
|
150
|
+
root.innerHTML = '';
|
|
151
|
+
root.append(h('p', {className: 'gma-error', text: `That quiz could not load: ${errText(err)}`}), h('a', {href: SIDEBAR, text: 'Back to Quiz'}));
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
bannerTrail(container, [{text: 'Quiz', href: SIDEBAR}, {text: id ? quiz.title : 'New quiz'}]);
|
|
156
|
+
if (id) kit.addBannerAction($container, {id: 'quiz-plays', icon: 'award', title: 'Plays and leaderboard', onClick: () => { location.hash = `#/plugins/quiz/plays/${encodeURIComponent(id)}`; }});
|
|
157
|
+
|
|
158
|
+
const snapshot = () => JSON.stringify(quiz);
|
|
159
|
+
let saved = snapshot();
|
|
160
|
+
const dirty = () => snapshot() !== saved;
|
|
161
|
+
leaveGuard = (e) => { if (dirty() && ROUTE.test(location.hash)) { e.preventDefault(); e.returnValue = ''; } };
|
|
162
|
+
window.addEventListener('beforeunload', leaveGuard);
|
|
163
|
+
lifetime.add(() => { window.removeEventListener('beforeunload', leaveGuard); leaveGuard = null; });
|
|
164
|
+
|
|
165
|
+
const dirtyNote = h('span', {className: 'is-dirty', hidden: true, text: 'Unsaved changes'});
|
|
166
|
+
const errorBox = h('p', {className: 'gma-error', hidden: true, role: 'alert'});
|
|
167
|
+
const touched = () => { dirtyNote.hidden = !dirty(); };
|
|
168
|
+
|
|
169
|
+
// Text inputs write straight into `quiz`; numbers as numbers.
|
|
170
|
+
const input = (key, attrs = {}, obj = quiz) => h('input', {className: 'form-input', value: obj[key] ?? '', ...attrs,
|
|
171
|
+
onInput: (e) => { obj[key] = attrs.type === 'number' ? Number(e.target.value) || 0 : e.target.value; touched(); }});
|
|
172
|
+
const textarea = (key, rows = 3, obj = quiz, attrs = {}) => h('textarea', {className: 'form-input', rows, ...attrs,
|
|
173
|
+
onInput: (e) => { obj[key] = e.target.value; touched(); }}, obj[key] || '');
|
|
174
|
+
const select = (key, options, obj = quiz, onChange = null) => {
|
|
175
|
+
const el = h('select', {className: 'form-select', onChange: (e) => { obj[key] = e.target.value; touched(); onChange?.(e.target.value); }},
|
|
176
|
+
Object.entries(options).map(([v, label]) => h('option', {value: v, text: label})));
|
|
177
|
+
el.value = obj[key];
|
|
178
|
+
return el;
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const shortcode = h('code');
|
|
182
|
+
const showShortcode = () => { shortcode.textContent = quiz.slug ? shortcodeOf(quiz.slug) : 'Saved quizzes get a shortcode'; };
|
|
183
|
+
showShortcode();
|
|
184
|
+
|
|
185
|
+
const minutes = h('input', {className: 'form-input', type: 'number', min: 0, step: 0.5, value: quiz.timeLimit ? quiz.timeLimit / 60 : 0,
|
|
186
|
+
onInput: (e) => { quiz.timeLimit = Math.round((Number(e.target.value) || 0) * 60); touched(); }});
|
|
187
|
+
const opens = h('input', {className: 'form-input', type: 'datetime-local', value: toLocalInput(quiz.opensAt), onInput: (e) => { quiz.opensAt = fromLocalInput(e.target.value); touched(); }});
|
|
188
|
+
const closes = h('input', {className: 'form-input', type: 'datetime-local', value: toLocalInput(quiz.closesAt), onInput: (e) => { quiz.closesAt = fromLocalInput(e.target.value); touched(); }});
|
|
189
|
+
|
|
190
|
+
const details = h('section', {className: 'gma-card'},
|
|
191
|
+
h('h3', {className: 'gma-h', text: 'The quiz'}),
|
|
192
|
+
h('div', {className: 'gma-grid'},
|
|
193
|
+
field('Title', input('title', {maxlength: 120, placeholder: 'Capital cities'})),
|
|
194
|
+
field('Short name', input('slug', {maxlength: 60, placeholder: 'made from the title'}), 'Used in the shortcode. Letters, numbers and hyphens.'),
|
|
195
|
+
field('Status', select('status', STATUS_LABEL), 'Only an open quiz can be played. A draft is not shown at all.')),
|
|
196
|
+
field('Introduction', textarea('intro', 3), 'Shown above the Start button.'),
|
|
197
|
+
h('div', {className: 'gma-grid'},
|
|
198
|
+
field('Opens', opens, 'Optional. Open quizzes can be played from this time.'),
|
|
199
|
+
field('Closes', closes, 'Optional. After this, it shows the leaderboard but cannot be played.')),
|
|
200
|
+
h('div', {className: 'gma-shortcode'}, h('span', {className: 'gma-muted', text: 'Shortcode:'}), shortcode,
|
|
201
|
+
h('button', {type: 'button', className: 'btn btn-xs btn-ghost', onClick: () => quiz.slug && copyText(shortcodeOf(quiz.slug), 'Shortcode copied')}, icon('copy'), ' Copy')));
|
|
202
|
+
|
|
203
|
+
const rules = h('section', {className: 'gma-card'},
|
|
204
|
+
h('h3', {className: 'gma-h', text: 'Rules and leaderboard'}),
|
|
205
|
+
h('div', {className: 'gma-grid'},
|
|
206
|
+
field('Time for the whole quiz (minutes)', minutes, '0 = no limit. When it runs out the quiz ends with what they have.'),
|
|
207
|
+
field('Seconds for each question', input('questionSeconds', {type: 'number', min: 0, max: 3600}), '0 = no limit. A question can set its own.'),
|
|
208
|
+
field('Ask this many questions', input('draw', {type: 'number', min: 0}), '0 = all of them. Fewer picks a random set for each player.'),
|
|
209
|
+
field('Goes each (per name)', input('attemptsPerName', {type: 'number', min: 0, max: 100}), '0 = as many as they like.'),
|
|
210
|
+
field('Show the right answers', select('showAnswers', SHOW_LABEL)),
|
|
211
|
+
field('Leaderboard size', input('leaderboardSize', {type: 'number', min: 3, max: 100}))),
|
|
212
|
+
h('div', {className: 'gma-grid'},
|
|
213
|
+
toggle('Shuffle the questions', quiz.shuffleQuestions, (v) => { quiz.shuffleQuestions = v; touched(); }),
|
|
214
|
+
toggle('Shuffle the answers', quiz.shuffleOptions, (v) => { quiz.shuffleOptions = v; touched(); }),
|
|
215
|
+
toggle('Show a leaderboard', quiz.leaderboard, (v) => { quiz.leaderboard = v; touched(); }, 'Highest score wins; the faster time breaks a tie.')));
|
|
216
|
+
|
|
217
|
+
const qList = h('div', {className: 'qza-questions'});
|
|
218
|
+
const qCount = h('span', {className: 'gma-muted'});
|
|
219
|
+
const questions = h('section', {className: 'gma-card'},
|
|
220
|
+
h('div', {className: 'gma-bar'}, h('h3', {className: 'gma-h', text: 'Questions'}), qCount, h('span', {className: 'gma-spacer'}),
|
|
221
|
+
h('button', {type: 'button', className: 'btn btn-sm btn-primary', onClick: () => addQuestion('single')}, icon('plus'), ' Add a question')),
|
|
222
|
+
qList,
|
|
223
|
+
h('div', {className: 'gma-bar'},
|
|
224
|
+
h('button', {type: 'button', className: 'btn btn-sm btn-outline', onClick: () => addQuestion('single')}, icon('plus'), ' Add a question')));
|
|
225
|
+
|
|
226
|
+
function addQuestion(kind) {
|
|
227
|
+
quiz.questions.push(blankQuestion(kind));
|
|
228
|
+
drawQuestions();
|
|
229
|
+
touched();
|
|
230
|
+
qList.lastElementChild?.querySelector('textarea')?.focus();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function move(i, by) {
|
|
234
|
+
const j = i + by;
|
|
235
|
+
if (j < 0 || j >= quiz.questions.length) return;
|
|
236
|
+
[quiz.questions[i], quiz.questions[j]] = [quiz.questions[j], quiz.questions[i]];
|
|
237
|
+
drawQuestions();
|
|
238
|
+
touched();
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function questionCard(q, i) {
|
|
242
|
+
const n = quiz.questions.length;
|
|
243
|
+
const head = h('div', {className: 'gma-bar'},
|
|
244
|
+
h('strong', {text: `Question ${i + 1}`}),
|
|
245
|
+
select('kind', KIND_LABEL, q, (kind) => {
|
|
246
|
+
// A change of kind keeps what still makes sense.
|
|
247
|
+
if (kind === 'truefalse') q.options = blankQuestion('truefalse').options;
|
|
248
|
+
else if (kind === 'text') { q.accepted = q.accepted?.length ? q.accepted : q.options.filter(o => o.correct && o.text).map(o => o.text); q.options = []; }
|
|
249
|
+
else if (!q.options.length || q.options[0].id === 'true') q.options = blankQuestion('single').options;
|
|
250
|
+
if (kind === 'single' && q.options.filter(o => o.correct).length > 1) q.options.forEach((o, k) => { o.correct = k === q.options.findIndex(x => x.correct); });
|
|
251
|
+
drawQuestions();
|
|
252
|
+
}),
|
|
253
|
+
h('span', {className: 'gma-spacer'}),
|
|
254
|
+
h('button', {type: 'button', className: 'btn btn-xs btn-ghost', title: 'Move up', disabled: i === 0, onClick: () => move(i, -1)}, icon('arrow-up')),
|
|
255
|
+
h('button', {type: 'button', className: 'btn btn-xs btn-ghost', title: 'Move down', disabled: i === n - 1, onClick: () => move(i, 1)}, icon('arrow-down')),
|
|
256
|
+
h('button', {type: 'button', className: 'btn btn-xs btn-ghost', title: 'Copy this question', onClick: () => {
|
|
257
|
+
quiz.questions.splice(i + 1, 0, {...structuredClone(q), id: qid(), options: q.options.map(o => ({...o, id: o.id === 'true' || o.id === 'false' ? o.id : qid()}))});
|
|
258
|
+
drawQuestions(); touched();
|
|
259
|
+
}}, icon('copy')),
|
|
260
|
+
h('button', {type: 'button', className: 'btn btn-xs btn-ghost gma-danger-text', title: 'Delete this question', onClick: async () => {
|
|
261
|
+
if (q.text && !await E.confirm(`Delete question ${i + 1}?`)) return;
|
|
262
|
+
quiz.questions.splice(i, 1);
|
|
263
|
+
drawQuestions(); touched();
|
|
264
|
+
}}, icon('trash')));
|
|
265
|
+
|
|
266
|
+
const answers = h('div', {className: 'qza-answers'});
|
|
267
|
+
if (q.kind === 'text') {
|
|
268
|
+
const acc = h('textarea', {className: 'form-input', rows: 3, placeholder: 'Tokyo',
|
|
269
|
+
onInput: (e) => { q.accepted = e.target.value.split('\n'); touched(); }}, (q.accepted || []).join('\n'));
|
|
270
|
+
answers.append(field('Accepted answers, one a line', acc, 'Capitals, accents, spaces, punctuation and a leading "the" do not matter.'));
|
|
271
|
+
} else {
|
|
272
|
+
const group = `qz-${q.id}`;
|
|
273
|
+
answers.append(h('p', {className: 'gma-note', text: q.kind === 'multiple' ? 'Tick every right answer.' : 'Choose the right answer.'}));
|
|
274
|
+
q.options.forEach((o, k) => {
|
|
275
|
+
const mark = h('input', {type: q.kind === 'multiple' ? 'checkbox' : 'radio', name: group, checked: o.correct, title: 'Right answer',
|
|
276
|
+
onChange: (e) => {
|
|
277
|
+
if (q.kind === 'multiple') o.correct = e.target.checked;
|
|
278
|
+
else q.options.forEach(x => { x.correct = x === o; });
|
|
279
|
+
touched();
|
|
280
|
+
}});
|
|
281
|
+
const text = q.kind === 'truefalse'
|
|
282
|
+
? h('span', {className: 'qza-fixed', text: o.text})
|
|
283
|
+
: h('input', {className: 'form-input', value: o.text, maxlength: 200, placeholder: `Answer ${k + 1}`, onInput: (e) => { o.text = e.target.value; touched(); }});
|
|
284
|
+
answers.append(h('div', {className: `qza-answer${o.correct ? ' is-right' : ''}`},
|
|
285
|
+
h('label', {className: 'qza-mark'}, mark, h('span', {className: 'sr-only', text: 'Right answer'})), text,
|
|
286
|
+
q.kind !== 'truefalse' ? h('button', {type: 'button', className: 'btn btn-xs btn-ghost', title: 'Remove this answer', disabled: q.options.length <= 2,
|
|
287
|
+
onClick: () => { q.options.splice(k, 1); drawQuestions(); touched(); }}, icon('x')) : null));
|
|
288
|
+
mark.addEventListener('change', () => answers.querySelectorAll('.qza-answer').forEach((row, r) => row.classList.toggle('is-right', q.options[r]?.correct)));
|
|
289
|
+
});
|
|
290
|
+
if (q.kind !== 'truefalse' && q.options.length < 8) {
|
|
291
|
+
answers.append(h('button', {type: 'button', className: 'btn btn-xs btn-ghost', onClick: () => {
|
|
292
|
+
q.options.push({id: qid(), text: '', correct: false}); drawQuestions(); touched();
|
|
293
|
+
qList.children[i]?.querySelectorAll('.qza-answer input.form-input')[q.options.length - 1]?.focus();
|
|
294
|
+
}}, icon('plus'), ' Add an answer'));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
return h('div', {className: 'qza-question', dataset: {id: q.id}},
|
|
299
|
+
head,
|
|
300
|
+
field('Question', textarea('text', 2, q, {maxlength: 1000, placeholder: 'What is the capital of Japan?'})),
|
|
301
|
+
answers,
|
|
302
|
+
h('details', {className: 'qza-more', open: Boolean(q.image || q.explanation || q.seconds || q.points !== 1)},
|
|
303
|
+
h('summary', {text: 'Points, time, picture and explanation'}),
|
|
304
|
+
h('div', {className: 'gma-grid'},
|
|
305
|
+
field('Points', input('points', {type: 'number', min: 0, max: 100}, q)),
|
|
306
|
+
field('Seconds for this question', input('seconds', {type: 'number', min: 0, max: 3600}, q), '0 = the quiz\'s own setting.'),
|
|
307
|
+
field('Picture (address)', input('image', {placeholder: '/media/…', maxlength: 500}, q), 'A picture from Media, or an https:// address.')),
|
|
308
|
+
field('Explanation', textarea('explanation', 2, q, {maxlength: 1000}), 'Shown with the right answer, when the quiz shows answers.')));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function drawQuestions() {
|
|
312
|
+
qList.innerHTML = '';
|
|
313
|
+
quiz.questions.forEach((q, i) => qList.append(questionCard(q, i)));
|
|
314
|
+
if (!quiz.questions.length) qList.append(h('p', {className: 'gma-muted', text: 'No questions yet.'}));
|
|
315
|
+
const total = quiz.questions.reduce((s, q) => s + (Number(q.points) || 0), 0);
|
|
316
|
+
qCount.textContent = quiz.questions.length ? `${quiz.questions.length} · ${total} point${total === 1 ? '' : 's'}` : '';
|
|
317
|
+
I.scan(qList);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
const saveBtn = h('button', {type: 'button', className: 'btn btn-primary', onClick: () => save()}, icon('check'), ' Save');
|
|
321
|
+
const bar = h('div', {className: 'gma-save'}, saveBtn,
|
|
322
|
+
h('a', {className: 'btn btn-ghost', href: SIDEBAR, text: 'Back to the list'}), dirtyNote, h('span', {className: 'gma-spacer'}), errorBox);
|
|
323
|
+
|
|
324
|
+
async function save() {
|
|
325
|
+
errorBox.hidden = true;
|
|
326
|
+
saveBtn.disabled = true;
|
|
327
|
+
const body = {...quiz, questions: quiz.questions.map(q => ({...q, accepted: (q.accepted || []).map(a => a.trim()).filter(Boolean)}))};
|
|
328
|
+
try {
|
|
329
|
+
const out = id ? await request('PUT', `${API}/quizzes/${encodeURIComponent(id)}`, body) : await request('POST', `${API}/quizzes`, body);
|
|
330
|
+
quiz = {...blankQuiz(), ...out};
|
|
331
|
+
saved = snapshot();
|
|
332
|
+
touched();
|
|
333
|
+
showShortcode();
|
|
334
|
+
E.toast(`${out.title} saved.`, {type: 'success'});
|
|
335
|
+
if (!id) { location.hash = `#/plugins/quiz/edit/${encodeURIComponent(out.id)}`; return; }
|
|
336
|
+
bannerTrail(container, [{text: 'Quiz', href: SIDEBAR}, {text: out.title}]);
|
|
337
|
+
drawQuestions();
|
|
338
|
+
} catch (err) {
|
|
339
|
+
errorBox.textContent = errText(err);
|
|
340
|
+
errorBox.hidden = false;
|
|
341
|
+
} finally {
|
|
342
|
+
saveBtn.disabled = false;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
root.innerHTML = '';
|
|
347
|
+
root.append(details, rules, questions, bar);
|
|
348
|
+
drawQuestions();
|
|
349
|
+
I.scan(root);
|
|
350
|
+
if (!id) details.querySelector('input')?.focus();
|
|
351
|
+
}
|
|
352
|
+
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"slug": "quiz-plays",
|
|
3
|
+
"title": "Quiz - Plays",
|
|
4
|
+
"description": "Every go at a quiz: the name, the score and the time. Look at them in Quiz, not here.",
|
|
5
|
+
"plugin": "quiz",
|
|
6
|
+
"fields": [
|
|
7
|
+
{"name": "gameId", "label": "Quiz", "type": "text"},
|
|
8
|
+
{"name": "name", "label": "Name", "type": "text"},
|
|
9
|
+
{"name": "status", "label": "Status", "type": "text"},
|
|
10
|
+
{"name": "score", "label": "Score", "type": "number"},
|
|
11
|
+
{"name": "timeMs", "label": "Time (ms)", "type": "number"},
|
|
12
|
+
{"name": "hidden", "label": "Hidden from the leaderboard", "type": "boolean"}
|
|
13
|
+
],
|
|
14
|
+
"api": {
|
|
15
|
+
"create": {"enabled": false, "access": "admin"},
|
|
16
|
+
"read": {"enabled": false, "access": "admin"},
|
|
17
|
+
"update": {"enabled": false, "access": "admin"},
|
|
18
|
+
"delete": {"enabled": false, "access": "admin"}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
{
|
|
2
|
+
"slug": "quiz-quizzes",
|
|
3
|
+
"title": "Quiz - Quizzes",
|
|
4
|
+
"description": "The quizzes and their questions, answers included. Edit them in Quiz, not here.",
|
|
5
|
+
"plugin": "quiz",
|
|
6
|
+
"fields": [
|
|
7
|
+
{"name": "title", "label": "Title", "type": "text"},
|
|
8
|
+
{"name": "slug", "label": "Short name", "type": "text"},
|
|
9
|
+
{"name": "status", "label": "Status", "type": "text"}
|
|
10
|
+
],
|
|
11
|
+
"api": {
|
|
12
|
+
"create": {"enabled": false, "access": "admin"},
|
|
13
|
+
"read": {"enabled": false, "access": "admin"},
|
|
14
|
+
"update": {"enabled": false, "access": "admin"},
|
|
15
|
+
"delete": {"enabled": false, "access": "admin"}
|
|
16
|
+
}
|
|
17
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<script type="module" src="/plugins/quiz/public/quiz.js?v=1.0.0"></script>
|