From 1151c7104928dd289d19d87f212a19fb5a478b0c Mon Sep 17 00:00:00 2001 From: Ramon Date: Tue, 14 Jul 2026 21:58:24 +0200 Subject: [PATCH] v0.3.22-beta: Windows-stijl mappenverkenner voor borden MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- VERSION | 2 +- public/css/teach.css | 78 +++++------ public/index.html | 13 +- public/js/board.js | 304 +++++++++++++++++++----------------------- public/js/core.js | 18 ++- public/js/explorer.js | 278 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 476 insertions(+), 217 deletions(-) create mode 100644 public/js/explorer.js diff --git a/VERSION b/VERSION index 9742761..139b21a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.21-beta +0.3.22-beta diff --git a/public/css/teach.css b/public/css/teach.css index 42068c1..48dcf51 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -131,7 +131,7 @@ heeft toegewezen, als vaste (niet-sleepbare) kaarten in speelmodus */ #pupilView{display:none;} body.pupil-mode #board,body.pupil-mode #fab,body.pupil-mode #fabBoardsBtn, - body.pupil-mode #boardStrip,body.pupil-mode #boardsPanel,body.pupil-mode #fabmenu, + body.pupil-mode #boardStrip,body.pupil-mode #fabmenu, body.pupil-mode #hud,body.pupil-mode #btnFolders,body.pupil-mode #btnSave{display:none !important;} body.pupil-mode #pupilView{ display:flex; flex-wrap:wrap; gap:18px; align-content:flex-start; @@ -209,41 +209,40 @@ color:#fff; background:var(--green); border-color:var(--green); width:90px; min-height:130px; font-weight:900; } - /* folder management panel (save menu) */ - #boardsPanel{ - position:fixed; right:16px; top:64px; z-index:5600; width:300px; max-height:66vh; - overflow:auto; display:none; flex-direction:column; gap:8px; padding:12px; - border-radius:16px; background:rgba(255,255,255,.96); backdrop-filter:blur(12px); - box-shadow:0 10px 30px rgba(30,50,90,.3); border:1px solid rgba(255,255,255,.7); + /* mappenverkenner (Windows-stijl, vervangt het oude mappenpaneel) */ + .modal.explorer{width:min(860px,94vw);} + .ex-bar{display:flex; gap:10px; align-items:center; margin:10px 0 4px; flex-wrap:wrap;} + .ex-crumbs{display:flex; gap:4px; align-items:center; flex:1; min-width:0; flex-wrap:wrap;} + .ex-crumb{ + border:none; background:var(--surface-2); border-radius:8px; padding:6px 10px; + cursor:pointer; font:inherit; font-weight:700; font-size:13.5px; } - #boardsPanel.open{display:flex;} - .bp-folder{display:flex; align-items:center; gap:6px; font-weight:800; font-size:14px; margin-top:4px;} - .bp-folder .bpf-name{flex:1; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;} - .bp-folder button{ - border:none; background:var(--surface-2); border-radius:8px; padding:4px 8px; font:inherit; - font-weight:800; font-size:11.5px; cursor:pointer; color:var(--muted); flex:none; + .ex-crumb:hover{background:var(--surface-3);} + .ex-sep{color:var(--muted); font-weight:800;} + .ex-tools{display:flex; gap:8px; flex-wrap:wrap;} + .ex-msg{min-height:18px; color:var(--red); font-size:13px; font-weight:700;} + .ex-grid{ + display:grid; grid-template-columns:repeat(auto-fill,minmax(150px,1fr)); + gap:12px; margin-top:8px; } - .bp-folder button:hover{background:var(--surface-3); color:var(--ink);} - .bp-boards{display:flex; flex-wrap:wrap; gap:6px;} - .bp-board{ - display:flex; align-items:center; gap:5px; border:1px solid var(--line); background:var(--surface); - border-radius:10px; padding:5px 10px; font:inherit; font-weight:700; font-size:12.5px; - cursor:pointer; color:var(--muted); + .ex-tile{ + border:1.5px solid var(--line); border-radius:14px; padding:10px; background:var(--surface); + display:flex; flex-direction:column; align-items:center; gap:6px; cursor:pointer; + user-select:none; touch-action:none; min-height:110px; justify-content:center; } - .bp-board.active{border-color:var(--accent); background:var(--accent-soft); color:var(--accent-ink);} - .bp-board .bpb-x{border:none; background:transparent; color:var(--red); font-weight:900; - cursor:pointer; padding:0; font-size:11px; opacity:.8;} - .bp-board:hover .bpb-x{opacity:1;} - .bp-board .bpb-mv{color:var(--muted); font-weight:400;} - .bp-board .bpb-mv-sel{font:inherit; max-width:150px;} - .bp-board input{border:none; outline:none; font:inherit; width:80px; background:transparent;} - .bp-folder input{border:none; outline:none; font:inherit; width:110px; background:transparent; border-bottom:1px solid var(--line);} - .bp-new{display:flex; gap:6px; margin-top:6px;} - .bp-new input{ - flex:1; min-width:0; border:1px solid var(--line); border-radius:10px; padding:7px 9px; - font:inherit; font-size:13px; outline:none; + .ex-tile:hover{border-color:var(--blue);} + .ex-tile.sel{border-color:var(--blue); background:var(--accent-soft);} + .ex-tile.active-item{border-color:var(--accent); border-width:2.5px;} + .ex-tile .ex-ico{font-size:36px; line-height:1;} + .ex-tile canvas{max-width:100%; border-radius:8px; border:1px solid var(--line);} + .ex-name{font-weight:700; font-size:13.5px; text-align:center; word-break:break-word;} + .ex-count{font-size:12px; color:var(--muted); font-weight:700;} + .ex-tile input{ + border:2px solid var(--blue); border-radius:8px; font:inherit; font-size:13.5px; + width:100%; outline:none; padding:4px 6px; text-align:center; } - .bp-hint{font-size:11px; color:var(--muted); font-weight:700;} + .ex-ghost{position:fixed; z-index:7000; opacity:.75; pointer-events:none; width:150px; left:-999px;} + .ex-drop{outline:3px solid var(--blue); outline-offset:2px;} #fabmenu.open{display:flex;} .fab-item{ display:flex; align-items:center; gap:10px; border:none; @@ -922,7 +921,7 @@ body.hc .lg-ed-form input[type=text],body.hc .ed-pictorow input{border-color:#000; color:#000;} body.hc .np-result{background:#0047ab;} body.hc .np-row label,body.hc .thint,body.hc .guestnote,body.hc .ed-credit, - body.hc .bp-hint,body.hc .gcard .gd,body.hc .st-footer{color:#222;} + body.hc .gcard .gd,body.hc .st-footer{color:#222;} body.hc .gcard{border-color:#000; background:var(--surface);} body.hc .gcat-title{color:#000;} body.hc .tbtn.ghost{background:var(--surface); border:2px solid #000; color:#000;} @@ -935,8 +934,6 @@ body.hc .timer .presets button,body.hc .timer .main button,body.hc .ticon, body.hc .hud-btn,body.hc .mm-seg button{background:var(--surface); border:1.5px solid #000; color:#000;} body.hc .timer .main .bstart{background:#006b2d; border-color:#006b2d; color:#fff;} - body.hc .bp-board{border-color:#000; color:#000;} - body.hc .bp-board.active{background:#ffd400;} body.hc .dock-item{color:#000;} body.hc .media-msg{color:#ddd;} /* =========== Flash words widget =========== */ @@ -1182,9 +1179,6 @@ body.hc .am-reset,body.hc .am-reset-row{border:1.5px solid #000; background:var( /* ---------- tablet (≤1024px) ---------- */ @media (max-width:1024px){ - /* #boardsPanel had een vaste 300px-breedte die op smallere - schermen overflowt; #boardStrip deed dit al goed. */ - #boardsPanel{width:min(300px, calc(100vw - 32px));} } /* ---------- telefoon (≤600px) ---------- */ @@ -1193,7 +1187,6 @@ body.hc .am-reset,body.hc .am-reset-row{border:1.5px solid #000; background:var( /* alleen de uil op kleine schermen, woordmerk verdwijnt */ .logo-word{display:none;} .tbtn{padding:0 10px; font-size:13px;} - #boardsPanel{top:calc(var(--tbar) + 2px); max-height:min(66vh,480px);} /* beheerpaneel: dichte rijen worden gestapeld i.p.v. verdrukt */ .am-row{flex-direction:column; align-items:stretch;} @@ -1240,8 +1233,7 @@ body.tb-left #board,body.tb-left #pupilView{inset:0 0 0 var(--tbar);} body.tb-right #board,body.tb-right #pupilView{inset:0 var(--tbar) 0 0;} body.tb-right #fab{right:calc(var(--tbar) + 16px);} body.tb-right #fabBoardsBtn{right:calc(var(--tbar) + 88px);} -body.tb-right #boardStrip,body.tb-right #boardsPanel{right:calc(var(--tbar) + 12px);} -body.tb-left #boardsPanel,body.tb-right #boardsPanel{top:12px;} +body.tb-right #boardStrip{right:calc(var(--tbar) + 12px);} body.tb-left #fabmenu{right:24px;} body.tb-right #fabmenu{right:calc(var(--tbar) + 16px);} @@ -1250,7 +1242,7 @@ body.tb-right #fabmenu{right:calc(var(--tbar) + 16px);} i.p.v. blur - rustiger en lichter voor oudere digiborden. ============================================================ */ body.no-glass .widget,body.no-glass #hud,body.no-glass .fab-item, -body.no-glass #boardStrip,body.no-glass #boardsPanel,body.no-glass .mm-top, +body.no-glass #boardStrip,body.no-glass .mm-top, body.no-glass .widget.bare .widget-head{ backdrop-filter:none; -webkit-backdrop-filter:none; background:var(--surface); @@ -1277,7 +1269,7 @@ body.dark .widget-head{background:rgba(255,255,255,.04); border-bottom-color:rgb body.dark #hud{background:var(--glass); border-color:var(--glass-line);} body.dark .fab-item{background:var(--glass-strong); color:var(--ink);} body.dark .fab-item:hover{background:var(--surface-3);} -body.dark #boardStrip,body.dark #boardsPanel{background:var(--glass-strong); border-color:var(--glass-line);} +body.dark #boardStrip{background:var(--glass-strong); border-color:var(--glass-line);} body.dark .widget.bare .widget-head{background:var(--glass-strong);} body.dark .mm-top{background:var(--glass-strong);} body.dark .mm-pop{background:var(--surface); box-shadow:var(--shadow-2);} diff --git a/public/index.html b/public/index.html index 40e21c6..a262f77 100644 --- a/public/index.html +++ b/public/index.html @@ -31,7 +31,17 @@
-
+
@@ -233,6 +243,7 @@ + diff --git a/public/js/board.js b/public/js/board.js index ca6de9c..4ec3daf 100644 --- a/public/js/board.js +++ b/public/js/board.js @@ -991,7 +991,14 @@ function boardsFromData(data){ } function curSlot(){ if(BS.f >= BS.folders.length) BS.f = 0; - const fo = BS.folders[BS.f]; + let fo = BS.folders[BS.f]; + /* lege mappen bestaan sinds de verkenner: wijs dan uit naar een map mét + borden, of geef de actieve lege map alsnog een vers bord */ + if(!fo.boards.length){ + const fi = BS.folders.findIndex(f=>f.boards.length); + if(fi >= 0){ BS.f = fi; fo = BS.folders[BS.f]; } + else fo.boards.push(newBoardEntry(T("boardName")+" 1")); + } if(BS.b >= fo.boards.length) BS.b = 0; return fo.boards[BS.b]; } @@ -1007,23 +1014,143 @@ function switchTo(f, b){ BS.f = f; BS.b = b; loadCurrentSlot(); } -const boardsPanel = document.getElementById("boardsPanel"); const btnFolders = document.getElementById("btnFolders"); btnFolders.addEventListener("click", ()=>{ - renderBoardsPanel(); - boardsPanel.classList.toggle("open"); -}); -document.addEventListener("pointerdown", e=>{ - if(!boardsPanel.classList.contains("open")) return; - if(e.target.closest("#btnFolders") || e.target.closest("#boardsPanel")) return; - boardsPanel.classList.remove("open"); + stashCurrent(); /* verse thumbnails, ook van het actieve bord */ + openExplorer(boardsExplorerAdapter()); }); function updateBoardsUI(){ btnFolders.title = `${T("boardsNav")} · ${BS.folders[BS.f].name} / ${curSlot().name}`; fabBoardsBtn.title = `${BS.folders[BS.f].name} / ${curSlot().name}`; - if(boardsPanel.classList.contains("open")) renderBoardsPanel(); if(boardStrip.classList.contains("open")) renderStrip(); } + +/* ---- verkenner-adapter: virtuele boom over de platte BS.folders-lijst; + nesting zit in de mapnaam als pad met "/"-scheiding ("Rekenen/Groep 4"), + dus bestaande data en de server (findBoardById op bord-id) blijven werken */ +const splitBoardPath = n => n.split("/").map(s=>s.trim()).filter(Boolean); +const joinBoardPath = p => p.join("/"); +function boardsExplorerAdapter(){ + const norm = f => joinBoardPath(splitBoardPath(f.name)); + const entryAt = path => BS.folders.find(f=>norm(f)===joinBoardPath(path)); + const findBoard = id => { + for(let fi=0; fib.id===id); + if(bi >= 0) return { fi, bi, fo: BS.folders[fi], bo: BS.folders[fi].boards[bi] }; + } + return null; + }; + const activeId = ()=>curSlot().id; + /* indices herstellen nadat entries verschoven/verwijderd zijn */ + const relocate = (id)=>{ + const hit = findBoard(id); + if(hit){ BS.f = hit.fi; BS.b = hit.bi; } + }; + const mutated = ()=>{ markBoardChange(); updateBoardsUI(); }; + return { + title: ()=>T("boardsNav"), + allowRootItems: false, + newItemLabel: ()=>T("newBoard"), + list(path){ + const p = joinBoardPath(path); + const folderMap = new Map(); + let items = []; + BS.folders.forEach((f, fi)=>{ + const j = norm(f); + if(j === p){ + items = f.boards.map((bo, bi)=>({ + key: bo.id, name: bo.name, + active: fi===BS.f && bi===BS.b, + tile: ()=>boardThumb((fi===BS.f && bi===BS.b) ? serializeBoard() : bo.data), + })); + }else if(!p || j.startsWith(p + "/")){ + const seg = (p ? j.slice(p.length + 1) : j).split("/")[0]; + if(seg) folderMap.set(seg, (folderMap.get(seg)||0) + f.boards.length); + } + }); + const folders = [...folderMap].map(([name, count])=>({name, count})) + .sort((a,b)=>a.name.localeCompare(b.name)); + return { folders, items }; + }, + createFolder(path, name){ + const full = [...path, name]; + if(entryAt(full) || this.list(path).folders.some(f=>f.name===name)) return T("exExists"); + BS.folders.push({ name: joinBoardPath(full), boards: [] }); + mutated(); + return null; + }, + renameFolder(path, oldName, newName){ + return this.moveFolder([...path, oldName], path, newName); + }, + moveFolder(srcPath, targetPath, newName){ + const from = joinBoardPath(srcPath); + const to = joinBoardPath([...targetPath, newName || srcPath[srcPath.length-1]]); + if(from === to) return null; + if(BS.folders.some(f=>{ const j = norm(f); return j === to || j.startsWith(to + "/"); })) return T("exExists"); + const id = activeId(); + BS.folders.forEach(f=>{ + const j = norm(f); + if(j === from || j.startsWith(from + "/")) f.name = to + j.slice(from.length); + }); + relocate(id); + mutated(); + return null; + }, + deleteFolder(path, name){ + const full = joinBoardPath([...path, name]); + const under = BS.folders.filter(f=>{ const j = norm(f); return j === full || j.startsWith(full + "/"); }); + if(under.some(f=>f.boards.length)) return T("exFolderNotEmpty"); + const id = activeId(); + BS.folders = BS.folders.filter(f=>!under.includes(f)); + if(!BS.folders.length) BS.folders.push({ name: T("folderName")+" 1", boards: [] }); + relocate(id); + mutated(); + return null; + }, + createItem(path){ + let entry = entryAt(path); + if(!entry){ entry = { name: joinBoardPath(path), boards: [] }; BS.folders.push(entry); } + entry.boards.push(newBoardEntry(`${T("boardName")} ${entry.boards.length+1}`)); + mutated(); + return null; + }, + renameItem(id, name){ + const hit = findBoard(id); + if(!hit) return null; + hit.bo.name = name; + mutated(); + return null; + }, + moveItem(id, targetPath){ + const hit = findBoard(id); + if(!hit) return null; + let entry = entryAt(targetPath); + if(!entry){ entry = { name: joinBoardPath(targetPath), boards: [] }; BS.folders.push(entry); } + if(entry === hit.fo) return null; + const act = activeId(); + hit.fo.boards.splice(hit.bi, 1); + entry.boards.push(hit.bo); + relocate(act); + mutated(); + return null; + }, + deleteItem(id){ + const hit = findBoard(id); + if(!hit) return null; + if(flatBoards().length <= 1) return T("exLastBoard"); + const wasActive = id === activeId(); + hit.fo.boards.splice(hit.bi, 1); + if(wasActive){ BS.f = 0; BS.b = 0; loadCurrentSlot(); } + else relocate(activeId()); + mutated(); + return null; + }, + openItem(id){ + const hit = findBoard(id); + if(hit && !(hit.fi===BS.f && hit.bi===BS.b)) switchTo(hit.fi, hit.bi); + }, + }; +} function flatBoards(){ const out = []; BS.folders.forEach((fo,fi)=>fo.boards.forEach((bo,bi)=>out.push({fi,bi,bo,fo}))); @@ -1135,162 +1262,5 @@ function renderStrip(){ }); boardStrip.appendChild(add); } -function renderBoardsPanel(){ - boardsPanel.innerHTML = ""; - BS.folders.forEach((fo, fi)=>{ - const fr = document.createElement("div"); - fr.className = "bp-folder"; - const nm = document.createElement("span"); - nm.className = "bpf-name"; nm.textContent = "📁 " + fo.name; - /* map hernoemen: zelfde dblclick-inline-input-patroon als bord hernoemen */ - nm.addEventListener("dblclick", ()=>{ - if(fr.querySelector("input")) return; - const inp = document.createElement("input"); - inp.value = fo.name; inp.maxLength = 20; - fr.replaceChild(inp, nm); - inp.focus(); inp.select(); - let done = false; - const commit = ()=>{ - if(done) return; done = true; - fo.name = inp.value.trim() || fo.name; - markBoardChange(); - renderBoardsPanel(); updateBoardsUI(); - }; - inp.addEventListener("keydown", ev=>{ ev.stopPropagation(); if(ev.key==="Enter") commit(); }); - inp.addEventListener("blur", commit); - inp.addEventListener("pointerdown", ev=>ev.stopPropagation()); - }); - fr.appendChild(nm); - const add = document.createElement("button"); - add.textContent = T("newBoard"); - add.addEventListener("click", ()=>{ - fo.boards.push(newBoardEntry(`${T("boardName")} ${fo.boards.length+1}`)); - switchTo(fi, fo.boards.length-1); - renderBoardsPanel(); - }); - fr.appendChild(add); - if(BS.folders.length > 1){ - const del = document.createElement("button"); - del.textContent = "🗑"; - del.addEventListener("click", ()=>{ - if(fi === BS.f){ BS.folders.splice(fi,1); BS.f = 0; BS.b = 0; loadCurrentSlot(); } - else{ BS.folders.splice(fi,1); if(BS.f > fi) BS.f--; } - markBoardChange(); - renderBoardsPanel(); - }); - fr.appendChild(del); - } - boardsPanel.appendChild(fr); - const row = document.createElement("div"); - row.className = "bp-boards"; - fo.boards.forEach((bo, bi)=>{ - const chip = document.createElement("div"); - chip.className = "bp-board" + (fi===BS.f && bi===BS.b ? " active" : ""); - const lbl = document.createElement("span"); - lbl.textContent = bo.name; - chip.appendChild(lbl); - chip.addEventListener("click", e=>{ - if(e.target.classList.contains("bpb-x") || e.target.tagName==="INPUT") return; - if(fi===BS.f && bi===BS.b) return; - switchTo(fi, bi); - renderBoardsPanel(); - }); - chip.addEventListener("dblclick", ()=>{ - if(chip.querySelector("input")) return; - const inp = document.createElement("input"); - inp.value = bo.name; inp.maxLength = 20; - chip.replaceChild(inp, lbl); - inp.focus(); inp.select(); - let done = false; - const commit = ()=>{ - if(done) return; done = true; - bo.name = inp.value.trim() || bo.name; - markBoardChange(); - renderBoardsPanel(); updateBoardsUI(); - }; - inp.addEventListener("keydown", ev=>{ ev.stopPropagation(); if(ev.key==="Enter") commit(); }); - inp.addEventListener("blur", commit); - inp.addEventListener("pointerdown", ev=>ev.stopPropagation()); - }); - /* bord verplaatsen naar een andere map: klik toont een inline select - met de overige mappen; het actieve bord verhuist mee (indices volgen) */ - if(BS.folders.length > 1){ - const mv = document.createElement("button"); - mv.className = "bpb-x bpb-mv"; mv.textContent = "📁"; - mv.title = T("moveBoardTip"); - mv.addEventListener("click", ev=>{ - ev.stopPropagation(); - if(chip.querySelector("select")) return; - const sel = document.createElement("select"); - sel.className = "bpb-mv-sel"; - const ph = document.createElement("option"); - ph.value = ""; ph.textContent = T("moveBoardTip"); - sel.appendChild(ph); - BS.folders.forEach((f2, f2i)=>{ - if(f2i === fi) return; - const o = document.createElement("option"); - o.value = String(f2i); o.textContent = "📁 " + f2.name; - sel.appendChild(o); - }); - sel.addEventListener("click", e2=>e2.stopPropagation()); - sel.addEventListener("pointerdown", e2=>e2.stopPropagation()); - sel.addEventListener("blur", ()=>sel.remove()); - sel.addEventListener("change", ()=>{ - if(sel.value === "") return; - const ti = +sel.value; - const wasCur = (fi===BS.f && bi===BS.b); - fo.boards.splice(bi,1); - BS.folders[ti].boards.push(bo); - if(!fo.boards.length) fo.boards.push(newBoardEntry(T("boardName")+" 1")); - if(wasCur){ BS.f = ti; BS.b = BS.folders[ti].boards.length-1; } - else if(fi===BS.f && bi < BS.b) BS.b--; - markBoardChange(); - renderBoardsPanel(); updateBoardsUI(); - }); - chip.appendChild(sel); - sel.focus(); - }); - chip.appendChild(mv); - } - if(fo.boards.length > 1 || BS.folders.length > 1){ - const x = document.createElement("button"); - x.className = "bpb-x"; x.textContent = "✕"; - x.addEventListener("click", ev=>{ - ev.stopPropagation(); - const wasCur = (fi===BS.f && bi===BS.b); - fo.boards.splice(bi,1); - if(!fo.boards.length) fo.boards.push(newBoardEntry(T("boardName")+" 1")); - if(wasCur){ BS.b = 0; loadCurrentSlot(); } - else if(fi===BS.f && bi < BS.b) BS.b--; - markBoardChange(); - renderBoardsPanel(); - }); - chip.appendChild(x); - } - row.appendChild(chip); - }); - boardsPanel.appendChild(row); - }); - const nf = document.createElement("div"); - nf.className = "bp-new"; - const inp = document.createElement("input"); - inp.placeholder = T("folderPh"); inp.maxLength = 20; - const addFolder = ()=>{ - const name = inp.value.trim() || `${T("folderName")} ${BS.folders.length+1}`; - BS.folders.push({ name, boards:[newBoardEntry(T("boardName")+" 1")] }); - inp.value = ""; - markBoardChange(); - renderBoardsPanel(); - }; - inp.addEventListener("keydown", e=>{ e.stopPropagation(); if(e.key==="Enter") addFolder(); }); - const nb = document.createElement("button"); - nb.className = "tbtn"; nb.textContent = T("newFolder"); - nb.addEventListener("click", addFolder); - nf.appendChild(inp); nf.appendChild(nb); - boardsPanel.appendChild(nf); - const hint = document.createElement("div"); - hint.className = "bp-hint"; hint.textContent = T("renameTip"); - boardsPanel.appendChild(hint); -} document.addEventListener("langchange", updateBoardsUI); diff --git a/public/js/core.js b/public/js/core.js index 98ee408..67a720a 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.21-beta"; +const VERSION = "0.3.22-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -75,8 +75,12 @@ const I18N = { boardsNav:"Borden en mappen", newBoard:"+ Bord", newFolder:"+ Map", folderPh:"naam nieuwe map…", boardName:"Bord", folderName:"Map", delBoard:"Bord verwijderen (2× tikken)", - renameTip:"Dubbelklik op een bord of map om te hernoemen", - moveBoardTip:"verplaats naar map…", + exFolder:"Map", exRename:"Hernoem", exDelete:"Verwijder", exConfirm:"zeker?", + exSelectFirst:"Klik eerst een map of bord aan.", exNoSlash:"Een naam mag geen / bevatten.", + exExists:"Die naam bestaat al.", exEmpty:"Deze map is leeg.", + exFolderNotEmpty:"Map is niet leeg - verplaats of verwijder eerst de inhoud.", + exIntoSelf:"Een map kan niet in zichzelf.", exNoRootItems:"Borden horen in een map.", + exLastBoard:"Het laatste bord kan niet weg.", hudMin:"Menu inklappen/uitklappen", hudOrient:"Horizontaal of verticaal", wFull:"Maximaliseren", wUnfull:"Terugzetten", wZoom:"Inhoud groter of kleiner (voor slechtzienden)", @@ -243,8 +247,12 @@ const I18N = { boardsNav:"Boards and folders", newBoard:"+ Board", newFolder:"+ Folder", folderPh:"name of new folder…", boardName:"Board", folderName:"Folder", delBoard:"Delete board (tap twice)", - renameTip:"Double-click a board or folder to rename it", - moveBoardTip:"move to folder…", + exFolder:"Folder", exRename:"Rename", exDelete:"Delete", exConfirm:"sure?", + exSelectFirst:"Select a folder or board first.", exNoSlash:"A name cannot contain /.", + exExists:"That name already exists.", exEmpty:"This folder is empty.", + exFolderNotEmpty:"Folder is not empty - move or delete its contents first.", + exIntoSelf:"A folder cannot go inside itself.", exNoRootItems:"Boards belong in a folder.", + exLastBoard:"The last board cannot be deleted.", 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 new file mode 100644 index 0000000..a4d4fbe --- /dev/null +++ b/public/js/explorer.js @@ -0,0 +1,278 @@ +/* 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"); } + } +})();