teach/public/js/explorer.js
Ramon 6d5d4ef520
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 37s
v0.3.30-beta: ankerdiagrammen als vaste tak in de hoofdverkenner
- Nieuwe generieke withBranch-combinator (explorer.js): een tweede adapter
  verschijnt als vaste map in de root van de hoofdboom; item-sleutels worden
  geprefixt en slepen tussen de takken wordt geweigerd (ander soort inhoud)
- De borden-verkenner (📁 in de menubalk) toont nu " Ankerdiagrammen" met
  daarin de eigen diagrammappen én de gedeelde 🌐/🏫-takken voor ankers -
  borden en ankerwoorden op één plek, met dezelfde regels (slepen, kopiëren,
  alleen-lezen, één-klik-mappen)
- Diagram openen vanuit de hoofdverkenner plaatst een nieuw ankerwidget op
  het huidige bord met dat diagram geladen; het 🗂-knopje op een widget
  blijft in dát widget laden (adapter naar module-niveau getild met een
  onOpen-parameter, gedeeld door beide ingangen)
2026-07-15 04:12:43 +02:00

664 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* teach - generieke mappenverkenner (Windows-stijl): één modal met een
broodkruimelpad, werkbalk (nieuwe map / nieuw item / hernoem / verwijder)
en een raster van map- en itemtegels. Klik = selecteren, dubbelklik =
openen, slepen = verplaatsen (pointer-events, werkt ook op touch).
Nesting is virtueel: paden zijn arrays van segmenten; de adapter bepaalt
hoe die op de onderliggende (platte) data worden afgebeeld. Mutaties in de
adapter geven null terug bij succes of een foutmelding (string) voor de
gebruiker.
Adapter-interface:
title() - venstertitel
list(path) - {folders:[{name,count}], items:[{key,name,tile()?,icon?,active?,movable?,deletable?}]}
createFolder(path, name) - nieuwe (lege) map
renameFolder(path, oldName, newName)
moveFolder(srcPath, targetPath)
deleteFolder(path, name) - alleen als leeg
createItem?(path) - optioneel: nieuw item in deze map
newItemLabel?() - label voor de nieuw-item-knop
allowRootItems - mogen items in de root staan (anders alleen mappen)
renameItem(key, name)
moveItem(key, targetPath)
deleteItem(key)
openItem(key) - dubbelklik op een item (modal sluit daarna)
*/
"use strict";
(function(){
const wrap = document.getElementById("explorerWrap");
const titleEl = document.getElementById("explorerTitle");
const crumbsEl = document.getElementById("exCrumbs");
const toolsEl = document.getElementById("exTools");
const msgEl = document.getElementById("exMsg");
const gridEl = document.getElementById("exGrid");
let AD = null; /* actieve adapter */
let PATH = []; /* huidig pad (segmenten) */
let SEL = null; /* selectie: {type:"folder"|"item", key} */
let onCloseCb = null;
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");
};
function closeExplorer(){
wrap.classList.remove("open");
AD = null; SEL = null;
if(onCloseCb){ const cb = onCloseCb; onCloseCb = null; cb(); }
}
wrap.addEventListener("click", e=>{ if(e.target===wrap) closeExplorer(); });
document.querySelector(".explorer-close").addEventListener("click", closeExplorer);
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;
if(n.includes("/")){ msg(T("exNoSlash")); return null; }
if(n.length > 20) return n.slice(0,20);
return n;
};
/* inline invoer in een tegel (nieuwe map / hernoemen) - zelfde
Enter/blur-commit + Escape-annuleer als de bord-hernoem-inputs */
function inlineInput(host, initial, commit){
const inp = document.createElement("input");
inp.value = initial; inp.maxLength = 20;
host.innerHTML = "";
host.appendChild(inp);
inp.focus(); inp.select();
let done = false;
const finish = ok=>{
if(done) return; done = true;
if(ok){ const n = validName(inp.value); if(n){ commit(n); return; } }
render();
};
inp.addEventListener("keydown", ev=>{
ev.stopPropagation();
if(ev.key==="Enter") finish(true);
if(ev.key==="Escape") finish(false);
});
inp.addEventListener("blur", ()=>finish(true));
inp.addEventListener("pointerdown", ev=>ev.stopPropagation());
}
/* slepen: tegel volgt de aanwijzer (ghost), maptegels en broodkruimels
zijn drop-doelen ([data-drop]) en lichten op onder de aanwijzer */
function makeDraggable(el, payload){
el.addEventListener("pointerdown", e=>{
if(e.target.closest("input,button")) return;
const sx = e.clientX, sy = e.clientY;
let dragging = false, ghost = null, target = null;
const move = ev=>{
if(!dragging){
if(Math.hypot(ev.clientX-sx, ev.clientY-sy) < 8) return;
if(payload.movable === false) return;
dragging = true;
ghost = el.cloneNode(true);
ghost.classList.add("ex-ghost");
document.body.appendChild(ghost);
}
ev.preventDefault();
const zf = VZ();
ghost.style.left = (ev.clientX/zf + 10) + "px";
ghost.style.top = (ev.clientY/zf + 10) + "px";
const under = document.elementFromPoint(ev.clientX, ev.clientY);
const drop = under && under.closest("[data-drop]");
if(target && target !== drop) target.classList.remove("ex-drop");
target = (drop && drop !== el) ? drop : null;
if(target) target.classList.add("ex-drop");
};
const up = ()=>{
document.removeEventListener("pointermove", move);
document.removeEventListener("pointerup", up);
if(!dragging) return;
/* de click ná een sleep mag geen map openen/selecteren */
el.dataset.justDragged = "1";
setTimeout(()=>{ delete el.dataset.justDragged; }, 0);
ghost.remove();
if(target){
target.classList.remove("ex-drop");
const dest = target.dataset.drop === "" ? [] : target.dataset.drop.split("/");
doMove(payload, dest);
}
};
document.addEventListener("pointermove", move);
document.addEventListener("pointerup", up);
});
}
async function doMove(payload, dest){
msg("");
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; }
res = await AD.moveFolder(src, dest);
}else{
if(!dest.length && !AD.allowRootItems){ msg(T("exNoRootItems")); return; }
res = await AD.moveItem(payload.key, dest);
}
SEL = null;
render();
showResult(res);
}
function tileEl(cls){
const d = document.createElement("div");
d.className = "ex-tile " + cls;
return d;
}
const nameEl = (t)=>{ const n = document.createElement("div"); n.className = "ex-name"; n.textContent = t; return n; };
/* let op: render() wist de melding niet - mutatie-handlers zetten hun
foutmelding juist ná de her-render (anders verdween die meteen weer) */
function render(){
if(!AD) return;
titleEl.textContent = AD.title();
/* broodkruimels: 🏠-root + één knop per segment, allemaal drop-doel */
crumbsEl.innerHTML = "";
const crumb = (label, path)=>{
const b = document.createElement("button");
b.type = "button"; b.className = "ex-crumb";
b.textContent = label;
b.dataset.drop = path.join("/");
b.addEventListener("click", ()=>{ PATH = path; SEL = null; render(); msg(""); });
crumbsEl.appendChild(b);
return b;
};
crumb("🏠", []);
PATH.forEach((seg, i)=>{
const sep = document.createElement("span");
sep.className = "ex-sep"; sep.textContent = "";
crumbsEl.appendChild(sep);
crumb(seg, PATH.slice(0, i+1));
});
/* werkbalk */
toolsEl.innerHTML = "";
const tool = (label, fn)=>{
const b = document.createElement("button");
b.type = "button"; b.className = "tbtn ghost";
b.textContent = label;
b.addEventListener("click", fn);
toolsEl.appendChild(b);
return b;
};
tool(" " + T("exFolder"), ()=>{
const t = tileEl("folder");
const ico = document.createElement("div");
ico.className = "ex-ico"; ico.textContent = "📁";
t.appendChild(ico);
const holder = document.createElement("div");
t.appendChild(holder);
gridEl.prepend(t);
inlineInput(holder, "", async (n)=>{
const res = await AD.createFolder(PATH, n);
render();
showResult(res);
});
});
if(AD.createItem && (PATH.length || AD.allowRootItems)){
tool(" " + AD.newItemLabel(), async ()=>{
const res = await AD.createItem(PATH);
render();
showResult(res);
});
}
/* mappen openen met één klik, dus zonder selectie werken hernoemen en
verwijderen op de map waar je nu in staat (het laatste broodkruimel) */
tool("✏ " + T("exRename"), ()=>{
if(!SEL){
if(!PATH.length){ msg(T("exSelectFirst")); return; }
const crumbs = crumbsEl.querySelectorAll(".ex-crumb");
const last = crumbs[crumbs.length-1];
const oldName = PATH[PATH.length-1];
inlineInput(last, oldName, async (n)=>{
const res = await AD.renameFolder(PATH.slice(0,-1), oldName, n);
if(!res) PATH = [...PATH.slice(0,-1), n];
render();
showResult(res);
});
return;
}
const t = gridEl.querySelector(".ex-tile.sel .ex-name");
if(!t) return;
inlineInput(t, t.textContent, async (n)=>{
const res = await AD.renameItem(SEL.key, n);
SEL = null;
render();
showResult(res);
});
});
const delBtn = tool("🗑 " + T("exDelete"), async ()=>{
if(!SEL && !PATH.length){ msg(T("exSelectFirst")); return; }
if(!delBtn.classList.contains("danger")){
delBtn.classList.add("danger");
delBtn.textContent = "🗑 " + T("exConfirm");
return;
}
let res;
if(SEL){ res = await AD.deleteItem(SEL.key); }
else{
res = await AD.deleteFolder(PATH.slice(0,-1), PATH[PATH.length-1]);
if(!res) PATH = PATH.slice(0,-1);
}
SEL = null;
render();
showResult(res);
});
/* optioneel (gedeelde school-tak): alleen-lezen aan/uit op het
geselecteerde item of anders op de huidige map */
if(AD.lockLabel){
const ll = AD.lockLabel(PATH);
if(ll) tool(ll, async ()=>{
const res = await AD.toggleLock(SEL, PATH);
SEL = null;
render();
showResult(res);
});
}
/* raster: eerst mappen, dan items */
gridEl.innerHTML = "";
const { folders, items } = AD.list(PATH);
folders.forEach(f=>{
const t = tileEl("folder");
t.dataset.drop = [...PATH, f.name].join("/");
const ico = document.createElement("div");
ico.className = "ex-ico"; ico.textContent = "📁";
t.appendChild(ico);
t.appendChild(nameEl(f.name));
const c = document.createElement("div");
c.className = "ex-count"; c.textContent = (f.locked ? "🔒 " : "") + f.count;
t.appendChild(c);
/* één klik opent de map (Windows-webstijl); slepen blijft werken via
de bewegingsdrempel, en de click direct ná een sleep wordt genegeerd */
t.addEventListener("click", ()=>{
if(t.dataset.justDragged) return;
PATH = [...PATH, f.name]; SEL = null; render(); msg("");
});
makeDraggable(t, {type:"folder", key:f.name, movable: f.fixed !== true});
gridEl.appendChild(t);
});
items.forEach(it=>{
const t = tileEl("item" + (it.active ? " active-item" : "") + (SEL && SEL.type==="item" && SEL.key===it.key ? " sel" : ""));
if(it.tile){ t.appendChild(it.tile()); }
else{
const ico = document.createElement("div");
ico.className = "ex-ico"; ico.textContent = it.icon || "📄";
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", ()=>{
if(t.dataset.justDragged) return;
SEL = {type:"item", key:it.key}; markSel(t);
});
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);
});
if(!folders.length && !items.length){
const d = document.createElement("div");
d.className = "guestnote"; d.textContent = T("exEmpty");
gridEl.appendChild(d);
}
}
/* selectie visueel bijwerken zonder volledige her-render (behoudt dblclick) */
function markSel(tile){
gridEl.querySelectorAll(".ex-tile.sel").forEach(x=>x.classList.remove("sel"));
tile.classList.add("sel");
/* een nieuwe selectie ont-wapent de verwijderknop */
const db = [...toolsEl.children].find(b=>b.classList.contains("danger"));
if(db){ db.classList.remove("danger"); db.textContent = "🗑 " + T("exDelete"); }
}
})();
/* =========================================================
Combineert twee adapters tot één boom: sub verschijnt als vaste map
(rootLabel) in de root van main. Slepen tussen de twee takken wordt
geweigerd (ander soort inhoud). Item-sleutels van sub worden geprefixt
zodat ze nooit botsen met die van main.
opts: { wrongTypeMsg, branchCreateMsg, branchCount() }
==========================================================*/
function withBranch(rootLabel, main, sub, opts){
const ENC = "br|";
const enc = k => ENC + String(k);
const dec = k => { const raw = String(k).slice(ENC.length); return /^\d+$/.test(raw) ? +raw : raw; };
const isEnc = k => typeof k === "string" && k.startsWith(ENC);
const inBranch = path => path.length > 0 && path[0] === rootLabel;
const wrongType = ()=>(opts && opts.wrongTypeMsg) || T("exExists");
const shiftSel = sel => (sel && sel.type === "item" && isEnc(sel.key)) ? { type:"item", key: dec(sel.key) } : sel;
return {
title: main.title,
allowRootItems: main.allowRootItems,
newItemLabel: main.newItemLabel,
async init(){
if(main.init) await main.init();
if(sub.init) await sub.init();
},
list(path){
if(inBranch(path)){
const r = sub.list(path.slice(1));
return { folders: r.folders, items: r.items.map(it=>({ ...it, key: enc(it.key) })) };
}
const r = main.list(path);
if(path.length === 0){
r.folders.push({ name: rootLabel, count: opts && opts.branchCount ? opts.branchCount() : "", fixed: true });
}
return r;
},
createItem: main.createItem ? (path)=>{
if(inBranch(path)) return (opts && opts.branchCreateMsg) || T("shNoCreateHere");
return main.createItem(path);
} : undefined,
createFolder(path, name){
if(inBranch(path)) return sub.createFolder(path.slice(1), name);
if(path.length === 0 && name === rootLabel) return T("exExists");
return main.createFolder(path, name);
},
renameFolder(path, oldName, newName){
if(path.length === 0 && oldName === rootLabel) return T("exFixedFolder");
return inBranch(path) ? sub.renameFolder(path.slice(1), oldName, newName) : main.renameFolder(path, oldName, newName);
},
moveFolder(srcPath, targetPath, newName){
if(srcPath.length === 1 && srcPath[0] === rootLabel) return T("exFixedFolder");
const s = inBranch(srcPath), t = inBranch(targetPath);
if(s !== t) return wrongType();
return s ? sub.moveFolder(srcPath.slice(1), targetPath.slice(1), newName) : main.moveFolder(srcPath, targetPath, newName);
},
deleteFolder(path, name){
if(path.length === 0 && name === rootLabel) return T("exFixedFolder");
return inBranch(path) ? sub.deleteFolder(path.slice(1), name) : main.deleteFolder(path, name);
},
renameItem(k, name){ return isEnc(k) ? sub.renameItem(dec(k), name) : main.renameItem(k, name); },
moveItem(k, dest){
const s = isEnc(k), t = inBranch(dest);
if(s !== t) return wrongType();
return s ? sub.moveItem(dec(k), dest.slice(1)) : main.moveItem(k, dest);
},
deleteItem(k){ return isEnc(k) ? sub.deleteItem(dec(k)) : main.deleteItem(k); },
openItem(k){ return isEnc(k) ? sub.openItem(dec(k)) : main.openItem(k); },
lockLabel(path){
if(inBranch(path)) return sub.lockLabel ? sub.lockLabel(path.slice(1)) : null;
return main.lockLabel ? main.lockLabel(path) : null;
},
toggleLock(sel, path){
if(inBranch(path)) return sub.toggleLock ? sub.toggleLock(shiftSel(sel), path.slice(1)) : null;
return main.toggleLock ? main.toggleLock(sel, path) : null;
},
};
}
/* =========================================================
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.canManage ? "" : "🔒", it.ownerName || ""].filter(Boolean).join(" ") || 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 under = inScope(scope).filter(it=>{ const ij = join(split(it.folder)); return ij === full || ij.startsWith(full + "/"); });
folderMap.set(seg, { count: under.length, locked: under.length > 0 && under.some(it=>!it.canManage) });
});
const folders = [...folderMap].map(([name, v])=>({name, count: v.count, locked: v.locked}))
.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;
const under = inScope(scope).filter(it=>{ const j = join(split(it.folder)); return j === from || j.startsWith(from + "/"); });
if(under.some(it=>!it.canManage)) return T("shReadonly");
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);
const meta = cache.find(it=>"sh:"+it.id === key);
if(meta && !meta.canManage) return T("shReadonly");
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){
if(!meta.canManage) return T("shReadonly");
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);
const meta = cache.find(it=>"sh:"+it.id === key);
if(meta && !meta.canManage) return T("shReadonly");
try{
await api(`/shared/${kind}/${key.slice(3)}`, { method:"DELETE" });
await refresh();
return null;
}catch(e){ return wrapErr(e); }
},
/* alleen-lezen aan/uit in de school-tak: op het geselecteerde item, of
zonder selectie op de map waar je nu in staat (alle items eronder) */
lockLabel(path){
if(!isShared(path) || scopeOf(path[0]) !== "school") return null;
const roles = [currentUser.role, ...(currentUser.extraRoles||[])];
if(!roles.includes("admin") && !roles.includes("super")) return null;
return "🔒 " + T("exLock");
},
async toggleLock(sel, path){
try{
if(sel && sel.type === "item" && String(sel.key).startsWith("sh:")){
const meta = cache.find(it=>"sh:"+it.id === sel.key);
if(!meta) return null;
await api(`/shared/${kind}/${meta.id}`, { method:"PATCH", body:{ readonly: !meta.readonly } });
await refresh();
return { ok: meta.readonly ? T("shUnlockedOk") : T("shLockedOk") };
}
const sub = path.slice(1);
if(!sub.length) return T("exSelectFirst");
const from = join(sub);
const under = inScope("school").filter(it=>{ const j = join(split(it.folder)); return j === from || j.startsWith(from + "/"); });
if(!under.length) return T("exEmpty");
const lock = under.some(it=>!it.readonly);
await api(`/shared/${kind}/folder`, { method:"PATCH", body:{ scope: "school", from, readonly: lock } });
await refresh();
return { ok: lock ? T("shLockedOk") : T("shUnlockedOk") };
}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); }
},
};
}