diff --git a/VERSION b/VERSION index 29aaad3..f4b22a8 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.28-beta +0.3.29-beta diff --git a/public/js/core.js b/public/js/core.js index b8db1c1..df48ed7 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -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.28-beta"; +const VERSION = "0.3.29-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -88,6 +88,8 @@ const I18N = { 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.", + shReadonly:"Alleen lezen - kopieer het naar je eigen mappen om te bewerken.", + exLock:"Alleen lezen", shLockedOk:"Vergrendeld ✓", shUnlockedOk:"Ontgrendeld ✓", hudMin:"Menu inklappen/uitklappen", hudOrient:"Horizontaal of verticaal", wFull:"Maximaliseren", wUnfull:"Terugzetten", wZoom:"Inhoud groter of kleiner (voor slechtzienden)", @@ -267,6 +269,8 @@ const I18N = { 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.", + shReadonly:"Read-only - copy it to your own folders to edit.", + exLock:"Read-only", shLockedOk:"Locked ✓", shUnlockedOk:"Unlocked ✓", hudMin:"Collapse/expand menu", hudOrient:"Horizontal or vertical", wFull:"Maximize", wUnfull:"Restore", wZoom:"Content bigger or smaller (low vision)", diff --git a/public/js/explorer.js b/public/js/explorer.js index 2f9d2f4..af09041 100644 --- a/public/js/explorer.js +++ b/public/js/explorer.js @@ -121,6 +121,9 @@ 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"); @@ -212,46 +215,79 @@ 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){ msg(T("exSelectFirst")); return; } + 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; - const cur = SEL.type==="folder" ? SEL.key : t.textContent; - inlineInput(t, cur, async (n)=>{ - const res = SEL.type==="folder" ? await AD.renameFolder(PATH, SEL.key, n) : await AD.renameItem(SEL.key, n); + 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){ msg(T("exSelectFirst")); return; } + if(!SEL && !PATH.length){ msg(T("exSelectFirst")); return; } if(!delBtn.classList.contains("danger")){ delBtn.classList.add("danger"); delBtn.textContent = "🗑 " + T("exConfirm"); return; } - const res = SEL.type==="folder" ? await AD.deleteFolder(PATH, SEL.key) : await AD.deleteItem(SEL.key); + 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" + (SEL && SEL.type==="folder" && SEL.key===f.name ? " sel" : "")); + 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.count; + c.className = "ex-count"; c.textContent = (f.locked ? "🔒 " : "") + f.count; t.appendChild(c); - t.addEventListener("click", ()=>{ SEL = {type:"folder", key:f.name}; markSel(t); }); - t.addEventListener("dblclick", ()=>{ PATH = [...PATH, f.name]; SEL = null; render(); msg(""); }); + /* éé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); }); @@ -269,7 +305,10 @@ hn.className = "ex-hint"; hn.textContent = it.hint; t.appendChild(hn); } - t.addEventListener("click", ()=>{ SEL = {type:"item", key:it.key}; markSel(t); }); + 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; } @@ -357,7 +396,7 @@ function withSharedBranches(kind, own, hooks){ items.push({ key: "sh:"+it.id, name: it.name, icon: kind === "board" ? "▦" : "⚓", - hint: it.ownerName || undefined, + hint: [it.canManage ? "" : "🔒", it.ownerName || ""].filter(Boolean).join(" ") || undefined, }); }else if(!p || j.startsWith(p + "/")) seen.add(j); }); @@ -368,10 +407,10 @@ function withSharedBranches(kind, own, hooks){ 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 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, count])=>({name, count})) + const folders = [...folderMap].map(([name, v])=>({name, count: v.count, locked: v.locked})) .sort((a,b)=>a.name.localeCompare(b.name)); return { folders, items }; }, @@ -406,6 +445,8 @@ function withSharedBranches(kind, own, hooks){ 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)); } @@ -454,6 +495,8 @@ function withSharedBranches(kind, own, hooks){ }, 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(); @@ -479,6 +522,7 @@ function withSharedBranches(kind, own, hooks){ 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(); @@ -497,12 +541,42 @@ function withSharedBranches(kind, own, hooks){ }, 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{