From 09a5d84bba34192044d7d133b8f5e07504eb9988 Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 14 Jul 2026 10:40:58 +0200 Subject: [PATCH] v0.3.01-beta: verbeter automatisch opslaan van borden --- VERSION | 2 +- public/css/teach.css | 2 ++ public/js/board.js | 44 ++++++++++++++++++++++----- public/js/core.js | 71 ++++++++++++++++++++++++++++++++++---------- src/server.js | 4 +++ 5 files changed, 99 insertions(+), 24 deletions(-) diff --git a/VERSION b/VERSION index b8522ca..2359ad6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.00 +0.3.01-beta diff --git a/public/css/teach.css b/public/css/teach.css index 8a9f691..86b9ef8 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -92,6 +92,8 @@ .tbtn:disabled{opacity:.55; cursor:default;} #btnSave{min-width:150px; text-align:center;} #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);} #langToggle{display:flex; background:var(--surface-2); border-radius:999px; padding:3px; gap:2px;} #langToggle button{ diff --git a/public/js/board.js b/public/js/board.js index 4186105..1577edf 100644 --- a/public/js/board.js +++ b/public/js/board.js @@ -3,6 +3,18 @@ Widget framework ==========================================================*/ 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"); let zTop = 10, widgetCount = 0; const instances = new Set(); @@ -88,7 +100,7 @@ function makeWidget(def, saved){ nh = Math.max(def.minH || 180, Math.min(nh, maxH)); 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); }); win.appendChild(rs); @@ -114,7 +126,7 @@ function makeWidget(def, saved){ win.style.zIndex = ++zTop; } } - minBtn.addEventListener("click", ()=>setMin(true)); + minBtn.addEventListener("click", ()=>{ setMin(true); markBoardChange(); }); /* per-widget content zoom for low-vision pupils */ let wz = (saved && saved.wz) || 1; function applyWZ(){ @@ -127,6 +139,7 @@ function makeWidget(def, saved){ zBtn.addEventListener("click", ()=>{ wz = wz===1 ? 1.25 : wz===1.25 ? 1.5 : 1; applyWZ(); + markBoardChange(); }); applyWZ(); function setMax(m){ @@ -148,7 +161,7 @@ function makeWidget(def, saved){ } 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=>{ if(e.target===close || e.target===minBtn || e.target===maxBtn) return; 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.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); }); @@ -185,10 +198,12 @@ function makeWidget(def, saved){ const inst = { def, win, api }; instances.add(inst); close.addEventListener("click", ()=>{ + markBoardChange(); if(pill){ pill.remove(); pill = null; } instances.delete(inst); win.remove(); updateEmptyHint(); }); updateEmptyHint(); + markBoardChange(); return inst; } 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){ + RESTORING = true; [...instances].forEach(inst=>inst.win.remove()); instances.clear(); dock.innerHTML = ""; @@ -235,6 +254,7 @@ function restoreBoard(b){ board.querySelectorAll(".bimg").forEach(x=>x.remove()); (b.imgs||[]).forEach(im=>addBoardImage(im.src, im.x, im.y, im.w)); updateEmptyHint(); + RESTORING = false; } /* ---------- board drawing layer (pen mode) ---------- */ @@ -265,9 +285,11 @@ function setPen(on){ refreshPenbar(); } function setBoardBg(bg){ + const changed = BOARD.bg !== bg; BOARD.bg = bg; board.className = (bg && bg!=="blank") ? "bg-"+bg : ""; refreshPenbar(); + if(changed) markBoardChange(); } const PEN_TOOLS = [["hand","✋","toolHand"],["pen","✏️","toolPen"],["vulp","🖋️","toolFountain"],["mark","🖍️","toolMark"],["text","🔤","toolText"],["erase","🧽","eraser"]]; PEN_TOOLS.forEach(([id,icon])=>{ @@ -646,6 +668,7 @@ bInk.addEventListener("pointermove", e=>{ }); }); const bStop = ()=>{ + if(bDraw) markBoardChange(); bDraw = false; bCtx.globalAlpha = 1; if(DTOOLS.lens) DTOOLS.lens.lensDraw(); @@ -807,7 +830,7 @@ function addBoardImage(src, x, y, w){ d.appendChild(im); const xb = document.createElement("button"); xb.className = "dtool-x"; xb.textContent = "✕"; - xb.addEventListener("click", ()=>d.remove()); + xb.addEventListener("click", ()=>{ d.remove(); markBoardChange(); }); d.appendChild(xb); const rs = document.createElement("div"); rs.className = "bimg-rs"; @@ -816,7 +839,7 @@ function addBoardImage(src, x, y, w){ const zf = VZ(); const sw = d.offsetWidth, sx = e.clientX; 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); }); d.appendChild(rs); @@ -827,10 +850,11 @@ function addBoardImage(src, x, y, w){ const zf = VZ(); 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 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); }); board.appendChild(d); + markBoardChange(); return d; } document.addEventListener("langchange", refreshFab); @@ -874,6 +898,7 @@ function loadCurrentSlot(){ } function switchTo(f, b){ stashCurrent(); + markBoardChange(); BS.f = f; BS.b = b; loadCurrentSlot(); } @@ -982,6 +1007,7 @@ function renderStrip(){ if(!fo.boards.length) fo.boards.push(newBoardEntry(T("boardName")+" 1")); if(isCur){ BS.f = it.fi; BS.b = 0; loadCurrentSlot(); } else if(it.fi===BS.f && it.bi < BS.b) BS.b--; + markBoardChange(); renderStrip(); }); row.appendChild(x); @@ -1026,6 +1052,7 @@ function renderBoardsPanel(){ del.addEventListener("click", ()=>{ 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--; } + markBoardChange(); renderBoardsPanel(); }); fr.appendChild(del); @@ -1055,6 +1082,7 @@ function renderBoardsPanel(){ const commit = ()=>{ if(done) return; done = true; bo.name = inp.value.trim() || bo.name; + markBoardChange(); renderBoardsPanel(); updateBoardsUI(); }; 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(wasCur){ BS.b = 0; loadCurrentSlot(); } else if(fi===BS.f && bi < BS.b) BS.b--; + markBoardChange(); renderBoardsPanel(); }); chip.appendChild(x); @@ -1087,6 +1116,7 @@ function renderBoardsPanel(){ const name = inp.value.trim() || `${T("folderName")} ${BS.folders.length+1}`; BS.folders.push({ name, boards:[newBoardEntry(T("boardName")+" 1")] }); inp.value = ""; + markBoardChange(); renderBoardsPanel(); }; inp.addEventListener("keydown", e=>{ e.stopPropagation(); if(e.key==="Enter") addFolder(); }); diff --git a/public/js/core.js b/public/js/core.js index cc10548..1d0d373 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* 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. */ -const VERSION = "0.3.00"; +const VERSION = "0.3.01-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -166,7 +166,8 @@ const I18N = { errName:"Naam: 2–20 letters of cijfers.", errPass:"Wachtwoord: minimaal 4 tekens.", 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:{ addWidget:"Widget", chooseWidget:"Choose a widget", saveBoard:"Save board", @@ -305,7 +306,8 @@ const I18N = { errName:"Name: 2–20 letters or digits.", errPass:"Password: at least 4 characters.", 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"; @@ -363,18 +365,59 @@ async function api(path, opts={}){ 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 lastSaveError = null; +function canPersist(){ + return !!currentUser && currentUser.role !== "pupil"; +} function persistUser(){ - if(!currentUser) return Promise.resolve(); + if(!canPersist()) return Promise.resolve(true); return api("/me/data", { method:"PUT", body:{ 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(){ - if(!currentUser) return; - clearTimeout(saveTimer); - saveTimer = setTimeout(()=>{ stashCurrent(); persistUser(); }, 800); + scheduleSave(); } function generalExtraFromData(data){ return (data && data.generalExtra) ? data.generalExtra : {nl:[], en:[]}; @@ -569,8 +612,7 @@ document.getElementById("btnOwnPw").addEventListener("click", async ()=>{ /* save board */ document.getElementById("btnSave").addEventListener("click", async ()=>{ - const btn = document.getElementById("btnSave"); - const lbl = btn.querySelector("span"); + const lbl = document.getElementById("btnSave").querySelector("span"); if(!currentUser){ lbl.textContent = T("loginFirst"); setTimeout(()=>{ lbl.textContent = T("saveBoard"); }, 2200); @@ -578,11 +620,8 @@ document.getElementById("btnSave").addEventListener("click", async ()=>{ loginWrap.classList.add("open"); return; } - stashCurrent(); - await persistUser(); - lbl.textContent = T("boardSaved"); - btn.classList.add("ok"); - setTimeout(()=>{ lbl.textContent = T("saveBoard"); btn.classList.remove("ok"); }, 1800); + clearTimeout(saveTimer); /* expliciet opslaan gaat vóór de geplande autosave */ + await doSave(); }); /* ========================================================= diff --git a/src/server.js b/src/server.js index e2314ef..411f044 100644 --- a/src/server.js +++ b/src/server.js @@ -14,6 +14,10 @@ const HOST = process.env.HOST ?? '0.0.0.0'; const app = Fastify({ logger: true, 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 -----------------------------------------------------------