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,