domma-cms 1.8.32 → 1.8.33
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 +5 -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/free-tier.lock.json +43 -1
- 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
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: Guide
|
|
3
|
+
order: 1
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
Run quizzes on your website. Visitors type their name, answer against the clock and land on a leaderboard. Quiz is free.
|
|
7
|
+
|
|
8
|
+
## What it does
|
|
9
|
+
|
|
10
|
+
- **Four kinds of question:** one right answer, more than one right answer, true or false, and a typed answer (case, accents, punctuation and a leading "the" do not matter, and you can accept more than one spelling).
|
|
11
|
+
- **Timing kept by the server.** A time limit for the whole quiz, a default per question, or a time on any single question. The clock starts when the question is shown, so a slow connection or a reload does not cost or gain anyone time.
|
|
12
|
+
- **Marking on the server.** The right answers never reach the visitor's browser until they have answered.
|
|
13
|
+
- **Leaderboards** for today, this week, this month and all time. Each name appears once, with its best go. Equal scores are split by time.
|
|
14
|
+
- **Your choice of when to show answers:** after each question, at the end, or never.
|
|
15
|
+
- **Shuffling:** questions, answers, or a random draw of so many questions from a bigger bank.
|
|
16
|
+
- **Opening and closing times**, and a limit on goes per name.
|
|
17
|
+
- The sidebar badge shows how many quizzes have been finished today, and you get a notification when someone takes the top spot.
|
|
18
|
+
|
|
19
|
+
## Getting started
|
|
20
|
+
|
|
21
|
+
1. Switch Quiz on in **Tools**.
|
|
22
|
+
2. Open **Tools > Games > Quiz** and choose **New quiz**.
|
|
23
|
+
3. Give it a title and add your questions. Tick the right answer (or answers) on each one.
|
|
24
|
+
4. Press **Test** and play it yourself (see below).
|
|
25
|
+
5. Set the status to **Open** and save.
|
|
26
|
+
6. Put it on any page:
|
|
27
|
+
|
|
28
|
+
```
|
|
29
|
+
[quiz id="capitals" /]
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`id` is the quiz's short name, shown in the list and under the title in the editor.
|
|
33
|
+
|
|
34
|
+
## Testing a quiz
|
|
35
|
+
|
|
36
|
+
**Test** (in the editor, on each quiz in the list, and on its Plays screen) plays the quiz right there in the admin, exactly as your players will see it: the same clock, the same marking, the same screens, and the answers shown when the quiz shows them.
|
|
37
|
+
|
|
38
|
+
- It plays the version you last saved. With unsaved changes the button says **Save and test**, and saves first.
|
|
39
|
+
- It works whatever the status, so you can try a draft before anyone can see it, or a closed quiz.
|
|
40
|
+
- A test go is never saved to the leaderboard. It is not in the Plays list, it does not count towards the goes each name is allowed, and it does not raise a notification. At the end it tells you where it would have come.
|
|
41
|
+
- Each new test clears away the one before.
|
|
42
|
+
|
|
43
|
+
## A leaderboard on its own
|
|
44
|
+
|
|
45
|
+
The quiz shows its own leaderboard at the end. To show one somewhere else, for example on your home page:
|
|
46
|
+
|
|
47
|
+
```
|
|
48
|
+
[quiz-leaderboard id="capitals" period="week" limit="10" title="This week's best" /]
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
`period` is `today`, `week`, `month` or `all`. Visitors can switch period on the board.
|
|
52
|
+
|
|
53
|
+
## Players and fair play
|
|
54
|
+
|
|
55
|
+
- Players only give a name: 2 to 24 letters, numbers, spaces and `. ' - _`. Rude names are refused.
|
|
56
|
+
- A play belongs to the browser that started it. Reloading carries on where they were.
|
|
57
|
+
- Running out of time on a question marks it wrong and moves on. Running out of time on the whole quiz ends it, and the time counted is the limit.
|
|
58
|
+
|
|
59
|
+
## Looking after the board
|
|
60
|
+
|
|
61
|
+
Open a quiz's **Plays** to see every go (your own test goes are not shown). You can hide a play from the leaderboard (it stays in the list), delete it, or reset the whole leaderboard, for example before a new quiz night.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
<script type="module" src="/plugins/quiz/public/quiz.js?v=1.
|
|
1
|
+
<script type="module" src="/plugins/quiz/public/quiz.js?v=1.1.0"></script>
|
package/plugins/quiz/plugin.js
CHANGED
|
@@ -6,6 +6,9 @@
|
|
|
6
6
|
* GET /quizzes every quiz as a list row
|
|
7
7
|
* GET /quizzes/:id · POST /quizzes · PUT /quizzes/:id · DELETE /quizzes/:id
|
|
8
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)
|
|
9
12
|
* GET /quizzes/:id/plays · DELETE /quizzes/:id/plays · PUT /plays/:id · DELETE /plays/:id
|
|
10
13
|
*
|
|
11
14
|
* Players (no sign-in), under /public - the server keeps the time and marks every answer:
|
|
@@ -21,7 +24,7 @@
|
|
|
21
24
|
*/
|
|
22
25
|
|
|
23
26
|
import {cleanPlayerName} from '../_lib/games/names.js';
|
|
24
|
-
import {boardQuery, fail, isBot, limited, mountPlayAdmin, todayBadge, tokenOf} from '../_lib/games/http.js';
|
|
27
|
+
import {boardQuery, fail, isBot, limited, mountPlayAdmin, testerName, todayBadge, tokenOf} from '../_lib/games/http.js';
|
|
25
28
|
import {answerText, cleanQuiz, isOpen, maxScoreOf, planPlay, rightText, summary} from './lib/quiz.js';
|
|
26
29
|
import {answer, catchUp, feedbackFor, serve, view} from './lib/engine.js';
|
|
27
30
|
import {allQuizzes, ensureCollections, getQuiz, plays, quizBySlug, removeQuiz, saveQuiz, uniqueSlug} from './lib/store.js';
|
|
@@ -97,7 +100,7 @@ export default async function quizPlugin(fastify, options) {
|
|
|
97
100
|
|
|
98
101
|
fastify.get('/quizzes', can('read'), async () => {
|
|
99
102
|
const everyPlay = await plays.all();
|
|
100
|
-
return {quizzes: (await allQuizzes()).map(q => summary(q, everyPlay.filter(p => p.gameId === q.id)))
|
|
103
|
+
return {quizzes: (await allQuizzes()).map(q => summary(q, everyPlay.filter(p => p.gameId === q.id && p.test !== true)))
|
|
101
104
|
.sort((a, b) => String(b.updatedAt).localeCompare(String(a.updatedAt)))};
|
|
102
105
|
});
|
|
103
106
|
|
|
@@ -134,6 +137,29 @@ export default async function quizPlugin(fastify, options) {
|
|
|
134
137
|
|
|
135
138
|
mountPlayAdmin(fastify, {store: plays, base: '/quizzes', can, exists: async (id) => Boolean(await getQuiz(id))});
|
|
136
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
|
+
|
|
137
163
|
// ---- Players ----------------------------------------------------------------
|
|
138
164
|
|
|
139
165
|
/** A quiz the public may see: anything but a draft. */
|
|
@@ -175,6 +201,9 @@ export default async function quizPlugin(fastify, options) {
|
|
|
175
201
|
* The play behind a request, brought up to date with the clock (and saved
|
|
176
202
|
* when that changed it). Null after answering with the reason.
|
|
177
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
|
+
|
|
178
207
|
async function livePlay(request, reply, {serveNow = false} = {}) {
|
|
179
208
|
const opened = await plays.open(request.params.id, tokenOf(request));
|
|
180
209
|
if (!opened) { fail(reply, 404, 'That game is not here any more. Start again.', {code: 'gone'}); return null; }
|
|
@@ -186,6 +215,7 @@ export default async function quizPlugin(fastify, options) {
|
|
|
186
215
|
const b = serveNow && serve(quiz, p, now);
|
|
187
216
|
return a || b ? p : null;
|
|
188
217
|
});
|
|
218
|
+
if (!play) { gone(reply); return null; }
|
|
189
219
|
if (play.status === 'finished' && opened.status === 'playing') await afterFinish(quiz, play);
|
|
190
220
|
return {quiz, play, now};
|
|
191
221
|
}
|
|
@@ -193,7 +223,7 @@ export default async function quizPlugin(fastify, options) {
|
|
|
193
223
|
/** A new top of the all-time board tells the admins. */
|
|
194
224
|
async function afterFinish(quiz, play) {
|
|
195
225
|
try {
|
|
196
|
-
if (!quiz.leaderboard || await plays.placeOf(play, 'all') !== 1) return;
|
|
226
|
+
if (play.test || !quiz.leaderboard || await plays.placeOf(play, 'all') !== 1) return;
|
|
197
227
|
hooks.notify?.({
|
|
198
228
|
title: `New top score on "${quiz.title}"`,
|
|
199
229
|
body: `${play.name}: ${play.score} of ${play.maxScore} in ${Math.round(play.timeMs / 1000)} seconds`,
|
|
@@ -217,6 +247,7 @@ export default async function quizPlugin(fastify, options) {
|
|
|
217
247
|
outcome = answer(quiz, p, request.body?.q, request.body?.answer, Date.now());
|
|
218
248
|
return outcome.error ? null : p;
|
|
219
249
|
});
|
|
250
|
+
if (!play) return gone(reply);
|
|
220
251
|
if (outcome?.error) return fail(reply, 409, outcome.error, {code: outcome.code || 'finished', play: view(quiz, play)});
|
|
221
252
|
if (play.status === 'finished') await afterFinish(quiz, play);
|
|
222
253
|
return {correct: outcome.correct, late: outcome.late, points: outcome.points, feedback: feedbackFor(quiz, outcome.q, outcome), play: view(quiz, play)};
|
|
@@ -231,8 +262,11 @@ export default async function quizPlugin(fastify, options) {
|
|
|
231
262
|
name: play.name, score: play.score, maxScore: play.maxScore, timeMs: play.timeMs, timeUp: play.state.timeUp === true,
|
|
232
263
|
correct: play.state.answers.filter(a => a.correct).length, total: play.state.order.length,
|
|
233
264
|
hidden: play.hidden === true,
|
|
234
|
-
|
|
235
|
-
|
|
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,
|
|
236
270
|
leaderboard: quiz.leaderboard,
|
|
237
271
|
review: review(quiz, play)
|
|
238
272
|
};
|
package/plugins/quiz/plugin.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "quiz",
|
|
3
3
|
"displayName": "Quiz",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.1.0",
|
|
5
5
|
"tier": "free",
|
|
6
|
+
"minCmsVersion": "1.8.33",
|
|
6
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.",
|
|
7
8
|
"author": "Domma CMS",
|
|
8
|
-
"date": "2026-10-
|
|
9
|
+
"date": "2026-10-21",
|
|
9
10
|
"icon": "help-circle",
|
|
10
11
|
"permissions": [
|
|
11
12
|
{
|
|
@@ -70,7 +71,7 @@
|
|
|
70
71
|
],
|
|
71
72
|
"views": {
|
|
72
73
|
"plugin-quiz": {
|
|
73
|
-
"entry": "quiz/admin/views/quiz.js?v=
|
|
74
|
+
"entry": "quiz/admin/views/quiz.js?v=1.1.0",
|
|
74
75
|
"exportName": "quizView"
|
|
75
76
|
}
|
|
76
77
|
}
|
|
@@ -1 +1,36 @@
|
|
|
1
|
-
|
|
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; }
|
|
@@ -1,7 +1,32 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Quiz - the player, in the browser.
|
|
3
|
+
*
|
|
4
|
+
* Mounts on every `[data-quiz]` placeholder the [quiz] shortcode wrote, and
|
|
5
|
+
* every `[data-quiz-board]` the [quiz-leaderboard] one wrote. The quiz runs on
|
|
6
|
+
* the SERVER: this asks for one question at a time (never with its answer),
|
|
7
|
+
* sends each answer to be marked, and shows the clock the server keeps. A play
|
|
8
|
+
* in progress is remembered for the tab's next load, so a reload carries on
|
|
9
|
+
* where it was - its clock never stopped.
|
|
10
|
+
*
|
|
11
|
+
* domma-reactive (window.M): one template per mount, one M.applyBindings, and
|
|
12
|
+
* text from the server only through bindings.
|
|
13
|
+
*
|
|
14
|
+
* `mount(el, {test})` is the same player for an admin testing the quiz from
|
|
15
|
+
* its Tool: `test.info()` and `test.start()` go through the admin's own API,
|
|
16
|
+
* the play is a test one (never on a board), and nothing is remembered.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import {Countdown, addStyles, celebrate, configOf, fmtDuration, memory, mountBoards, mountLeaderboard, ordinal, request, uid} from '/plugins/_lib/public/games/games.js';
|
|
20
|
+
|
|
21
|
+
const M = window.M;
|
|
22
|
+
const API = '/api/plugins/quiz/public';
|
|
23
|
+
const NAME_KEY = 'games.name';
|
|
24
|
+
const playKey = (slug) => `quiz.play.${slug}`;
|
|
25
|
+
|
|
26
|
+
const TEMPLATE = `
|
|
2
27
|
<p class="gm-muted qz-live" role="status" aria-live="polite" data-bind-text="live.value"></p>
|
|
3
28
|
|
|
4
|
-
<section class="gm-card" hidden data-bind-hidden="screen.value !== 'loading'"><p class="gm-muted">Loading the quiz
|
|
29
|
+
<section class="gm-card" hidden data-bind-hidden="screen.value !== 'loading'"><p class="gm-muted">Loading the quiz…</p></section>
|
|
5
30
|
|
|
6
31
|
<section class="gm-card" hidden data-bind-hidden="screen.value !== 'error'">
|
|
7
32
|
<p class="gm-error" data-bind-text="problem.value"></p>
|
|
@@ -13,13 +38,14 @@ import{Countdown as C,addStyles as N,celebrate as Y,configOf as Q,fmtDuration as
|
|
|
13
38
|
<ul class="gm-facts" data-each="facts.value key=k"><li data-bind-text="k"></li></ul>
|
|
14
39
|
<p class="gm-note" hidden data-bind-hidden="info.value.open" data-bind-text="info.value.reason"></p>
|
|
15
40
|
<form class="gm-start" hidden data-bind-hidden="!info.value.open" data-on-submit="start" novalidate>
|
|
16
|
-
<
|
|
41
|
+
<p class="gm-note gm-test-note" hidden data-bind-hidden="!testing">A test go: it plays exactly as your players see it, and it is not saved to the leaderboard.</p>
|
|
42
|
+
<div class="gm-field" hidden data-bind-hidden="testing">
|
|
17
43
|
<label for="%U%-name">Your name</label>
|
|
18
44
|
<input id="%U%-name" class="gm-input" maxlength="24" autocomplete="nickname" data-model="name.value"
|
|
19
45
|
data-bind-aria-invalid="problem.value ? 'true' : false" aria-describedby="%U%-err">
|
|
20
46
|
</div>
|
|
21
47
|
<div class="gm-hp" aria-hidden="true"><label>Website <input tabindex="-1" autocomplete="off" data-model="website.value"></label></div>
|
|
22
|
-
<button type="submit" class="btn btn-primary" data-bind-disabled="busy.value">Start the quiz</button>
|
|
48
|
+
<button type="submit" class="btn btn-primary" data-bind-disabled="busy.value" data-bind-text="testing ? 'Start the test' : 'Start the quiz'">Start the quiz</button>
|
|
23
49
|
</form>
|
|
24
50
|
<p class="gm-error" id="%U%-err" hidden data-bind-hidden="!problem.value" data-bind-text="problem.value"></p>
|
|
25
51
|
</section>
|
|
@@ -62,7 +88,7 @@ import{Countdown as C,addStyles as N,celebrate as Y,configOf as Q,fmtDuration as
|
|
|
62
88
|
<p class="gm-muted" data-bind-text="res.value.detail"></p>
|
|
63
89
|
<p class="gm-result-place" hidden data-bind-hidden="!res.value.placeText" data-bind-text="res.value.placeText"></p>
|
|
64
90
|
<div class="gm-actions">
|
|
65
|
-
<button type="button" class="btn btn-primary" data-on-click="again">Play again</button>
|
|
91
|
+
<button type="button" class="btn btn-primary" data-on-click="again" data-bind-text="testing ? 'Test again' : 'Play again'">Play again</button>
|
|
66
92
|
</div>
|
|
67
93
|
<div class="qz-review" hidden data-bind-hidden="!review.value.length">
|
|
68
94
|
<h3>Your answers</h3>
|
|
@@ -77,4 +103,270 @@ import{Countdown as C,addStyles as N,celebrate as Y,configOf as Q,fmtDuration as
|
|
|
77
103
|
</div>
|
|
78
104
|
</section>
|
|
79
105
|
|
|
80
|
-
<div class="qz-board-slot"></div
|
|
106
|
+
<div class="qz-board-slot"></div>`;
|
|
107
|
+
|
|
108
|
+
const LETTERS = 'ABCDEFGH';
|
|
109
|
+
|
|
110
|
+
/** The server's words: the public request's Error, or the admin's (its reply is on `err.body`). */
|
|
111
|
+
const errText = (err) => err?.body?.error || err?.message || String(err);
|
|
112
|
+
|
|
113
|
+
function facts(info) {
|
|
114
|
+
const out = [`${info.questions} question${info.questions === 1 ? '' : 's'}`];
|
|
115
|
+
if (info.timeLimit) out.push(`${fmtDuration(info.timeLimit * 1000)} to finish`);
|
|
116
|
+
if (info.questionSeconds) out.push(`${info.questionSeconds} seconds a question`);
|
|
117
|
+
else if (info.perQuestionTimes) out.push('Some questions are timed');
|
|
118
|
+
if (info.attemptsPerName === 1) out.push('One go each');
|
|
119
|
+
else if (info.attemptsPerName > 1) out.push(`${info.attemptsPerName} goes each`);
|
|
120
|
+
if (info.leaderboard) out.push('Fastest top score wins');
|
|
121
|
+
return out.map(k => ({k}));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* @param {HTMLElement} el
|
|
126
|
+
* @param {{test?: {info: () => Promise<object>, start: () => Promise<{playId: string, token: string, name: string, info: object}>}}} [opts]
|
|
127
|
+
*/
|
|
128
|
+
export function mount(el, {test = null} = {}) {
|
|
129
|
+
const cfg = test ? {slug: 'test'} : configOf(el, 'data-quiz');
|
|
130
|
+
if (!cfg?.slug || !M) return;
|
|
131
|
+
addStyles(['/plugins/quiz/public/quiz.css?v=1.1.0']);
|
|
132
|
+
const U = uid('qz');
|
|
133
|
+
el.innerHTML = TEMPLATE.replaceAll('%U%', U);
|
|
134
|
+
|
|
135
|
+
const s = {
|
|
136
|
+
testing: Boolean(test),
|
|
137
|
+
screen: M.observable('loading'),
|
|
138
|
+
live: M.observable(''),
|
|
139
|
+
problem: M.observable(''),
|
|
140
|
+
busy: M.observable(false),
|
|
141
|
+
info: M.observable({title: '', intro: '', open: false, reason: ''}),
|
|
142
|
+
facts: M.observable([]),
|
|
143
|
+
name: M.observable(String(memory.get(NAME_KEY) || '')),
|
|
144
|
+
website: M.observable(''),
|
|
145
|
+
progress: M.observable(''),
|
|
146
|
+
q: M.observable({text: '', kind: 'single', image: ''}),
|
|
147
|
+
options: M.observable([]),
|
|
148
|
+
typed: M.observable(''),
|
|
149
|
+
clockText: M.observable(''),
|
|
150
|
+
clockShown: M.observable(false),
|
|
151
|
+
clockLow: M.observable(false),
|
|
152
|
+
trackWidth: M.observable('0%'),
|
|
153
|
+
fb: M.observable({}),
|
|
154
|
+
res: M.observable({}),
|
|
155
|
+
review: M.observable([])
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
let play = test ? null : memory.get(playKey(cfg.slug)) || null; // {playId, token}
|
|
159
|
+
let board = null;
|
|
160
|
+
let current = null; // the play's last view from the server
|
|
161
|
+
let qClock = null;
|
|
162
|
+
let totalClock = null;
|
|
163
|
+
|
|
164
|
+
const focus = (id) => setTimeout(() => el.querySelector(`#${U}-${id}`)?.focus({preventScroll: false}), 30);
|
|
165
|
+
const say = (t) => { s.live.value = ''; setTimeout(() => { s.live.value = t; }, 20); };
|
|
166
|
+
|
|
167
|
+
function showClock() {
|
|
168
|
+
const qLeft = qClock?.left.peek() ?? 0;
|
|
169
|
+
const tLeft = totalClock?.left.peek() ?? 0;
|
|
170
|
+
const useQ = current?.questionLeftMs != null && (current?.totalLeftMs == null || qLeft <= tLeft);
|
|
171
|
+
const left = useQ ? qLeft : tLeft;
|
|
172
|
+
const whole = useQ ? (current?.question?.seconds || 0) * 1000 : (s.info.peek().timeLimit || 0) * 1000;
|
|
173
|
+
s.clockShown.value = current?.questionLeftMs != null || current?.totalLeftMs != null;
|
|
174
|
+
s.clockText.value = fmtDuration(left);
|
|
175
|
+
s.clockLow.value = left > 0 && left <= 5000;
|
|
176
|
+
s.trackWidth.value = whole ? `${Math.max(0, Math.min(100, (left / whole) * 100)).toFixed(1)}%` : `${((current?.index || 0) / (current?.total || 1)) * 100}%`;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function stopClocks() {
|
|
180
|
+
qClock?.stop();
|
|
181
|
+
totalClock?.stop();
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
qClock = new Countdown(() => { if (s.screen.peek() === 'question') send(null, {timeout: true}); });
|
|
185
|
+
totalClock = new Countdown(() => { if (['question', 'feedback'].includes(s.screen.peek())) setTimeout(load, 600); });
|
|
186
|
+
const stopTicks = M.effect(() => { qClock.left.value; totalClock.left.value; showClock(); });
|
|
187
|
+
|
|
188
|
+
function forget() {
|
|
189
|
+
if (!test) memory.remove(playKey(cfg.slug));
|
|
190
|
+
play = null;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
async function loadInfo() {
|
|
194
|
+
const info = test ? await test.info() : await request(`${API}/quiz/${encodeURIComponent(cfg.slug)}`);
|
|
195
|
+
s.info.value = info;
|
|
196
|
+
s.facts.value = facts(info);
|
|
197
|
+
if (info.leaderboard && !board && !test) {
|
|
198
|
+
board = mountLeaderboard(el.querySelector('.qz-board-slot'), {url: `${API}/quiz/${encodeURIComponent(cfg.slug)}/leaderboard`, limit: info.leaderboardSize, title: 'Leaderboard'});
|
|
199
|
+
}
|
|
200
|
+
return info;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Where the play is now, from the server; draws the screen that goes with it. */
|
|
204
|
+
async function load() {
|
|
205
|
+
if (!play) { s.screen.value = 'intro'; return; }
|
|
206
|
+
try {
|
|
207
|
+
const v = await request(`${API}/play/${encodeURIComponent(play.playId)}`, {token: play.token});
|
|
208
|
+
current = v;
|
|
209
|
+
if (v.status !== 'playing') { stopClocks(); return showResult(); }
|
|
210
|
+
drawQuestion(v);
|
|
211
|
+
} catch (err) {
|
|
212
|
+
if (err.code === 'gone' || err.status === 404) { forget(); s.screen.value = 'intro'; return; }
|
|
213
|
+
s.problem.value = err.message;
|
|
214
|
+
s.screen.value = 'error';
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function drawQuestion(v) {
|
|
219
|
+
const q = v.question;
|
|
220
|
+
s.q.value = {text: q.text, kind: q.kind, image: q.image || ''};
|
|
221
|
+
s.options.value = q.options.map((o, i) => ({id: o.id, text: o.text, letter: LETTERS[i] || '', picked: false}));
|
|
222
|
+
s.typed.value = '';
|
|
223
|
+
s.progress.value = `Question ${v.index + 1} of ${v.total}`;
|
|
224
|
+
s.busy.value = false;
|
|
225
|
+
s.screen.value = 'question';
|
|
226
|
+
qClock.start(v.questionLeftMs ?? 0);
|
|
227
|
+
totalClock.start(v.totalLeftMs ?? 0);
|
|
228
|
+
showClock();
|
|
229
|
+
if (q.kind === 'text') focus('typed');
|
|
230
|
+
else focus('q');
|
|
231
|
+
say(`Question ${v.index + 1} of ${v.total}. ${q.text}`);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
async function send(answer, {timeout = false} = {}) {
|
|
235
|
+
if (s.busy.peek() || !current?.question) return;
|
|
236
|
+
s.busy.value = true;
|
|
237
|
+
qClock.stop();
|
|
238
|
+
try {
|
|
239
|
+
const r = await request(`${API}/play/${encodeURIComponent(play.playId)}/answer`, {
|
|
240
|
+
method: 'POST', token: play.token, body: {q: current.question.id, answer}
|
|
241
|
+
});
|
|
242
|
+
current = r.play;
|
|
243
|
+
const done = r.play.status !== 'playing';
|
|
244
|
+
if (r.feedback) {
|
|
245
|
+
s.fb.value = {
|
|
246
|
+
correct: r.correct,
|
|
247
|
+
title: r.correct ? 'Right!' : timeout || r.late ? 'Out of time' : 'Not quite',
|
|
248
|
+
rightAnswer: r.feedback.rightAnswer, explanation: r.feedback.explanation,
|
|
249
|
+
nextLabel: done ? 'See your score' : 'Next question'
|
|
250
|
+
};
|
|
251
|
+
s.screen.value = 'feedback';
|
|
252
|
+
say(s.fb.peek().title);
|
|
253
|
+
focus('fb');
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
if (done) { stopClocks(); return showResult(); }
|
|
257
|
+
// The next question's clock starts when the server hands it over, so ask for it.
|
|
258
|
+
await load();
|
|
259
|
+
} catch (err) {
|
|
260
|
+
if (err.code === 'not-current' || err.code === 'finished') return load();
|
|
261
|
+
s.busy.value = false;
|
|
262
|
+
s.live.value = err.message;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
async function showResult() {
|
|
267
|
+
stopClocks();
|
|
268
|
+
try {
|
|
269
|
+
const r = await request(`${API}/play/${encodeURIComponent(play.playId)}/result`, {token: play.token});
|
|
270
|
+
const placeText = r.test
|
|
271
|
+
? `A test go, so it is not on the leaderboard.${r.wouldPlace ? ` It would have come ${ordinal(r.wouldPlace)} of all time.` : ''}`
|
|
272
|
+
: r.place ? `You came ${ordinal(r.place)} of all time${r.placeToday && r.placeToday !== r.place ? `, ${ordinal(r.placeToday)} today` : ''}.` : '';
|
|
273
|
+
s.res.value = {
|
|
274
|
+
title: r.timeUp ? `Time's up, ${r.name}!` : `Well done, ${r.name}!`,
|
|
275
|
+
scoreText: `${r.score} / ${r.maxScore}`,
|
|
276
|
+
detail: `${r.correct} of ${r.total} right in ${fmtDuration(r.timeMs)}.`,
|
|
277
|
+
placeText
|
|
278
|
+
};
|
|
279
|
+
s.review.value = (r.review || []).map((x, i) => ({
|
|
280
|
+
k: `r${i}`, text: `${i + 1}. ${x.text}`, correct: x.correct, right: x.right, explanation: x.explanation,
|
|
281
|
+
mark: x.correct ? '✓' : '✗', yoursText: x.yours ? `You said: ${x.yours}` : x.late ? 'Out of time' : 'No answer'
|
|
282
|
+
}));
|
|
283
|
+
s.screen.value = 'result';
|
|
284
|
+
say(`${s.res.peek().title} ${s.res.peek().detail} ${placeText}`);
|
|
285
|
+
focus('res');
|
|
286
|
+
board?.refresh(play.playId);
|
|
287
|
+
if (r.place === 1 && !r.test) celebrate();
|
|
288
|
+
} catch (err) {
|
|
289
|
+
if (err.status === 404) { forget(); s.screen.value = 'intro'; return; }
|
|
290
|
+
s.problem.value = err.message;
|
|
291
|
+
s.screen.value = 'error';
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function begin() {
|
|
296
|
+
if (s.busy.peek()) return;
|
|
297
|
+
s.problem.value = '';
|
|
298
|
+
s.busy.value = true;
|
|
299
|
+
try {
|
|
300
|
+
const r = test ? await test.start() : await request(`${API}/quiz/${encodeURIComponent(cfg.slug)}/start`, {
|
|
301
|
+
method: 'POST', body: {name: s.name.peek(), website: s.website.peek()}
|
|
302
|
+
});
|
|
303
|
+
play = {playId: r.playId, token: r.token};
|
|
304
|
+
if (test) {
|
|
305
|
+
if (r.info) { s.info.value = r.info; s.facts.value = facts(r.info); }
|
|
306
|
+
} else {
|
|
307
|
+
memory.set(NAME_KEY, r.name);
|
|
308
|
+
memory.set(playKey(cfg.slug), play);
|
|
309
|
+
}
|
|
310
|
+
await load();
|
|
311
|
+
} catch (err) {
|
|
312
|
+
s.problem.value = errText(err);
|
|
313
|
+
s.busy.value = false;
|
|
314
|
+
if (!test) focus('name');
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const methods = {
|
|
319
|
+
// Form handlers answer false at once (no native submit, no reload); the work runs after.
|
|
320
|
+
start() { begin(); return false; },
|
|
321
|
+
choose(opt) {
|
|
322
|
+
if (s.busy.peek()) return;
|
|
323
|
+
if (s.q.peek().kind === 'multiple') {
|
|
324
|
+
s.options.value = s.options.peek().map(o => (o.id === opt.id ? {...o, picked: !o.picked} : o));
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
send(opt.id);
|
|
328
|
+
},
|
|
329
|
+
sendPicked() {
|
|
330
|
+
if (s.q.peek().kind === 'text') return send(s.typed.peek());
|
|
331
|
+
send(s.options.peek().filter(o => o.picked).map(o => o.id));
|
|
332
|
+
},
|
|
333
|
+
sendTyped() { send(s.typed.peek()); return false; },
|
|
334
|
+
next() {
|
|
335
|
+
if (current?.status !== 'playing') return showResult();
|
|
336
|
+
load();
|
|
337
|
+
},
|
|
338
|
+
async again() {
|
|
339
|
+
forget();
|
|
340
|
+
s.problem.value = '';
|
|
341
|
+
s.busy.value = false;
|
|
342
|
+
await loadInfo().catch(() => {});
|
|
343
|
+
s.screen.value = 'intro';
|
|
344
|
+
focus('name');
|
|
345
|
+
}
|
|
346
|
+
};
|
|
347
|
+
|
|
348
|
+
const bindings = M.applyBindings({...s, ...methods}, el, {methods});
|
|
349
|
+
|
|
350
|
+
loadInfo().then(load).catch((err) => {
|
|
351
|
+
s.problem.value = err.status === 404 ? 'This quiz is not available.' : errText(err);
|
|
352
|
+
s.screen.value = 'error';
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
// Gone from the page (an SPA swap): stop the clocks.
|
|
356
|
+
const dispose = () => { stopClocks(); stopTicks?.(); board?.dispose(); bindings?.dispose?.(); watch.disconnect(); };
|
|
357
|
+
const watch = new MutationObserver(() => { if (!el.isConnected) dispose(); });
|
|
358
|
+
watch.observe(document.body, {childList: true, subtree: true});
|
|
359
|
+
return {dispose};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
function mountAll() {
|
|
363
|
+
for (const el of document.querySelectorAll('[data-quiz]')) {
|
|
364
|
+
if (el.dataset.gmMounted) continue;
|
|
365
|
+
el.dataset.gmMounted = '1';
|
|
366
|
+
mount(el);
|
|
367
|
+
}
|
|
368
|
+
mountBoards('data-quiz-board', (slug) => `${API}/quiz/${encodeURIComponent(slug)}/leaderboard`);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountAll);
|
|
372
|
+
else mountAll();
|