/* 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); } }, }; }