domma-cms 1.8.45 → 1.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/admin/js/lib/code-format.js +50 -40
- package/admin/js/templates/docs/usage-users-roles.html +4 -0
- package/admin/js/views/page-editor.js +10 -8
- package/bin/lib/left-free-tier.js +83 -0
- package/bin/update.js +24 -0
- package/docs/DommaFeatures.md +47 -7
- package/docs/configuration.md +19 -7
- package/docs/plugin-development.md +7 -0
- package/package.json +1 -1
- package/plugins/_lib/games/names.js +6 -0
- package/plugins/_lib/public/games/motion.css +1 -0
- package/plugins/_lib/public/games/motion.js +28 -0
- package/plugins/free-tier.lock.json +1 -43
- package/server/routes/api/api-endpoints.js +5 -1
- package/server/routes/api/collections.js +4 -3
- package/server/routes/api/forms.js +22 -0
- package/server/routes/api/views.js +18 -0
- package/server/services/collectionAccess.js +97 -15
- package/server/services/permissionRegistry.js +39 -11
- package/plugins/crossword/CLAUDE.md +0 -37
- package/plugins/crossword/admin/css/index.css +0 -18
- package/plugins/crossword/admin/templates/crossword.html +0 -1
- package/plugins/crossword/admin/views/crossword.js +0 -449
- package/plugins/crossword/collections/crossword-plays.json +0 -20
- package/plugins/crossword/collections/crossword-puzzles.json +0 -17
- package/plugins/crossword/config.js +0 -6
- package/plugins/crossword/docs/guide.md +0 -53
- package/plugins/crossword/inject/body.html +0 -1
- package/plugins/crossword/lib/crossword.js +0 -170
- package/plugins/crossword/lib/store.js +0 -56
- package/plugins/crossword/plugin.js +0 -274
- package/plugins/crossword/plugin.json +0 -79
- package/plugins/crossword/public/crossword.css +0 -36
- package/plugins/crossword/public/crossword.js +0 -519
- package/plugins/crossword/public/lib/layout.js +0 -199
- package/plugins/quiz/CLAUDE.md +0 -43
- package/plugins/quiz/admin/css/index.css +0 -13
- package/plugins/quiz/admin/templates/quiz.html +0 -1
- package/plugins/quiz/admin/views/quiz.js +0 -530
- package/plugins/quiz/collections/quiz-plays.json +0 -20
- package/plugins/quiz/collections/quiz-quizzes.json +0 -17
- package/plugins/quiz/config.js +0 -6
- package/plugins/quiz/docs/guide.md +0 -61
- package/plugins/quiz/inject/body.html +0 -1
- package/plugins/quiz/lib/engine.js +0 -115
- package/plugins/quiz/lib/quiz.js +0 -225
- package/plugins/quiz/lib/store.js +0 -56
- package/plugins/quiz/plugin.js +0 -275
- package/plugins/quiz/plugin.json +0 -79
- package/plugins/quiz/public/quiz.css +0 -36
- package/plugins/quiz/public/quiz.js +0 -372
package/plugins/quiz/plugin.js
DELETED
|
@@ -1,275 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Quiz - the API (mounted at /api/plugins/quiz).
|
|
3
|
-
*
|
|
4
|
-
* Admin (the `quiz` permission; read to look, manage to change):
|
|
5
|
-
* GET /count sidebar badge: plays finished today
|
|
6
|
-
* GET /quizzes every quiz as a list row
|
|
7
|
-
* GET /quizzes/:id · POST /quizzes · PUT /quizzes/:id · DELETE /quizzes/:id
|
|
8
|
-
* POST /quizzes/:id/copy a copy, as a draft
|
|
9
|
-
* GET /quizzes/:id/test what the player's start screen shows, as if it were open
|
|
10
|
-
* POST /quizzes/:id/test a TEST play of it as saved (draft or closed too) -> {playId, token, name, info};
|
|
11
|
-
* played through the /public/play routes, never on a board (see _lib/games/plays.js)
|
|
12
|
-
* GET /quizzes/:id/plays · DELETE /quizzes/:id/plays · PUT /plays/:id · DELETE /plays/:id
|
|
13
|
-
*
|
|
14
|
-
* Players (no sign-in), under /public - the server keeps the time and marks every answer:
|
|
15
|
-
* GET /public/quiz/:slug title, intro, how many questions, the limits
|
|
16
|
-
* GET /public/quiz/:slug/leaderboard ?period=today|week|month|all&limit=
|
|
17
|
-
* POST /public/quiz/:slug/start {name} -> {playId, token} (the token is shown once)
|
|
18
|
-
* GET /public/play/:id where the play is now: the current question (no answer), time left
|
|
19
|
-
* POST /public/play/:id/answer {q, answer} -> marked; feedback when the quiz shows answers as you go
|
|
20
|
-
* GET /public/play/:id/result score, time, place, and the answers when the quiz shows them
|
|
21
|
-
* Each /public/play call carries the token in `X-Play-Token`.
|
|
22
|
-
*
|
|
23
|
-
* Shortcodes: [quiz id="<short name>" /] and [quiz-leaderboard id="<short name>" period="week" limit="10" /].
|
|
24
|
-
*/
|
|
25
|
-
|
|
26
|
-
import {cleanPlayerName} from '../_lib/games/names.js';
|
|
27
|
-
import {boardQuery, fail, isBot, limited, mountPlayAdmin, testerName, todayBadge, tokenOf} from '../_lib/games/http.js';
|
|
28
|
-
import {answerText, cleanQuiz, isOpen, maxScoreOf, planPlay, rightText, summary} from './lib/quiz.js';
|
|
29
|
-
import {answer, catchUp, feedbackFor, serve, view} from './lib/engine.js';
|
|
30
|
-
import {allQuizzes, ensureCollections, getQuiz, plays, quizBySlug, removeQuiz, saveQuiz, uniqueSlug} from './lib/store.js';
|
|
31
|
-
|
|
32
|
-
const esc = (s) => String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"').replace(/'/g, ''');
|
|
33
|
-
const slugAttr = (v) => String(v || '').toLowerCase().replace(/[^a-z0-9-]/g, '').slice(0, 60);
|
|
34
|
-
|
|
35
|
-
function registerShortcodes(hooks) {
|
|
36
|
-
if (typeof hooks.registerShortcode !== 'function') return;
|
|
37
|
-
// Self-closing. The page's script (public/quiz.js, on every page via inject/body.html) mounts on the placeholder.
|
|
38
|
-
hooks.registerShortcode('quiz', (attrStr, _body, ctx) => {
|
|
39
|
-
const a = ctx.parseShortcodeAttrs(attrStr || '');
|
|
40
|
-
const slug = slugAttr(a.id || a.slug);
|
|
41
|
-
if (!slug) return '<p class="gm-muted">[quiz] needs id="the quiz\'s short name".</p>';
|
|
42
|
-
return `<div class="gm-app qz-app" data-quiz='${esc(JSON.stringify({slug}))}'><p class="gm-muted">Loading the quiz…</p></div>`;
|
|
43
|
-
});
|
|
44
|
-
hooks.registerShortcode('quiz-leaderboard', (attrStr, _body, ctx) => {
|
|
45
|
-
const a = ctx.parseShortcodeAttrs(attrStr || '');
|
|
46
|
-
const id = slugAttr(a.id || a.slug);
|
|
47
|
-
if (!id) return '<p class="gm-muted">[quiz-leaderboard] needs id="the quiz\'s short name".</p>';
|
|
48
|
-
const cfg = {id, period: String(a.period || 'all'), limit: Math.max(1, Math.min(100, Number(a.limit) || 10)), title: String(a.title || 'Leaderboard').slice(0, 80)};
|
|
49
|
-
return `<div class="qz-board" data-quiz-board='${esc(JSON.stringify(cfg))}'></div>`;
|
|
50
|
-
// First: the engine matches [quiz…/] by prefix, so [quiz] would otherwise take [quiz-leaderboard …/] too.
|
|
51
|
-
}, {priority: 5});
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
/** What anyone may know about a quiz before playing it. */
|
|
55
|
-
function publicInfo(quiz) {
|
|
56
|
-
const state = isOpen(quiz);
|
|
57
|
-
const count = quiz.draw || quiz.questions.length;
|
|
58
|
-
return {
|
|
59
|
-
slug: quiz.slug, title: quiz.title, intro: quiz.intro, open: state.open, reason: state.reason, opensAt: state.opensAt || '',
|
|
60
|
-
questions: count, timeLimit: quiz.timeLimit, questionSeconds: quiz.questionSeconds,
|
|
61
|
-
perQuestionTimes: quiz.questions.some(q => q.seconds > 0),
|
|
62
|
-
showAnswers: quiz.showAnswers, leaderboard: quiz.leaderboard, leaderboardSize: quiz.leaderboardSize,
|
|
63
|
-
attemptsPerName: quiz.attemptsPerName
|
|
64
|
-
};
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** The review at the end: every question, their answer and the right one - only when the quiz shows answers. */
|
|
68
|
-
function review(quiz, play) {
|
|
69
|
-
if (quiz.showAnswers === 'never') return null;
|
|
70
|
-
return play.state.order.map((slot, i) => {
|
|
71
|
-
const q = quiz.questions.find(x => x.id === slot.q);
|
|
72
|
-
const a = play.state.answers[i];
|
|
73
|
-
if (!q) return null;
|
|
74
|
-
return {
|
|
75
|
-
text: q.text, image: q.image, points: q.points,
|
|
76
|
-
yours: a ? (a.given == null ? '' : answerText(q, a.given)) : '',
|
|
77
|
-
right: rightText(q), correct: a?.correct === true, late: a?.late === true, explanation: q.explanation || ''
|
|
78
|
-
};
|
|
79
|
-
}).filter(Boolean);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
export default async function quizPlugin(fastify, options) {
|
|
83
|
-
const {authenticate, requirePermission} = options.auth;
|
|
84
|
-
const hooks = options.hooks || {};
|
|
85
|
-
const can = (action) => ({preHandler: [authenticate, requirePermission('quiz', action)]});
|
|
86
|
-
|
|
87
|
-
await ensureCollections(fastify.log);
|
|
88
|
-
registerShortcodes(hooks);
|
|
89
|
-
|
|
90
|
-
if (typeof hooks.registerNotificationSource === 'function') {
|
|
91
|
-
try {
|
|
92
|
-
hooks.registerNotificationSource({id: 'top-scores', label: 'Quiz top scores', severity: 'info', defaultOn: true,
|
|
93
|
-
description: 'Someone has gone top of a quiz\'s all-time leaderboard.'});
|
|
94
|
-
} catch { /* older engine */ }
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// ---- Admin ------------------------------------------------------------------
|
|
98
|
-
|
|
99
|
-
fastify.get('/count', can('read'), async () => todayBadge(plays, 'quiz play'));
|
|
100
|
-
|
|
101
|
-
fastify.get('/quizzes', can('read'), async () => {
|
|
102
|
-
const everyPlay = await plays.all();
|
|
103
|
-
return {quizzes: (await allQuizzes()).map(q => summary(q, everyPlay.filter(p => p.gameId === q.id && p.test !== true)))
|
|
104
|
-
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))};
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
fastify.get('/quizzes/:id', can('read'), async (request, reply) => {
|
|
108
|
-
const quiz = await getQuiz(request.params.id);
|
|
109
|
-
return quiz || fail(reply, 404, 'That quiz is not here any more.');
|
|
110
|
-
});
|
|
111
|
-
|
|
112
|
-
async function save(request, reply, id) {
|
|
113
|
-
const current = id ? await getQuiz(id) : null;
|
|
114
|
-
if (id && !current) return fail(reply, 404, 'That quiz is not here any more.');
|
|
115
|
-
const {quiz, errors} = cleanQuiz(request.body || {});
|
|
116
|
-
if (Object.keys(errors).length) return fail(reply, 400, Object.values(errors)[0], {errors});
|
|
117
|
-
quiz.slug = await uniqueSlug(quiz.slug, id || '');
|
|
118
|
-
const saved = await saveQuiz(id, quiz);
|
|
119
|
-
return id ? saved : reply.code(201).send(saved);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
fastify.post('/quizzes', can('manage'), (request, reply) => save(request, reply, ''));
|
|
123
|
-
fastify.put('/quizzes/:id', can('manage'), (request, reply) => save(request, reply, request.params.id));
|
|
124
|
-
|
|
125
|
-
fastify.post('/quizzes/:id/copy', can('manage'), async (request, reply) => {
|
|
126
|
-
const quiz = await getQuiz(request.params.id);
|
|
127
|
-
if (!quiz) return fail(reply, 404, 'That quiz is not here any more.');
|
|
128
|
-
const {id, createdAt, updatedAt, ...rest} = quiz;
|
|
129
|
-
const copy = {...rest, title: `${quiz.title} (copy)`.slice(0, 120), status: 'draft', slug: await uniqueSlug(quiz.slug)};
|
|
130
|
-
return reply.code(201).send(await saveQuiz('', copy));
|
|
131
|
-
});
|
|
132
|
-
|
|
133
|
-
fastify.delete('/quizzes/:id', can('manage'), async (request, reply) => {
|
|
134
|
-
if (!await getQuiz(request.params.id)) return fail(reply, 404, 'That quiz is not here any more.');
|
|
135
|
-
return {ok: true, playsRemoved: await removeQuiz(request.params.id)};
|
|
136
|
-
});
|
|
137
|
-
|
|
138
|
-
mountPlayAdmin(fastify, {store: plays, base: '/quizzes', can, exists: async (id) => Boolean(await getQuiz(id))});
|
|
139
|
-
|
|
140
|
-
// The admin tries the quiz from the Tool: the saved version, whatever its status or dates, through
|
|
141
|
-
// the same player routes as anyone. The play is marked `test`, so it never counts anywhere.
|
|
142
|
-
const testInfo = (quiz) => ({...publicInfo(quiz), open: true, reason: ''});
|
|
143
|
-
|
|
144
|
-
fastify.get('/quizzes/:id/test', can('manage'), async (request, reply) => {
|
|
145
|
-
const quiz = await getQuiz(request.params.id);
|
|
146
|
-
if (!quiz) return fail(reply, 404, 'That quiz is not here any more.');
|
|
147
|
-
return {info: testInfo(quiz)};
|
|
148
|
-
});
|
|
149
|
-
|
|
150
|
-
fastify.post('/quizzes/:id/test', can('manage'), async (request, reply) => {
|
|
151
|
-
const quiz = await getQuiz(request.params.id);
|
|
152
|
-
if (!quiz) return fail(reply, 404, 'That quiz is not here any more.');
|
|
153
|
-
if (!quiz.questions?.length) return fail(reply, 409, 'Add a question and save, then test it.', {code: 'empty'});
|
|
154
|
-
await plays.clearTests(quiz.id);
|
|
155
|
-
const plan = planPlay(quiz);
|
|
156
|
-
const name = testerName(request);
|
|
157
|
-
const play = await plays.start({gameId: quiz.id, name, maxScore: maxScoreOf(quiz, plan), state: plan,
|
|
158
|
-
userId: String(request.user?.id || ''), test: true});
|
|
159
|
-
return reply.code(201).send({playId: play.id, token: play.token, name, total: plan.order.length, maxScore: play.maxScore,
|
|
160
|
-
info: testInfo(quiz)});
|
|
161
|
-
});
|
|
162
|
-
|
|
163
|
-
// ---- Players ----------------------------------------------------------------
|
|
164
|
-
|
|
165
|
-
/** A quiz the public may see: anything but a draft. */
|
|
166
|
-
const shownQuiz = async (slug) => {
|
|
167
|
-
const quiz = await quizBySlug(slug);
|
|
168
|
-
return quiz && quiz.status !== 'draft' ? quiz : null;
|
|
169
|
-
};
|
|
170
|
-
|
|
171
|
-
fastify.get('/public/quiz/:slug', limited(120), async (request, reply) => {
|
|
172
|
-
const quiz = await shownQuiz(request.params.slug);
|
|
173
|
-
if (!quiz) return fail(reply, 404, 'There is no quiz here.');
|
|
174
|
-
return publicInfo(quiz);
|
|
175
|
-
});
|
|
176
|
-
|
|
177
|
-
fastify.get('/public/quiz/:slug/leaderboard', limited(120), async (request, reply) => {
|
|
178
|
-
const quiz = await shownQuiz(request.params.slug);
|
|
179
|
-
if (!quiz || !quiz.leaderboard) return fail(reply, 404, 'There is no leaderboard here.');
|
|
180
|
-
const {period, limit} = boardQuery(request.query, quiz.leaderboardSize);
|
|
181
|
-
return {title: quiz.title, period, rows: await plays.board(quiz.id, {period, limit})};
|
|
182
|
-
});
|
|
183
|
-
|
|
184
|
-
fastify.post('/public/quiz/:slug/start', limited(20, 10), async (request, reply) => {
|
|
185
|
-
const quiz = await shownQuiz(request.params.slug);
|
|
186
|
-
if (!quiz) return fail(reply, 404, 'There is no quiz here.');
|
|
187
|
-
const state = isOpen(quiz);
|
|
188
|
-
if (!state.open) return fail(reply, 409, state.reason, {code: 'closed'});
|
|
189
|
-
if (isBot(request.body)) return fail(reply, 400, 'Something went wrong. Please try again.');
|
|
190
|
-
const {name, error} = cleanPlayerName(request.body?.name);
|
|
191
|
-
if (error) return fail(reply, 400, error, {code: 'name'});
|
|
192
|
-
if (quiz.attemptsPerName && await plays.attemptsBy(quiz.id, name) >= quiz.attemptsPerName) {
|
|
193
|
-
return fail(reply, 409, quiz.attemptsPerName === 1 ? `${name} has already had a go at this quiz.` : `${name} has already had ${quiz.attemptsPerName} goes at this quiz.`, {code: 'attempts'});
|
|
194
|
-
}
|
|
195
|
-
const plan = planPlay(quiz);
|
|
196
|
-
const play = await plays.start({gameId: quiz.id, name, maxScore: maxScoreOf(quiz, plan), state: plan});
|
|
197
|
-
return reply.code(201).send({playId: play.id, token: play.token, name, total: plan.order.length, maxScore: play.maxScore});
|
|
198
|
-
});
|
|
199
|
-
|
|
200
|
-
/**
|
|
201
|
-
* The play behind a request, brought up to date with the clock (and saved
|
|
202
|
-
* when that changed it). Null after answering with the reason.
|
|
203
|
-
*/
|
|
204
|
-
/** The play went while this request was on its way (a reset, a delete, or the next test clearing it). */
|
|
205
|
-
const gone = (reply) => fail(reply, 404, 'That game is not here any more. Start again.', {code: 'gone'});
|
|
206
|
-
|
|
207
|
-
async function livePlay(request, reply, {serveNow = false} = {}) {
|
|
208
|
-
const opened = await plays.open(request.params.id, tokenOf(request));
|
|
209
|
-
if (!opened) { fail(reply, 404, 'That game is not here any more. Start again.', {code: 'gone'}); return null; }
|
|
210
|
-
const quiz = await getQuiz(opened.gameId);
|
|
211
|
-
if (!quiz) { fail(reply, 404, 'That quiz is not here any more.', {code: 'gone'}); return null; }
|
|
212
|
-
const now = Date.now();
|
|
213
|
-
const play = await plays.change(opened.id, (p) => {
|
|
214
|
-
const a = catchUp(quiz, p, now);
|
|
215
|
-
const b = serveNow && serve(quiz, p, now);
|
|
216
|
-
return a || b ? p : null;
|
|
217
|
-
});
|
|
218
|
-
if (!play) { gone(reply); return null; }
|
|
219
|
-
if (play.status === 'finished' && opened.status === 'playing') await afterFinish(quiz, play);
|
|
220
|
-
return {quiz, play, now};
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
/** A new top of the all-time board tells the admins. */
|
|
224
|
-
async function afterFinish(quiz, play) {
|
|
225
|
-
try {
|
|
226
|
-
if (play.test || !quiz.leaderboard || await plays.placeOf(play, 'all') !== 1) return;
|
|
227
|
-
hooks.notify?.({
|
|
228
|
-
title: `New top score on "${quiz.title}"`,
|
|
229
|
-
body: `${play.name}: ${play.score} of ${play.maxScore} in ${Math.round(play.timeMs / 1000)} seconds`,
|
|
230
|
-
severity: 'info', link: `#/plugins/quiz/plays/${quiz.id}`, source: 'top-scores', dedupeKey: `quiz-top:${play.id}`
|
|
231
|
-
});
|
|
232
|
-
} catch { /* a notice never breaks a play */ }
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
fastify.get('/public/play/:id', limited(240), async (request, reply) => {
|
|
236
|
-
const live = await livePlay(request, reply, {serveNow: true});
|
|
237
|
-
if (!live) return reply;
|
|
238
|
-
return view(live.quiz, live.play, live.now);
|
|
239
|
-
});
|
|
240
|
-
|
|
241
|
-
fastify.post('/public/play/:id/answer', limited(240), async (request, reply) => {
|
|
242
|
-
const live = await livePlay(request, reply);
|
|
243
|
-
if (!live) return reply;
|
|
244
|
-
const {quiz} = live;
|
|
245
|
-
let outcome = null;
|
|
246
|
-
const play = await plays.change(live.play.id, (p) => {
|
|
247
|
-
outcome = answer(quiz, p, request.body?.q, request.body?.answer, Date.now());
|
|
248
|
-
return outcome.error ? null : p;
|
|
249
|
-
});
|
|
250
|
-
if (!play) return gone(reply);
|
|
251
|
-
if (outcome?.error) return fail(reply, 409, outcome.error, {code: outcome.code || 'finished', play: view(quiz, play)});
|
|
252
|
-
if (play.status === 'finished') await afterFinish(quiz, play);
|
|
253
|
-
return {correct: outcome.correct, late: outcome.late, points: outcome.points, feedback: feedbackFor(quiz, outcome.q, outcome), play: view(quiz, play)};
|
|
254
|
-
});
|
|
255
|
-
|
|
256
|
-
fastify.get('/public/play/:id/result', limited(120), async (request, reply) => {
|
|
257
|
-
const live = await livePlay(request, reply);
|
|
258
|
-
if (!live) return reply;
|
|
259
|
-
const {quiz, play} = live;
|
|
260
|
-
if (play.status !== 'finished') return fail(reply, 409, 'This play has not finished yet.', {code: 'playing'});
|
|
261
|
-
return {
|
|
262
|
-
name: play.name, score: play.score, maxScore: play.maxScore, timeMs: play.timeMs, timeUp: play.state.timeUp === true,
|
|
263
|
-
correct: play.state.answers.filter(a => a.correct).length, total: play.state.order.length,
|
|
264
|
-
hidden: play.hidden === true,
|
|
265
|
-
test: play.test === true,
|
|
266
|
-
place: quiz.leaderboard && !play.hidden && !play.test ? await plays.placeOf(play, 'all') : 0,
|
|
267
|
-
placeToday: quiz.leaderboard && !play.hidden && !play.test ? await plays.placeOf(play, 'today') : 0,
|
|
268
|
-
// A test is on no board; this is where it would have come.
|
|
269
|
-
wouldPlace: quiz.leaderboard && play.test ? await plays.placeOf(play, 'all') : 0,
|
|
270
|
-
leaderboard: quiz.leaderboard,
|
|
271
|
-
review: review(quiz, play)
|
|
272
|
-
};
|
|
273
|
-
});
|
|
274
|
-
}
|
|
275
|
-
|
package/plugins/quiz/plugin.json
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "quiz",
|
|
3
|
-
"displayName": "Quiz",
|
|
4
|
-
"version": "1.1.0",
|
|
5
|
-
"tier": "free",
|
|
6
|
-
"minCmsVersion": "1.8.33",
|
|
7
|
-
"description": "Make quizzes in the admin and put them on any page. Players type a name, answer against the clock, and see where they came on the leaderboard. The server keeps the time and marks every answer, so the browser never holds the answers.",
|
|
8
|
-
"author": "Domma CMS",
|
|
9
|
-
"date": "2026-10-21",
|
|
10
|
-
"icon": "help-circle",
|
|
11
|
-
"permissions": [
|
|
12
|
-
{
|
|
13
|
-
"key": "quiz",
|
|
14
|
-
"label": "Quiz",
|
|
15
|
-
"group": "Plugins",
|
|
16
|
-
"icon": "help-circle",
|
|
17
|
-
"description": "See quizzes and who played them (read), or make, change and remove quizzes and plays (manage).",
|
|
18
|
-
"actions": [
|
|
19
|
-
{
|
|
20
|
-
"key": "read",
|
|
21
|
-
"label": "See",
|
|
22
|
-
"description": "Open Quiz and see the quizzes, plays and leaderboards"
|
|
23
|
-
},
|
|
24
|
-
{
|
|
25
|
-
"key": "manage",
|
|
26
|
-
"label": "Manage",
|
|
27
|
-
"description": "Make, change and delete quizzes; hide or remove plays; reset a leaderboard"
|
|
28
|
-
}
|
|
29
|
-
],
|
|
30
|
-
"grant": [
|
|
31
|
-
"admin"
|
|
32
|
-
]
|
|
33
|
-
}
|
|
34
|
-
],
|
|
35
|
-
"inject": {
|
|
36
|
-
"bodyEnd": "inject/body.html"
|
|
37
|
-
},
|
|
38
|
-
"admin": {
|
|
39
|
-
"css": [
|
|
40
|
-
"../_lib/admin/games/games.css",
|
|
41
|
-
"admin/css/index.css"
|
|
42
|
-
],
|
|
43
|
-
"sidebar": [
|
|
44
|
-
{
|
|
45
|
-
"id": "quiz",
|
|
46
|
-
"text": "Quiz",
|
|
47
|
-
"icon": "help-circle",
|
|
48
|
-
"url": "#/plugins/quiz",
|
|
49
|
-
"section": "#/plugins/quiz",
|
|
50
|
-
"permission": "quiz",
|
|
51
|
-
"group": "Games",
|
|
52
|
-
"countUrl": "/api/plugins/quiz/count"
|
|
53
|
-
}
|
|
54
|
-
],
|
|
55
|
-
"routes": [
|
|
56
|
-
{
|
|
57
|
-
"path": "/plugins/quiz",
|
|
58
|
-
"view": "plugin-quiz",
|
|
59
|
-
"title": "Quiz - Domma CMS"
|
|
60
|
-
},
|
|
61
|
-
{
|
|
62
|
-
"path": "/plugins/quiz/:a",
|
|
63
|
-
"view": "plugin-quiz",
|
|
64
|
-
"title": "Quiz - Domma CMS"
|
|
65
|
-
},
|
|
66
|
-
{
|
|
67
|
-
"path": "/plugins/quiz/:a/:b",
|
|
68
|
-
"view": "plugin-quiz",
|
|
69
|
-
"title": "Quiz - Domma CMS"
|
|
70
|
-
}
|
|
71
|
-
],
|
|
72
|
-
"views": {
|
|
73
|
-
"plugin-quiz": {
|
|
74
|
-
"entry": "quiz/admin/views/quiz.js?v=1.1.0",
|
|
75
|
-
"exportName": "quizView"
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
}
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
/* Quiz - the player. Shared pieces (cards, clock, leaderboard) are in /plugins/_lib/public/games/games.css. */
|
|
2
|
-
|
|
3
|
-
.qz-q { font-size: 1.35rem; line-height: 1.35; margin: 0 0 1rem; outline: none; }
|
|
4
|
-
.qz-image { display: block; max-width: 100%; max-height: 320px; border-radius: 8px; margin: 0 0 1rem; }
|
|
5
|
-
.qz-hint { margin: -.5rem 0 .75rem; }
|
|
6
|
-
|
|
7
|
-
.qz-options { display: grid; gap: .6rem; }
|
|
8
|
-
@media (min-width: 640px) { .qz-options { grid-template-columns: 1fr 1fr; } }
|
|
9
|
-
.qz-option {
|
|
10
|
-
display: flex; align-items: center; gap: .75rem; width: 100%; text-align: left; font: inherit; cursor: pointer;
|
|
11
|
-
padding: .8rem 1rem; border-radius: 10px; border: 2px solid var(--gm-line); background: var(--gm-soft); color: inherit;
|
|
12
|
-
transition: border-color .12s, transform .12s;
|
|
13
|
-
}
|
|
14
|
-
.qz-option:hover:not(:disabled) { border-color: var(--gm-accent); transform: translateY(-1px); }
|
|
15
|
-
.qz-option:focus-visible { outline: 2px solid var(--gm-accent); outline-offset: 2px; }
|
|
16
|
-
.qz-option:disabled { cursor: default; opacity: .7; }
|
|
17
|
-
.qz-option.is-picked { border-color: var(--gm-accent); background: color-mix(in srgb, var(--gm-accent) 15%, transparent); }
|
|
18
|
-
.qz-letter {
|
|
19
|
-
flex: none; display: inline-grid; place-items: center; width: 1.8rem; height: 1.8rem; border-radius: 50%;
|
|
20
|
-
background: var(--gm-accent); color: #fff; font-weight: 700; font-size: .9rem;
|
|
21
|
-
}
|
|
22
|
-
.qz-typed { display: flex; flex-direction: column; gap: .35rem; max-width: 420px; }
|
|
23
|
-
|
|
24
|
-
.qz-verdict { font-size: 1.8rem; margin: 0 0 .5rem; outline: none; }
|
|
25
|
-
.qz-verdict.is-right { color: var(--gm-good); }
|
|
26
|
-
.qz-verdict.is-wrong { color: var(--gm-bad); }
|
|
27
|
-
|
|
28
|
-
.qz-review { margin-top: 1.5rem; }
|
|
29
|
-
.qz-review h3 { margin: 0 0 .5rem; font-size: 1.1rem; }
|
|
30
|
-
.qz-review-list { margin: 0; padding-left: 0; list-style: none; display: grid; gap: .5rem; }
|
|
31
|
-
.qz-review-list li { padding: .6rem .8rem; border-radius: 8px; border-left: 4px solid var(--gm-line); background: var(--gm-soft); }
|
|
32
|
-
.qz-review-list li.is-right { border-left-color: var(--gm-good); }
|
|
33
|
-
.qz-review-list li.is-wrong { border-left-color: var(--gm-bad); }
|
|
34
|
-
.qz-review-q { margin: 0 0 .25rem; font-weight: 600; }
|
|
35
|
-
.qz-review-a { margin: 0; }
|
|
36
|
-
.qz-live { position: absolute; left: -9999px; }
|