teach/public/js/explorer.js
Ramon e327049f1b
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 33s
v0.3.23-beta: beheer woordthema's in dezelfde mappenverkenner
- 🗂-knop in de woorden-editor opent de Windows-stijl verkenner met de
  thema's van de actieve taal (★-tegels met woordaantal); dubbelklik
  selecteert het thema in de editor
- Geneste mappen via het folder-veld als pad; nieuwe lege mappen bestaan
  sessie-lokaal tot er een thema in wordt gezet (mappen zijn afgeleid van
  de themavelden)
- Ankerthema's staan vast in de map "": niet versleepbaar, hernoemen/
  verwijderen geweigerd (anchor.js beheert ze)
- Het 📁-knopje + inline map-invoer uit v0.3.21 zijn vervangen door de
  verkenner; de optgroup-weergave in de dropdowns blijft en toont het pad
2026-07-14 22:01:59 +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, 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));
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"); }
}
})();