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
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:
parent
2528e5b8f6
commit
0411375a3c
13 changed files with 388 additions and 46 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.2.21
|
0.2.22
|
||||||
|
|
|
||||||
16
db/005_assignments.sql
Normal file
16
db/005_assignments.sql
Normal 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;
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -838,21 +838,28 @@ 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 */
|
||||||
|
} 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;
|
return bs;
|
||||||
}
|
}
|
||||||
return freshBS();
|
|
||||||
}
|
|
||||||
function curSlot(){
|
function curSlot(){
|
||||||
if(BS.f >= BS.folders.length) BS.f = 0;
|
if(BS.f >= BS.folders.length) BS.f = 0;
|
||||||
const fo = BS.folders[BS.f];
|
const fo = BS.folders[BS.f];
|
||||||
|
|
@ -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();
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
|
|
||||||
|
|
@ -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
64
public/js/pupil.js
Normal 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 = "";
|
||||||
|
}
|
||||||
|
|
@ -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,6 +113,7 @@ 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");
|
||||||
|
if(!readonly){
|
||||||
pic.title = T("pickPic");
|
pic.title = T("pickPic");
|
||||||
pic.addEventListener("pointerdown", ev=>ev.stopPropagation());
|
pic.addEventListener("pointerdown", ev=>ev.stopPropagation());
|
||||||
pic.addEventListener("click", ev=>{
|
pic.addEventListener("click", ev=>{
|
||||||
|
|
@ -124,6 +126,7 @@ function mountAnchor(root, initState){
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
}
|
||||||
c.appendChild(pic);
|
c.appendChild(pic);
|
||||||
const span = document.createElement("span");
|
const span = document.createElement("span");
|
||||||
span.className = "an-word";
|
span.className = "an-word";
|
||||||
|
|
@ -136,6 +139,7 @@ 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";
|
||||||
}
|
}
|
||||||
|
if(!readonly){
|
||||||
const dot = document.createElement("button");
|
const dot = document.createElement("button");
|
||||||
dot.className = "an-dot";
|
dot.className = "an-dot";
|
||||||
dot.style.background = cell.c || "#cdd7e4";
|
dot.style.background = cell.c || "#cdd7e4";
|
||||||
|
|
@ -149,12 +153,13 @@ function mountAnchor(root, initState){
|
||||||
c.appendChild(dot);
|
c.appendChild(dot);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
c.dataset.gx = gx; c.dataset.gy = gy;
|
c.dataset.gx = gx; c.dataset.gy = gy;
|
||||||
layer.appendChild(c);
|
layer.appendChild(c);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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;
|
||||||
|
|
|
||||||
|
|
@ -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,11 +150,13 @@ 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);
|
||||||
|
if(!readonly){
|
||||||
const edit = document.createElement("button");
|
const edit = document.createElement("button");
|
||||||
edit.className = "lg-edit"; edit.textContent = "✎";
|
edit.className = "lg-edit"; edit.textContent = "✎";
|
||||||
edit.addEventListener("click", openEditor);
|
edit.addEventListener("click", openEditor);
|
||||||
cardsEl.appendChild(edit);
|
cardsEl.appendChild(edit);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
buildCardButtons();
|
buildCardButtons();
|
||||||
|
|
||||||
root.querySelectorAll(".lg-levels button").forEach(b=>{
|
root.querySelectorAll(".lg-levels button").forEach(b=>{
|
||||||
|
|
@ -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");
|
||||||
|
|
|
||||||
114
src/api.js
114
src/api.js
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue