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
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:
parent
e327049f1b
commit
86478ebaca
7 changed files with 175 additions and 7 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.3.23-beta
|
||||
0.3.24-beta
|
||||
|
|
|
|||
|
|
@ -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;}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -42,11 +42,19 @@ function mountAnchor(root, initState, opts){
|
|||
root.innerHTML = `
|
||||
<div class="an">
|
||||
<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>`;
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue