diff --git a/VERSION b/VERSION
index a224768..c83b0f6 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.3.23-beta
+0.3.24-beta
diff --git a/public/css/teach.css b/public/css/teach.css
index 48dcf51..23ac311 100644
--- a/public/css/teach.css
+++ b/public/css/teach.css
@@ -966,6 +966,14 @@
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);
}
+ /* 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:active{cursor:grabbing;}
.an-layer{position:absolute; inset:0;}
diff --git a/public/js/core.js b/public/js/core.js
index 503e16d..129b1d1 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.23-beta";
+const VERSION = "0.3.24-beta";
(function(){
const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION;
@@ -55,6 +55,9 @@ const I18N = {
newTheme:"Nieuw thema", themeNamePh:"naam van het themaโฆ", delTheme:"Thema verwijderen",
themeFolder:"Thema's en mappen",
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 โ",
muteAll:"Alle geluiden aan/uit", sndWidget:"Geluid van deze widget aan/uit",
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",
themeFolder:"Themes and folders",
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 โ",
muteAll:"All sounds on/off", sndWidget:"This widget's sound on/off",
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
zijn door borden met afbeeldingen) */
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(){
if(!canPersist()) return Promise.resolve(true);
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){
/* 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:{
- 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;
}else{
@@ -556,6 +562,7 @@ async function hydrateFromServer(){
DATA_REV = typeof res.rev === "number" ? res.rev : 0;
THEMES = themesFromData(d);
GENERAL_EXTRA = generalExtraFromData(d);
+ ANCHOR_LIB = Array.isArray(d.anchors) ? d.anchors : [];
BS = boardsFromData(d);
document.dispatchEvent(new CustomEvent("wordschange"));
loadCurrentSlot();
@@ -566,6 +573,7 @@ function resetToGuest(){
stopPupilView();
THEMES = {nl:[],en:[]};
GENERAL_EXTRA = {nl:[],en:[]};
+ ANCHOR_LIB = [];
BS = freshBS();
loadCurrentSlot();
updateUserUI();
@@ -668,6 +676,7 @@ async function loadBackups(){
const d = res.data || {};
THEMES = themesFromData(d);
GENERAL_EXTRA = generalExtraFromData(d);
+ ANCHOR_LIB = Array.isArray(d.anchors) ? d.anchors : [];
BS = boardsFromData(d);
document.dispatchEvent(new CustomEvent("wordschange"));
loadCurrentSlot();
diff --git a/public/js/data.js b/public/js/data.js
index 3211ea9..cbef1b4 100644
--- a/public/js/data.js
+++ b/public/js/data.js
@@ -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.
{nl:[{name:"Herfst", words:[[word,pic],...]}], 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){
if(!data) return {nl:[],en:[]};
if(data.themes) return data.themes;
diff --git a/public/js/widgets/anchor.js b/public/js/widgets/anchor.js
index 63fe607..10f1c59 100644
--- a/public/js/widgets/anchor.js
+++ b/public/js/widgets/anchor.js
@@ -42,11 +42,19 @@ function mountAnchor(root, initState, opts){
root.innerHTML = `
`;
const stage = root.querySelector(".an-stage"),
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;
/* 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("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);
document.addEventListener("langchange", render);
syncSource(true);
diff --git a/src/api.js b/src/api.js
index 3a39f19..67adb6f 100644
--- a/src/api.js
+++ b/src/api.js
@@ -236,13 +236,15 @@ export default async function api(app) {
// 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
// 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) => {
need(req, reply);
const section = req.params.section;
if (!(section in DATA_SECTIONS)) return fail(reply, 404, 'onbekende sectie');
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)
return fail(reply, 413, 'data te groot');
await snapshotUserData(req.user.id);
diff --git a/test/save-system.test.js b/test/save-system.test.js
index cadedd7..ea4d7be 100644
--- a/test/save-system.test.js
+++ b/test/save-system.test.js
@@ -99,6 +99,22 @@ test('PUT /me/data/:section weigert onbekende secties', async () => {
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 () => {
const { app, calls } = await makeApp({ dataRev: 3 });
const res = await app.inject({ method: 'PUT', url: '/api/me/data', cookies: sessionCookie,