From 0411375a3c12f14c9fb7a7451bd324cd6ef691ed Mon Sep 17 00:00:00 2001 From: Ramon Date: Mon, 13 Jul 2026 23:14:26 +0200 Subject: [PATCH] v0.2.22-beta: leerling-omgeving (aparte pagina zonder whiteboard) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Nieuwe tabel `assignments`: een leerkracht koppelt één van haar eigen borden aan een klas (standaard) of een individuele leerling (uitzondering, voor wie op een ander niveau werkt). - Geen momentopname: /my/assignment zoekt het bord live op bij de leerkracht, dus nieuwe woorden die zij toevoegt komen vanzelf door zodra de leerling-pagina opnieuw pollt (elke 20s). - Leerlingen loggen in op een vereenvoudigde pagina zonder whiteboard/ tekengereedschap: enkel de toegewezen taal-/rekenwidgets, als vaste (niet-sleepbare) kaarten in speelmodus. - Speelmodus verbergt bewerkfuncties in Ankerwoorden (plaatje kiezen, woord typen, kleur wijzigen) en Letterblokken (thema/woordbeheer) - overige widgets waren al zuiver speelgericht. - Elk bord krijgt een stabiel id (board.js) zodat een toewijzing een bord blijft vinden ook na hernoemen of herordenen. - Nieuw tabblad "Toewijzingen" in het beheerpaneel: per klas of leerling een eigen bord kiezen, met een expliciete toewijs-stap. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01DuxHJtk5aHe2pA3xwCLEAk --- VERSION | 2 +- db/005_assignments.sql | 16 +++++ public/css/teach.css | 16 +++++ public/index.html | 2 + public/js/admin.js | 95 ++++++++++++++++++++++++++++- public/js/board.js | 31 ++++++---- public/js/core.js | 23 ++++++- public/js/permissions.js | 1 + public/js/pupil.js | 64 ++++++++++++++++++++ public/js/widgets/anchor.js | 53 ++++++++-------- public/js/widgets/letters.js | 16 +++-- src/api.js | 114 +++++++++++++++++++++++++++++++++++ src/permissions.js | 1 + 13 files changed, 388 insertions(+), 46 deletions(-) create mode 100644 db/005_assignments.sql create mode 100644 public/js/pupil.js diff --git a/VERSION b/VERSION index 599028f..109a20f 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.2.21 +0.2.22 diff --git a/db/005_assignments.sql b/db/005_assignments.sql new file mode 100644 index 0000000..9cff863 --- /dev/null +++ b/db/005_assignments.sql @@ -0,0 +1,16 @@ +-- Toewijzingen: een leerkracht koppelt één van haar eigen borden (via het +-- stabiele board-id uit users.data) aan een klas of een individuele leerling. +-- Puur additief, leeg bij aanmaak. Precies één van class_id/pupil_id gezet: +-- een klas-toewijzing (standaard voor iedereen in de klas) of een leerling- +-- toewijzing (uitzondering, voor een leerling op een ander niveau). +CREATE TABLE IF NOT EXISTS assignments ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + class_id BIGINT REFERENCES classes(id) ON DELETE CASCADE, + pupil_id BIGINT REFERENCES users(id) ON DELETE CASCADE, + teacher_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE, + board_id TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + CHECK (num_nonnulls(class_id, pupil_id) = 1) +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_assignments_class ON assignments(class_id) WHERE class_id IS NOT NULL; +CREATE UNIQUE INDEX IF NOT EXISTS idx_assignments_pupil ON assignments(pupil_id) WHERE pupil_id IS NOT NULL; diff --git a/public/css/teach.css b/public/css/teach.css index 0573b8f..e683997 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -84,6 +84,22 @@ body.pen #boardInk{pointer-events:auto; cursor:crosshair;} body.pen.pen-text #boardInk{cursor:text;} body.pen.pen-hand #boardInk{pointer-events:none;} /* hand tool: interact with everything */ + /* ---------- Leerling-omgeving ---------- */ + /* geen whiteboard/tekengereedschap voor leerlingen - alleen wat de leerkracht + 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 #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; + position:absolute; inset:58px 0 0 0; overflow:auto; padding:18px; box-sizing:border-box; + } + #pupilView .pupil-card{ + position:static; max-width:100%; box-shadow:0 6px 20px rgba(30,50,90,.12); + } + .pupil-card .widget-body{width:100%; height:100%;} + .pupil-empty{margin:auto; color:var(--muted); font-size:16px; font-weight:600;} /* floating add-button (FAB) */ #fab{ position:fixed; right:24px; bottom:24px; z-index:5600; width:60px; height:60px; diff --git a/public/index.html b/public/index.html index db59f51..680fbe4 100644 --- a/public/index.html +++ b/public/index.html @@ -31,6 +31,7 @@
+
@@ -166,6 +167,7 @@ + diff --git a/public/js/admin.js b/public/js/admin.js index 0714572..d162472 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -7,6 +7,9 @@ const modal = document.getElementById("adminModal"); const btn = document.getElementById("btnAdmin"); let SCHOOLS = [], USERS = [], CLASSES = [], selSchool = null; + /* huidige klas-toewijzingen (leerling-omgeving), per klas-id - opnieuw + opgehaald bij elke reload() zoals CLASSES/USERS hierboven */ + let ASSIGNMENTS = {}; /* resultaat van een laatste multireset (klas/school): blijft zichtbaar totdat de beheerder het venster zelf sluit, zodat wachtwoorden niet meteen weer verdwijnen na een reload() */ @@ -73,7 +76,12 @@ USERS = (await api("/admin/users"+q)).users; const cq = currentUser.role==="super" ? (selSchool ? "?school="+selSchool : "?school=0") : ""; CLASSES = (await api("/admin/classes"+cq)).classes || []; - }catch(e){ USERS = []; CLASSES = []; } + ASSIGNMENTS = {}; + if(CLASSES.length){ + const results = await Promise.all(CLASSES.map(c=>api("/assignments/class/"+c.id).catch(()=>({assignment:null})))); + CLASSES.forEach((c,i)=>{ ASSIGNMENTS[c.id] = results[i].assignment; }); + } + }catch(e){ USERS = []; CLASSES = []; ASSIGNMENTS = {}; } render(); } @@ -407,6 +415,88 @@ } } + /* 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. */ + function assignmentRow(target, boardOptions, current){ + const row = h("div","am-row"); + row.appendChild(h("span","am-name", target.label)); + 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 : ""; + const info = h("span","am-code", current ? `${T("amAssignedAs")} “${current.boardName || current.boardId}”` : ""); + row.appendChild(sel); + row.appendChild(info); + const apply = h("button","am-btn", T("amAssign")); + apply.type = "button"; + apply.addEventListener("click", async ()=>{ + try{ + if(sel.value) await api(`/assignments/${target.type}/${target.id}`, {method:"PUT", body:{boardId: sel.value}}); + 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 = a ? `${T("amAssignedAs")} “${a.boardName || a.boardId}”` : ""; + msg(T("amAssignSaved")); + } + }catch(e){ msg(e.message); } + }); + row.appendChild(apply); + return row; + } + + /* leerling-omgeving: welk eigen bord ziet een klas (standaard), of een individuele + leerling (uitzondering voor wie op een ander niveau werkt) - zie roadmap-memo. */ + function renderToewijzingenPanel(panel){ + const schoolChosen = currentUser.role!=="super" || !!selSchool; + if(!schoolChosen){ + 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]))); + panel.appendChild(classList); + + panel.appendChild(h("div","am-group", T("amAssignPupils"))); + const pupils = USERS.filter(u=>u.role==="pupil"); + const pupilSel = h("select","am-sel"); + pupilSel.appendChild(new Option(T("amPickPupil"), "")); + const byClass = new Map(); + pupils.forEach(u=>{ + const key = u.classId || "none"; + if(!byClass.has(key)) byClass.set(key, []); + byClass.get(key).push(u); + }); + const addGroup = (label, us)=>{ + if(!us || !us.length) return; + const og = document.createElement("optgroup"); + og.label = label; + us.forEach(u=>og.appendChild(new Option(u.displayName, u.id))); + pupilSel.appendChild(og); + }; + CLASSES.forEach(c=>addGroup(c.name, byClass.get(c.id))); + addGroup(T("amNoClass"), byClass.get("none")); + panel.appendChild(pupilSel); + const pupilRowHolder = h("div"); + panel.appendChild(pupilRowHolder); + pupilSel.addEventListener("change", async ()=>{ + pupilRowHolder.innerHTML = ""; + if(!pupilSel.value) return; + 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)); + }); + } + /* zoekfilter voor de gebruikerslijst: verbergt niet-matchende rijen en lege groepen via style.display i.p.v. een her-render, zodat het zoekveld focus houdt tijdens het typen (een volledige render() zou het veld elke toets opnieuw aanmaken) */ @@ -511,6 +601,7 @@ if(currentUser.role==="super") tabDefs.push(["scholen", T("amOverarching")]); tabDefs.push(["klassen", T("amClasses")]); tabDefs.push(["gebruikers", T("amUsers")]); + tabDefs.push(["toewijzingen", T("amAssignments")]); if(!tabDefs.some(([t])=>t===adminTab)) adminTab = tabDefs[0][0]; const tabsBar = h("div","ed-tabs"); @@ -523,7 +614,7 @@ }); modal.appendChild(tabsBar); - const panels = { scholen: renderScholenPanel, klassen: renderKlassenPanel, gebruikers: renderGebruikersPanel }; + const panels = { scholen: renderScholenPanel, klassen: renderKlassenPanel, gebruikers: renderGebruikersPanel, toewijzingen: renderToewijzingenPanel }; tabDefs.forEach(([t])=>{ const panel = h("div","am-tabpanel"); panel.dataset.panel = t; diff --git a/public/js/board.js b/public/js/board.js index c3faa7c..4186105 100644 --- a/public/js/board.js +++ b/public/js/board.js @@ -838,20 +838,27 @@ document.addEventListener("langchange", refreshFab); /* ========================================================= Multiple boards, organised in folders (quick nav next to +) ==========================================================*/ +/* stabiel board-id, onafhankelijk van naam/positie - nodig zodat een toewijzing + aan een klas/leerling (zie pupil.js) een bord blijft vinden ook als het wordt + hernoemd of andere borden ervoor/erna worden toegevoegd/verwijderd */ +function genBoardId(){ return "b" + Date.now().toString(36) + Math.random().toString(36).slice(2,8); } +function newBoardEntry(name){ return { id: genBoardId(), name, data:null }; } function freshBS(){ return { folders:[{ name: LANG==="nl" ? "Map 1" : "Folder 1", - boards:[{ name: LANG==="nl" ? "Bord 1" : "Board 1", data:null }] }], + boards:[newBoardEntry(LANG==="nl" ? "Bord 1" : "Board 1")] }], f:0, b:0 }; } let BS = freshBS(); function boardsFromData(data){ - if(data && data.boards && data.boards.folders && data.boards.folders.length) return data.boards; - if(data && data.board){ - const bs = freshBS(); + let bs; + if(data && data.boards && data.boards.folders && data.boards.folders.length) bs = data.boards; + else if(data && data.board){ + bs = freshBS(); bs.folders[0].boards[0].data = data.board; /* migrate the old single board */ - return bs; - } - return freshBS(); + } else bs = freshBS(); + /* borden opgeslagen vóór dit id-systeem bestond krijgen er hier alsnog één */ + bs.folders.forEach(fo=>fo.boards.forEach(bo=>{ if(!bo.id) bo.id = genBoardId(); })); + return bs; } function curSlot(){ if(BS.f >= BS.folders.length) BS.f = 0; @@ -972,7 +979,7 @@ function renderStrip(){ } const fo = BS.folders[it.fi]; fo.boards.splice(it.bi, 1); - if(!fo.boards.length) fo.boards.push({ name:T("boardName")+" 1", data:null }); + if(!fo.boards.length) fo.boards.push(newBoardEntry(T("boardName")+" 1")); if(isCur){ BS.f = it.fi; BS.b = 0; loadCurrentSlot(); } else if(it.fi===BS.f && it.bi < BS.b) BS.b--; renderStrip(); @@ -991,7 +998,7 @@ function renderStrip(){ add.title = T("newBoard"); add.addEventListener("click", ()=>{ const fo = BS.folders[BS.f]; - fo.boards.push({ name:`${T("boardName")} ${fo.boards.length+1}`, data:null }); + fo.boards.push(newBoardEntry(`${T("boardName")} ${fo.boards.length+1}`)); switchTo(BS.f, fo.boards.length-1); renderStrip(); }); @@ -1008,7 +1015,7 @@ function renderBoardsPanel(){ const add = document.createElement("button"); add.textContent = T("newBoard"); add.addEventListener("click", ()=>{ - fo.boards.push({ name:`${T("boardName")} ${fo.boards.length+1}`, data:null }); + fo.boards.push(newBoardEntry(`${T("boardName")} ${fo.boards.length+1}`)); switchTo(fi, fo.boards.length-1); renderBoardsPanel(); }); @@ -1061,7 +1068,7 @@ function renderBoardsPanel(){ ev.stopPropagation(); const wasCur = (fi===BS.f && bi===BS.b); fo.boards.splice(bi,1); - if(!fo.boards.length) fo.boards.push({ name:T("boardName")+" 1", data:null }); + 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--; renderBoardsPanel(); @@ -1078,7 +1085,7 @@ function renderBoardsPanel(){ 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:[{ name:T("boardName")+" 1", data:null }] }); + BS.folders.push({ name, boards:[newBoardEntry(T("boardName")+" 1")] }); inp.value = ""; renderBoardsPanel(); }; diff --git a/public/js/core.js b/public/js/core.js index 7a7f417..1241889 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.2.21"; +const VERSION = "0.2.22"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -91,6 +91,7 @@ const I18N = { wg_ball:"Ballonnenwoord", wg_ball_d:"Raad het woord letter voor letter voordat de ballonnen knappen.", wg_anchor:"Ankerwoorden", wg_anchor_d:"Onbeperkt woordraster rond een thema: elk woord hoort bij de woorden eromheen.", anchorPh:"thema…", anchorHint:"Tik op een vak en typ · sleep de achtergrond om te schuiven", + anchorHintReadonly:"Sleep de achtergrond om te schuiven", anchorColor:"Kleur: klik om te wisselen (verbind woorden met dezelfde kleur)", ballWin:"Geraden! 🎉", ballLose:"Helaas! Het woord was:", bgDots:"Stippen", toolRuler:"Liniaal", toolGeo:"Geodriehoek", toolLens:"Vergrootglas", @@ -132,6 +133,7 @@ const I18N = { codePh:"koppelcode", beheer:"Beheer", roleSuper:"Overkoepelend beheerder", roleAdmin:"Schoolbeheerder", roleTeacher:"Groepsleiding", rolePupil:"Leerling", + pupilEmpty:"Je leerkracht heeft nog niets voor je klaargezet.", amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen", amOverarching:"Overkoepelend", amPickSchoolHint:"Kies eerst een school bij het tabblad “Overkoepelend”.", amNewSchool:"+ School", amNewClass:"+ Klas", amNewTeacher:"+ Groepsleiding", @@ -146,6 +148,9 @@ const I18N = { amPwHidden:"al gebruikt", amGenerate:"genereer wachtwoord", amNewPw:"nieuw wachtwoord", amPwSet:"Wachtwoord ingesteld:", amPwOptional:"wachtwoord (leeg = koppelcode)", + amAssignments:"Toewijzingen", amAssignClasses:"Per klas (standaard)", amAssignPupils:"Per leerling (uitzondering)", + amPickPupil:"— kies een leerling —", amNoAssignment:"— geen toewijzing —", + amAssignedAs:"Toegewezen:", amAssign:"Toewijzen", amAssignSaved:"Toewijzing opgeslagen.", amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?", amCopy:"kopieer", amCopyAll:"kopieer alles", amResetClass:"reset wachtwoorden klas", amResetSchool:"reset alle leerlingwachtwoorden", @@ -220,6 +225,7 @@ const I18N = { wg_ball:"Balloon word", wg_ball_d:"Guess the word letter by letter before the balloons pop.", wg_anchor:"Anchor words", wg_anchor_d:"Unlimited word grid around a theme: every word relates to its neighbours.", anchorPh:"theme…", anchorHint:"Tap a cell and type · drag the background to pan", + anchorHintReadonly:"Drag the background to pan", anchorColor:"Colour: click to cycle (connect words with the same colour)", ballWin:"You got it! 🎉", ballLose:"Oh no! The word was:", bgDots:"Dots", toolRuler:"Ruler", toolGeo:"Set square", toolLens:"Magnifier", @@ -261,6 +267,7 @@ const I18N = { codePh:"link code", beheer:"Management", roleSuper:"Overarching administrator", roleAdmin:"School administrator", roleTeacher:"Group leader", rolePupil:"Pupil", + pupilEmpty:"Your teacher hasn't set up anything for you yet.", amUsers:"Users", amClasses:"Classes", amSchools:"Schools", amOverarching:"Overarching", amPickSchoolHint:"First pick a school in the “Overarching” tab.", amNewSchool:"+ School", amNewClass:"+ Class", amNewTeacher:"+ Group leader", @@ -275,6 +282,9 @@ const I18N = { amPwHidden:"already used", amGenerate:"generate password", amNewPw:"new password", amPwSet:"Password set:", amPwOptional:"password (empty = link code)", + amAssignments:"Assignments", amAssignClasses:"Per class (default)", amAssignPupils:"Per pupil (override)", + amPickPupil:"— pick a pupil —", amNoAssignment:"— no assignment —", + amAssignedAs:"Assigned:", amAssign:"Assign", amAssignSaved:"Assignment saved.", amConfirmDel:"Are you sure you want to delete this user?", amCopy:"copy", amCopyAll:"copy all", amResetClass:"reset class passwords", amResetSchool:"reset all pupil passwords", @@ -356,6 +366,16 @@ function generalExtraFromData(data){ return (data && data.generalExtra) ? data.generalExtra : {nl:[], en:[]}; } async function hydrateFromServer(){ + /* leerlingen hebben geen eigen borden - zij zien enkel wat de leerkracht + heeft toegewezen (zie pupil.js), nooit het whiteboard */ + if(currentUser && currentUser.role === "pupil"){ + THEMES = {nl:[],en:[]}; + GENERAL_EXTRA = {nl:[],en:[]}; + startPupilView(); + document.dispatchEvent(new CustomEvent("userchange")); + return; + } + stopPupilView(); const d = (await api("/me/data")).data || {}; THEMES = themesFromData(d); GENERAL_EXTRA = generalExtraFromData(d); @@ -368,6 +388,7 @@ function resetToGuest(){ currentUser = null; TOKEN = null; try{ localStorage.removeItem("teach.token"); }catch(e){} + stopPupilView(); THEMES = {nl:[],en:[]}; GENERAL_EXTRA = {nl:[],en:[]}; BS = freshBS(); diff --git a/public/js/permissions.js b/public/js/permissions.js index d87f844..7aa90d9 100644 --- a/public/js/permissions.js +++ b/public/js/permissions.js @@ -12,6 +12,7 @@ const PERMISSIONS = { 'users.staffCredentials': ['super', 'admin'], 'users.genpw': ['super', 'admin', 'teacher'], 'users.role.change': ['super'], + 'assignments.manage': ['super', 'admin', 'teacher'], }; const CREATABLE_ROLES = { diff --git a/public/js/pupil.js b/public/js/pupil.js new file mode 100644 index 0000000..10c7ec5 --- /dev/null +++ b/public/js/pupil.js @@ -0,0 +1,64 @@ +/* teach - leerling-omgeving: toont enkel de widgets die de leerkracht heeft + klaargezet (via een toewijzing aan de klas of de leerling zelf), in + speelmodus (readonly) en zonder de sleep/vergroot/sluit-chrome van het bord. + Geen momentopname: /my/assignment zoekt live in het bord van de leerkracht, + dus deze pagina pollt periodiek zodat nieuwe woorden vanzelf doorkomen. */ +const PUPIL_WIDGET_CATS = ["taal", "rekenen"]; +const pupilView = document.getElementById("pupilView"); +let pupilPollTimer = null; +let pupilLastSig = null; + +function pupilAllowedDefs(){ + return REGISTRY.filter(d => PUPIL_WIDGET_CATS.includes(d.cat)); +} + +function renderPupilWidgets(widgets){ + pupilView.innerHTML = ""; + const defs = pupilAllowedDefs(); + const usable = widgets + .map(w => ({ w, def: defs.find(d => d.id === w.id) })) + .filter(x => x.def); + if(!usable.length){ + const hint = document.createElement("div"); + hint.className = "pupil-empty"; + hint.textContent = T("pupilEmpty"); + pupilView.appendChild(hint); + return; + } + usable.forEach(({ w, def })=>{ + const card = document.createElement("div"); + card.className = "widget pupil-card"; + card.style.width = def.w + "px"; + card.style.minHeight = def.h + "px"; + const body = document.createElement("div"); + body.className = "widget-body"; + card.appendChild(body); + pupilView.appendChild(card); + def.mount(body, w.state || null, { readonly:true }); + }); +} + +async function pupilPoll(){ + try{ + const res = await api("/my/assignment"); + const sig = JSON.stringify(res.widgets || []); + if(sig !== pupilLastSig){ + pupilLastSig = sig; + renderPupilWidgets(res.widgets || []); + } + }catch(e){ /* stille mislukking - volgende poll probeert opnieuw */ } +} + +function startPupilView(){ + document.body.classList.add("pupil-mode"); + pupilLastSig = null; + pupilPoll(); + clearInterval(pupilPollTimer); + pupilPollTimer = setInterval(pupilPoll, 20000); +} +function stopPupilView(){ + document.body.classList.remove("pupil-mode"); + clearInterval(pupilPollTimer); + pupilPollTimer = null; + pupilView.innerHTML = ""; +} diff --git a/public/js/widgets/anchor.js b/public/js/widgets/anchor.js index d63b038..d510427 100644 --- a/public/js/widgets/anchor.js +++ b/public/js/widgets/anchor.js @@ -2,7 +2,8 @@ /* ========================================================= Anchor words — unlimited word grid around a theme ==========================================================*/ -function mountAnchor(root, initState){ +function mountAnchor(root, initState, opts){ + const readonly = !!(opts && opts.readonly); const CW = 128, CH = 56, G = 10; /* colour cycle for connecting words: none → green → blue → red → purple → orange → yellow */ const COLS = [null,"#3fae6a","#3b7dd8","#e0554d","#9b59b6","#f28c38","#d9a400"]; @@ -112,17 +113,19 @@ function mountAnchor(root, initState){ /* no automatic match (or not tried yet) - stays empty, but doubles as a shortcut to manually pick a picture instead of showing a fake anchor icon */ pic.classList.add("an-pic-empty"); - pic.title = T("pickPic"); - pic.addEventListener("pointerdown", ev=>ev.stopPropagation()); - pic.addEventListener("click", ev=>{ - ev.stopPropagation(); - openPicPicker(normWord, chosen=>{ - S.pics[normWord] = chosen; - syncSource(); - render(); - persistWords(); + if(!readonly){ + pic.title = T("pickPic"); + pic.addEventListener("pointerdown", ev=>ev.stopPropagation()); + pic.addEventListener("click", ev=>{ + ev.stopPropagation(); + openPicPicker(normWord, chosen=>{ + S.pics[normWord] = chosen; + syncSource(); + render(); + persistWords(); + }); }); - }); + } } c.appendChild(pic); const span = document.createElement("span"); @@ -136,17 +139,19 @@ function mountAnchor(root, initState){ c.style.borderColor = cell.c; c.style.background = cell.c + "1c"; } - const dot = document.createElement("button"); - dot.className = "an-dot"; - dot.style.background = cell.c || "#cdd7e4"; - dot.title = T("anchorColor"); - dot.addEventListener("pointerdown", ev=>ev.stopPropagation()); - dot.addEventListener("click", ev=>{ - ev.stopPropagation(); - cell.c = COLS[(COLS.indexOf(cell.c)+1) % COLS.length]; - render(); - }); - c.appendChild(dot); + if(!readonly){ + const dot = document.createElement("button"); + dot.className = "an-dot"; + dot.style.background = cell.c || "#cdd7e4"; + dot.title = T("anchorColor"); + dot.addEventListener("pointerdown", ev=>ev.stopPropagation()); + dot.addEventListener("click", ev=>{ + ev.stopPropagation(); + cell.c = COLS[(COLS.indexOf(cell.c)+1) % COLS.length]; + render(); + }); + c.appendChild(dot); + } } } c.dataset.gx = gx; c.dataset.gy = gy; @@ -154,7 +159,7 @@ function mountAnchor(root, initState){ } } applyPan(); - hint.textContent = T("anchorHint"); + hint.textContent = T(readonly ? "anchorHintReadonly" : "anchorHint"); } /* pan by dragging, click (without moving) to type */ let pd = null; @@ -178,7 +183,7 @@ function mountAnchor(root, initState){ const {moved, target} = pd; pd = null; if(moved){ render(); return; } - if(target) editCell(target); + if(target && !readonly) editCell(target); }); stage.addEventListener("pointercancel", ()=>{ const moved = pd && pd.moved; diff --git a/public/js/widgets/letters.js b/public/js/widgets/letters.js index 1e7a163..b12dfd2 100644 --- a/public/js/widgets/letters.js +++ b/public/js/widgets/letters.js @@ -1,5 +1,6 @@ /* widget: Letterblokken (Ik leer lezen) */ -function mountLetterGame(root, initState){ +function mountLetterGame(root, initState, opts){ + const readonly = !!(opts && opts.readonly); const S = { level:1, card:0, wordIdx:0, stars:0, lang:LANG, count:6, order:[], snd:true, amode:"blocks" }; if(initState){ S.level = initState.level||1; @@ -149,10 +150,12 @@ function mountLetterGame(root, initState){ fillWordSourceSel(srcSel, S.card); srcSel.addEventListener("change", ()=>{ S.card = srcSel.value; newRound(); render(); }); cardsEl.appendChild(srcSel); - const edit = document.createElement("button"); - edit.className = "lg-edit"; edit.textContent = "✎"; - edit.addEventListener("click", openEditor); - cardsEl.appendChild(edit); + if(!readonly){ + const edit = document.createElement("button"); + edit.className = "lg-edit"; edit.textContent = "✎"; + edit.addEventListener("click", openEditor); + cardsEl.appendChild(edit); + } } buildCardButtons(); @@ -226,7 +229,8 @@ function mountLetterGame(root, initState){ b.textContent = T("lvl"+b.dataset.l); b.classList.toggle("active", +b.dataset.l===S.level); }); - cardsEl.querySelector(".lg-edit").title = T("manage"); + const editBtn = cardsEl.querySelector(".lg-edit"); + if(editBtn) editBtn.title = T("manage"); numSel.title = T("numWords"); const allOpt = numSel.querySelector('option[value="all"]'); if(allOpt) allOpt.textContent = T("all"); diff --git a/src/api.js b/src/api.js index 5a71d8d..5dafef4 100644 --- a/src/api.js +++ b/src/api.js @@ -374,6 +374,120 @@ export default async function api(app) { await pool.query('DELETE FROM users WHERE id = $1', [u.id]); return { ok: true }; }); + + // ---- toewijzingen (leerling-omgeving) ------------------------------------------ + // Een leerkracht koppelt een eigen bord (via het stabiele board-id) aan een klas + // (standaard voor iedereen erin) of een individuele leerling (uitzondering, voor + // wie op een ander niveau werkt). Leerling-eigen koppeling wint van de klas- + // koppeling. Geen momentopname: het bord wordt live opgezocht in de data van de + // leerkracht, dus nieuwe woorden die de leerkracht toevoegt komen vanzelf door. + const classOwnedByTeacher = async (classId, userId) => + !!(await pool.query('SELECT 1 FROM class_teachers WHERE class_id = $1 AND user_id = $2', [classId, userId])).rows[0]; + const findBoardById = (data, boardId) => { + const folders = data?.boards?.folders || []; + for (const fo of folders) { + const bo = (fo.boards || []).find((b) => b.id === boardId); + if (bo) return bo; + } + return null; + }; + const describeAssignment = async (a) => { + const t = (await pool.query('SELECT display_name, username, data FROM users WHERE id = $1', [a.teacher_id])).rows[0]; + const board = t ? findBoardById(t.data, a.board_id) : null; + return { + teacherId: Number(a.teacher_id), + teacherName: t ? (t.display_name || t.username) : null, + boardId: a.board_id, + boardName: board ? board.name : null, + updatedAt: a.updated_at, + }; + }; + + app.get('/assignments/class/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; + if (!c) return fail(reply, 404, 'klas onbekend'); + if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten'); + if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten'); + const a = (await pool.query('SELECT * FROM assignments WHERE class_id = $1', [c.id])).rows[0]; + return { assignment: a ? await describeAssignment(a) : null }; + }); + app.put('/assignments/class/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const { boardId } = req.body ?? {}; + if (!boardId) return fail(reply, 400, 'boardId verplicht'); + const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; + if (!c) return fail(reply, 404, 'klas onbekend'); + if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten'); + if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten'); + await pool.query( + `INSERT INTO assignments (class_id, teacher_id, board_id) VALUES ($1,$2,$3) + ON CONFLICT (class_id) WHERE class_id IS NOT NULL + DO UPDATE SET teacher_id = $2, board_id = $3, updated_at = now()`, + [c.id, req.user.id, boardId]); + return { ok: true }; + }); + app.delete('/assignments/class/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0]; + if (!c) return fail(reply, 404, 'klas onbekend'); + if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten'); + if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten'); + await pool.query('DELETE FROM assignments WHERE class_id = $1', [c.id]); + return { ok: true }; + }); + + const pupilAccessible = async (req, u) => { + if (!u || u.role !== 'pupil' || !sameSchool(req, u)) return false; + if (teacherOnly(req) && !(u.class_id && (await classOwnedByTeacher(u.class_id, req.user.id)))) return false; + return true; + }; + app.get('/assignments/pupil/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; + if (!u) return fail(reply, 404, 'gebruiker onbekend'); + if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten'); + const a = (await pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [u.id])).rows[0]; + return { assignment: a ? await describeAssignment(a) : null }; + }); + app.put('/assignments/pupil/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const { boardId } = req.body ?? {}; + if (!boardId) return fail(reply, 400, 'boardId verplicht'); + const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; + if (!u) return fail(reply, 404, 'gebruiker onbekend'); + if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten'); + await pool.query( + `INSERT INTO assignments (pupil_id, teacher_id, board_id) VALUES ($1,$2,$3) + ON CONFLICT (pupil_id) WHERE pupil_id IS NOT NULL + DO UPDATE SET teacher_id = $2, board_id = $3, updated_at = now()`, + [u.id, req.user.id, boardId]); + return { ok: true }; + }); + app.delete('/assignments/pupil/:id', async (req, reply) => { + need(req, reply, PERMISSIONS['assignments.manage']); + const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0]; + if (!u) return fail(reply, 404, 'gebruiker onbekend'); + if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten'); + await pool.query('DELETE FROM assignments WHERE pupil_id = $1', [u.id]); + return { ok: true }; + }); + + // Leerling-kant: de effectieve toewijzing (eigen override, anders de klas). + // Live opgezocht (geen kopie) zodat nieuwe woorden bij de leerkracht meteen + // doorkomen zodra de leerling-pagina opnieuw ophaalt (polling). + app.get('/my/assignment', async (req, reply) => { + need(req, reply); + let a = (await pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [req.user.id])).rows[0]; + if (!a && req.user.class_id) { + a = (await pool.query('SELECT * FROM assignments WHERE class_id = $1', [req.user.class_id])).rows[0]; + } + if (!a) return { widgets: [], updatedAt: null }; + const t = (await pool.query('SELECT data FROM users WHERE id = $1', [a.teacher_id])).rows[0]; + const board = t ? findBoardById(t.data, a.board_id) : null; + const widgets = (board?.data?.widgets || []).map((w) => ({ id: w.id, state: w.state })); + return { widgets, updatedAt: a.updated_at }; + }); } // Eerste super-beheerder aanmaken als die nog niet bestaat. diff --git a/src/permissions.js b/src/permissions.js index 8bb05f3..f8eb4ec 100644 --- a/src/permissions.js +++ b/src/permissions.js @@ -14,6 +14,7 @@ export const PERMISSIONS = { 'users.staffCredentials': ['super', 'admin'], 'users.genpw': ['super', 'admin', 'teacher'], 'users.role.change': ['super'], + 'assignments.manage': ['super', 'admin', 'teacher'], }; // Welke rol een account met welke rol mag aanmaken.