v0.3.27-beta: gedeelde bibliotheek in de mappenverkenner
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 21s

- withSharedBranches (explorer.js): wikkelt een eigen-inhoud-adapter en
  voegt in de root de vaste takken "🌐 Systeem" (systeembreed) en
  "🏫 School" (binnen de eigen school) toe
- Slepen tussen bomen = kopiëren: eigen bord/diagram of hele map naar een
  gedeelde tak publiceert (POST /shared, kopie - origineel blijft); gedeeld
  item of map naar de eigen boom kopieert (borden krijgen nieuw id + verse
  wids); binnen een tak is slepen echt verplaatsen (PATCH, server bewaakt)
- Dubbelklik op een gedeeld bord kopieert en opent het; op een gedeeld
  ankerdiagram laadt het in het widget
- Verkenner ondersteunt nu async adapters (init/mutaties), groene
  ok-meldingen ("Gedeeld ✓"/"Gekopieerd ✓") en een eigenaar-hint per tegel
- Gasten en leerlingen zien alleen de eigen boom
This commit is contained in:
Ramon 2026-07-14 22:34:54 +02:00
parent 939c4ee68b
commit e21bc177e1
6 changed files with 375 additions and 31 deletions

View file

@ -1 +1 @@
0.3.26-beta
0.3.27-beta

View file

@ -221,6 +221,8 @@
.ex-sep{color:var(--muted); font-weight:800;}
.ex-tools{display:flex; gap:8px; flex-wrap:wrap;}
.ex-msg{min-height:18px; color:var(--red); font-size:13px; font-weight:700;}
.ex-msg.ok{color:var(--green, #2c8c4b);}
.ex-hint{font-size:11px; color:var(--muted); font-weight:600;}
.ex-grid{
display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr));
gap:12px; margin-top:8px;

View file

@ -1017,8 +1017,62 @@ function switchTo(f, b){
const btnFolders = document.getElementById("btnFolders");
btnFolders.addEventListener("click", ()=>{
stashCurrent(); /* verse thumbnails, ook van het actieve bord */
openExplorer(boardsExplorerAdapter());
openExplorer(withSharedBranches("board", boardsExplorerAdapter(), boardsSharedHooks()));
});
/* koppelstuk voor de gedeelde bibliotheek (zie withSharedBranches in
explorer.js): borden exporteren/importeren als {name, data}. Een kopie
krijgt altijd een nieuw bord-id en verse wids, anders zouden toewijzingen
en voortgang van verschillende gebruikers naar hetzelfde id verwijzen. */
function freshBoardCopy(name, data){
const d = data ? structuredClone(data) : null;
if(d && Array.isArray(d.widgets)) d.widgets.forEach(w=>{ w.wid = genWidgetId(); });
return { id: genBoardId(), name, data: d };
}
function boardsSharedHooks(){
const entryAtPath = path => BS.folders.find(f=>joinBoardPath(splitBoardPath(f.name))===joinBoardPath(path));
const ensureEntry = path => {
let e = entryAtPath(path);
if(!e){ e = { name: joinBoardPath(path), boards: [] }; BS.folders.push(e); }
return e;
};
const boardData = hit => (hit.fi===BS.f && hit.bi===BS.b) ? serializeBoard() : (hit.bo.data || emptyBoardData());
return {
exportItem(id){
const hit = findBoardEntry(id);
return hit ? { name: hit.bo.name, data: boardData(hit) } : null;
},
exportFolder(path){
const p = joinBoardPath(path);
const out = [];
BS.folders.forEach((f, fi)=>{
const j = joinBoardPath(splitBoardPath(f.name));
if(j === p || j.startsWith(p + "/")){
const rel = j === p ? [] : j.slice(p.length + 1).split("/");
f.boards.forEach((bo, bi)=>out.push({
name: bo.name, rel,
data: (fi===BS.f && bi===BS.b) ? serializeBoard() : (bo.data || emptyBoardData()),
}));
}
});
return out;
},
copyIn(item, destPath){
if(!destPath.length) return T("exNoRootItems");
ensureEntry(destPath).boards.push(freshBoardCopy(item.name, item.data));
markBoardChange();
return null;
},
openShared(item, relPath){
const entry = ensureEntry(relPath.length ? relPath : [T("shGlobal")]);
const copy = freshBoardCopy(item.name, item.data);
entry.boards.push(copy);
markBoardChange();
const hit = findBoardEntry(copy.id);
if(hit) switchTo(hit.fi, hit.bi);
return null;
},
};
}
function updateBoardsUI(){
btnFolders.title = `${T("boardsNav")} · ${BS.folders[BS.f].name} / ${curSlot().name}`;
fabBoardsBtn.title = `${BS.folders[BS.f].name} / ${curSlot().name}`;
@ -1030,16 +1084,17 @@ function updateBoardsUI(){
dus bestaande data en de server (findBoardById op bord-id) blijven werken */
const splitBoardPath = n => n.split("/").map(s=>s.trim()).filter(Boolean);
const joinBoardPath = p => p.join("/");
function boardsExplorerAdapter(){
const norm = f => joinBoardPath(splitBoardPath(f.name));
const entryAt = path => BS.folders.find(f=>norm(f)===joinBoardPath(path));
const findBoard = id => {
function findBoardEntry(id){
for(let fi=0; fi<BS.folders.length; fi++){
const bi = BS.folders[fi].boards.findIndex(b=>b.id===id);
if(bi >= 0) return { fi, bi, fo: BS.folders[fi], bo: BS.folders[fi].boards[bi] };
}
return null;
};
}
function boardsExplorerAdapter(){
const norm = f => joinBoardPath(splitBoardPath(f.name));
const entryAt = path => BS.folders.find(f=>norm(f)===joinBoardPath(path));
const findBoard = findBoardEntry;
const activeId = ()=>curSlot().id;
/* indices herstellen nadat entries verschoven/verwijderd zijn */
const relocate = (id)=>{

View file

@ -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.26-beta";
const VERSION = "0.3.27-beta";
(function(){
const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION;
@ -85,6 +85,9 @@ const I18N = {
exFolderNotEmpty:"Map is niet leeg - verplaats of verwijder eerst de inhoud.",
exIntoSelf:"Een map kan niet in zichzelf.", exNoRootItems:"Borden horen in een map.",
exLastBoard:"Het laatste bord kan niet weg.",
shGlobal:"Systeem", shSchool:"School",
shPublished:"Gedeeld ✓", shCopied:"Gekopieerd naar je eigen mappen ✓",
shNoCreateHere:"Nieuw maken kan alleen in je eigen mappen - sleep het daarna hierheen om te delen.",
hudMin:"Menu inklappen/uitklappen", hudOrient:"Horizontaal of verticaal",
wFull:"Maximaliseren", wUnfull:"Terugzetten",
wZoom:"Inhoud groter of kleiner (voor slechtzienden)",
@ -261,6 +264,9 @@ const I18N = {
exFolderNotEmpty:"Folder is not empty - move or delete its contents first.",
exIntoSelf:"A folder cannot go inside itself.", exNoRootItems:"Boards belong in a folder.",
exLastBoard:"The last board cannot be deleted.",
shGlobal:"System", shSchool:"School",
shPublished:"Shared ✓", shCopied:"Copied to your own folders ✓",
shNoCreateHere:"Create it in your own folders first - then drag it here to share.",
hudMin:"Collapse/expand menu", hudOrient:"Horizontal or vertical",
wFull:"Maximize", wUnfull:"Restore",
wZoom:"Content bigger or smaller (low vision)",

View file

@ -37,8 +37,10 @@
let SEL = null; /* selectie: {type:"folder"|"item", key} */
let onCloseCb = null;
window.openExplorer = function(adapter, onClose){
window.openExplorer = async function(adapter, onClose){
AD = adapter; PATH = []; SEL = null; onCloseCb = onClose || null;
/* adapters met externe data (gedeelde bibliotheek) laden die eerst */
if(AD.init){ try{ await AD.init(); }catch(e){} }
render();
msg("");
wrap.classList.add("open");
@ -51,7 +53,13 @@
wrap.addEventListener("click", e=>{ if(e.target===wrap) closeExplorer(); });
document.querySelector(".explorer-close").addEventListener("click", closeExplorer);
const msg = t => { msgEl.textContent = t || ""; };
const msg = t => { msgEl.classList.remove("ok"); msgEl.textContent = t || ""; };
const msgOk = t => { msgEl.classList.add("ok"); msgEl.textContent = t || ""; };
/* mutaties geven null (ok), een foutstring, of {ok:"..."} (groene melding) */
function showResult(res){
if(res && res.ok) msgOk(res.ok);
else if(res) msg(res);
}
const validName = (name)=>{
const n = (name||"").trim();
if(!n) return null;
@ -124,21 +132,21 @@
document.addEventListener("pointerup", up);
});
}
function doMove(payload, dest){
async function doMove(payload, dest){
msg("");
let err = null;
let res = null;
if(payload.type === "folder"){
const src = [...PATH, payload.key];
/* niet in zichzelf of een submap slepen */
if(dest.length >= src.length && src.every((s,i)=>dest[i]===s)){ msg(T("exIntoSelf")); return; }
err = AD.moveFolder(src, dest);
res = await AD.moveFolder(src, dest);
}else{
if(!dest.length && !AD.allowRootItems){ msg(T("exNoRootItems")); return; }
err = AD.moveItem(payload.key, dest);
res = await AD.moveItem(payload.key, dest);
}
SEL = null;
render();
if(err) msg(err);
showResult(res);
}
function tileEl(cls){
@ -191,17 +199,17 @@
const holder = document.createElement("div");
t.appendChild(holder);
gridEl.prepend(t);
inlineInput(holder, "", (n)=>{
const err = AD.createFolder(PATH, n);
inlineInput(holder, "", async (n)=>{
const res = await AD.createFolder(PATH, n);
render();
if(err) msg(err);
showResult(res);
});
});
if(AD.createItem && (PATH.length || AD.allowRootItems)){
tool(" " + AD.newItemLabel(), ()=>{
const err = AD.createItem(PATH);
tool(" " + AD.newItemLabel(), async ()=>{
const res = await AD.createItem(PATH);
render();
if(err) msg(err);
showResult(res);
});
}
tool("✏ " + T("exRename"), ()=>{
@ -209,24 +217,24 @@
const t = gridEl.querySelector(".ex-tile.sel .ex-name");
if(!t) return;
const cur = SEL.type==="folder" ? SEL.key : t.textContent;
inlineInput(t, cur, (n)=>{
const err = SEL.type==="folder" ? AD.renameFolder(PATH, SEL.key, n) : AD.renameItem(SEL.key, n);
inlineInput(t, cur, async (n)=>{
const res = SEL.type==="folder" ? await AD.renameFolder(PATH, SEL.key, n) : await AD.renameItem(SEL.key, n);
SEL = null;
render();
if(err) msg(err);
showResult(res);
});
});
const delBtn = tool("🗑 " + T("exDelete"), ()=>{
const delBtn = tool("🗑 " + T("exDelete"), async ()=>{
if(!SEL){ msg(T("exSelectFirst")); return; }
if(!delBtn.classList.contains("danger")){
delBtn.classList.add("danger");
delBtn.textContent = "🗑 " + T("exConfirm");
return;
}
const err = SEL.type==="folder" ? AD.deleteFolder(PATH, SEL.key) : AD.deleteItem(SEL.key);
const res = SEL.type==="folder" ? await AD.deleteFolder(PATH, SEL.key) : await AD.deleteItem(SEL.key);
SEL = null;
render();
if(err) msg(err);
showResult(res);
});
/* raster: eerst mappen, dan items */
@ -256,8 +264,17 @@
t.appendChild(ico);
}
t.appendChild(nameEl(it.name));
if(it.hint){
const hn = document.createElement("div");
hn.className = "ex-hint"; hn.textContent = it.hint;
t.appendChild(hn);
}
t.addEventListener("click", ()=>{ SEL = {type:"item", key:it.key}; markSel(t); });
t.addEventListener("dblclick", ()=>{ AD.openItem(it.key); closeExplorer(); });
t.addEventListener("dblclick", async ()=>{
const res = await AD.openItem(it.key);
if(typeof res === "string"){ render(); msg(res); return; }
closeExplorer();
});
makeDraggable(t, {type:"item", key:it.key, movable: it.movable !== false});
gridEl.appendChild(t);
});
@ -276,3 +293,222 @@
if(db){ db.classList.remove("danger"); db.textContent = "🗑 " + T("exDelete"); }
}
})();
/* =========================================================
Gedeelde bibliotheek in de verkenner: wikkelt een "eigen inhoud"-adapter
en voegt in de root twee vaste takken toe: 🌐 Systeem (systeembreed,
gepubliceerd door de systeemmanager) en 🏫 School (gedeeld binnen de
eigen school). Slepen tussen de bomen = kopiëren:
eigen item/map gedeelde tak = publiceren (POST /shared)
gedeeld item/map eigen boom = kopiëren naar eigen collectie
Binnen een gedeelde tak = echt verplaatsen (PATCH, server bewaakt rechten).
hooks (per soort inhoud):
exportItem(key) -> {name, data} | null
exportFolder(path) -> [{name, rel:[...], data}] (alles onder het pad)
copyIn(sharedItem, destPath) -> err|null (gedeeld item in eigen boom zetten)
openShared(sharedItem, relPath)-> err|null (dubbelklik op gedeeld item)
==========================================================*/
function withSharedBranches(kind, own, hooks){
/* gasten en leerlingen zien alleen de eigen boom */
if(!currentUser || currentUser.role === "pupil") return own;
const GLOBAL = "🌐 " + T("shGlobal");
const SCHOOL = "🏫 " + T("shSchool");
const branches = [GLOBAL];
if(currentUser.schoolId != null) branches.push(SCHOOL);
const scopeOf = seg => seg === GLOBAL ? "global" : "school";
const split = n => (n||"").split("/").map(s=>s.trim()).filter(Boolean);
const join = p => p.join("/");
let cache = [];
const pending = { global: new Set(), school: new Set() };
const inScope = scope => cache.filter(it=>it.scope===scope);
const isShared = path => path.length > 0 && branches.includes(path[0]);
const refresh = async ()=>{ cache = (await api("/shared/"+kind)).items || []; };
const wrapErr = e => e && e.message ? e.message : String(e);
async function publishOne(scope, folder, name, data){
await api("/shared/"+kind, { method:"POST", body:{ name, folder, data, scope } });
}
async function fetchShared(id){
return (await api(`/shared/${kind}/${id}`)).item;
}
return {
title: own.title,
allowRootItems: own.allowRootItems,
newItemLabel: own.newItemLabel,
async init(){ try{ await refresh(); }catch(e){ cache = []; } },
list(path){
if(!isShared(path)){
const r = own.list(path);
if(path.length === 0){
branches.forEach(b=>r.folders.push({ name:b, count: inScope(scopeOf(b)).length, fixed:true }));
}
return r;
}
const scope = scopeOf(path[0]);
const sub = path.slice(1);
const p = join(sub);
const folderMap = new Map();
const items = [];
const seen = new Set(pending[scope]);
inScope(scope).forEach(it=>{
const j = join(split(it.folder));
if(j === p){
items.push({
key: "sh:"+it.id, name: it.name,
icon: kind === "board" ? "▦" : "⚓",
hint: it.ownerName || undefined,
});
}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 count = inScope(scope).filter(it=>{ const ij = join(split(it.folder)); return ij === full || ij.startsWith(full + "/"); }).length;
folderMap.set(seg, count);
});
const folders = [...folderMap].map(([name, count])=>({name, count}))
.sort((a,b)=>a.name.localeCompare(b.name));
return { folders, items };
},
createItem: own.createItem ? (path)=>{
if(isShared(path)) return T("shNoCreateHere");
return own.createItem(path);
} : undefined,
createFolder(path, name){
if(!isShared(path)){
if(path.length === 0 && branches.includes(name)) return T("exExists");
return own.createFolder(path, name);
}
const scope = scopeOf(path[0]);
if(this.list(path).folders.some(f=>f.name===name)) return T("exExists");
pending[scope].add(join([...path.slice(1), name]));
return null;
},
async renameFolder(path, oldName, newName){
if(!isShared(path) && !(path.length === 0 && branches.includes(oldName)))
return own.renameFolder(path, oldName, newName);
return this.moveFolder([...path, oldName], path, newName);
},
async moveFolder(srcPath, targetPath, newName){
const srcShared = isShared(srcPath), tgtShared = isShared(targetPath);
if(srcPath.length === 1 && branches.includes(srcPath[0])) return T("exFixedFolder");
if(!srcShared && !tgtShared) return own.moveFolder(srcPath, targetPath, newName);
const last = newName || srcPath[srcPath.length-1];
try{
if(srcShared && tgtShared && scopeOf(srcPath[0]) === scopeOf(targetPath[0])){
/* binnen dezelfde tak: echte verplaatsing via prefix-rewrite */
const scope = scopeOf(srcPath[0]);
const from = join(srcPath.slice(1));
const to = join([...targetPath.slice(1), last]);
if(from === to) return null;
await api(`/shared/${kind}/folder`, { method:"PATCH", body:{ scope, from, to } });
[...pending[scope]].forEach(j=>{
if(j === from || j.startsWith(from + "/")){ pending[scope].delete(j); pending[scope].add(to + j.slice(from.length)); }
});
await refresh();
return null;
}
if(!srcShared && tgtShared){
/* hele eigen map publiceren (kopie; het origineel blijft staan) */
const scope = scopeOf(targetPath[0]);
const base = [...targetPath.slice(1), last];
const list = hooks.exportFolder(srcPath);
for(const it of list) await publishOne(scope, join([...base, ...it.rel]), it.name, it.data);
await refresh();
return { ok: T("shPublished") };
}
/* gedeelde map → eigen boom (of andere tak): itemsgewijs kopiëren */
const scope = scopeOf(srcPath[0]);
const from = join(srcPath.slice(1));
const under = inScope(scope).filter(it=>{ const j = join(split(it.folder)); return j === from || j.startsWith(from + "/"); });
for(const it of under){
const full = await fetchShared(it.id);
const rel = split(it.folder).slice(srcPath.length - 1);
if(tgtShared){
await publishOne(scopeOf(targetPath[0]), join([...targetPath.slice(1), last, ...rel]), full.name, full.data);
}else{
const err = hooks.copyIn(full, [...targetPath, last, ...rel]);
if(err) return err;
}
}
if(tgtShared) await refresh();
return { ok: tgtShared ? T("shPublished") : T("shCopied") };
}catch(e){ return wrapErr(e); }
},
async deleteFolder(path, name){
if(!isShared(path)){
if(path.length === 0 && branches.includes(name)) return T("exFixedFolder");
return own.deleteFolder(path, name);
}
const scope = scopeOf(path[0]);
const full = join([...path.slice(1), name]);
if(inScope(scope).some(it=>{ const j = join(split(it.folder)); return j === full || j.startsWith(full + "/"); }))
return T("exFolderNotEmpty");
[...pending[scope]].forEach(j=>{ if(j === full || j.startsWith(full + "/")) pending[scope].delete(j); });
return null;
},
async renameItem(key, name){
if(typeof key !== "string" || !key.startsWith("sh:")) return own.renameItem(key, name);
try{
await api(`/shared/${kind}/${key.slice(3)}`, { method:"PATCH", body:{ name } });
await refresh();
return null;
}catch(e){ return wrapErr(e); }
},
async moveItem(key, dest){
const srcShared = typeof key === "string" && key.startsWith("sh:");
const tgtShared = isShared(dest);
try{
if(!srcShared && !tgtShared) return own.moveItem(key, dest);
if(!srcShared && tgtShared){
/* eigen item publiceren (kopie) */
const exp = hooks.exportItem(key);
if(!exp) return null;
const scope = scopeOf(dest[0]);
await publishOne(scope, join(dest.slice(1)), exp.name, exp.data);
pending[scope].delete(join(dest.slice(1)));
await refresh();
return { ok: T("shPublished") };
}
const id = key.slice(3);
if(srcShared && tgtShared){
const meta = cache.find(it=>"sh:"+it.id === key);
if(meta && scopeOf(dest[0]) === meta.scope){
await api(`/shared/${kind}/${id}`, { method:"PATCH", body:{ folder: join(dest.slice(1)) } });
pending[meta.scope].delete(join(dest.slice(1)));
await refresh();
return null;
}
const full = await fetchShared(id);
await publishOne(scopeOf(dest[0]), join(dest.slice(1)), full.name, full.data);
await refresh();
return { ok: T("shPublished") };
}
/* gedeeld item → eigen boom: kopiëren */
const full = await fetchShared(id);
const err = hooks.copyIn(full, dest);
return err || { ok: T("shCopied") };
}catch(e){ return wrapErr(e); }
},
async deleteItem(key){
if(typeof key !== "string" || !key.startsWith("sh:")) return own.deleteItem(key);
try{
await api(`/shared/${kind}/${key.slice(3)}`, { method:"DELETE" });
await refresh();
return null;
}catch(e){ return wrapErr(e); }
},
async openItem(key){
if(typeof key !== "string" || !key.startsWith("sh:")) return own.openItem(key);
try{
const full = await fetchShared(key.slice(3));
return hooks.openShared(full, split(full.folder));
}catch(e){ return wrapErr(e); }
},
};
}

View file

@ -344,7 +344,52 @@ function mountAnchor(root, initState, opts){
saveBtn.textContent = "✓";
setTimeout(()=>{ saveBtn.textContent = orig; }, 1200);
});
libBtn.addEventListener("click", ()=>openExplorer(anchorLibAdapter()));
/* koppelstuk voor de gedeelde bibliotheek: diagrammen als {name, data} */
const anchorSharedHooks = ()=>{
const split = n => (n||"").split("/").map(s=>s.trim()).filter(Boolean);
const join = p => p.join("/");
return {
exportItem(i){
const it = ANCHOR_LIB[i];
return it ? { name: it.name, data: { cells: structuredClone(it.cells||{}), pics: structuredClone(it.pics||{}) } } : null;
},
exportFolder(path){
const p = join(path);
const out = [];
ANCHOR_LIB.forEach(it=>{
const j = join(split(it.folder));
if(j === p || j.startsWith(p + "/")){
const rel = j === p ? [] : j.slice(p.length + 1).split("/");
out.push({ name: it.name, rel, data: { cells: structuredClone(it.cells||{}), pics: structuredClone(it.pics||{}) } });
}
});
return out;
},
copyIn(item, destPath){
const entry = {
id: "al-"+Math.random().toString(36).slice(2,10),
name: item.name,
cells: structuredClone(item.data.cells || {}),
pics: structuredClone(item.data.pics || {}),
};
if(destPath.length) entry.folder = join(destPath);
ANCHOR_LIB.push(entry);
scheduleSave("anchors");
return null;
},
openShared(item){
S.cells = structuredClone(item.data.cells || {});
S.pics = structuredClone(item.data.pics || {});
S.pan = {x:0, y:0};
syncSource();
render();
persistWords();
markBoardChange();
return null;
},
};
};
libBtn.addEventListener("click", ()=>openExplorer(withSharedBranches("anchor", anchorLibAdapter(), anchorSharedHooks())));
}
new ResizeObserver(render).observe(stage);