From e555876fa482f80a9b140c49d423db474e9331d8 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sat, 18 Jul 2026 13:26:26 +0200 Subject: [PATCH] feat: cast bord/widget vanaf whiteboard naar klas of leerling (v0.4.15-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nieuwe Cast-knop (📡) op de whiteboard-toolbar opent een sheet om het geopende bord - of één widget erop met kijken/werken - direct naar een klas of leerling uit te zenden, zonder om te weg via Instellingen. - Hergebruikt de bestaande PUT/DELETE /assignments/class|pupil routes ongewijzigd; geen serverwijziging nodig. - Instellingen > Toewijzingen blijft bestaan maar toont nu alleen de huidige toewijzing + een "Loskoppelen"-knop (aanmaken/wijzigen gebeurt voortaan via Cast). --- VERSION | 2 +- public/css/teach.css | 13 +++- public/index.html | 2 + public/js/admin.js | 88 +++++++------------------- public/js/cast.js | 146 +++++++++++++++++++++++++++++++++++++++++++ public/js/core.js | 12 +++- 6 files changed, 191 insertions(+), 72 deletions(-) create mode 100644 public/js/cast.js diff --git a/VERSION b/VERSION index 2a6aad5..1760d49 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.14-beta +0.4.15-beta diff --git a/public/css/teach.css b/public/css/teach.css index daa269b..826813f 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -135,7 +135,8 @@ #pupilView{display:none;} body.pupil-mode #board,body.pupil-mode #fab,body.pupil-mode #fabBoardsBtn, 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 #hud,body.pupil-mode #btnFolders,body.pupil-mode #btnSave, + body.pupil-mode #btnCast{display:none !important;} body.pupil-mode #pupilView{ display:flex; flex-wrap:wrap; gap:18px; align-content:flex-start; position:absolute; inset:var(--tbar) 0 0 0; overflow:auto; padding:18px; box-sizing:border-box; @@ -160,7 +161,8 @@ #parentView{display:none;} body.parent-mode #board,body.parent-mode #fab,body.parent-mode #fabBoardsBtn, body.parent-mode #boardStrip,body.parent-mode #fabmenu, - body.parent-mode #hud,body.parent-mode #btnFolders,body.parent-mode #btnSave{display:none !important;} + body.parent-mode #hud,body.parent-mode #btnFolders,body.parent-mode #btnSave, + body.parent-mode #btnCast{display:none !important;} body.parent-mode #parentView{ display:block; position:absolute; inset:var(--tbar) 0 0 0; overflow:auto; padding:24px 18px; box-sizing:border-box; @@ -1316,12 +1318,16 @@ font-size:11.5px; font-weight:700; text-transform:uppercase; letter-spacing:.4px; color:var(--muted); margin:14px 0 6px; } -.am-person{cursor:pointer;} +.am-person,.am-target{cursor:pointer;} .am-person-info{flex:1; display:flex; flex-direction:column; min-width:0; gap:1px;} .am-person-info .am-name{font-size:14px;} .am-person-info .am-role{font-size:11.5px; color:var(--muted); font-weight:600;} .am-sheet-backdrop{position:fixed; inset:0; background:rgba(15,22,38,.4); z-index:60; display:none;} .am-sheet-backdrop.open{display:block;} +/* de Cast-sheet opent vanaf het bord zelf (buiten Instellingen om), dus moet + boven de zwevende pen-HUD (#hud, z-index 5500) uitkomen - zelfde laag als + .modalwrap (6000), niet de 60 die volstaat zodra je al in Instellingen zit */ +#castBackdrop{z-index:6000;} .am-sheet{ position:absolute; left:0; right:0; bottom:0; max-width:520px; margin:0 auto; background:var(--surface); border-radius:18px 18px 0 0; padding:16px 18px 24px; @@ -1456,6 +1462,7 @@ } button.am-chip{cursor:pointer; color:var(--muted);} button.am-chip.on{border-color:var(--accent); background:var(--accent-soft); color:var(--accent-ink);} +.am-chip.active{border-color:var(--green); background:color-mix(in srgb, var(--green) 15%, var(--surface)); color:var(--green);} .am-chipx{border:none; background:transparent; color:var(--red); font-weight:900; cursor:pointer; padding:0; font-size:11px;} .am-reset{ background:var(--surface-2); border:1px solid var(--line); border-radius:12px; diff --git a/public/index.html b/public/index.html index 4494662..be8443c 100644 --- a/public/index.html +++ b/public/index.html @@ -43,6 +43,7 @@ + @@ -61,6 +62,7 @@ + diff --git a/public/js/admin.js b/public/js/admin.js index 1c98eb9..965dbe6 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -621,19 +621,9 @@ return box; } - /* toewijzingsrij: kies een van je eigen borden (uit BS, board.js) voor een klas of - een individuele leerling. Geen momentopname - de leerling-pagina zoekt het bord - live op, dus nieuwe woorden die je toevoegt komen vanzelf door zonder opnieuw - toe te wijzen. Een expliciete "Toewijzen"-knop i.p.v. direct bij het kiezen van - een bord opslaan, zodat een klas-brede wijziging niet per ongeluk gebeurt. */ - /* widgets van een eigen bord (voor de één-widget-keuze in assignmentRow); - widgets zonder wid komen uit borden van vóór v0.3.13 en verschijnen pas - na de eerstvolgende save van dat bord */ - function boardWidgets(boardId){ - let found = null; - BS.folders.forEach(fo=>fo.boards.forEach(bo=>{ if(bo.id===boardId) found = bo; })); - return ((found && found.data && found.data.widgets) || []).filter(w=>w.wid); - } + /* toewijzingsrij: overzicht van de huidige toewijzing (aangemaakt via de + Cast-knop op het whiteboard, cast.js) + loskoppelen. Aanmaken/wijzigen + gebeurt niet meer hier - zie cast.js. */ function describeCurrent(current){ if(!current) return ""; let s = `${T("amAssignedAs")} “${current.boardName || current.boardId}”`; @@ -643,56 +633,23 @@ } return s; } - function assignmentRow(target, boardOptions, current){ - const sel = h("select","am-sel"); - sel.appendChild(new Option(T("amNoAssignment"), "")); - boardOptions.forEach(b=>sel.appendChild(new Option(b.label, b.id))); - sel.value = current ? current.boardId : ""; - /* keuze: heel bord (default, bestaand gedrag) of één widget + modus */ - const widgetSel = h("select","am-sel am-widget-sel"); - const modeSel = h("select","am-sel am-mode-sel"); - modeSel.appendChild(new Option(T("amModeWork"), "werken")); - modeSel.appendChild(new Option(T("amModeWatch"), "kijken")); - const syncWidgetSel = ()=>{ - widgetSel.innerHTML = ""; - widgetSel.appendChild(new Option(T("amWholeBoard"), "")); - boardWidgets(sel.value).forEach(w=>widgetSel.appendChild(new Option(T("wg_"+w.id), w.wid))); - widgetSel.value = (current && current.boardId===sel.value && current.widgetId) ? current.widgetId : ""; - widgetSel.style.display = sel.value ? "" : "none"; - syncModeSel(); - }; - const syncModeSel = ()=>{ modeSel.style.display = (sel.value && widgetSel.value) ? "" : "none"; }; - modeSel.value = current && current.mode ? current.mode : "werken"; - sel.addEventListener("change", syncWidgetSel); - widgetSel.addEventListener("change", syncModeSel); - const info = h("span","am-code", describeCurrent(current)); - syncWidgetSel(); - const apply = h("button","am-btn", T("amAssign")); - apply.type = "button"; - apply.addEventListener("click", async ()=>{ - try{ - if(sel.value){ - const body = { boardId: sel.value }; - if(widgetSel.value){ body.widgetId = widgetSel.value; body.mode = modeSel.value; } - await api(`/assignments/${target.type}/${target.id}`, {method:"PUT", body}); - } - else await api(`/assignments/${target.type}/${target.id}`, {method:"DELETE"}); - if(target.type==="class"){ await reload(); } - else{ - const a = (await api("/assignments/pupil/"+target.id)).assignment; - info.textContent = describeCurrent(a); - msg(T("amAssignSaved")); - } - }catch(e){ msg(e.message); } - }); - /* kop toont compact de huidige stand; de keuzes klappen uit via Bewerken */ + function assignmentRow(target, current){ + const info = h("span","am-code", describeCurrent(current) || T("amNoAssignment")); const tools = h("div","am-tools"); - const inline = h("div","am-inline"); - inline.appendChild(sel); - inline.appendChild(widgetSel); - inline.appendChild(modeSel); - inline.appendChild(apply); - tools.appendChild(inline); + if(current){ + const del = dangerBtn(T("amUnlink"), null, async ()=>{ + try{ + await api(`/assignments/${target.type}/${target.id}`, {method:"DELETE"}); + if(target.type==="class"){ await reload(); } + else{ + info.textContent = T("amNoAssignment"); + del.remove(); + msg(T("amAssignSaved")); + } + }catch(e){ msg(e.message); } + }); + tools.appendChild(del); + } tools.appendChild(info); return groupCard({ title: target.label, @@ -730,13 +687,10 @@ panel.appendChild(h("div","guestnote", T("amPickSchoolHint"))); return; } - const boardOptions = []; - BS.folders.forEach(fo=>fo.boards.forEach(bo=>boardOptions.push({ id: bo.id, label: `${fo.name} / ${bo.name}` }))); - panel.appendChild(h("div","am-group", T("amAssignClasses"))); const classList = h("div","am-list"); if(!CLASSES.length) classList.appendChild(h("div","guestnote", T("amNoClass"))); - CLASSES.forEach(c=>classList.appendChild(assignmentRow({type:"class", id:c.id, label:c.name}, boardOptions, ASSIGNMENTS[c.id]))); + CLASSES.forEach(c=>classList.appendChild(assignmentRow({type:"class", id:c.id, label:c.name}, ASSIGNMENTS[c.id]))); panel.appendChild(classList); panel.appendChild(h("div","am-group", T("amAssignPupils"))); @@ -752,7 +706,7 @@ const u = pupils.find(x=>String(x.id)===pupilSel.value); let current = null; try{ current = (await api("/assignments/pupil/"+u.id)).assignment; }catch(e){} - pupilRowHolder.appendChild(assignmentRow({type:"pupil", id:u.id, label:u.displayName}, boardOptions, current)); + pupilRowHolder.appendChild(assignmentRow({type:"pupil", id:u.id, label:u.displayName}, current)); }); } diff --git a/public/js/cast.js b/public/js/cast.js new file mode 100644 index 0000000..6346b1a --- /dev/null +++ b/public/js/cast.js @@ -0,0 +1,146 @@ +/* teach - cast (📡): het geopende bord (of één widget erop) rechtstreeks + vanaf de toolbar uitzenden naar een klas of leerling, i.p.v. de omweg via + Instellingen > Toewijzingen. Hergebruikt dezelfde PUT/DELETE-routes als + admin.js's assignmentRow - geen serverwijziging, alleen een korter pad. */ +"use strict"; +const btnCast = document.getElementById("btnCast"); +btnCast.title = T("cast"); +document.addEventListener("langchange", ()=>{ + btnCast.title = T("cast"); + if(castOpen) renderCast(); +}); + +function h(tag, cls, text){ + const el = document.createElement(tag); + if(cls) el.className = cls; + if(text != null) el.textContent = text; + return el; +} + +const castBackdrop = h("div","am-sheet-backdrop"); +castBackdrop.id = "castBackdrop"; +document.body.appendChild(castBackdrop); +castBackdrop.addEventListener("click", e=>{ if(e.target===castBackdrop) closeCast(); }); + +let castOpen = false; +let castLoading = false; +let castClasses = [], castPupils = []; +let castSelWidget = "", castSelMode = "werken"; +/* toewijzingen die deze sessie al gelukt zijn ("type:id"), zodat je hetzelfde + bord naar meerdere klassen/leerlingen achter elkaar kunt casten zonder dat + de sheet na elke keer sluit */ +let castActiveKeys = new Set(); +let castError = ""; + +/* widgets van het live-open bord, zelfde bron als serializeBoard() (board.js) */ +function currentBoardWidgets(){ + return [...instances].map(inst=>({ wid: inst.wid, id: inst.def.id })).filter(w=>w.wid); +} + +btnCast.addEventListener("click", async ()=>{ + stashCurrent(); /* verse widget-state, zelfde als btnFolders doet */ + castOpen = true; + castSelWidget = ""; castSelMode = "werken"; + castActiveKeys = new Set(); castError = ""; + castLoading = true; + renderCast(); + try{ + const [cRes, uRes] = await Promise.all([ api("/admin/classes"), api("/admin/users") ]); + castClasses = cRes.classes || []; + castPupils = (uRes.users || []).filter(u=>u.role==="pupil"); + }catch(e){ + castClasses = []; castPupils = []; castError = e.message; + } + castLoading = false; + renderCast(); +}); + +function closeCast(){ castOpen = false; renderCast(); } + +function castTargetRow(target){ + const row = h("div","am-row am-target"); + const info = h("div","am-person-info"); + info.appendChild(h("span","am-name", target.label)); + if(target.sub) info.appendChild(h("span","am-role", target.sub)); + row.appendChild(info); + const key = target.type+":"+target.id; + if(castActiveKeys.has(key)) row.appendChild(h("span","am-chip active", T("castActive"))); + row.addEventListener("click", async ()=>{ + try{ + const body = { boardId: curSlot().id }; + if(castSelWidget){ body.widgetId = castSelWidget; body.mode = castSelMode; } + await api(`/assignments/${target.type}/${target.id}`, { method:"PUT", body }); + castActiveKeys.add(key); + castError = ""; + }catch(e){ castError = e.message; } + renderCast(); + }); + return row; +} + +function renderCast(){ + castBackdrop.classList.toggle("open", castOpen); + castBackdrop.innerHTML = ""; + if(!castOpen) return; + + const sheet = h("div","am-sheet"); + castBackdrop.appendChild(sheet); + + const head = h("div","am-kebab-row"); + const titleWrap = h("div"); + titleWrap.appendChild(h("h2","am-nav-title", T("castTitle"))); + titleWrap.appendChild(h("div","am-role", `${BS.folders[BS.f].name} / ${curSlot().name}`)); + head.appendChild(titleWrap); + const closeBtn = h("button","am-btn", T("close")); + closeBtn.type = "button"; + closeBtn.addEventListener("click", closeCast); + head.appendChild(closeBtn); + sheet.appendChild(head); + + sheet.appendChild(h("div","am-msg", castError)); + + sheet.appendChild(h("div","am-group", T("castWhat"))); + const widgetSel = h("select","am-sel"); + widgetSel.appendChild(new Option(T("amWholeBoard"), "")); + currentBoardWidgets().forEach(w=>widgetSel.appendChild(new Option(T("wg_"+w.id), w.wid))); + widgetSel.value = castSelWidget; + const modeSel = h("select","am-sel"); + modeSel.appendChild(new Option(T("amModeWork"), "werken")); + modeSel.appendChild(new Option(T("amModeWatch"), "kijken")); + modeSel.value = castSelMode; + modeSel.style.display = castSelWidget ? "" : "none"; + widgetSel.addEventListener("change", ()=>{ + castSelWidget = widgetSel.value; + modeSel.style.display = castSelWidget ? "" : "none"; + }); + modeSel.addEventListener("change", ()=>{ castSelMode = modeSel.value; }); + const whatRow = h("div","am-inline"); + whatRow.appendChild(widgetSel); + whatRow.appendChild(modeSel); + sheet.appendChild(whatRow); + + sheet.appendChild(h("div","am-group", T("castClasses"))); + const classList = h("div","am-list"); + if(castLoading) classList.appendChild(h("div","guestnote", "…")); + else if(!castClasses.length) classList.appendChild(h("div","guestnote", T("castNoClasses"))); + else castClasses.forEach(c=>classList.appendChild(castTargetRow({type:"class", id:c.id, label:c.name}))); + sheet.appendChild(classList); + + sheet.appendChild(h("div","am-group", T("castPupils"))); + const pupilList = h("div","am-list"); + if(castLoading) pupilList.appendChild(h("div","guestnote", "…")); + else if(!castPupils.length) pupilList.appendChild(h("div","guestnote", T("castNoPupils"))); + else{ + const byClass = new Map(); + castPupils.forEach(u=>{ + const key = u.classId || "none"; + if(!byClass.has(key)) byClass.set(key, []); + byClass.get(key).push(u); + }); + castClasses.forEach(c=>(byClass.get(c.id) || []).forEach(u=> + pupilList.appendChild(castTargetRow({type:"pupil", id:u.id, label:u.displayName, sub:c.name})))); + (byClass.get("none") || []).forEach(u=> + pupilList.appendChild(castTargetRow({type:"pupil", id:u.id, label:u.displayName, sub:T("amUnassigned")}))); + } + sheet.appendChild(pupilList); +} diff --git a/public/js/core.js b/public/js/core.js index 9a02296..5993d12 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.4.14-beta"; +const VERSION = "0.4.15-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -315,7 +315,12 @@ const I18N = { amAssignments:"Toewijzingen", amAssignClasses:"Per klas (standaard)", amAssignPupils:"Per leerling (uitzondering)", amPickPupil:"— kies een leerling —", amNoAssignment:"— geen toewijzing —", amAssignedAs:"Toegewezen:", amAssign:"Toewijzen", amAssignSaved:"Toewijzing opgeslagen.", + amUnlink:"Loskoppelen", amWholeBoard:"— heel bord —", amModeWatch:"kijken", amModeWork:"werken", + cast:"Cast", castTitle:"Cast bord", castWhat:"Wat cast je?", + castClasses:"Klassen", castPupils:"Leerlingen", + castNoClasses:"Geen klassen in deze school.", castNoPupils:"Geen leerlingen in deze school.", + castActive:"✓ Actief", amProgress:"Voortgang", amProgressEmpty:"Nog geen voortgang voor deze leerling.", amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?", amCopy:"kopieer", amCopyAll:"kopieer alles", @@ -617,7 +622,12 @@ const I18N = { amAssignments:"Assignments", amAssignClasses:"Per class (default)", amAssignPupils:"Per pupil (override)", amPickPupil:"— pick a pupil —", amNoAssignment:"— no assignment —", amAssignedAs:"Assigned:", amAssign:"Assign", amAssignSaved:"Assignment saved.", + amUnlink:"Unlink", amWholeBoard:"— whole board —", amModeWatch:"watch", amModeWork:"work", + cast:"Cast", castTitle:"Cast board", castWhat:"What are you casting?", + castClasses:"Classes", castPupils:"Pupils", + castNoClasses:"No classes in this school.", castNoPupils:"No pupils in this school.", + castActive:"✓ Active", amProgress:"Progress", amProgressEmpty:"No progress for this pupil yet.", amConfirmDel:"Are you sure you want to delete this user?", amCopy:"copy", amCopyAll:"copy all",