v0.3.24-beta: bewaar ankerdiagrammen los van het bord (bibliotheek)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 18s

- Nieuwe ankerbibliotheek ANCHOR_LIB, opgeslagen in een eigen data-sectie
  "anchors" (server-whitelist + 5MB-cap; lijst i.p.v. object toegestaan);
  reist mee in de volledige save, hydrate, gastreset en reservekopie-herstel
- Ankerwidget: 💾 bewaart het huidige diagram (naam = middelwoord, met
  ✓-feedback; leeg diagram wordt geweigerd met een hint), 🗂 opent dezelfde
  Windows-stijl mappenverkenner met de bewaarde diagrammen (geneste mappen,
  slepen, hernoemen, verwijderen)
- Dubbelklik op een bewaard diagram laadt het in het widget (cellen +
  plaatjes, thema-spiegel en bord-autosave volgen automatisch)
- Nieuwe servertest: anchors-sectie accepteert een lijst, andere secties
  weigeren die (en andersom)
This commit is contained in:
Ramon 2026-07-14 22:14:37 +02:00
parent e327049f1b
commit 86478ebaca
7 changed files with 175 additions and 7 deletions

View file

@ -1 +1 @@
0.3.23-beta 0.3.24-beta

View file

@ -966,6 +966,14 @@
flex:none; font-size:11.5px; font-weight:700; color:var(--muted); flex:none; font-size:11.5px; font-weight:700; color:var(--muted);
padding:6px 10px; background:var(--surface-2); border-bottom:1px solid var(--line); padding:6px 10px; background:var(--surface-2); border-bottom:1px solid var(--line);
} }
/* bibliotheekknoppen: bewaar (💾) en verkenner (🗂) rechtsboven op de stage */
.an{position:relative;}
.an-tools{position:absolute; top:34px; right:8px; z-index:3; display:flex; gap:6px;}
.an-tools button{
border:1px solid var(--line); background:var(--surface); border-radius:10px;
width:34px; height:34px; cursor:pointer; font-size:15px; opacity:.85;
}
.an-tools button:hover{opacity:1; border-color:var(--blue);}
.an-stage{flex:1; min-height:0; position:relative; overflow:hidden; background:var(--surface); cursor:grab; touch-action:none;} .an-stage{flex:1; min-height:0; position:relative; overflow:hidden; background:var(--surface); cursor:grab; touch-action:none;}
.an-stage:active{cursor:grabbing;} .an-stage:active{cursor:grabbing;}
.an-layer{position:absolute; inset:0;} .an-layer{position:absolute; inset:0;}

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.23-beta"; const VERSION = "0.3.24-beta";
(function(){ (function(){
const tag = document.getElementById("verTag"); const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION; tag.textContent = "v"+VERSION;
@ -55,6 +55,9 @@ const I18N = {
newTheme:"Nieuw thema", themeNamePh:"naam van het thema…", delTheme:"Thema verwijderen", newTheme:"Nieuw thema", themeNamePh:"naam van het thema…", delTheme:"Thema verwijderen",
themeFolder:"Thema's en mappen", themeFolder:"Thema's en mappen",
exFixedFolder:"Deze map wordt automatisch beheerd.", exFixedFolder:"Deze map wordt automatisch beheerd.",
anchorLibTitle:"Ankerdiagrammen", anchorLibSave:"Bewaar dit diagram in de bibliotheek",
anchorLibOpen:"Ankerbibliotheek openen", anchorLibSaved:"Bewaard ✓",
anchorLibEmptyGrid:"Zet eerst woorden in het diagram.",
numWords:"Woorden per spel", all:"Alle", noThemes:"— nog geen thema's —", numWords:"Woorden per spel", all:"Alle", noThemes:"— nog geen thema's —",
muteAll:"Alle geluiden aan/uit", sndWidget:"Geluid van deze widget aan/uit", muteAll:"Alle geluiden aan/uit", sndWidget:"Geluid van deze widget aan/uit",
catTaal:"Taal", catRekenen:"Rekenen", catClass:"Feest & klas", catTools:"Hulpmiddelen", catMedia:"Media", catTaal:"Taal", catRekenen:"Rekenen", catClass:"Feest & klas", catTools:"Hulpmiddelen", catMedia:"Media",
@ -228,6 +231,9 @@ const I18N = {
newTheme:"New theme", themeNamePh:"name of the theme…", delTheme:"Delete theme", newTheme:"New theme", themeNamePh:"name of the theme…", delTheme:"Delete theme",
themeFolder:"Themes and folders", themeFolder:"Themes and folders",
exFixedFolder:"This folder is managed automatically.", exFixedFolder:"This folder is managed automatically.",
anchorLibTitle:"Anchor diagrams", anchorLibSave:"Save this diagram to the library",
anchorLibOpen:"Open anchor library", anchorLibSaved:"Saved ✓",
anchorLibEmptyGrid:"Put words in the diagram first.",
numWords:"Words per game", all:"All", noThemes:"— no themes yet —", numWords:"Words per game", all:"All", noThemes:"— no themes yet —",
muteAll:"All sounds on/off", sndWidget:"This widget's sound on/off", muteAll:"All sounds on/off", sndWidget:"This widget's sound on/off",
catTaal:"Language", catRekenen:"Maths", catClass:"Celebration & class", catTools:"Tools", catMedia:"Media", catTaal:"Language", catRekenen:"Maths", catClass:"Celebration & class", catTools:"Tools", catMedia:"Media",
@ -456,7 +462,7 @@ function canPersist(){
verstuurt alleen die secties i.p.v. altijd de complete blob (die MB's kan verstuurt alleen die secties i.p.v. altijd de complete blob (die MB's kan
zijn door borden met afbeeldingen) */ zijn door borden met afbeeldingen) */
const DIRTY = new Set(); const DIRTY = new Set();
const DATA_SECTIONS = { themes: ()=>THEMES, generalExtra: ()=>GENERAL_EXTRA, boards: ()=>BS }; const DATA_SECTIONS = { themes: ()=>THEMES, generalExtra: ()=>GENERAL_EXTRA, boards: ()=>BS, anchors: ()=>ANCHOR_LIB };
function persistUser(){ function persistUser(){
if(!canPersist()) return Promise.resolve(true); if(!canPersist()) return Promise.resolve(true);
const todo = [...DIRTY].filter(s=>s in DATA_SECTIONS); const todo = [...DIRTY].filter(s=>s in DATA_SECTIONS);
@ -464,7 +470,7 @@ function persistUser(){
if(!todo.length || todo.length >= Object.keys(DATA_SECTIONS).length){ if(!todo.length || todo.length >= Object.keys(DATA_SECTIONS).length){
/* alles (of niets specifieks) gewijzigd: volledige save als terugval */ /* alles (of niets specifieks) gewijzigd: volledige save als terugval */
const res = await api("/me/data", { method:"PUT", headers:{ "x-data-rev": String(DATA_REV) }, body:{ const res = await api("/me/data", { method:"PUT", headers:{ "x-data-rev": String(DATA_REV) }, body:{
themes: THEMES, generalExtra: GENERAL_EXTRA, boards: BS themes: THEMES, generalExtra: GENERAL_EXTRA, boards: BS, anchors: ANCHOR_LIB
}}); }});
if(typeof res.rev === "number") DATA_REV = res.rev; if(typeof res.rev === "number") DATA_REV = res.rev;
}else{ }else{
@ -556,6 +562,7 @@ async function hydrateFromServer(){
DATA_REV = typeof res.rev === "number" ? res.rev : 0; DATA_REV = typeof res.rev === "number" ? res.rev : 0;
THEMES = themesFromData(d); THEMES = themesFromData(d);
GENERAL_EXTRA = generalExtraFromData(d); GENERAL_EXTRA = generalExtraFromData(d);
ANCHOR_LIB = Array.isArray(d.anchors) ? d.anchors : [];
BS = boardsFromData(d); BS = boardsFromData(d);
document.dispatchEvent(new CustomEvent("wordschange")); document.dispatchEvent(new CustomEvent("wordschange"));
loadCurrentSlot(); loadCurrentSlot();
@ -566,6 +573,7 @@ function resetToGuest(){
stopPupilView(); stopPupilView();
THEMES = {nl:[],en:[]}; THEMES = {nl:[],en:[]};
GENERAL_EXTRA = {nl:[],en:[]}; GENERAL_EXTRA = {nl:[],en:[]};
ANCHOR_LIB = [];
BS = freshBS(); BS = freshBS();
loadCurrentSlot(); loadCurrentSlot();
updateUserUI(); updateUserUI();
@ -668,6 +676,7 @@ async function loadBackups(){
const d = res.data || {}; const d = res.data || {};
THEMES = themesFromData(d); THEMES = themesFromData(d);
GENERAL_EXTRA = generalExtraFromData(d); GENERAL_EXTRA = generalExtraFromData(d);
ANCHOR_LIB = Array.isArray(d.anchors) ? d.anchors : [];
BS = boardsFromData(d); BS = boardsFromData(d);
document.dispatchEvent(new CustomEvent("wordschange")); document.dispatchEvent(new CustomEvent("wordschange"));
loadCurrentSlot(); loadCurrentSlot();

View file

@ -49,6 +49,12 @@ const shuffle = a => { a=a.slice(); for(let i=a.length-1;i>0;i--){const j=Math.f
/* custom themes: folders of own words. /* custom themes: folders of own words.
{nl:[{name:"Herfst", words:[[word,pic],...]}], en:[...]} */ {nl:[{name:"Herfst", words:[[word,pic],...]}], en:[...]} */
let THEMES = {nl:[], en:[]}; let THEMES = {nl:[], en:[]};
/* ankerbibliotheek: los van borden bewaarde ankerdiagrammen, herbruikbaar op
elk bord. [{id, name, folder?, cells, pics}] - cells/pics in dezelfde vorm
als de widget-staat van anchor.js; folder is een pad ("A/B") zoals bij de
thema's en borden in de mappenverkenner. */
let ANCHOR_LIB = [];
function themesFromData(data){ function themesFromData(data){
if(!data) return {nl:[],en:[]}; if(!data) return {nl:[],en:[]};
if(data.themes) return data.themes; if(data.themes) return data.themes;

View file

@ -42,11 +42,19 @@ function mountAnchor(root, initState, opts){
root.innerHTML = ` root.innerHTML = `
<div class="an"> <div class="an">
<div class="an-hint"></div> <div class="an-hint"></div>
<div class="an-tools">
<button class="an-save" type="button">💾</button>
<button class="an-lib" type="button">🗂</button>
</div>
<div class="an-stage"><div class="an-layer"></div></div> <div class="an-stage"><div class="an-layer"></div></div>
</div>`; </div>`;
const stage = root.querySelector(".an-stage"), const stage = root.querySelector(".an-stage"),
layer = root.querySelector(".an-layer"), layer = root.querySelector(".an-layer"),
hint = root.querySelector(".an-hint"); hint = root.querySelector(".an-hint"),
toolsEl = root.querySelector(".an-tools"),
saveBtn = root.querySelector(".an-save"),
libBtn = root.querySelector(".an-lib");
if(readonly) toolsEl.remove();
const key = (x,y)=>x+","+y; const key = (x,y)=>x+","+y;
/* register this grid as a word source for the language widgets */ /* register this grid as a word source for the language widgets */
@ -220,6 +228,125 @@ function mountAnchor(root, initState, opts){
inp.addEventListener("blur", commit); inp.addEventListener("blur", commit);
inp.addEventListener("pointerdown", ev=>ev.stopPropagation()); inp.addEventListener("pointerdown", ev=>ev.stopPropagation());
} }
/* ---- ankerbibliotheek: diagram los van het bord bewaren en hergebruiken.
Opslag in ANCHOR_LIB (eigen save-sectie "anchors"), beheer via dezelfde
Windows-stijl mappenverkenner als de borden en thema's. */
function anchorLibAdapter(){
const split = n => (n||"").split("/").map(s=>s.trim()).filter(Boolean);
const join = p => p.join("/");
const wordCount = it => Object.keys(it.cells||{}).length;
const pending = new Set();
const changed = ()=>scheduleSave("anchors");
return {
title: ()=>T("anchorLibTitle"),
allowRootItems: true,
list(path){
const p = join(path);
const folderMap = new Map();
const items = [];
const seen = new Set(pending);
ANCHOR_LIB.forEach((it, i)=>{
const j = join(split(it.folder));
if(j === p) items.push({ key: i, name: `${it.name} (${wordCount(it)})`, icon: "⚓" });
else if(!p || j.startsWith(p + "/")) seen.add(j);
});
seen.forEach(j=>{
if(j === p) return;
if(p && !j.startsWith(p + "/")) return;
if(!p && !j) return;
const seg = (p ? j.slice(p.length + 1) : j).split("/")[0];
if(!seg) return;
const full = p ? p + "/" + seg : seg;
const under = ANCHOR_LIB.filter(it=>{ const ij = join(split(it.folder)); return ij === full || ij.startsWith(full + "/"); }).length;
folderMap.set(seg, under);
});
const folders = [...folderMap].map(([name, count])=>({name, count}))
.sort((a,b)=>a.name.localeCompare(b.name));
return { folders, items };
},
createFolder(path, name){
if(this.list(path).folders.some(f=>f.name===name)) return T("exExists");
pending.add(join([...path, name]));
return null;
},
renameFolder(path, oldName, newName){
return this.moveFolder([...path, oldName], path, newName);
},
moveFolder(srcPath, targetPath, newName){
const from = join(srcPath);
const to = join([...targetPath, newName || srcPath[srcPath.length-1]]);
if(from === to) return null;
if(ANCHOR_LIB.some(it=>{ const j = join(split(it.folder)); return j === to || j.startsWith(to + "/"); }) || pending.has(to)) return T("exExists");
ANCHOR_LIB.forEach(it=>{
const j = join(split(it.folder));
if(j === from || j.startsWith(from + "/")) it.folder = to + j.slice(from.length);
});
[...pending].forEach(j=>{
if(j === from || j.startsWith(from + "/")){ pending.delete(j); pending.add(to + j.slice(from.length)); }
});
changed();
return null;
},
deleteFolder(path, name){
const full = join([...path, name]);
if(ANCHOR_LIB.some(it=>{ const j = join(split(it.folder)); return j === full || j.startsWith(full + "/"); })) return T("exFolderNotEmpty");
[...pending].forEach(j=>{ if(j === full || j.startsWith(full + "/")) pending.delete(j); });
return null;
},
renameItem(i, name){
if(!ANCHOR_LIB[i]) return null;
ANCHOR_LIB[i].name = name;
changed();
return null;
},
moveItem(i, targetPath){
const it = ANCHOR_LIB[i];
if(!it) return null;
if(targetPath.length) it.folder = join(targetPath); else delete it.folder;
pending.delete(join(targetPath));
changed();
return null;
},
deleteItem(i){
if(!ANCHOR_LIB[i]) return null;
ANCHOR_LIB.splice(i, 1);
changed();
return null;
},
/* dubbelklik: bewaard diagram in dít widget laden */
openItem(i){
const it = ANCHOR_LIB[i];
if(!it) return;
S.cells = structuredClone(it.cells || {});
S.pics = structuredClone(it.pics || {});
S.pan = {x:0, y:0};
syncSource();
render();
persistWords();
/* programmatisch geladen: de bord-autosave-listeners zien dit niet */
markBoardChange();
},
};
}
if(!readonly){
saveBtn.title = T("anchorLibSave");
libBtn.title = T("anchorLibOpen");
saveBtn.addEventListener("click", ()=>{
if(!Object.keys(S.cells).length){ hint.textContent = T("anchorLibEmptyGrid"); return; }
ANCHOR_LIB.push({
id: "al-"+Math.random().toString(36).slice(2,10),
name: reg.name,
cells: structuredClone(S.cells),
pics: structuredClone(S.pics),
});
scheduleSave("anchors");
const orig = saveBtn.textContent;
saveBtn.textContent = "✓";
setTimeout(()=>{ saveBtn.textContent = orig; }, 1200);
});
libBtn.addEventListener("click", ()=>openExplorer(anchorLibAdapter()));
}
new ResizeObserver(render).observe(stage); new ResizeObserver(render).observe(stage);
document.addEventListener("langchange", render); document.addEventListener("langchange", render);
syncSource(true); syncSource(true);

View file

@ -236,13 +236,15 @@ export default async function api(app) {
// Gesectioneerd opslaan: alleen de gewijzigde sub-key vervangen (jsonb_set) // Gesectioneerd opslaan: alleen de gewijzigde sub-key vervangen (jsonb_set)
// i.p.v. de hele blob versturen. Zelfde rev/409- en snapshot-gedrag als de // i.p.v. de hele blob versturen. Zelfde rev/409- en snapshot-gedrag als de
// volledige PUT hierboven, die blijft bestaan voor restore/eerste save. // volledige PUT hierboven, die blijft bestaan voor restore/eerste save.
const DATA_SECTIONS = { boards: 20, themes: 2, generalExtra: 2 }; // cap in MB const DATA_SECTIONS = { boards: 20, themes: 2, generalExtra: 2, anchors: 5 }; // cap in MB
app.put('/me/data/:section', { bodyLimit: 21 * 1024 * 1024 }, async (req, reply) => { app.put('/me/data/:section', { bodyLimit: 21 * 1024 * 1024 }, async (req, reply) => {
need(req, reply); need(req, reply);
const section = req.params.section; const section = req.params.section;
if (!(section in DATA_SECTIONS)) return fail(reply, 404, 'onbekende sectie'); if (!(section in DATA_SECTIONS)) return fail(reply, 404, 'onbekende sectie');
const part = req.body ?? {}; const part = req.body ?? {};
if (!part || typeof part !== 'object' || Array.isArray(part)) return fail(reply, 400, 'ongeldige data'); // anchors is een lijst, de overige secties zijn objecten
if (!part || typeof part !== 'object') return fail(reply, 400, 'ongeldige data');
if (Array.isArray(part) !== (section === 'anchors')) return fail(reply, 400, 'ongeldige data');
if (Buffer.byteLength(JSON.stringify(part), 'utf8') > DATA_SECTIONS[section] * 1024 * 1024) if (Buffer.byteLength(JSON.stringify(part), 'utf8') > DATA_SECTIONS[section] * 1024 * 1024)
return fail(reply, 413, 'data te groot'); return fail(reply, 413, 'data te groot');
await snapshotUserData(req.user.id); await snapshotUserData(req.user.id);

View file

@ -99,6 +99,22 @@ test('PUT /me/data/:section weigert onbekende secties', async () => {
await app.close(); await app.close();
}); });
test('PUT /me/data/anchors accepteert een lijst (en de andere secties juist niet)', async () => {
const { app, calls } = await makeApp({ dataRev: 3 });
const ok = await app.inject({ method: 'PUT', url: '/api/me/data/anchors', cookies: sessionCookie,
headers: { 'x-data-rev': '3' }, payload: [{ id: 'al-1', name: 'water', cells: {}, pics: {} }] });
assert.equal(ok.statusCode, 200, ok.body);
const upd = calls.find((c) => c.sql.includes('jsonb_set'));
assert.equal(upd.params[0], '{anchors}');
const bad = await app.inject({ method: 'PUT', url: '/api/me/data/themes', cookies: sessionCookie,
headers: { 'x-data-rev': '4' }, payload: [1, 2] });
assert.equal(bad.statusCode, 400);
const badAnchors = await app.inject({ method: 'PUT', url: '/api/me/data/anchors', cookies: sessionCookie,
headers: { 'x-data-rev': '4' }, payload: { niet: 'een lijst' } });
assert.equal(badAnchors.statusCode, 400);
await app.close();
});
test('PUT /me/data maakt eerst een snapshot en ruimt oude versies op', async () => { test('PUT /me/data maakt eerst een snapshot en ruimt oude versies op', async () => {
const { app, calls } = await makeApp({ dataRev: 3 }); const { app, calls } = await makeApp({ dataRev: 3 });
const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie, const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie,