teach/public/js/explorer.js
Ramon 1151c71049 v0.3.22-beta: Windows-stijl mappenverkenner voor borden
- Nieuw public/js/explorer.js: generieke verkenner-modal met broodkruimelpad,
  werkbalk (nieuwe map/nieuw item/hernoem/verwijder met bevestigingsstap),
  raster van map- en itemtegels, dubbelklik om te openen en slepen om te
  verplaatsen (pointer-events, werkt op touch; drop-doel licht op)
- Geneste mappen zonder datamigratie: nesting zit in de mapnaam als pad met
  "/"-scheiding; server en toewijzingen (bord-id's) blijven onaangeroerd;
  "/" wordt geweigerd in nieuwe namen
- Borden-adapter in board.js: bordtegels met boardThumb-preview, actief bord
  gemarkeerd en gevolgd bij verplaatsen/hernoemen (prefix-rewrite), map
  verwijderen alleen als leeg, laatste bord onverwijderbaar
- 📁-knop in de menubalk opent de verkenner; het oude mappenpaneel
  (renderBoardsPanel + #boardsPanel + CSS) is vervangen; de boardStrip-
  snelwissel blijft; curSlot() heelt lege actieve mappen zelf
2026-07-14 21:58:24 +02:00

278 lines
11 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 = function(adapter, onClose){
AD = adapter; PATH = []; SEL = null; onCloseCb = onClose || null;
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.textContent = t || ""; };
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;
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);
});
}
function doMove(payload, dest){
msg("");
let err = 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);
}else{
if(!dest.length && !AD.allowRootItems){ msg(T("exNoRootItems")); return; }
err = AD.moveItem(payload.key, dest);
}
SEL = null;
render();
if(err) msg(err);
}
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, "", (n)=>{
const err = AD.createFolder(PATH, n);
render();
if(err) msg(err);
});
});
if(AD.createItem && (PATH.length || AD.allowRootItems)){
tool(" " + AD.newItemLabel(), ()=>{
const err = AD.createItem(PATH);
render();
if(err) msg(err);
});
}
tool("✏ " + T("exRename"), ()=>{
if(!SEL){ msg(T("exSelectFirst")); 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, (n)=>{
const err = SEL.type==="folder" ? AD.renameFolder(PATH, SEL.key, n) : AD.renameItem(SEL.key, n);
SEL = null;
render();
if(err) msg(err);
});
});
const delBtn = tool("🗑 " + T("exDelete"), ()=>{
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);
SEL = null;
render();
if(err) msg(err);
});
/* 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" : ""));
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;
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(""); });
makeDraggable(t, {type:"folder", key:f.name});
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));
t.addEventListener("click", ()=>{ SEL = {type:"item", key:it.key}; markSel(t); });
t.addEventListener("dblclick", ()=>{ AD.openItem(it.key); 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"); }
}
})();