Voeg 28 herkenbare kleurplaten en creatieve niveaus toe (v0.4.66-beta) #4

Open
bes-r wants to merge 207 commits from bes-r/kleurplaten-v0-4-66 into main AGit
5 changed files with 99 additions and 24 deletions
Showing only changes of commit 09a5d84bba - Show all commits

View file

@ -1 +1 @@
0.3.00 0.3.01-beta

View file

@ -92,6 +92,8 @@
.tbtn:disabled{opacity:.55; cursor:default;} .tbtn:disabled{opacity:.55; cursor:default;}
#btnSave{min-width:150px; text-align:center;} #btnSave{min-width:150px; text-align:center;}
#btnSave.ok{background:var(--green); color:#fff;} #btnSave.ok{background:var(--green); color:#fff;}
/* mislukte (auto)save: blijft rood staan tot een save wél lukt */
#toolbar #btnSave.err{background:var(--red); color:#fff;}
#btnUser.on{background:var(--accent-soft); color:var(--accent-ink);} #btnUser.on{background:var(--accent-soft); color:var(--accent-ink);}
#langToggle{display:flex; background:var(--surface-2); border-radius:999px; padding:3px; gap:2px;} #langToggle{display:flex; background:var(--surface-2); border-radius:999px; padding:3px; gap:2px;}
#langToggle button{ #langToggle button{

View file

@ -3,6 +3,18 @@
Widget framework Widget framework
==========================================================*/ ==========================================================*/
const board = document.getElementById("board"); const board = document.getElementById("board");
/* autosave-haakje: board.js meldt wijzigingen bij core.js, behalve tijdens restore */
const markBoardChange = ()=>{ if(!RESTORING) scheduleSave(); };
/* vangnet voor widget-INTERNE wijzigingen (notities typen, mindmap slepen,
schrijfblad, spelvoortgang): elke interactie in een widget-body plant een
(debounced) save - goedkoop, en dekt alle widgets zonder ze stuk voor stuk
te instrumenteren */
document.addEventListener("input", e=>{
if(e.target.closest && e.target.closest(".widget-body")) markBoardChange();
}, true);
document.addEventListener("pointerup", e=>{
if(e.target.closest && e.target.closest(".widget-body")) markBoardChange();
}, true);
const dock = document.getElementById("dock"); const dock = document.getElementById("dock");
let zTop = 10, widgetCount = 0; let zTop = 10, widgetCount = 0;
const instances = new Set(); const instances = new Set();
@ -88,7 +100,7 @@ function makeWidget(def, saved){
nh = Math.max(def.minH || 180, Math.min(nh, maxH)); nh = Math.max(def.minH || 180, Math.min(nh, maxH));
win.style.width = nw+"px"; win.style.height = nh+"px"; win.style.width = nw+"px"; win.style.height = nh+"px";
}; };
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); markBoardChange(); };
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
}); });
win.appendChild(rs); win.appendChild(rs);
@ -114,7 +126,7 @@ function makeWidget(def, saved){
win.style.zIndex = ++zTop; win.style.zIndex = ++zTop;
} }
} }
minBtn.addEventListener("click", ()=>setMin(true)); minBtn.addEventListener("click", ()=>{ setMin(true); markBoardChange(); });
/* per-widget content zoom for low-vision pupils */ /* per-widget content zoom for low-vision pupils */
let wz = (saved && saved.wz) || 1; let wz = (saved && saved.wz) || 1;
function applyWZ(){ function applyWZ(){
@ -127,6 +139,7 @@ function makeWidget(def, saved){
zBtn.addEventListener("click", ()=>{ zBtn.addEventListener("click", ()=>{
wz = wz===1 ? 1.25 : wz===1.25 ? 1.5 : 1; wz = wz===1 ? 1.25 : wz===1.25 ? 1.5 : 1;
applyWZ(); applyWZ();
markBoardChange();
}); });
applyWZ(); applyWZ();
function setMax(m){ function setMax(m){
@ -148,7 +161,7 @@ function makeWidget(def, saved){
} }
maxBtn.title = T(win.dataset.max==="1" ? "wUnfull" : "wFull"); maxBtn.title = T(win.dataset.max==="1" ? "wUnfull" : "wFull");
} }
maxBtn.addEventListener("click", ()=>setMax(win.dataset.max!=="1")); maxBtn.addEventListener("click", ()=>{ setMax(win.dataset.max!=="1"); markBoardChange(); });
head.addEventListener("dblclick", e=>{ head.addEventListener("dblclick", e=>{
if(e.target===close || e.target===minBtn || e.target===maxBtn) return; if(e.target===close || e.target===minBtn || e.target===maxBtn) return;
setMin(true); setMin(true);
@ -174,7 +187,7 @@ function makeWidget(def, saved){
win.style.left = Math.max(0, Math.min(board.clientWidth - 60, ev.clientX/zf - sx)) + "px"; win.style.left = Math.max(0, Math.min(board.clientWidth - 60, ev.clientX/zf - sx)) + "px";
win.style.top = Math.max(0, Math.min(board.clientHeight - 40, ev.clientY/zf - sy)) + "px"; win.style.top = Math.max(0, Math.min(board.clientHeight - 40, ev.clientY/zf - sy)) + "px";
}; };
const up = ()=>{ document.removeEventListener("pointermove",move); document.removeEventListener("pointerup",up); }; const up = ()=>{ document.removeEventListener("pointermove",move); document.removeEventListener("pointerup",up); markBoardChange(); };
document.addEventListener("pointermove",move); document.addEventListener("pointerup",up); document.addEventListener("pointermove",move); document.addEventListener("pointerup",up);
}); });
@ -185,10 +198,12 @@ function makeWidget(def, saved){
const inst = { def, win, api }; const inst = { def, win, api };
instances.add(inst); instances.add(inst);
close.addEventListener("click", ()=>{ close.addEventListener("click", ()=>{
markBoardChange();
if(pill){ pill.remove(); pill = null; } if(pill){ pill.remove(); pill = null; }
instances.delete(inst); win.remove(); updateEmptyHint(); instances.delete(inst); win.remove(); updateEmptyHint();
}); });
updateEmptyHint(); updateEmptyHint();
markBoardChange();
return inst; return inst;
} }
function updateEmptyHint(){ function updateEmptyHint(){
@ -221,7 +236,11 @@ function serializeBoard(){
})) }))
}; };
} }
/* tijdens het terugzetten van een bord geen autosaves plannen: het herstellen
zelf is geen wijziging (en zou anders direct na inloggen al een save triggeren) */
let RESTORING = false;
function restoreBoard(b){ function restoreBoard(b){
RESTORING = true;
[...instances].forEach(inst=>inst.win.remove()); [...instances].forEach(inst=>inst.win.remove());
instances.clear(); instances.clear();
dock.innerHTML = ""; dock.innerHTML = "";
@ -235,6 +254,7 @@ function restoreBoard(b){
board.querySelectorAll(".bimg").forEach(x=>x.remove()); board.querySelectorAll(".bimg").forEach(x=>x.remove());
(b.imgs||[]).forEach(im=>addBoardImage(im.src, im.x, im.y, im.w)); (b.imgs||[]).forEach(im=>addBoardImage(im.src, im.x, im.y, im.w));
updateEmptyHint(); updateEmptyHint();
RESTORING = false;
} }
/* ---------- board drawing layer (pen mode) ---------- */ /* ---------- board drawing layer (pen mode) ---------- */
@ -265,9 +285,11 @@ function setPen(on){
refreshPenbar(); refreshPenbar();
} }
function setBoardBg(bg){ function setBoardBg(bg){
const changed = BOARD.bg !== bg;
BOARD.bg = bg; BOARD.bg = bg;
board.className = (bg && bg!=="blank") ? "bg-"+bg : ""; board.className = (bg && bg!=="blank") ? "bg-"+bg : "";
refreshPenbar(); refreshPenbar();
if(changed) markBoardChange();
} }
const PEN_TOOLS = [["hand","✋","toolHand"],["pen","✏️","toolPen"],["vulp","🖋️","toolFountain"],["mark","🖍️","toolMark"],["text","🔤","toolText"],["erase","🧽","eraser"]]; const PEN_TOOLS = [["hand","✋","toolHand"],["pen","✏️","toolPen"],["vulp","🖋️","toolFountain"],["mark","🖍️","toolMark"],["text","🔤","toolText"],["erase","🧽","eraser"]];
PEN_TOOLS.forEach(([id,icon])=>{ PEN_TOOLS.forEach(([id,icon])=>{
@ -646,6 +668,7 @@ bInk.addEventListener("pointermove", e=>{
}); });
}); });
const bStop = ()=>{ const bStop = ()=>{
if(bDraw) markBoardChange();
bDraw = false; bDraw = false;
bCtx.globalAlpha = 1; bCtx.globalAlpha = 1;
if(DTOOLS.lens) DTOOLS.lens.lensDraw(); if(DTOOLS.lens) DTOOLS.lens.lensDraw();
@ -807,7 +830,7 @@ function addBoardImage(src, x, y, w){
d.appendChild(im); d.appendChild(im);
const xb = document.createElement("button"); const xb = document.createElement("button");
xb.className = "dtool-x"; xb.textContent = "✕"; xb.className = "dtool-x"; xb.textContent = "✕";
xb.addEventListener("click", ()=>d.remove()); xb.addEventListener("click", ()=>{ d.remove(); markBoardChange(); });
d.appendChild(xb); d.appendChild(xb);
const rs = document.createElement("div"); const rs = document.createElement("div");
rs.className = "bimg-rs"; rs.className = "bimg-rs";
@ -816,7 +839,7 @@ function addBoardImage(src, x, y, w){
const zf = VZ(); const zf = VZ();
const sw = d.offsetWidth, sx = e.clientX; const sw = d.offsetWidth, sx = e.clientX;
const mv = ev=>{ d.style.width = Math.max(80, sw + (ev.clientX - sx)/zf)+"px"; }; const mv = ev=>{ d.style.width = Math.max(80, sw + (ev.clientX - sx)/zf)+"px"; };
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); markBoardChange(); };
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
}); });
d.appendChild(rs); d.appendChild(rs);
@ -827,10 +850,11 @@ function addBoardImage(src, x, y, w){
const zf = VZ(); const zf = VZ();
const ox = e.clientX/zf - d.offsetLeft, oy = e.clientY/zf - d.offsetTop; const ox = e.clientX/zf - d.offsetLeft, oy = e.clientY/zf - d.offsetTop;
const mv = ev=>{ d.style.left = (ev.clientX/zf-ox)+"px"; d.style.top = (ev.clientY/zf-oy)+"px"; }; const mv = ev=>{ d.style.left = (ev.clientX/zf-ox)+"px"; d.style.top = (ev.clientY/zf-oy)+"px"; };
const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); }; const up = ()=>{ document.removeEventListener("pointermove",mv); document.removeEventListener("pointerup",up); markBoardChange(); };
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up); document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
}); });
board.appendChild(d); board.appendChild(d);
markBoardChange();
return d; return d;
} }
document.addEventListener("langchange", refreshFab); document.addEventListener("langchange", refreshFab);
@ -874,6 +898,7 @@ function loadCurrentSlot(){
} }
function switchTo(f, b){ function switchTo(f, b){
stashCurrent(); stashCurrent();
markBoardChange();
BS.f = f; BS.b = b; BS.f = f; BS.b = b;
loadCurrentSlot(); loadCurrentSlot();
} }
@ -982,6 +1007,7 @@ function renderStrip(){
if(!fo.boards.length) fo.boards.push(newBoardEntry(T("boardName")+" 1")); if(!fo.boards.length) fo.boards.push(newBoardEntry(T("boardName")+" 1"));
if(isCur){ BS.f = it.fi; BS.b = 0; loadCurrentSlot(); } if(isCur){ BS.f = it.fi; BS.b = 0; loadCurrentSlot(); }
else if(it.fi===BS.f && it.bi < BS.b) BS.b--; else if(it.fi===BS.f && it.bi < BS.b) BS.b--;
markBoardChange();
renderStrip(); renderStrip();
}); });
row.appendChild(x); row.appendChild(x);
@ -1026,6 +1052,7 @@ function renderBoardsPanel(){
del.addEventListener("click", ()=>{ del.addEventListener("click", ()=>{
if(fi === BS.f){ BS.folders.splice(fi,1); BS.f = 0; BS.b = 0; loadCurrentSlot(); } if(fi === BS.f){ BS.folders.splice(fi,1); BS.f = 0; BS.b = 0; loadCurrentSlot(); }
else{ BS.folders.splice(fi,1); if(BS.f > fi) BS.f--; } else{ BS.folders.splice(fi,1); if(BS.f > fi) BS.f--; }
markBoardChange();
renderBoardsPanel(); renderBoardsPanel();
}); });
fr.appendChild(del); fr.appendChild(del);
@ -1055,6 +1082,7 @@ function renderBoardsPanel(){
const commit = ()=>{ const commit = ()=>{
if(done) return; done = true; if(done) return; done = true;
bo.name = inp.value.trim() || bo.name; bo.name = inp.value.trim() || bo.name;
markBoardChange();
renderBoardsPanel(); updateBoardsUI(); renderBoardsPanel(); updateBoardsUI();
}; };
inp.addEventListener("keydown", ev=>{ ev.stopPropagation(); if(ev.key==="Enter") commit(); }); inp.addEventListener("keydown", ev=>{ ev.stopPropagation(); if(ev.key==="Enter") commit(); });
@ -1071,6 +1099,7 @@ function renderBoardsPanel(){
if(!fo.boards.length) fo.boards.push(newBoardEntry(T("boardName")+" 1")); if(!fo.boards.length) fo.boards.push(newBoardEntry(T("boardName")+" 1"));
if(wasCur){ BS.b = 0; loadCurrentSlot(); } if(wasCur){ BS.b = 0; loadCurrentSlot(); }
else if(fi===BS.f && bi < BS.b) BS.b--; else if(fi===BS.f && bi < BS.b) BS.b--;
markBoardChange();
renderBoardsPanel(); renderBoardsPanel();
}); });
chip.appendChild(x); chip.appendChild(x);
@ -1087,6 +1116,7 @@ function renderBoardsPanel(){
const name = inp.value.trim() || `${T("folderName")} ${BS.folders.length+1}`; const name = inp.value.trim() || `${T("folderName")} ${BS.folders.length+1}`;
BS.folders.push({ name, boards:[newBoardEntry(T("boardName")+" 1")] }); BS.folders.push({ name, boards:[newBoardEntry(T("boardName")+" 1")] });
inp.value = ""; inp.value = "";
markBoardChange();
renderBoardsPanel(); renderBoardsPanel();
}; };
inp.addEventListener("keydown", e=>{ e.stopPropagation(); if(e.key==="Enter") addFolder(); }); inp.addEventListener("keydown", e=>{ e.stopPropagation(); if(e.key==="Enter") addFolder(); });

View file

@ -2,7 +2,7 @@
"use strict"; "use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline). /* version shown until /api/version resolves (or if the fetch fails, e.g. offline).
Kept in sync by hand with the VERSION file at the repo root on every release. */ Kept in sync by hand with the VERSION file at the repo root on every release. */
const VERSION = "0.3.00"; const VERSION = "0.3.01-beta";
(function(){ (function(){
const tag = document.getElementById("verTag"); const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION; tag.textContent = "v"+VERSION;
@ -166,7 +166,8 @@ const I18N = {
errName:"Naam: 220 letters of cijfers.", errName:"Naam: 220 letters of cijfers.",
errPass:"Wachtwoord: minimaal 4 tekens.", errPass:"Wachtwoord: minimaal 4 tekens.",
errCrypto:"Inloggen werkt niet in deze browser.", errCrypto:"Inloggen werkt niet in deze browser.",
hello:"Hoi", boardSaved:"Opgeslagen ✓", loginFirst:"Eerst inloggen" hello:"Hoi", boardSaved:"Opgeslagen ✓", loginFirst:"Eerst inloggen",
saving:"Opslaan…", saveFailed:"⚠ Niet opgeslagen"
}, },
en:{ en:{
addWidget:"Widget", chooseWidget:"Choose a widget", saveBoard:"Save board", addWidget:"Widget", chooseWidget:"Choose a widget", saveBoard:"Save board",
@ -305,7 +306,8 @@ const I18N = {
errName:"Name: 220 letters or digits.", errName:"Name: 220 letters or digits.",
errPass:"Password: at least 4 characters.", errPass:"Password: at least 4 characters.",
errCrypto:"Login does not work in this browser.", errCrypto:"Login does not work in this browser.",
hello:"Hi", boardSaved:"Saved ✓", loginFirst:"Log in first" hello:"Hi", boardSaved:"Saved ✓", loginFirst:"Log in first",
saving:"Saving…", saveFailed:"⚠ Not saved"
} }
}; };
let LANG = "nl"; let LANG = "nl";
@ -363,18 +365,59 @@ async function api(path, opts={}){
return j; return j;
} }
/* alles van de gebruiker (borden, thema's, eigen woorden) staat op de server */ /* =========================================================
Opslaan - alles van de gebruiker staat op de server.
Autosave: elke wijziging plant (debounced) een save; de status
is altijd zichtbaar op de opslaan-knop en fouten worden NIET
meer ingeslikt (die stilte verborg de 1MB-limiet-bug).
==========================================================*/
let saveTimer = null; let saveTimer = null;
let lastSaveError = null;
function canPersist(){
return !!currentUser && currentUser.role !== "pupil";
}
function persistUser(){ function persistUser(){
if(!currentUser) return Promise.resolve(); if(!canPersist()) return Promise.resolve(true);
return api("/me/data", { method:"PUT", body:{ return api("/me/data", { method:"PUT", body:{
themes: THEMES, generalExtra: GENERAL_EXTRA, boards: BS themes: THEMES, generalExtra: GENERAL_EXTRA, boards: BS
}}).catch(()=>{}); }}).then(()=>{ lastSaveError = null; return true; })
.catch(e=>{ lastSaveError = e.message; return false; });
}
/* status op de opslaan-knop: idle / saving / saved / error */
function setSaveStatus(state){
const btn = document.getElementById("btnSave");
const lbl = btn.querySelector("span");
btn.classList.remove("ok","err");
btn.title = "";
if(state==="saving"){ lbl.textContent = T("saving"); }
else if(state==="saved"){ lbl.textContent = T("boardSaved"); btn.classList.add("ok"); }
else if(state==="error"){
lbl.textContent = T("saveFailed");
btn.classList.add("err");
btn.title = lastSaveError || "";
}
else lbl.textContent = T("saveBoard");
}
let saveStatusTimer = null;
async function doSave(){
if(!canPersist()) return false;
stashCurrent();
setSaveStatus("saving");
const ok = await persistUser();
clearTimeout(saveStatusTimer);
setSaveStatus(ok ? "saved" : "error");
/* een fout blijft staan tot een geslaagde save; succes ebt weg */
if(ok) saveStatusTimer = setTimeout(()=>setSaveStatus("idle"), 2000);
return ok;
}
/* autosave: door alles aan te roepen wat het bord of de woorden wijzigt */
function scheduleSave(){
if(!canPersist()) return;
clearTimeout(saveTimer);
saveTimer = setTimeout(doSave, 2500);
} }
function persistWords(){ function persistWords(){
if(!currentUser) return; scheduleSave();
clearTimeout(saveTimer);
saveTimer = setTimeout(()=>{ stashCurrent(); persistUser(); }, 800);
} }
function generalExtraFromData(data){ function generalExtraFromData(data){
return (data && data.generalExtra) ? data.generalExtra : {nl:[], en:[]}; return (data && data.generalExtra) ? data.generalExtra : {nl:[], en:[]};
@ -569,8 +612,7 @@ document.getElementById("btnOwnPw").addEventListener("click", async ()=>{
/* save board */ /* save board */
document.getElementById("btnSave").addEventListener("click", async ()=>{ document.getElementById("btnSave").addEventListener("click", async ()=>{
const btn = document.getElementById("btnSave"); const lbl = document.getElementById("btnSave").querySelector("span");
const lbl = btn.querySelector("span");
if(!currentUser){ if(!currentUser){
lbl.textContent = T("loginFirst"); lbl.textContent = T("loginFirst");
setTimeout(()=>{ lbl.textContent = T("saveBoard"); }, 2200); setTimeout(()=>{ lbl.textContent = T("saveBoard"); }, 2200);
@ -578,11 +620,8 @@ document.getElementById("btnSave").addEventListener("click", async ()=>{
loginWrap.classList.add("open"); loginWrap.classList.add("open");
return; return;
} }
stashCurrent(); clearTimeout(saveTimer); /* expliciet opslaan gaat vóór de geplande autosave */
await persistUser(); await doSave();
lbl.textContent = T("boardSaved");
btn.classList.add("ok");
setTimeout(()=>{ lbl.textContent = T("saveBoard"); btn.classList.remove("ok"); }, 1800);
}); });
/* ========================================================= /* =========================================================

View file

@ -14,6 +14,10 @@ const HOST = process.env.HOST ?? '0.0.0.0';
const app = Fastify({ const app = Fastify({
logger: true, logger: true,
trustProxy: true, // achter nginx reverse proxy trustProxy: true, // achter nginx reverse proxy
// borden bevatten tekeningen/foto's als base64 in de JSON; de Fastify-standaard
// van 1 MB weigerde die stilletjes (413) waardoor opslaan leek te lukken maar
// niets bewaarde. Ruime limiet + eerlijke foutmelding client-side (core.js).
bodyLimit: 25 * 1024 * 1024,
}); });
// --- Database pool ----------------------------------------------------------- // --- Database pool -----------------------------------------------------------