Verbeter grafische kwaliteit van kleurplaten (v0.4.67-beta) #5
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.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;
|
||||
|
|
|
|||
|
|
@ -31,6 +31,7 @@
|
|||
</div>
|
||||
|
||||
<div id="board"></div>
|
||||
<div id="pupilView"></div>
|
||||
<button id="fab">+</button>
|
||||
<button id="fabBoardsBtn">🗂</button>
|
||||
<div id="boardStrip"></div>
|
||||
|
|
@ -166,6 +167,7 @@
|
|||
<script src="js/widgets/names.js"></script>
|
||||
<script src="js/widgets/mind.js"></script>
|
||||
<script src="js/board.js"></script>
|
||||
<script src="js/pupil.js"></script>
|
||||
<script src="js/admin.js"></script>
|
||||
<script src="js/app.js"></script>
|
||||
</body>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
} 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 freshBS();
|
||||
}
|
||||
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();
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
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
|
||||
==========================================================*/
|
||||
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,6 +113,7 @@ 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");
|
||||
if(!readonly){
|
||||
pic.title = T("pickPic");
|
||||
pic.addEventListener("pointerdown", ev=>ev.stopPropagation());
|
||||
pic.addEventListener("click", ev=>{
|
||||
|
|
@ -124,6 +126,7 @@ function mountAnchor(root, initState){
|
|||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
c.appendChild(pic);
|
||||
const span = document.createElement("span");
|
||||
span.className = "an-word";
|
||||
|
|
@ -136,6 +139,7 @@ function mountAnchor(root, initState){
|
|||
c.style.borderColor = cell.c;
|
||||
c.style.background = cell.c + "1c";
|
||||
}
|
||||
if(!readonly){
|
||||
const dot = document.createElement("button");
|
||||
dot.className = "an-dot";
|
||||
dot.style.background = cell.c || "#cdd7e4";
|
||||
|
|
@ -149,12 +153,13 @@ function mountAnchor(root, initState){
|
|||
c.appendChild(dot);
|
||||
}
|
||||
}
|
||||
}
|
||||
c.dataset.gx = gx; c.dataset.gy = gy;
|
||||
layer.appendChild(c);
|
||||
}
|
||||
}
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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,11 +150,13 @@ function mountLetterGame(root, initState){
|
|||
fillWordSourceSel(srcSel, S.card);
|
||||
srcSel.addEventListener("change", ()=>{ S.card = srcSel.value; newRound(); render(); });
|
||||
cardsEl.appendChild(srcSel);
|
||||
if(!readonly){
|
||||
const edit = document.createElement("button");
|
||||
edit.className = "lg-edit"; edit.textContent = "✎";
|
||||
edit.addEventListener("click", openEditor);
|
||||
cardsEl.appendChild(edit);
|
||||
}
|
||||
}
|
||||
buildCardButtons();
|
||||
|
||||
root.querySelectorAll(".lg-levels button").forEach(b=>{
|
||||
|
|
@ -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");
|
||||
|
|
|
|||
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]);
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
Loading…
Reference in a new issue