All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 56s
664 lines
28 KiB
JavaScript
664 lines
28 KiB
JavaScript
/* 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 || activeRole() === "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 = activeRoles();
|
||
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); }
|
||
},
|
||
};
|
||
}
|