Voeg thematische kleurplaten en creatieve niveaus toe (v0.4.65-beta) #3
6 changed files with 476 additions and 217 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.3.21-beta
|
||||
0.3.22-beta
|
||||
|
|
|
|||
|
|
@ -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);}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,17 @@
|
|||
<button id="fab">+</button>
|
||||
<button id="fabBoardsBtn">🗂</button>
|
||||
<div id="boardStrip"></div>
|
||||
<div id="boardsPanel"></div>
|
||||
<div class="modalwrap" id="explorerWrap" role="dialog" aria-modal="true" aria-labelledby="explorerTitle">
|
||||
<div class="modal explorer">
|
||||
<div class="gallery-head"><h2 id="explorerTitle"></h2><button class="wclose explorer-close" type="button">✕</button></div>
|
||||
<div class="ex-bar">
|
||||
<div class="ex-crumbs" id="exCrumbs"></div>
|
||||
<div class="ex-tools" id="exTools"></div>
|
||||
</div>
|
||||
<div class="ex-msg" id="exMsg"></div>
|
||||
<div class="ex-grid" id="exGrid"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="fabmenu">
|
||||
<button class="fab-item" data-f="widgets"><span class="fi">🧩</span><span class="fl"></span></button>
|
||||
<button class="fab-item" data-f="draw"><span class="fi">✏️</span><span class="fl"></span></button>
|
||||
|
|
@ -233,6 +243,7 @@
|
|||
<script src="js/core.js"></script>
|
||||
<script src="js/permissions.js"></script>
|
||||
<script src="js/data.js"></script>
|
||||
<script src="js/explorer.js"></script>
|
||||
<script src="js/picker.js"></script>
|
||||
<script src="js/widgets/letters.js"></script>
|
||||
<script src="js/widgets/write.js"></script>
|
||||
|
|
|
|||
|
|
@ -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; fi<BS.folders.length; fi++){
|
||||
const bi = BS.folders[fi].boards.findIndex(b=>b.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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
278
public/js/explorer.js
Normal file
278
public/js/explorer.js
Normal file
|
|
@ -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"); }
|
||||
}
|
||||
})();
|
||||
Loading…
Reference in a new issue