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
|
@@ -1,7 +1,35 @@
|
|
|
1
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Crossword - the player, in the browser.
|
|
3
|
+
*
|
|
4
|
+
* Mounts on every `[data-crossword]` placeholder the [crossword] shortcode
|
|
5
|
+
* wrote, and every `[data-crossword-board]` the leaderboard one wrote. The
|
|
6
|
+
* server holds the answers: this gets the grid's shape and the clues when a
|
|
7
|
+
* play starts, sends the letters to be checked or judged, and shows the clock
|
|
8
|
+
* the server keeps. Letters typed so far are remembered per play, so a reload
|
|
9
|
+
* keeps them (the clock never stopped).
|
|
10
|
+
*
|
|
11
|
+
* The screens (start, playing, result) are domma-reactive bindings. The grid
|
|
12
|
+
* itself is built once per play as plain inputs, because it is all focus and
|
|
13
|
+
* keystrokes: typing moves along the word, arrows move, clicking a square
|
|
14
|
+
* twice turns the direction, Tab goes to the next clue.
|
|
15
|
+
*
|
|
16
|
+
* `mount(el, {test})` is the same player for an admin testing the crossword
|
|
17
|
+
* from its Tool: `test.info()` and `test.start()` go through the admin's own
|
|
18
|
+
* API, the play is a test one (never on a board), and nothing is remembered.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import {Stopwatch, Countdown, addStyles, celebrate, configOf, fmtDuration, memory, mountBoards, mountLeaderboard, ordinal, request, uid} from '/plugins/_lib/public/games/games.js';
|
|
22
|
+
|
|
23
|
+
const M = window.M;
|
|
24
|
+
const API = '/api/plugins/crossword/public';
|
|
25
|
+
const NAME_KEY = 'games.name';
|
|
26
|
+
const playKey = (slug) => `crossword.play.${slug}`;
|
|
27
|
+
const cellsKey = (playId) => `crossword.cells.${playId}`;
|
|
28
|
+
|
|
29
|
+
const TEMPLATE = `
|
|
2
30
|
<p class="cw-live" role="status" aria-live="polite" data-bind-text="live.value"></p>
|
|
3
31
|
|
|
4
|
-
<section class="gm-card" hidden data-bind-hidden="screen.value !== 'loading'"><p class="gm-muted">Loading the crossword
|
|
32
|
+
<section class="gm-card" hidden data-bind-hidden="screen.value !== 'loading'"><p class="gm-muted">Loading the crossword…</p></section>
|
|
5
33
|
<section class="gm-card" hidden data-bind-hidden="screen.value !== 'error'"><p class="gm-error" data-bind-text="problem.value"></p></section>
|
|
6
34
|
|
|
7
35
|
<section class="gm-card" hidden data-bind-hidden="screen.value !== 'intro'">
|
|
@@ -10,12 +38,13 @@ import{Stopwatch as q,Countdown as O,addStyles as N,celebrate as R,configOf as B
|
|
|
10
38
|
<ul class="gm-facts" data-each="facts.value key=k"><li data-bind-text="k"></li></ul>
|
|
11
39
|
<p class="gm-note" hidden data-bind-hidden="info.value.open" data-bind-text="info.value.reason"></p>
|
|
12
40
|
<form class="gm-start" hidden data-bind-hidden="!info.value.open" data-on-submit="start" novalidate>
|
|
13
|
-
<
|
|
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">
|
|
14
43
|
<label for="%U%-name">Your name</label>
|
|
15
44
|
<input id="%U%-name" class="gm-input" maxlength="24" autocomplete="nickname" data-model="name.value" aria-describedby="%U%-err">
|
|
16
45
|
</div>
|
|
17
46
|
<div class="gm-hp" aria-hidden="true"><label>Website <input tabindex="-1" autocomplete="off" data-model="website.value"></label></div>
|
|
18
|
-
<button type="submit" class="btn btn-primary" data-bind-disabled="busy.value">Start the clock</button>
|
|
47
|
+
<button type="submit" class="btn btn-primary" data-bind-disabled="busy.value" data-bind-text="testing ? 'Start the test' : 'Start the clock'">Start the clock</button>
|
|
19
48
|
</form>
|
|
20
49
|
<p class="gm-error" id="%U%-err" hidden data-bind-hidden="!problem.value" data-bind-text="problem.value"></p>
|
|
21
50
|
</section>
|
|
@@ -35,7 +64,7 @@ import{Stopwatch as q,Countdown as O,addStyles as N,celebrate as R,configOf as B
|
|
|
35
64
|
<p class="gm-result-score" hidden data-bind-hidden="!res.value.solved" data-bind-text="res.value.timeText"></p>
|
|
36
65
|
<p class="gm-muted" data-bind-text="res.value.detail"></p>
|
|
37
66
|
<p class="gm-result-place" hidden data-bind-hidden="!res.value.placeText" data-bind-text="res.value.placeText"></p>
|
|
38
|
-
<div class="gm-actions"><button type="button" class="btn btn-primary" data-on-click="again">Play again</button></div>
|
|
67
|
+
<div class="gm-actions"><button type="button" class="btn btn-primary" data-on-click="again" data-bind-text="testing ? 'Test again' : 'Play again'">Play again</button></div>
|
|
39
68
|
</div>
|
|
40
69
|
<p class="cw-current" aria-hidden="true" hidden data-bind-hidden="screen.value !== 'play' || !currentClue.value" data-bind-text="currentClue.value"></p>
|
|
41
70
|
<div class="cw-layout">
|
|
@@ -54,4 +83,437 @@ import{Stopwatch as q,Countdown as O,addStyles as N,celebrate as R,configOf as B
|
|
|
54
83
|
</div>
|
|
55
84
|
</section>
|
|
56
85
|
|
|
57
|
-
<div class="cw-board-slot"></div>`;function Y(r){const t=[`${r.answers} answers`,`${r.width} \xD7 ${r.height} grid`];return r.timeLimit&&t.push(`${g(r.timeLimit*1e3)} to solve it`),r.checks&&t.push(r.checkPenalty?`Checking costs ${r.checkPenalty} seconds`:"You can check your letters"),r.attemptsPerName===1&&t.push("One go each"),r.leaderboard&&t.push("Fastest solve wins"),t.map(l=>({k:l}))}class j{constructor(t,l,e,{onChange:a,onClue:n}){this.el=t,this.lists=l,this.p=e,this.onChange=a,this.onClue=n,this.letters=e.shape.map(i=>[...i].map(()=>"")),this.inputs=[],this.dir="across",this.pos=null,this.entries=[...e.across,...e.down],this.build()}open(t,l){return t>=0&&l>=0&&t<this.p.height&&l<this.p.width&&this.p.shape[t][l]==="."}entryAt(t,l,e){return this.entries.find(a=>a.dir===e&&(e==="across"?a.row===t&&l>=a.col&&l<a.col+a.length:a.col===l&&t>=a.row&&t<a.row+a.length))||null}cellsOf(t){return Array.from({length:t.length},(l,e)=>t.dir==="across"?[t.row,t.col+e]:[t.row+e,t.col])}build(){const{width:t,height:l,shape:e}=this.p,a=new Map(this.entries.map(n=>[`${n.row},${n.col}`,n.number]));this.el.innerHTML="",this.el.style.setProperty("--cw-cols",t);for(let n=0;n<l;n++){const i=document.createElement("div");i.className="cw-row",i.setAttribute("role","row"),this.inputs[n]=[];for(let o=0;o<t;o++){const d=document.createElement("div");if(d.className=e[n][o]==="."?"cw-cell":"cw-cell is-block",d.setAttribute("role","gridcell"),e[n][o]==="."){if(a.has(`${n},${o}`)){const p=document.createElement("span");p.className="cw-num",p.textContent=a.get(`${n},${o}`),d.append(p)}const u=document.createElement("input");u.className="cw-input",u.maxLength=2,u.autocomplete="off",u.setAttribute("autocapitalize","characters"),u.spellcheck=!1,u.setAttribute("aria-label",this.labelFor(n,o)),u.addEventListener("focus",()=>this.focusCell(n,o,{fromFocus:!0})),u.addEventListener("mousedown",p=>{this.pos&&this.pos[0]===n&&this.pos[1]===o&&(p.preventDefault(),this.turn())}),u.addEventListener("keydown",p=>this.key(p,n,o)),u.addEventListener("input",p=>this.typed(p,n,o)),d.append(u),this.inputs[n][o]=u}i.append(d)}this.el.append(i)}for(const n of this.lists){n.innerHTML="";for(const i of this.entries.filter(o=>o.dir===n.dataset.dir)){const o=document.createElement("li");o.dataset.key=`${i.dir}${i.number}`,o.value=i.number;const d=document.createElement("button");d.type="button",d.className="cw-clue";const u=document.createElement("strong");u.textContent=`${i.number} `,d.append(u,document.createTextNode(`${i.clue} (${i.enumeration})`)),d.addEventListener("click",()=>{this.dir=i.dir,this.focusCell(i.row,i.col)}),o.append(d),n.append(o)}}}labelFor(t,l){return`Square in ${["across","down"].map(a=>this.entryAt(t,l,a)).filter(Boolean).map(a=>`${a.number} ${a.dir}`).join(" and ")}`}focusCell(t,l,{fromFocus:e=!1}={}){this.open(t,l)&&(this.entryAt(t,l,this.dir)||(this.dir=this.dir==="across"?"down":"across"),this.pos=[t,l],e||this.inputs[t][l].focus(),this.paint())}turn(){const t=this.dir==="across"?"down":"across";this.pos&&this.entryAt(...this.pos,t)&&(this.dir=t,this.paint())}current(){return this.pos?this.entryAt(...this.pos,this.dir):null}paint(){const t=this.current(),l=new Set(t?this.cellsOf(t).map(([e,a])=>`${e},${a}`):[]);this.inputs.forEach((e,a)=>e.forEach((n,i)=>{if(!n)return;const o=n.parentElement;o.classList.toggle("is-word",l.has(`${a},${i}`)),o.classList.toggle("is-cursor",!!this.pos&&this.pos[0]===a&&this.pos[1]===i)}));for(const e of this.lists)for(const a of e.children){const n=t&&a.dataset.key===`${t.dir}${t.number}`;a.classList.toggle("is-on",!!n),n&&typeof a.scrollIntoView=="function"&&e.scrollHeight>e.clientHeight&&a.scrollIntoView({block:"nearest"})}this.onClue(t?`${t.number} ${t.dir}: ${t.clue} (${t.enumeration})`:"")}set(t,l,e){this.letters[t][l]=e,this.inputs[t][l].value=e,this.inputs[t][l].parentElement.classList.remove("is-wrong"),this.onChange()}step(t,l,e){const[a,n]=this.dir==="across"?[0,e]:[e,0];return this.open(t+a,l+n)?[t+a,l+n]:null}typed(t,l,e){const a=(t.target.value.toUpperCase().match(/[A-Z]/g)||[]).pop()||"";if(this.set(l,e,a),a){const n=this.step(l,e,1);n&&this.focusCell(...n)}}key(t,l,e){const a={ArrowUp:[-1,0,"down"],ArrowDown:[1,0,"down"],ArrowLeft:[0,-1,"across"],ArrowRight:[0,1,"across"]};if(a[t.key]){t.preventDefault();const[n,i,o]=a[t.key];if(this.dir!==o&&this.entryAt(l,e,o)){this.dir=o,this.paint();return}let[d,u]=[l+n,e+i];for(;d>=0&&u>=0&&d<this.p.height&&u<this.p.width&&!this.open(d,u);)d+=n,u+=i;this.open(d,u)&&this.focusCell(d,u)}else if(t.key==="Backspace")if(t.preventDefault(),this.letters[l][e])this.set(l,e,"");else{const n=this.step(l,e,-1);n&&(this.focusCell(...n),this.set(...n,""))}else if(t.key==="Delete")t.preventDefault(),this.set(l,e,"");else if(t.key===" ")t.preventDefault(),this.turn();else if(t.key==="Tab"){const n=this.current(),i=this.entries.indexOf(n),o=this.entries[(i+(t.shiftKey?-1:1)+this.entries.length)%this.entries.length];o&&(t.preventDefault(),this.dir=o.dir,this.focusCell(o.row,o.col))}else if(/^[a-z]$/i.test(t.key)&&!t.ctrlKey&&!t.metaKey&&!t.altKey){t.preventDefault(),this.set(l,e,t.key.toUpperCase());const n=this.step(l,e,1);n&&this.focusCell(...n)}}clearCurrent(){const t=this.current();if(t){for(const[l,e]of this.cellsOf(t))this.set(l,e,"");this.focusCell(t.row,t.col)}}rows(){return this.letters.map((t,l)=>t.map((e,a)=>this.open(l,a)&&e||" ").join(""))}load(t){!Array.isArray(t)||t.length!==this.p.height||t.forEach((l,e)=>[...String(l)].forEach((a,n)=>{this.open(e,n)&&/[A-Z]/.test(a)&&(this.letters[e][n]=a,this.inputs[e][n].value=a)}))}markWrong(t){for(const[l,e]of t)this.inputs[l]?.[e]?.parentElement.classList.add("is-wrong")}reveal(t,{lock:l=!0}={}){t.forEach((e,a)=>[...e].forEach((n,i)=>{const o=this.inputs[a]?.[i];o&&(o.value=n.trim(),l&&(o.readOnly=!0),o.parentElement.classList.remove("is-wrong","is-word","is-cursor"))})),this.pos=null;for(const e of this.lists)for(const a of e.children)a.classList.remove("is-on")}}function z(r){const t=B(r,"data-crossword");if(!t?.slug||!h)return;N(["/plugins/crossword/public/crossword.css?v=1.0.0"]);const l=W("cw");r.innerHTML=F.replaceAll("%U%",l);const e={screen:h.observable("loading"),live:h.observable(""),problem:h.observable(""),busy:h.observable(!1),info:h.observable({title:"",intro:"",open:!1,reason:"",checks:!1}),facts:h.observable([]),name:h.observable(String(m.get(S)||"")),website:h.observable(""),clock:new q,leftText:h.observable(""),leftShown:h.observable(!1),leftLow:h.observable(!1),penaltyText:h.observable(""),currentClue:h.observable(""),message:h.observable(""),messageTone:h.observable(""),checkLabel:h.observable("Check my letters"),res:h.observable({})};let a=m.get(k(t.slug))||null,n=null,i=null,o=0;const d=new O(()=>{e.screen.peek()==="play"&&setTimeout(v,3500)}),u=h.effect(()=>{const s=d.left.value;e.leftText.value=`${g(s)} left`,e.leftLow.value=s>0&&s<=6e4}),p=s=>{e.live.value="",setTimeout(()=>{e.live.value=s},20)},y=(s,c="")=>{e.message.value=s,e.messageTone.value=c?`is-${c}`:"",p(s)},w=()=>{m.remove(k(t.slug)),a=null},U=()=>{a&&i&&m.set($(a.playId),i.rows())};function C(s){o=s.penaltyMs||0,e.penaltyText.value=o?`+${Math.round(o/1e3)}s`:"",e.clock.start((s.elapsedMs||0)+o),e.leftShown.value=s.totalLeftMs!=null,s.totalLeftMs!=null?d.start(s.totalLeftMs):d.stop()}async function x(){const s=await f(`${b}/crossword/${encodeURIComponent(t.slug)}`);e.info.value=s,e.facts.value=Y(s),e.checkLabel.value=s.checkPenalty?`Check my letters (+${s.checkPenalty}s)`:"Check my letters",s.leaderboard&&!n&&(n=K(r.querySelector(".cw-board-slot"),{url:`${b}/crossword/${encodeURIComponent(t.slug)}/leaderboard`,limit:s.leaderboardSize,showScore:!1,title:"Fastest solvers"}))}async function v(){if(!a){e.screen.value="intro";return}try{const s=await f(`${b}/play/${encodeURIComponent(a.playId)}`,{token:a.token});if(s.status!=="playing")return T();i||(i=new j(r.querySelector(".cw-grid"),[...r.querySelectorAll(".cw-list")],s.puzzle,{onChange:()=>{U(),e.message.value=""},onClue:P=>{e.currentClue.value=P}}),i.load(m.get($(a.playId)))),C(s),e.busy.value=!1,e.screen.value="play";const c=s.puzzle.across[0]||s.puzzle.down[0];c&&!i.pos&&setTimeout(()=>{i.dir=c.dir,i.focusCell(c.row,c.col)},50)}catch(s){if(s.code==="gone"||s.status===404){w(),e.screen.value="intro";return}e.problem.value=s.message,e.screen.value="error"}}async function T(){e.clock.stop(),d.stop();try{const s=await f(`${b}/play/${encodeURIComponent(a.playId)}/result`,{token:a.token}),c=s.place?`You came ${M(s.place)} of all time${s.placeToday&&s.placeToday!==s.place?`, ${M(s.placeToday)} today`:""}.`:"";e.res.value={solved:s.solved,title:s.solved?`Solved, ${s.name}!`:`Time's up, ${s.name}.`,timeText:g(s.timeMs),detail:s.solved?s.penaltyMs?`Including ${Math.round(s.penaltyMs/1e3)} seconds of penalties${s.checks?` (${s.checks} check${s.checks===1?"":"s"})`:""}.`:"No penalties. Clean solve!":"The clock ran out before the grid was finished.",placeText:c},e.message.value="",e.currentClue.value="",s.solution&&i?i.reveal(s.solution):s.solution,e.screen.value="result",p(`${e.res.peek().title} ${s.solved?g(s.timeMs):""} ${c}`),setTimeout(()=>r.querySelector(`#${l}-res`)?.focus(),30),n?.refresh(a.playId),s.place===1&&R(),m.remove($(a.playId))}catch(s){if(s.status===404){w(),e.screen.value="intro";return}e.problem.value=s.message,e.screen.value="error"}}async function D(){if(!e.busy.peek()){e.problem.value="",e.busy.value=!0;try{const s=await f(`${b}/crossword/${encodeURIComponent(t.slug)}/start`,{method:"POST",body:{name:e.name.peek(),website:e.website.peek()}});m.set(S,s.name),a={playId:s.playId,token:s.token},m.set(k(t.slug),a),await v()}catch(s){e.problem.value=s.message,e.busy.value=!1,r.querySelector(`#${l}-name`)?.focus()}}}async function L(s){if(!(e.busy.peek()||!i)){e.busy.value=!0;try{const c=await f(`${b}/play/${encodeURIComponent(a.playId)}/${s}`,{method:"POST",token:a.token,body:{cells:i.rows()}});if(C(c.play),s==="check")i.markWrong(c.wrong),y(c.wrong.length?`${c.wrong.length} square${c.wrong.length===1?" is":"s are"} wrong (marked in red).`:c.empty?`Everything so far is right. ${c.empty} to go.`:`Everything is right. Press "I've finished"!`,c.wrong.length?"bad":"good");else{if(c.solved)return T();y(c.message,"bad")}}catch(c){if(c.code==="finished"||c.code==="gone")return v();y(c.message,"bad")}finally{e.busy.value=!1}}}const E={start(){return D(),!1},check(){L("check")},submit(){L("submit")},clearWord(){i?.clearCurrent()},async again(){w(),i=null,r.querySelector(".cw-grid").innerHTML="",r.querySelectorAll(".cw-list").forEach(s=>{s.innerHTML=""}),e.busy.value=!1,await x().catch(()=>{}),e.screen.value="intro"}};h.applyBindings({...e,...E},r,{methods:E}),x().then(v).catch(s=>{e.problem.value=s.status===404?"This crossword is not available.":s.message,e.screen.value="error"});const A=new MutationObserver(()=>{r.isConnected||(e.clock.stop(),d.stop(),u?.(),A.disconnect())});A.observe(document.body,{childList:!0,subtree:!0})}function I(){for(const r of document.querySelectorAll("[data-crossword]"))r.dataset.gmMounted||(r.dataset.gmMounted="1",z(r));H("data-crossword-board",r=>`${b}/crossword/${encodeURIComponent(r)}/leaderboard`,{showScore:!1})}document.readyState==="loading"?document.addEventListener("DOMContentLoaded",I):I();
|
|
86
|
+
<div class="cw-board-slot"></div>`;
|
|
87
|
+
|
|
88
|
+
function facts(info) {
|
|
89
|
+
const out = [`${info.answers} answers`, `${info.width} × ${info.height} grid`];
|
|
90
|
+
if (info.timeLimit) out.push(`${fmtDuration(info.timeLimit * 1000)} to solve it`);
|
|
91
|
+
if (info.checks) out.push(info.checkPenalty ? `Checking costs ${info.checkPenalty} seconds` : 'You can check your letters');
|
|
92
|
+
if (info.attemptsPerName === 1) out.push('One go each');
|
|
93
|
+
if (info.leaderboard) out.push('Fastest solve wins');
|
|
94
|
+
return out.map(k => ({k}));
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The grid as a player works it: letters, the cursor, the direction. Plain DOM. */
|
|
98
|
+
class Board {
|
|
99
|
+
constructor(el, clueLists, puzzle, {onChange, onClue}) {
|
|
100
|
+
this.el = el;
|
|
101
|
+
this.lists = clueLists;
|
|
102
|
+
this.p = puzzle;
|
|
103
|
+
this.onChange = onChange;
|
|
104
|
+
this.onClue = onClue;
|
|
105
|
+
this.letters = puzzle.shape.map(row => [...row].map(() => ''));
|
|
106
|
+
this.inputs = [];
|
|
107
|
+
this.dir = 'across';
|
|
108
|
+
this.pos = null;
|
|
109
|
+
this.entries = [...puzzle.across, ...puzzle.down];
|
|
110
|
+
this.build();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
open(r, c) { return r >= 0 && c >= 0 && r < this.p.height && c < this.p.width && this.p.shape[r][c] === '.'; }
|
|
114
|
+
|
|
115
|
+
/** The entry through (r, c) in a direction, or null. */
|
|
116
|
+
entryAt(r, c, dir) {
|
|
117
|
+
return this.entries.find(e => e.dir === dir && (dir === 'across'
|
|
118
|
+
? e.row === r && c >= e.col && c < e.col + e.length
|
|
119
|
+
: e.col === c && r >= e.row && r < e.row + e.length)) || null;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
cellsOf(e) {
|
|
123
|
+
return Array.from({length: e.length}, (_, i) => (e.dir === 'across' ? [e.row, e.col + i] : [e.row + i, e.col]));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
build() {
|
|
127
|
+
const {width, height, shape} = this.p;
|
|
128
|
+
const numbers = new Map(this.entries.map(e => [`${e.row},${e.col}`, e.number]));
|
|
129
|
+
this.el.innerHTML = '';
|
|
130
|
+
this.el.style.setProperty('--cw-cols', width);
|
|
131
|
+
for (let r = 0; r < height; r++) {
|
|
132
|
+
const rowEl = document.createElement('div');
|
|
133
|
+
rowEl.className = 'cw-row';
|
|
134
|
+
rowEl.setAttribute('role', 'row');
|
|
135
|
+
this.inputs[r] = [];
|
|
136
|
+
for (let c = 0; c < width; c++) {
|
|
137
|
+
const cell = document.createElement('div');
|
|
138
|
+
cell.className = shape[r][c] === '.' ? 'cw-cell' : 'cw-cell is-block';
|
|
139
|
+
cell.setAttribute('role', 'gridcell');
|
|
140
|
+
if (shape[r][c] === '.') {
|
|
141
|
+
if (numbers.has(`${r},${c}`)) {
|
|
142
|
+
const n = document.createElement('span');
|
|
143
|
+
n.className = 'cw-num';
|
|
144
|
+
n.textContent = numbers.get(`${r},${c}`);
|
|
145
|
+
cell.append(n);
|
|
146
|
+
}
|
|
147
|
+
const input = document.createElement('input');
|
|
148
|
+
input.className = 'cw-input';
|
|
149
|
+
input.maxLength = 2;
|
|
150
|
+
input.autocomplete = 'off';
|
|
151
|
+
input.setAttribute('autocapitalize', 'characters');
|
|
152
|
+
input.spellcheck = false;
|
|
153
|
+
input.setAttribute('aria-label', this.labelFor(r, c));
|
|
154
|
+
input.addEventListener('focus', () => this.focusCell(r, c, {fromFocus: true}));
|
|
155
|
+
input.addEventListener('mousedown', (e) => {
|
|
156
|
+
if (this.pos && this.pos[0] === r && this.pos[1] === c) { e.preventDefault(); this.turn(); }
|
|
157
|
+
});
|
|
158
|
+
input.addEventListener('keydown', (e) => this.key(e, r, c));
|
|
159
|
+
input.addEventListener('input', (e) => this.typed(e, r, c));
|
|
160
|
+
cell.append(input);
|
|
161
|
+
this.inputs[r][c] = input;
|
|
162
|
+
}
|
|
163
|
+
rowEl.append(cell);
|
|
164
|
+
}
|
|
165
|
+
this.el.append(rowEl);
|
|
166
|
+
}
|
|
167
|
+
for (const list of this.lists) {
|
|
168
|
+
list.innerHTML = '';
|
|
169
|
+
for (const e of this.entries.filter(x => x.dir === list.dataset.dir)) {
|
|
170
|
+
const li = document.createElement('li');
|
|
171
|
+
li.dataset.key = `${e.dir}${e.number}`;
|
|
172
|
+
li.value = e.number;
|
|
173
|
+
const b = document.createElement('button');
|
|
174
|
+
b.type = 'button';
|
|
175
|
+
b.className = 'cw-clue';
|
|
176
|
+
const num = document.createElement('strong');
|
|
177
|
+
num.textContent = `${e.number} `;
|
|
178
|
+
b.append(num, document.createTextNode(`${e.clue} (${e.enumeration})`));
|
|
179
|
+
b.addEventListener('click', () => { this.dir = e.dir; this.focusCell(e.row, e.col); });
|
|
180
|
+
li.append(b);
|
|
181
|
+
list.append(li);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
labelFor(r, c) {
|
|
187
|
+
const parts = ['across', 'down'].map(d => this.entryAt(r, c, d)).filter(Boolean).map(e => `${e.number} ${e.dir}`);
|
|
188
|
+
return `Square in ${parts.join(' and ')}`;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
focusCell(r, c, {fromFocus = false} = {}) {
|
|
192
|
+
if (!this.open(r, c)) return;
|
|
193
|
+
if (!this.entryAt(r, c, this.dir)) this.dir = this.dir === 'across' ? 'down' : 'across';
|
|
194
|
+
this.pos = [r, c];
|
|
195
|
+
if (!fromFocus) this.inputs[r][c].focus();
|
|
196
|
+
this.paint();
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
turn() {
|
|
200
|
+
const other = this.dir === 'across' ? 'down' : 'across';
|
|
201
|
+
if (this.pos && this.entryAt(...this.pos, other)) { this.dir = other; this.paint(); }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
current() { return this.pos ? this.entryAt(...this.pos, this.dir) : null; }
|
|
205
|
+
|
|
206
|
+
paint() {
|
|
207
|
+
const e = this.current();
|
|
208
|
+
const inWord = new Set(e ? this.cellsOf(e).map(([r, c]) => `${r},${c}`) : []);
|
|
209
|
+
this.inputs.forEach((row, r) => row.forEach((input, c) => {
|
|
210
|
+
if (!input) return;
|
|
211
|
+
const cell = input.parentElement;
|
|
212
|
+
cell.classList.toggle('is-word', inWord.has(`${r},${c}`));
|
|
213
|
+
cell.classList.toggle('is-cursor', Boolean(this.pos) && this.pos[0] === r && this.pos[1] === c);
|
|
214
|
+
}));
|
|
215
|
+
for (const list of this.lists) {
|
|
216
|
+
for (const li of list.children) {
|
|
217
|
+
const on = e && li.dataset.key === `${e.dir}${e.number}`;
|
|
218
|
+
li.classList.toggle('is-on', Boolean(on));
|
|
219
|
+
if (on && typeof li.scrollIntoView === 'function' && list.scrollHeight > list.clientHeight) li.scrollIntoView({block: 'nearest'});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
this.onClue(e ? `${e.number} ${e.dir}: ${e.clue} (${e.enumeration})` : '');
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
set(r, c, ch) {
|
|
226
|
+
this.letters[r][c] = ch;
|
|
227
|
+
this.inputs[r][c].value = ch;
|
|
228
|
+
this.inputs[r][c].parentElement.classList.remove('is-wrong');
|
|
229
|
+
this.onChange();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The next open square along the word (or the same one at its end). */
|
|
233
|
+
step(r, c, by) {
|
|
234
|
+
const [dr, dc] = this.dir === 'across' ? [0, by] : [by, 0];
|
|
235
|
+
return this.open(r + dr, c + dc) ? [r + dr, c + dc] : null;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
typed(e, r, c) {
|
|
239
|
+
const ch = (e.target.value.toUpperCase().match(/[A-Z]/g) || []).pop() || '';
|
|
240
|
+
this.set(r, c, ch);
|
|
241
|
+
if (ch) { const next = this.step(r, c, 1); if (next) this.focusCell(...next); }
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
key(e, r, c) {
|
|
245
|
+
const moves = {ArrowUp: [-1, 0, 'down'], ArrowDown: [1, 0, 'down'], ArrowLeft: [0, -1, 'across'], ArrowRight: [0, 1, 'across']};
|
|
246
|
+
if (moves[e.key]) {
|
|
247
|
+
e.preventDefault();
|
|
248
|
+
const [dr, dc, dir] = moves[e.key];
|
|
249
|
+
if (this.dir !== dir && this.entryAt(r, c, dir)) { this.dir = dir; this.paint(); return; }
|
|
250
|
+
let [rr, cc] = [r + dr, c + dc];
|
|
251
|
+
while (rr >= 0 && cc >= 0 && rr < this.p.height && cc < this.p.width && !this.open(rr, cc)) { rr += dr; cc += dc; }
|
|
252
|
+
if (this.open(rr, cc)) this.focusCell(rr, cc);
|
|
253
|
+
} else if (e.key === 'Backspace') {
|
|
254
|
+
e.preventDefault();
|
|
255
|
+
if (this.letters[r][c]) this.set(r, c, '');
|
|
256
|
+
else { const prev = this.step(r, c, -1); if (prev) { this.focusCell(...prev); this.set(...prev, ''); } }
|
|
257
|
+
} else if (e.key === 'Delete') {
|
|
258
|
+
e.preventDefault();
|
|
259
|
+
this.set(r, c, '');
|
|
260
|
+
} else if (e.key === ' ') {
|
|
261
|
+
e.preventDefault();
|
|
262
|
+
this.turn();
|
|
263
|
+
} else if (e.key === 'Tab') {
|
|
264
|
+
const cur = this.current();
|
|
265
|
+
const i = this.entries.indexOf(cur);
|
|
266
|
+
const next = this.entries[(i + (e.shiftKey ? -1 : 1) + this.entries.length) % this.entries.length];
|
|
267
|
+
if (next) { e.preventDefault(); this.dir = next.dir; this.focusCell(next.row, next.col); }
|
|
268
|
+
} else if (/^[a-z]$/i.test(e.key) && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
|
269
|
+
// Overtype: a letter replaces what is there, without waiting for the input event's quirks.
|
|
270
|
+
e.preventDefault();
|
|
271
|
+
this.set(r, c, e.key.toUpperCase());
|
|
272
|
+
const next = this.step(r, c, 1);
|
|
273
|
+
if (next) this.focusCell(...next);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
clearCurrent() {
|
|
278
|
+
const e = this.current();
|
|
279
|
+
if (!e) return;
|
|
280
|
+
for (const [r, c] of this.cellsOf(e)) this.set(r, c, '');
|
|
281
|
+
this.focusCell(e.row, e.col);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/** Rows of letters, a space for an empty square - what the server reads. */
|
|
285
|
+
rows() {
|
|
286
|
+
return this.letters.map((row, r) => row.map((ch, c) => (this.open(r, c) ? (ch || ' ') : ' ')).join(''));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
load(rows) {
|
|
290
|
+
if (!Array.isArray(rows) || rows.length !== this.p.height) return;
|
|
291
|
+
rows.forEach((row, r) => [...String(row)].forEach((ch, c) => {
|
|
292
|
+
if (this.open(r, c) && /[A-Z]/.test(ch)) { this.letters[r][c] = ch; this.inputs[r][c].value = ch; }
|
|
293
|
+
}));
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
markWrong(list) {
|
|
297
|
+
for (const [r, c] of list) this.inputs[r]?.[c]?.parentElement.classList.add('is-wrong');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
reveal(rows, {lock = true} = {}) {
|
|
301
|
+
rows.forEach((row, r) => [...row].forEach((ch, c) => {
|
|
302
|
+
const input = this.inputs[r]?.[c];
|
|
303
|
+
if (!input) return;
|
|
304
|
+
input.value = ch.trim();
|
|
305
|
+
if (lock) input.readOnly = true;
|
|
306
|
+
input.parentElement.classList.remove('is-wrong', 'is-word', 'is-cursor');
|
|
307
|
+
}));
|
|
308
|
+
this.pos = null;
|
|
309
|
+
for (const list of this.lists) for (const li of list.children) li.classList.remove('is-on');
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** The server's words: the public request's Error, or the admin's (its reply is on `err.body`). */
|
|
314
|
+
const errText = (err) => err?.body?.error || err?.message || String(err);
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* @param {HTMLElement} el
|
|
318
|
+
* @param {{test?: {info: () => Promise<object>, start: () => Promise<{playId: string, token: string, name: string, info: object}>}}} [opts]
|
|
319
|
+
*/
|
|
320
|
+
export function mount(el, {test = null} = {}) {
|
|
321
|
+
const cfg = test ? {slug: 'test'} : configOf(el, 'data-crossword');
|
|
322
|
+
if (!cfg?.slug || !M) return;
|
|
323
|
+
addStyles(['/plugins/crossword/public/crossword.css?v=1.1.0']);
|
|
324
|
+
const U = uid('cw');
|
|
325
|
+
el.innerHTML = TEMPLATE.replaceAll('%U%', U);
|
|
326
|
+
|
|
327
|
+
const s = {
|
|
328
|
+
testing: Boolean(test),
|
|
329
|
+
screen: M.observable('loading'), live: M.observable(''), problem: M.observable(''), busy: M.observable(false),
|
|
330
|
+
info: M.observable({title: '', intro: '', open: false, reason: '', checks: false}), facts: M.observable([]),
|
|
331
|
+
name: M.observable(String(memory.get(NAME_KEY) || '')), website: M.observable(''),
|
|
332
|
+
clock: new Stopwatch(), leftText: M.observable(''), leftShown: M.observable(false), leftLow: M.observable(false),
|
|
333
|
+
penaltyText: M.observable(''), currentClue: M.observable(''), message: M.observable(''), messageTone: M.observable(''),
|
|
334
|
+
checkLabel: M.observable('Check my letters'), res: M.observable({})
|
|
335
|
+
};
|
|
336
|
+
|
|
337
|
+
let play = test ? null : memory.get(playKey(cfg.slug)) || null;
|
|
338
|
+
let board = null;
|
|
339
|
+
let grid = null;
|
|
340
|
+
let penaltyMs = 0;
|
|
341
|
+
const left = new Countdown(() => { if (s.screen.peek() === 'play') setTimeout(load, 3500); });
|
|
342
|
+
const stopLeft = M.effect(() => {
|
|
343
|
+
const ms = left.left.value;
|
|
344
|
+
s.leftText.value = `${fmtDuration(ms)} left`;
|
|
345
|
+
s.leftLow.value = ms > 0 && ms <= 60000;
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
const say = (t) => { s.live.value = ''; setTimeout(() => { s.live.value = t; }, 20); };
|
|
349
|
+
const tell = (text, tone = '') => { s.message.value = text; s.messageTone.value = tone ? `is-${tone}` : ''; say(text); };
|
|
350
|
+
const forget = () => { if (!test) memory.remove(playKey(cfg.slug)); play = null; };
|
|
351
|
+
const saveCells = () => { if (play && grid && !test) memory.set(cellsKey(play.playId), grid.rows()); };
|
|
352
|
+
|
|
353
|
+
function setClock(v) {
|
|
354
|
+
penaltyMs = v.penaltyMs || 0;
|
|
355
|
+
s.penaltyText.value = penaltyMs ? `+${Math.round(penaltyMs / 1000)}s` : '';
|
|
356
|
+
s.clock.start((v.elapsedMs || 0) + penaltyMs);
|
|
357
|
+
s.leftShown.value = v.totalLeftMs != null;
|
|
358
|
+
if (v.totalLeftMs != null) left.start(v.totalLeftMs); else left.stop();
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function loadInfo() {
|
|
362
|
+
const info = test ? await test.info() : await request(`${API}/crossword/${encodeURIComponent(cfg.slug)}`);
|
|
363
|
+
showInfo(info);
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function showInfo(info) {
|
|
367
|
+
s.info.value = info;
|
|
368
|
+
s.facts.value = facts(info);
|
|
369
|
+
s.checkLabel.value = info.checkPenalty ? `Check my letters (+${info.checkPenalty}s)` : 'Check my letters';
|
|
370
|
+
if (info.leaderboard && !board && !test) {
|
|
371
|
+
board = mountLeaderboard(el.querySelector('.cw-board-slot'), {url: `${API}/crossword/${encodeURIComponent(cfg.slug)}/leaderboard`,
|
|
372
|
+
limit: info.leaderboardSize, showScore: false, title: 'Fastest solvers'});
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
async function load() {
|
|
377
|
+
if (!play) { s.screen.value = 'intro'; return; }
|
|
378
|
+
try {
|
|
379
|
+
const v = await request(`${API}/play/${encodeURIComponent(play.playId)}`, {token: play.token});
|
|
380
|
+
if (v.status !== 'playing') return showResult();
|
|
381
|
+
if (!grid) {
|
|
382
|
+
grid = new Board(el.querySelector('.cw-grid'), [...el.querySelectorAll('.cw-list')], v.puzzle, {
|
|
383
|
+
onChange: () => { saveCells(); s.message.value = ''; },
|
|
384
|
+
onClue: (t) => { s.currentClue.value = t; }
|
|
385
|
+
});
|
|
386
|
+
if (!test) grid.load(memory.get(cellsKey(play.playId)));
|
|
387
|
+
}
|
|
388
|
+
setClock(v);
|
|
389
|
+
s.busy.value = false;
|
|
390
|
+
s.screen.value = 'play';
|
|
391
|
+
const first = v.puzzle.across[0] || v.puzzle.down[0];
|
|
392
|
+
if (first && !grid.pos) setTimeout(() => { grid.dir = first.dir; grid.focusCell(first.row, first.col); }, 50);
|
|
393
|
+
} catch (err) {
|
|
394
|
+
if (err.code === 'gone' || err.status === 404) { forget(); s.screen.value = 'intro'; return; }
|
|
395
|
+
s.problem.value = err.message;
|
|
396
|
+
s.screen.value = 'error';
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
async function showResult() {
|
|
401
|
+
s.clock.stop();
|
|
402
|
+
left.stop();
|
|
403
|
+
try {
|
|
404
|
+
const r = await request(`${API}/play/${encodeURIComponent(play.playId)}/result`, {token: play.token});
|
|
405
|
+
const placeText = r.test
|
|
406
|
+
? `A test go, so it is not on the leaderboard.${r.wouldPlace ? ` It would have come ${ordinal(r.wouldPlace)} of all time.` : ''}`
|
|
407
|
+
: r.place ? `You came ${ordinal(r.place)} of all time${r.placeToday && r.placeToday !== r.place ? `, ${ordinal(r.placeToday)} today` : ''}.` : '';
|
|
408
|
+
s.res.value = {
|
|
409
|
+
solved: r.solved,
|
|
410
|
+
title: r.solved ? `Solved, ${r.name}!` : `Time's up, ${r.name}.`,
|
|
411
|
+
timeText: fmtDuration(r.timeMs),
|
|
412
|
+
detail: r.solved
|
|
413
|
+
? (r.penaltyMs ? `Including ${Math.round(r.penaltyMs / 1000)} seconds of penalties${r.checks ? ` (${r.checks} check${r.checks === 1 ? '' : 's'})` : ''}.` : 'No penalties. Clean solve!')
|
|
414
|
+
: 'The clock ran out before the grid was finished.',
|
|
415
|
+
placeText
|
|
416
|
+
};
|
|
417
|
+
s.message.value = '';
|
|
418
|
+
s.currentClue.value = '';
|
|
419
|
+
if (r.solution && grid) grid.reveal(r.solution);
|
|
420
|
+
else if (r.solution && !grid) { /* a finished play from an earlier visit: no grid to show */ }
|
|
421
|
+
s.screen.value = 'result';
|
|
422
|
+
say(`${s.res.peek().title} ${r.solved ? fmtDuration(r.timeMs) : ''} ${placeText}`);
|
|
423
|
+
setTimeout(() => el.querySelector(`#${U}-res`)?.focus(), 30);
|
|
424
|
+
board?.refresh(play.playId);
|
|
425
|
+
if (r.place === 1 && !r.test) celebrate();
|
|
426
|
+
memory.remove(cellsKey(play.playId));
|
|
427
|
+
} catch (err) {
|
|
428
|
+
if (err.status === 404) { forget(); s.screen.value = 'intro'; return; }
|
|
429
|
+
s.problem.value = err.message;
|
|
430
|
+
s.screen.value = 'error';
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
async function begin() {
|
|
435
|
+
if (s.busy.peek()) return;
|
|
436
|
+
s.problem.value = '';
|
|
437
|
+
s.busy.value = true;
|
|
438
|
+
try {
|
|
439
|
+
const r = test ? await test.start() : await request(`${API}/crossword/${encodeURIComponent(cfg.slug)}/start`, {
|
|
440
|
+
method: 'POST', body: {name: s.name.peek(), website: s.website.peek()}
|
|
441
|
+
});
|
|
442
|
+
play = {playId: r.playId, token: r.token};
|
|
443
|
+
if (test) {
|
|
444
|
+
if (r.info) showInfo(r.info);
|
|
445
|
+
} else {
|
|
446
|
+
memory.set(NAME_KEY, r.name);
|
|
447
|
+
memory.set(playKey(cfg.slug), play);
|
|
448
|
+
}
|
|
449
|
+
await load();
|
|
450
|
+
} catch (err) {
|
|
451
|
+
s.problem.value = errText(err);
|
|
452
|
+
s.busy.value = false;
|
|
453
|
+
if (!test) el.querySelector(`#${U}-name`)?.focus();
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
async function judge(path) {
|
|
458
|
+
if (s.busy.peek() || !grid) return;
|
|
459
|
+
s.busy.value = true;
|
|
460
|
+
try {
|
|
461
|
+
const r = await request(`${API}/play/${encodeURIComponent(play.playId)}/${path}`, {method: 'POST', token: play.token, body: {cells: grid.rows()}});
|
|
462
|
+
setClock(r.play);
|
|
463
|
+
if (path === 'check') {
|
|
464
|
+
grid.markWrong(r.wrong);
|
|
465
|
+
tell(r.wrong.length ? `${r.wrong.length} square${r.wrong.length === 1 ? ' is' : 's are'} wrong (marked in red).`
|
|
466
|
+
: r.empty ? `Everything so far is right. ${r.empty} to go.` : 'Everything is right. Press "I\'ve finished"!', r.wrong.length ? 'bad' : 'good');
|
|
467
|
+
} else if (r.solved) {
|
|
468
|
+
return showResult();
|
|
469
|
+
} else {
|
|
470
|
+
tell(r.message, 'bad');
|
|
471
|
+
}
|
|
472
|
+
} catch (err) {
|
|
473
|
+
if (err.code === 'finished' || err.code === 'gone') return load();
|
|
474
|
+
tell(err.message, 'bad');
|
|
475
|
+
} finally {
|
|
476
|
+
s.busy.value = false;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
const methods = {
|
|
481
|
+
start() { begin(); return false; },
|
|
482
|
+
check() { judge('check'); },
|
|
483
|
+
submit() { judge('submit'); },
|
|
484
|
+
clearWord() { grid?.clearCurrent(); },
|
|
485
|
+
async again() {
|
|
486
|
+
forget();
|
|
487
|
+
grid = null;
|
|
488
|
+
el.querySelector('.cw-grid').innerHTML = '';
|
|
489
|
+
el.querySelectorAll('.cw-list').forEach(l => { l.innerHTML = ''; });
|
|
490
|
+
s.busy.value = false;
|
|
491
|
+
await loadInfo().catch(() => {});
|
|
492
|
+
s.screen.value = 'intro';
|
|
493
|
+
}
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
const bindings = M.applyBindings({...s, ...methods}, el, {methods});
|
|
497
|
+
|
|
498
|
+
loadInfo().then(load).catch((err) => {
|
|
499
|
+
s.problem.value = err.status === 404 ? 'This crossword is not available.' : errText(err);
|
|
500
|
+
s.screen.value = 'error';
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
const dispose = () => { s.clock.stop(); left.stop(); stopLeft?.(); board?.dispose(); bindings?.dispose?.(); watch.disconnect(); };
|
|
504
|
+
const watch = new MutationObserver(() => { if (!el.isConnected) dispose(); });
|
|
505
|
+
watch.observe(document.body, {childList: true, subtree: true});
|
|
506
|
+
return {dispose};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function mountAll() {
|
|
510
|
+
for (const el of document.querySelectorAll('[data-crossword]')) {
|
|
511
|
+
if (el.dataset.gmMounted) continue;
|
|
512
|
+
el.dataset.gmMounted = '1';
|
|
513
|
+
mount(el);
|
|
514
|
+
}
|
|
515
|
+
mountBoards('data-crossword-board', (slug) => `${API}/crossword/${encodeURIComponent(slug)}/leaderboard`, {showScore: false});
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', mountAll);
|
|
519
|
+
else mountAll();
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"note": "Written by `make sync-free-tier` in dcms-marketplace. Do not edit these plugins here - edit them there and sync.",
|
|
3
|
-
"source": "dcms-marketplace@
|
|
3
|
+
"source": "dcms-marketplace@aac25e9",
|
|
4
4
|
"plugins": {
|
|
5
5
|
"blog": {
|
|
6
6
|
"version": "1.10.1",
|
|
@@ -125,6 +125,27 @@
|
|
|
125
125
|
"tests/pure.test.js": "8ba0c8f76b4c9efcc422f39ff29509612544d81f760025a4b9a5594af14e7bcf"
|
|
126
126
|
}
|
|
127
127
|
},
|
|
128
|
+
"crossword": {
|
|
129
|
+
"version": "1.1.0",
|
|
130
|
+
"files": {
|
|
131
|
+
"CLAUDE.md": "598736eec2dfa833f7bb84ca9376071d1143d98a5c01106d0ff453749af18e43",
|
|
132
|
+
"admin/css/index.css": "d8d32268504d5aefa9c5fbf48a0c67b978cdd01d3be0bc747205a001972e05db",
|
|
133
|
+
"admin/templates/crossword.html": "336b4a3b7efff1ab8004c0516d55f11ae2efdc98cb3b18133ea00ca07311e2aa",
|
|
134
|
+
"admin/views/crossword.js": "42f3aabd09483eddf34d25dae243a853c562a13348eafe4fc1977361c1b03f4e",
|
|
135
|
+
"collections/crossword-plays.json": "20577250f00ebe0f6503cc2aae83dba93d88381b76b0b1dbc76c7b856d4295ff",
|
|
136
|
+
"collections/crossword-puzzles.json": "1b70950c96e50ede4d6a0c8e59ca7767b3c684c1a7c68588dc6462e69dad596e",
|
|
137
|
+
"config.js": "7a15db33506a432f5c43a095366c2e82a3bd20ceded585fb4fb78b93ad6dff29",
|
|
138
|
+
"docs/guide.md": "1189300bbe20f032e9040fe027e84a6137ab560e2fe9b88219aba66ea336ec4d",
|
|
139
|
+
"inject/body.html": "9dad7927d5afbd5e53f1edaa4a420403cd2325ce908624f9643a087388c090ab",
|
|
140
|
+
"lib/crossword.js": "f8149db65fb3a7f82b2fbae6a9f8d3ceeeec95730e426cf6fc36ea058527df34",
|
|
141
|
+
"lib/store.js": "284b3b8abe9fdaa64a660d5ab0120856b893d166b28e8bda6cf0ef038baa74bc",
|
|
142
|
+
"plugin.js": "5c1885955d122200c7ff9629d7fb9fcb54c232edb4cec4658574ca90d73730e5",
|
|
143
|
+
"plugin.json": "c5f9e3d934423161af6ef5fbf52c0c3394e737245a4de21da72da6d75a790f44",
|
|
144
|
+
"public/crossword.css": "656f369ea6587b000ff56b8ab80010f4f76af10966537c180950074ec5169f61",
|
|
145
|
+
"public/crossword.js": "8418a2c52a3d351a95e271d3c9f564b7336243ccde75106a9998995575802173",
|
|
146
|
+
"public/lib/layout.js": "0026afd8b42277838c55e1c031fa07cfea1c640c47aaf385e461cc998c4619c0"
|
|
147
|
+
}
|
|
148
|
+
},
|
|
128
149
|
"feedback": {
|
|
129
150
|
"version": "1.2.0",
|
|
130
151
|
"files": {
|
|
@@ -164,6 +185,27 @@
|
|
|
164
185
|
"plugin.json": "e5ac7f3496d9a535fa4899d1de87a514535084d13acecd6a5897744b6174807a"
|
|
165
186
|
}
|
|
166
187
|
},
|
|
188
|
+
"quiz": {
|
|
189
|
+
"version": "1.1.0",
|
|
190
|
+
"files": {
|
|
191
|
+
"CLAUDE.md": "9d037c11b0de115bf17a6c53387116be037c0d4dcd1b6bf3dafa373539143e16",
|
|
192
|
+
"admin/css/index.css": "6598225cfb8b58c486eb390ac41c7f359c78f48e0c97bc8b354d3832f618afa8",
|
|
193
|
+
"admin/templates/quiz.html": "f0b65c406b0b6325a815906ca8da7d884d5d24f2504e05631e46c34a5ca0edb9",
|
|
194
|
+
"admin/views/quiz.js": "c58ba86ec989df8f29e8acff2266fb3463e301c5516bf9281193de539b7c1f44",
|
|
195
|
+
"collections/quiz-plays.json": "331d665a90b6dddf069245a2faf8dd454a36d387702343a7e36d1fe6dd5015f8",
|
|
196
|
+
"collections/quiz-quizzes.json": "ce9bacf6c05fb5c22f9a5c5105f624c7a9c6d78c2bd343072d9cb7587e920b1d",
|
|
197
|
+
"config.js": "24db3727a804678ef12290ae258c7cf52afbc7a425bb452d87b7b2467ff7c559",
|
|
198
|
+
"docs/guide.md": "a7f8f449b94ecc726dc82fc7a0ae7c335431fa757f9493859bdf16e633689c3f",
|
|
199
|
+
"inject/body.html": "07727ead4a04fdeb2759a19e8cc358ba2692b807fb741a6daafaf02463fe8233",
|
|
200
|
+
"lib/engine.js": "a554c7abfa357288bbd73f47c9bcbf8eab7d8222ff63b1fd119a3ca5f9fb79f4",
|
|
201
|
+
"lib/quiz.js": "d697bbd1af41575a9ecee49781695f2dfb4a585bf0230cc39ac5d174dc9c2762",
|
|
202
|
+
"lib/store.js": "a740d879185e20c036ac17ab5cbca74d1f71097f4af3bc121fcddc826d8686c6",
|
|
203
|
+
"plugin.js": "0ec6f96fc4922c7ef41b36bd757c02926ee99d50e78fe85f7f30b9fbd9605b25",
|
|
204
|
+
"plugin.json": "788960b6a31c7c1c2d525fb7cae9ab9b7c9310def5d778c275f22302ecaadf78",
|
|
205
|
+
"public/quiz.css": "e8869fb30e22763357464e9aa7a353306b6631b8a7418ba4e483331e66aada67",
|
|
206
|
+
"public/quiz.js": "dedc96469e440c979b20d7d95655fbb25062b00b27be569b2a55ec5a8ec0cbb5"
|
|
207
|
+
}
|
|
208
|
+
},
|
|
167
209
|
"security": {
|
|
168
210
|
"version": "1.2.1",
|
|
169
211
|
"files": {
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# quiz - AI Assistant Guide
|
|
2
|
+
|
|
3
|
+
## Purpose
|
|
4
|
+
|
|
5
|
+
Timed quizzes on the public site with name-only players and leaderboards. 1.1.0 (2026-10-21), `tier: free`,
|
|
6
|
+
disabled on arrival. Sibling of `crossword`; both sit in the sidebar's Tools > Games folder (`"group": "Games"`)
|
|
7
|
+
and share `plugins/_lib/games` (server) + `_lib/public/games` (browser) + `_lib/admin/games` (admin kit).
|
|
8
|
+
|
|
9
|
+
## Files
|
|
10
|
+
|
|
11
|
+
- `lib/quiz.js` - PURE rules: `cleanQuiz` (the only gate on what the admin saves), `isOpen`, `planPlay` (question
|
|
12
|
+
order, option order, `draw`, fixed at start), `publicQuestion` (never `correct`), `mark`, `foldAnswer`.
|
|
13
|
+
- `lib/engine.js` - PURE clock: `catchUp`, `serve`, `answer`, `end`, `view`. Each mutates the play and says if it
|
|
14
|
+
changed; the route saves through `plays.change()` (locked read-modify-write).
|
|
15
|
+
- `lib/store.js` - `quiz-quizzes` collection + `plays = new PlayStore('quiz-plays', {order: 'score'})`.
|
|
16
|
+
- `plugin.js` - routes (header comment lists them), shortcodes, the `top-scores` notification source.
|
|
17
|
+
- `public/quiz.js` - the player (domma-reactive bindings), injected on every page by `inject/body.html`. Exports
|
|
18
|
+
`mount(el, {test})`, which the admin's Test screen uses.
|
|
19
|
+
- `admin/views/quiz.js` - list, editor, test and plays screens, all domma-reactive through the admin kit
|
|
20
|
+
(`mountScreen`, `wireRows`, `sortableRows`, `testScreen`, `playsScreen` in `_lib/admin/games/kit.js`). Templates
|
|
21
|
+
are strings in the JS (a templateUrl goes through `_.render`, which eats data-each `{{ }}`); the questions are an
|
|
22
|
+
`observableArray` of question models with their answers nested as `{{#each options key=id}}`.
|
|
23
|
+
- Tests: `tests/plugins/games-pure.test.js`, `tests/plugins/games-api.test.js` in the CMS repo.
|
|
24
|
+
|
|
25
|
+
## Contracts
|
|
26
|
+
|
|
27
|
+
- **The server keeps time and marks.** A question's clock starts at `serve()` - the GET of `/public/play/:id` -
|
|
28
|
+
and only then can it be answered (`answer()` refuses an unserved one with `code: 'not-current'`). So the player
|
|
29
|
+
must GET the next question after each answer, not use the one in the answer response. `GRACE_MS` (3s) covers
|
|
30
|
+
latency; late answers are marked wrong, never refused.
|
|
31
|
+
- **The play token** (`X-Play-Token`) is returned once by start and compared with `timingSafeEqual`.
|
|
32
|
+
`PlayStore.adminView` strips it and the state from everything the admin sees.
|
|
33
|
+
- **Leaderboards** rank finished, unhidden plays, best per `nameKey`, score then time (`_lib/games/ranking.js`).
|
|
34
|
+
- **Shortcode prefix trap:** the engine's plugin-shortcode regex is a PREFIX match, so `[quiz` also matches
|
|
35
|
+
`[quiz-leaderboard`. `quiz-leaderboard` is registered with `{priority: 5}` so it runs first. Keep it that way.
|
|
36
|
+
- **Domma bindings:** a `data-on-submit` handler must return `false` synchronously - an async one returns a
|
|
37
|
+
Promise and the form submits natively (page reload).
|
|
38
|
+
- Collections' API verbs are all off: nothing here is readable through `/api/v1`.
|
|
39
|
+
- **Test plays** (1.1.0): `GET/POST /quizzes/:id/test` (manage) give the start screen and a play marked `test: true`,
|
|
40
|
+
for the SAVED quiz whatever its status. It is played through the ordinary `/public/play` routes. `PlayStore` leaves
|
|
41
|
+
tests out of `forGame` (so boards, places, goes-per-name and the admin list), `todayBadge` skips them,
|
|
42
|
+
`afterFinish` never notifies for one, and the next test of the game clears them (`clearTests`). `/result` answers
|
|
43
|
+
`test: true` and `wouldPlace` instead of a place. Tests: `games-api.test.js` "test plays".
|
|
@@ -1 +1,13 @@
|
|
|
1
|
-
|
|
1
|
+
/* Quiz admin - the question editor. Shared pieces are in /plugins/_lib/admin/games/games.css. */
|
|
2
|
+
.qza-questions { display: flex; flex-direction: column; gap: .75rem; }
|
|
3
|
+
.qza-question { border: 1px solid var(--gma-line); border-left: 4px solid var(--dm-primary); border-radius: var(--dm-radius-md, 8px);
|
|
4
|
+
padding: .85rem 1rem; display: flex; flex-direction: column; gap: .6rem; background: var(--dm-background, transparent); }
|
|
5
|
+
.qza-question .form-select { width: auto; }
|
|
6
|
+
.qza-answers { display: flex; flex-direction: column; gap: .4rem; }
|
|
7
|
+
.qza-answer { display: flex; align-items: center; gap: .5rem; padding: .25rem .4rem; border-radius: 6px; }
|
|
8
|
+
.qza-answer.is-right { background: color-mix(in srgb, var(--dm-success) 12%, transparent); }
|
|
9
|
+
.qza-answer .form-input { flex: 1; min-width: 0; }
|
|
10
|
+
.qza-mark { display: inline-flex; align-items: center; cursor: pointer; padding: .2rem; }
|
|
11
|
+
.qza-mark input { width: 1.1rem; height: 1.1rem; accent-color: var(--dm-success); }
|
|
12
|
+
.qza-fixed { flex: 1; font-weight: 500; }
|
|
13
|
+
.qza-more summary { cursor: pointer; color: var(--dm-text-muted); font-size: var(--dm-text-sm); margin-bottom: .5rem; }
|