v0.2.22-beta: leerling-omgeving (aparte pagina zonder whiteboard)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 9s

- 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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DuxHJtk5aHe2pA3xwCLEAk
This commit is contained in:
Ramon 2026-07-13 23:14:26 +02:00
parent 2528e5b8f6
commit 0411375a3c
13 changed files with 388 additions and 46 deletions

View file

@ -1 +1 @@
0.2.21 0.2.22

16
db/005_assignments.sql Normal file
View file

@ -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;

View file

@ -84,6 +84,22 @@
body.pen #boardInk{pointer-events:auto; cursor:crosshair;} body.pen #boardInk{pointer-events:auto; cursor:crosshair;}
body.pen.pen-text #boardInk{cursor:text;} body.pen.pen-text #boardInk{cursor:text;}
body.pen.pen-hand #boardInk{pointer-events:none;} /* hand tool: interact with everything */ 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) */ /* floating add-button (FAB) */
#fab{ #fab{
position:fixed; right:24px; bottom:24px; z-index:5600; width:60px; height:60px; position:fixed; right:24px; bottom:24px; z-index:5600; width:60px; height:60px;

View file

@ -31,6 +31,7 @@
</div> </div>
<div id="board"></div> <div id="board"></div>
<div id="pupilView"></div>
<button id="fab"></button> <button id="fab"></button>
<button id="fabBoardsBtn">🗂</button> <button id="fabBoardsBtn">🗂</button>
<div id="boardStrip"></div> <div id="boardStrip"></div>
@ -166,6 +167,7 @@
<script src="js/widgets/names.js"></script> <script src="js/widgets/names.js"></script>
<script src="js/widgets/mind.js"></script> <script src="js/widgets/mind.js"></script>
<script src="js/board.js"></script> <script src="js/board.js"></script>
<script src="js/pupil.js"></script>
<script src="js/admin.js"></script> <script src="js/admin.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>
</body> </body>

View file

@ -7,6 +7,9 @@
const modal = document.getElementById("adminModal"); const modal = document.getElementById("adminModal");
const btn = document.getElementById("btnAdmin"); const btn = document.getElementById("btnAdmin");
let SCHOOLS = [], USERS = [], CLASSES = [], selSchool = null; 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 /* resultaat van een laatste multireset (klas/school): blijft zichtbaar
totdat de beheerder het venster zelf sluit, zodat wachtwoorden niet totdat de beheerder het venster zelf sluit, zodat wachtwoorden niet
meteen weer verdwijnen na een reload() */ meteen weer verdwijnen na een reload() */
@ -73,7 +76,12 @@
USERS = (await api("/admin/users"+q)).users; USERS = (await api("/admin/users"+q)).users;
const cq = currentUser.role==="super" ? (selSchool ? "?school="+selSchool : "?school=0") : ""; const cq = currentUser.role==="super" ? (selSchool ? "?school="+selSchool : "?school=0") : "";
CLASSES = (await api("/admin/classes"+cq)).classes || []; 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(); 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 /* 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 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) */ 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")]); if(currentUser.role==="super") tabDefs.push(["scholen", T("amOverarching")]);
tabDefs.push(["klassen", T("amClasses")]); tabDefs.push(["klassen", T("amClasses")]);
tabDefs.push(["gebruikers", T("amUsers")]); tabDefs.push(["gebruikers", T("amUsers")]);
tabDefs.push(["toewijzingen", T("amAssignments")]);
if(!tabDefs.some(([t])=>t===adminTab)) adminTab = tabDefs[0][0]; if(!tabDefs.some(([t])=>t===adminTab)) adminTab = tabDefs[0][0];
const tabsBar = h("div","ed-tabs"); const tabsBar = h("div","ed-tabs");
@ -523,7 +614,7 @@
}); });
modal.appendChild(tabsBar); modal.appendChild(tabsBar);
const panels = { scholen: renderScholenPanel, klassen: renderKlassenPanel, gebruikers: renderGebruikersPanel }; const panels = { scholen: renderScholenPanel, klassen: renderKlassenPanel, gebruikers: renderGebruikersPanel, toewijzingen: renderToewijzingenPanel };
tabDefs.forEach(([t])=>{ tabDefs.forEach(([t])=>{
const panel = h("div","am-tabpanel"); const panel = h("div","am-tabpanel");
panel.dataset.panel = t; panel.dataset.panel = t;

View file

@ -838,20 +838,27 @@ document.addEventListener("langchange", refreshFab);
/* ========================================================= /* =========================================================
Multiple boards, organised in folders (quick nav next to +) 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(){ function freshBS(){
return { folders:[{ name: LANG==="nl" ? "Map 1" : "Folder 1", 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 }; f:0, b:0 };
} }
let BS = freshBS(); let BS = freshBS();
function boardsFromData(data){ function boardsFromData(data){
if(data && data.boards && data.boards.folders && data.boards.folders.length) return data.boards; let bs;
if(data && data.board){ if(data && data.boards && data.boards.folders && data.boards.folders.length) bs = data.boards;
const bs = freshBS(); else if(data && data.board){
bs = freshBS();
bs.folders[0].boards[0].data = data.board; /* migrate the old single board */ bs.folders[0].boards[0].data = data.board; /* migrate the old single board */
return bs; } else bs = freshBS();
} /* borden opgeslagen vóór dit id-systeem bestond krijgen er hier alsnog één */
return freshBS(); bs.folders.forEach(fo=>fo.boards.forEach(bo=>{ if(!bo.id) bo.id = genBoardId(); }));
return bs;
} }
function curSlot(){ function curSlot(){
if(BS.f >= BS.folders.length) BS.f = 0; if(BS.f >= BS.folders.length) BS.f = 0;
@ -972,7 +979,7 @@ function renderStrip(){
} }
const fo = BS.folders[it.fi]; const fo = BS.folders[it.fi];
fo.boards.splice(it.bi, 1); 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(); } if(isCur){ BS.f = it.fi; BS.b = 0; loadCurrentSlot(); }
else if(it.fi===BS.f && it.bi < BS.b) BS.b--; else if(it.fi===BS.f && it.bi < BS.b) BS.b--;
renderStrip(); renderStrip();
@ -991,7 +998,7 @@ function renderStrip(){
add.title = T("newBoard"); add.title = T("newBoard");
add.addEventListener("click", ()=>{ add.addEventListener("click", ()=>{
const fo = BS.folders[BS.f]; 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); switchTo(BS.f, fo.boards.length-1);
renderStrip(); renderStrip();
}); });
@ -1008,7 +1015,7 @@ function renderBoardsPanel(){
const add = document.createElement("button"); const add = document.createElement("button");
add.textContent = T("newBoard"); add.textContent = T("newBoard");
add.addEventListener("click", ()=>{ 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); switchTo(fi, fo.boards.length-1);
renderBoardsPanel(); renderBoardsPanel();
}); });
@ -1061,7 +1068,7 @@ function renderBoardsPanel(){
ev.stopPropagation(); ev.stopPropagation();
const wasCur = (fi===BS.f && bi===BS.b); const wasCur = (fi===BS.f && bi===BS.b);
fo.boards.splice(bi,1); 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(); } if(wasCur){ BS.b = 0; loadCurrentSlot(); }
else if(fi===BS.f && bi < BS.b) BS.b--; else if(fi===BS.f && bi < BS.b) BS.b--;
renderBoardsPanel(); renderBoardsPanel();
@ -1078,7 +1085,7 @@ function renderBoardsPanel(){
inp.placeholder = T("folderPh"); inp.maxLength = 20; inp.placeholder = T("folderPh"); inp.maxLength = 20;
const addFolder = ()=>{ const addFolder = ()=>{
const name = inp.value.trim() || `${T("folderName")} ${BS.folders.length+1}`; 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 = ""; inp.value = "";
renderBoardsPanel(); renderBoardsPanel();
}; };

View file

@ -2,7 +2,7 @@
"use strict"; "use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline). /* 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. */ 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(){ (function(){
const tag = document.getElementById("verTag"); const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION; 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_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.", 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", 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)", anchorColor:"Kleur: klik om te wisselen (verbind woorden met dezelfde kleur)",
ballWin:"Geraden! 🎉", ballLose:"Helaas! Het woord was:", ballWin:"Geraden! 🎉", ballLose:"Helaas! Het woord was:",
bgDots:"Stippen", toolRuler:"Liniaal", toolGeo:"Geodriehoek", toolLens:"Vergrootglas", bgDots:"Stippen", toolRuler:"Liniaal", toolGeo:"Geodriehoek", toolLens:"Vergrootglas",
@ -132,6 +133,7 @@ const I18N = {
codePh:"koppelcode", beheer:"Beheer", codePh:"koppelcode", beheer:"Beheer",
roleSuper:"Overkoepelend beheerder", roleAdmin:"Schoolbeheerder", roleSuper:"Overkoepelend beheerder", roleAdmin:"Schoolbeheerder",
roleTeacher:"Groepsleiding", rolePupil:"Leerling", roleTeacher:"Groepsleiding", rolePupil:"Leerling",
pupilEmpty:"Je leerkracht heeft nog niets voor je klaargezet.",
amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen", amOverarching:"Overkoepelend", amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen", amOverarching:"Overkoepelend",
amPickSchoolHint:"Kies eerst een school bij het tabblad “Overkoepelend”.", amPickSchoolHint:"Kies eerst een school bij het tabblad “Overkoepelend”.",
amNewSchool:" School", amNewClass:" Klas", amNewTeacher:" Groepsleiding", amNewSchool:" School", amNewClass:" Klas", amNewTeacher:" Groepsleiding",
@ -146,6 +148,9 @@ const I18N = {
amPwHidden:"al gebruikt", amGenerate:"genereer wachtwoord", amPwHidden:"al gebruikt", amGenerate:"genereer wachtwoord",
amNewPw:"nieuw wachtwoord", amPwSet:"Wachtwoord ingesteld:", amNewPw:"nieuw wachtwoord", amPwSet:"Wachtwoord ingesteld:",
amPwOptional:"wachtwoord (leeg = koppelcode)", 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?", amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?",
amCopy:"kopieer", amCopyAll:"kopieer alles", amCopy:"kopieer", amCopyAll:"kopieer alles",
amResetClass:"reset wachtwoorden klas", amResetSchool:"reset alle leerlingwachtwoorden", 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_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.", 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", 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)", anchorColor:"Colour: click to cycle (connect words with the same colour)",
ballWin:"You got it! 🎉", ballLose:"Oh no! The word was:", ballWin:"You got it! 🎉", ballLose:"Oh no! The word was:",
bgDots:"Dots", toolRuler:"Ruler", toolGeo:"Set square", toolLens:"Magnifier", bgDots:"Dots", toolRuler:"Ruler", toolGeo:"Set square", toolLens:"Magnifier",
@ -261,6 +267,7 @@ const I18N = {
codePh:"link code", beheer:"Management", codePh:"link code", beheer:"Management",
roleSuper:"Overarching administrator", roleAdmin:"School administrator", roleSuper:"Overarching administrator", roleAdmin:"School administrator",
roleTeacher:"Group leader", rolePupil:"Pupil", roleTeacher:"Group leader", rolePupil:"Pupil",
pupilEmpty:"Your teacher hasn't set up anything for you yet.",
amUsers:"Users", amClasses:"Classes", amSchools:"Schools", amOverarching:"Overarching", amUsers:"Users", amClasses:"Classes", amSchools:"Schools", amOverarching:"Overarching",
amPickSchoolHint:"First pick a school in the “Overarching” tab.", amPickSchoolHint:"First pick a school in the “Overarching” tab.",
amNewSchool:" School", amNewClass:" Class", amNewTeacher:" Group leader", amNewSchool:" School", amNewClass:" Class", amNewTeacher:" Group leader",
@ -275,6 +282,9 @@ const I18N = {
amPwHidden:"already used", amGenerate:"generate password", amPwHidden:"already used", amGenerate:"generate password",
amNewPw:"new password", amPwSet:"Password set:", amNewPw:"new password", amPwSet:"Password set:",
amPwOptional:"password (empty = link code)", 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?", amConfirmDel:"Are you sure you want to delete this user?",
amCopy:"copy", amCopyAll:"copy all", amCopy:"copy", amCopyAll:"copy all",
amResetClass:"reset class passwords", amResetSchool:"reset all pupil passwords", amResetClass:"reset class passwords", amResetSchool:"reset all pupil passwords",
@ -356,6 +366,16 @@ function generalExtraFromData(data){
return (data && data.generalExtra) ? data.generalExtra : {nl:[], en:[]}; return (data && data.generalExtra) ? data.generalExtra : {nl:[], en:[]};
} }
async function hydrateFromServer(){ 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 || {}; const d = (await api("/me/data")).data || {};
THEMES = themesFromData(d); THEMES = themesFromData(d);
GENERAL_EXTRA = generalExtraFromData(d); GENERAL_EXTRA = generalExtraFromData(d);
@ -368,6 +388,7 @@ function resetToGuest(){
currentUser = null; currentUser = null;
TOKEN = null; TOKEN = null;
try{ localStorage.removeItem("teach.token"); }catch(e){} try{ localStorage.removeItem("teach.token"); }catch(e){}
stopPupilView();
THEMES = {nl:[],en:[]}; THEMES = {nl:[],en:[]};
GENERAL_EXTRA = {nl:[],en:[]}; GENERAL_EXTRA = {nl:[],en:[]};
BS = freshBS(); BS = freshBS();

View file

@ -12,6 +12,7 @@ const PERMISSIONS = {
'users.staffCredentials': ['super', 'admin'], 'users.staffCredentials': ['super', 'admin'],
'users.genpw': ['super', 'admin', 'teacher'], 'users.genpw': ['super', 'admin', 'teacher'],
'users.role.change': ['super'], 'users.role.change': ['super'],
'assignments.manage': ['super', 'admin', 'teacher'],
}; };
const CREATABLE_ROLES = { const CREATABLE_ROLES = {

64
public/js/pupil.js Normal file
View file

@ -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 = "";
}

View file

@ -2,7 +2,8 @@
/* ========================================================= /* =========================================================
Anchor words unlimited word grid around a theme 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; const CW = 128, CH = 56, G = 10;
/* colour cycle for connecting words: none → green → blue → red → purple → orange → yellow */ /* colour cycle for connecting words: none → green → blue → red → purple → orange → yellow */
const COLS = [null,"#3fae6a","#3b7dd8","#e0554d","#9b59b6","#f28c38","#d9a400"]; 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 /* 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 */ shortcut to manually pick a picture instead of showing a fake anchor icon */
pic.classList.add("an-pic-empty"); pic.classList.add("an-pic-empty");
pic.title = T("pickPic"); if(!readonly){
pic.addEventListener("pointerdown", ev=>ev.stopPropagation()); pic.title = T("pickPic");
pic.addEventListener("click", ev=>{ pic.addEventListener("pointerdown", ev=>ev.stopPropagation());
ev.stopPropagation(); pic.addEventListener("click", ev=>{
openPicPicker(normWord, chosen=>{ ev.stopPropagation();
S.pics[normWord] = chosen; openPicPicker(normWord, chosen=>{
syncSource(); S.pics[normWord] = chosen;
render(); syncSource();
persistWords(); render();
persistWords();
});
}); });
}); }
} }
c.appendChild(pic); c.appendChild(pic);
const span = document.createElement("span"); const span = document.createElement("span");
@ -136,17 +139,19 @@ function mountAnchor(root, initState){
c.style.borderColor = cell.c; c.style.borderColor = cell.c;
c.style.background = cell.c + "1c"; c.style.background = cell.c + "1c";
} }
const dot = document.createElement("button"); if(!readonly){
dot.className = "an-dot"; const dot = document.createElement("button");
dot.style.background = cell.c || "#cdd7e4"; dot.className = "an-dot";
dot.title = T("anchorColor"); dot.style.background = cell.c || "#cdd7e4";
dot.addEventListener("pointerdown", ev=>ev.stopPropagation()); dot.title = T("anchorColor");
dot.addEventListener("click", ev=>{ dot.addEventListener("pointerdown", ev=>ev.stopPropagation());
ev.stopPropagation(); dot.addEventListener("click", ev=>{
cell.c = COLS[(COLS.indexOf(cell.c)+1) % COLS.length]; ev.stopPropagation();
render(); cell.c = COLS[(COLS.indexOf(cell.c)+1) % COLS.length];
}); render();
c.appendChild(dot); });
c.appendChild(dot);
}
} }
} }
c.dataset.gx = gx; c.dataset.gy = gy; c.dataset.gx = gx; c.dataset.gy = gy;
@ -154,7 +159,7 @@ function mountAnchor(root, initState){
} }
} }
applyPan(); applyPan();
hint.textContent = T("anchorHint"); hint.textContent = T(readonly ? "anchorHintReadonly" : "anchorHint");
} }
/* pan by dragging, click (without moving) to type */ /* pan by dragging, click (without moving) to type */
let pd = null; let pd = null;
@ -178,7 +183,7 @@ function mountAnchor(root, initState){
const {moved, target} = pd; const {moved, target} = pd;
pd = null; pd = null;
if(moved){ render(); return; } if(moved){ render(); return; }
if(target) editCell(target); if(target && !readonly) editCell(target);
}); });
stage.addEventListener("pointercancel", ()=>{ stage.addEventListener("pointercancel", ()=>{
const moved = pd && pd.moved; const moved = pd && pd.moved;

View file

@ -1,5 +1,6 @@
/* widget: Letterblokken (Ik leer lezen) */ /* 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" }; const S = { level:1, card:0, wordIdx:0, stars:0, lang:LANG, count:6, order:[], snd:true, amode:"blocks" };
if(initState){ if(initState){
S.level = initState.level||1; S.level = initState.level||1;
@ -149,10 +150,12 @@ function mountLetterGame(root, initState){
fillWordSourceSel(srcSel, S.card); fillWordSourceSel(srcSel, S.card);
srcSel.addEventListener("change", ()=>{ S.card = srcSel.value; newRound(); render(); }); srcSel.addEventListener("change", ()=>{ S.card = srcSel.value; newRound(); render(); });
cardsEl.appendChild(srcSel); cardsEl.appendChild(srcSel);
const edit = document.createElement("button"); if(!readonly){
edit.className = "lg-edit"; edit.textContent = "✎"; const edit = document.createElement("button");
edit.addEventListener("click", openEditor); edit.className = "lg-edit"; edit.textContent = "✎";
cardsEl.appendChild(edit); edit.addEventListener("click", openEditor);
cardsEl.appendChild(edit);
}
} }
buildCardButtons(); buildCardButtons();
@ -226,7 +229,8 @@ function mountLetterGame(root, initState){
b.textContent = T("lvl"+b.dataset.l); b.textContent = T("lvl"+b.dataset.l);
b.classList.toggle("active", +b.dataset.l===S.level); 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"); numSel.title = T("numWords");
const allOpt = numSel.querySelector('option[value="all"]'); const allOpt = numSel.querySelector('option[value="all"]');
if(allOpt) allOpt.textContent = T("all"); if(allOpt) allOpt.textContent = T("all");

View file

@ -374,6 +374,120 @@ export default async function api(app) {
await pool.query('DELETE FROM users WHERE id = $1', [u.id]); await pool.query('DELETE FROM users WHERE id = $1', [u.id]);
return { ok: true }; 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. // Eerste super-beheerder aanmaken als die nog niet bestaat.

View file

@ -14,6 +14,7 @@ export const PERMISSIONS = {
'users.staffCredentials': ['super', 'admin'], 'users.staffCredentials': ['super', 'admin'],
'users.genpw': ['super', 'admin', 'teacher'], 'users.genpw': ['super', 'admin', 'teacher'],
'users.role.change': ['super'], 'users.role.change': ['super'],
'assignments.manage': ['super', 'admin', 'teacher'],
}; };
// Welke rol een account met welke rol mag aanmaken. // Welke rol een account met welke rol mag aanmaken.