Verbeter anatomie en plaatsing van avataraccessoires (v0.4.46-beta) #1

Open
bes-r wants to merge 192 commits from bes-r/avatar-realism into main AGit
6 changed files with 439 additions and 11 deletions
Showing only changes of commit 5f9ffbfdde - Show all commits

View file

@ -1 +1 @@
0.3.57-beta 0.3.58-beta

View file

@ -152,6 +152,23 @@
box-shadow:0 2px 8px rgba(30,50,90,.15); box-shadow:0 2px 8px rgba(30,50,90,.15);
} }
.pupil-empty{margin:auto; color:var(--muted); font-size:16px; font-weight:600;} .pupil-empty{margin:auto; color:var(--muted); font-size:16px; font-weight:600;}
/* ---------- Ouderportaal ---------- */
/* ouders zien geen whiteboard: alleen het portaal met hun kinderen */
#parentView{display:none;}
body.parent-mode #board,body.parent-mode #fab,body.parent-mode #fabBoardsBtn,
body.parent-mode #boardStrip,body.parent-mode #fabmenu,
body.parent-mode #hud,body.parent-mode #btnFolders,body.parent-mode #btnSave{display:none !important;}
body.parent-mode #parentView{
display:block; position:absolute; inset:var(--tbar) 0 0 0;
overflow:auto; padding:24px 18px; box-sizing:border-box;
}
.parent-inner{
max-width:720px; margin:0 auto; display:flex; flex-direction:column; gap:10px;
}
.parent-title{margin:0 0 4px; font-size:22px;}
.parent-inner .am-row{flex-wrap:wrap;}
.parent-inner .am-row-tools{flex-basis:100%; display:flex; flex-direction:column; gap:8px;}
.parent-inner .am-row-tools:empty{display:none;}
/* 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

@ -30,6 +30,7 @@
<div id="board"></div> <div id="board"></div>
<div id="pupilView"></div> <div id="pupilView"></div>
<div id="parentView"></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>
@ -108,12 +109,12 @@
<div class="ed-tabs"> <div class="ed-tabs">
<button type="button" class="active" data-tab="login" data-i18n="tabLogin"></button> <button type="button" class="active" data-tab="login" data-i18n="tabLogin"></button>
<button type="button" data-tab="link" data-i18n="tabLinkCode"></button> <button type="button" data-tab="link" data-i18n="tabLinkCode"></button>
<button type="button" data-tab="parentreg" data-i18n="tabParentReg"></button>
</div> </div>
<div id="loginBox"> <div id="loginBox">
<select id="liSchool"></select> <select id="liSchool"></select>
<input type="text" id="liName" maxlength="30" autocomplete="username"> <input type="text" id="liName" maxlength="30" autocomplete="username">
<input type="password" id="liPass" maxlength="64" autocomplete="current-password"> <input type="password" id="liPass" maxlength="64" autocomplete="current-password">
<div id="loginMsg" class="formMsg"></div>
<div class="lrow"> <div class="lrow">
<button class="tbtn" id="btnLogin" data-i18n="loginBtn"></button> <button class="tbtn" id="btnLogin" data-i18n="loginBtn"></button>
</div> </div>
@ -130,6 +131,19 @@
<button class="tbtn ghost" id="btnLink" data-i18n="linkBtn"></button> <button class="tbtn ghost" id="btnLink" data-i18n="linkBtn"></button>
</div> </div>
</div> </div>
<div id="parentRegBox" style="display:none;">
<div class="lisub" data-i18n="parentRegIntro"></div>
<input type="text" id="prName" maxlength="30" autocomplete="username">
<div class="lrow">
<input type="password" id="prPass" maxlength="64" autocomplete="new-password">
<button class="tbtn ghost pwtoggle" type="button" id="btnPrPassToggle" data-i18n="pwShow"></button>
</div>
<input type="password" id="prPass2" maxlength="64" autocomplete="new-password">
<div class="lrow">
<button class="tbtn ghost" id="btnParentReg" data-i18n="parentRegBtn"></button>
</div>
</div>
<div id="loginMsg" class="formMsg"></div>
<div class="guestnote" data-i18n="guestNote"></div> <div class="guestnote" data-i18n="guestNote"></div>
<button class="tbtn ghost" type="button" id="btnGuestView" style="margin-top:10px;">🎨 <span data-i18n="stView"></span></button> <button class="tbtn ghost" type="button" id="btnGuestView" style="margin-top:10px;">🎨 <span data-i18n="stView"></span></button>
</div> </div>
@ -268,6 +282,7 @@
<script src="js/widgets/learning.js"></script> <script src="js/widgets/learning.js"></script>
<script src="js/board.js"></script> <script src="js/board.js"></script>
<script src="js/pupil.js"></script> <script src="js/pupil.js"></script>
<script src="js/parent.js"></script>
<script src="js/admin.js"></script> <script src="js/admin.js"></script>
<script src="js/image-catalog.js"></script> <script src="js/image-catalog.js"></script>
<script src="js/app.js"></script> <script src="js/app.js"></script>

View file

@ -11,6 +11,8 @@
/* huidige klas-toewijzingen (leerling-omgeving), per klas-id - opnieuw /* huidige klas-toewijzingen (leerling-omgeving), per klas-id - opnieuw
opgehaald bij elke reload() zoals CLASSES/USERS hierboven */ opgehaald bij elke reload() zoals CLASSES/USERS hierboven */
let ASSIGNMENTS = {}; let ASSIGNMENTS = {};
/* openstaande aanmeldverzoeken van ouders (thuiskind -> deze school) */
let LINK_REQUESTS = [];
/* 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() */
@ -175,7 +177,12 @@
const results = await Promise.all(CLASSES.map(c=>api("/assignments/class/"+c.id).catch(()=>({assignment:null})))); 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; }); CLASSES.forEach((c,i)=>{ ASSIGNMENTS[c.id] = results[i].assignment; });
} }
}catch(e){ USERS = []; CLASSES = []; ASSIGNMENTS = {}; } LINK_REQUESTS = [];
if(currentUser.role!=="super" || selSchool){
const lq = currentUser.role==="super" ? "?school="+selSchool : "";
LINK_REQUESTS = ((await api("/admin/link-requests"+lq).catch(()=>({}))).requests) || [];
}
}catch(e){ USERS = []; CLASSES = []; ASSIGNMENTS = {}; LINK_REQUESTS = []; }
render(); render();
} }
@ -290,6 +297,16 @@
}catch(e){ msg(e.message); } }catch(e){ msg(e.message); }
})); }));
} }
/* eenmalige ouder-code waarmee een ouder dit kind aan het eigen
ouderaccount koppelt (ouderportaal); een eerdere code vervalt */
actions.appendChild(dangerBtn("👪 " + T("amParentCode"), "amParentCodeWarn", async ()=>{
try{
const r = await api("/admin/users/"+u.id+"/parent-code", { body:{} });
rowResults.set(u.id, { label: T("amResultParentCode"), value: r.parentCode });
openRows.add(u.id);
reload();
}catch(e){ msg(e.message); }
}));
}else{ }else{
if(u.pending && u.linkCode){ if(u.pending && u.linkCode){
tools.appendChild(h("span","am-code", T("amCode")+": "+u.linkCode)); tools.appendChild(h("span","am-code", T("amCode")+": "+u.linkCode));
@ -811,7 +828,9 @@
wordt tellen alle groepen mee zodat een treffer nooit verstopt zit. */ wordt tellen alle groepen mee zodat een treffer nooit verstopt zit. */
function applyUserFilter(panel){ function applyUserFilter(panel){
const q = userFilter.trim().toLowerCase(); const q = userFilter.trim().toLowerCase();
const list = panel.querySelector(".am-list"); /* expliciet de gebruikerslijst: het Aanmeldingen-blok erboven is ook een
.am-list maar valt buiten het zoekfilter en de groepslogica */
const list = panel.querySelector(".am-userlist") || panel.querySelector(".am-list");
if(!list) return; if(!list) return;
let curGroup = null, groupVisible = false; let curGroup = null, groupVisible = false;
const closeGroup = ()=>{ const closeGroup = ()=>{
@ -842,7 +861,73 @@
Leerlingen krijgen een aparte behandeling: voor schoolbeheerder/super gegroepeerd Leerlingen krijgen een aparte behandeling: voor schoolbeheerder/super gegroepeerd
per klas (met een inline toevoegrij per klas), voor groepsleiding een platte lijst per klas (met een inline toevoegrij per klas), voor groepsleiding een platte lijst
(die dankzij de server-side scope toch al beperkt is tot de eigen klas(sen)). */ (die dankzij de server-side scope toch al beperkt is tot de eigen klas(sen)). */
/* aanmeldverzoek van een ouder (thuiskind -> deze school): de school kiest
adopteren (thuisaccount wordt de schoolleerling, evt. met klas en - bij
naamconflict - een andere gebruikersnaam), koppelen aan een bestaande
leerling, of afwijzen */
function linkRequestRow(r){
const row = h("div","am-row");
row.appendChild(h("span","am-name","🧒 " + r.pupil.displayName
+ (r.pupil.displayName!==r.pupil.username ? ` (${r.pupil.username})` : "")));
row.appendChild(h("span","am-role", `${T("amRequestVia")} 👪 ${r.parent.displayName}`));
const tools = h("div","am-row-tools");
const adoptWrap = h("div","am-inline");
const cs = classSel(null);
const un = h("input","am-inp");
un.placeholder = T("amAdoptUserPh");
un.style.display = "none";
un.addEventListener("keydown", ev=>ev.stopPropagation());
const adopt = h("button","am-btn", T("amAdopt"));
adopt.type = "button";
adopt.addEventListener("click", async ()=>{
const body = { action:"adopt", classId: cs.value ? +cs.value : null };
if(un.style.display!=="none" && un.value.trim()) body.username = un.value.trim();
try{ await api("/admin/link-requests/"+r.id+"/resolve", { body }); reload(); }
catch(e){
/* naamconflict binnen de school: veld tonen om direct een andere
gebruikersnaam mee te geven */
if(e.status===409){ un.style.display = ""; un.focus(); }
msg(e.message);
}
});
adoptWrap.appendChild(cs);
adoptWrap.appendChild(un);
adoptWrap.appendChild(adopt);
tools.appendChild(field("amAdopt", adoptWrap));
const matchWrap = h("div","am-inline");
const ms = h("select","am-sel");
fillPupilSelect(ms, USERS.filter(u=>u.role==="pupil"));
const mb = h("button","am-btn", T("amMatchBtn"));
mb.type = "button";
mb.addEventListener("click", async ()=>{
if(!ms.value) return;
try{ await api("/admin/link-requests/"+r.id+"/resolve", { body:{ action:"match", pupilId: +ms.value } }); reload(); }
catch(e){ msg(e.message); }
});
matchWrap.appendChild(ms);
matchWrap.appendChild(mb);
tools.appendChild(field("amMatchBtn", matchWrap));
const actions = h("div","am-actions");
actions.appendChild(dangerBtn("🗑 " + T("amReject"), "amRejectWarn", async ()=>{
try{ await api("/admin/link-requests/"+r.id+"/resolve", { body:{ action:"reject" } }); reload(); }
catch(e){ msg(e.message); }
}));
tools.appendChild(field("amFieldActions", actions));
row.appendChild(tools);
return row;
}
function renderGebruikersPanel(panel){ function renderGebruikersPanel(panel){
/* openstaande aanmeldingen bovenaan: dit vraagt actie van de school */
if(LINK_REQUESTS.length){
panel.appendChild(h("div","am-group", `${T("amLinkRequests")} (${LINK_REQUESTS.length})`));
const rl = h("div","am-reqlist am-list");
LINK_REQUESTS.forEach(r=>rl.appendChild(linkRequestRow(r)));
panel.appendChild(rl);
}
const filterBar = h("div","am-add"); const filterBar = h("div","am-add");
const filterInp = h("input","am-inp am-filter"); const filterInp = h("input","am-inp am-filter");
filterInp.type = "search"; filterInp.type = "search";
@ -853,8 +938,8 @@
filterBar.appendChild(filterInp); filterBar.appendChild(filterInp);
panel.appendChild(filterBar); panel.appendChild(filterBar);
const list = h("div","am-list"); const list = h("div","am-list am-userlist");
const staffGroups = [["super", T("amSuperGroup")], ["admin", T("roleAdmin")], ["teacher", T("roleTeacher")]]; const staffGroups = [["super", T("amSuperGroup")], ["admin", T("roleAdmin")], ["teacher", T("roleTeacher")], ["parent", T("amParents")]];
staffGroups.forEach(([role, label])=>{ staffGroups.forEach(([role, label])=>{
const us = USERS.filter(u=>u.role===role); const us = USERS.filter(u=>u.role===role);
if(!us.length) return; if(!us.length) return;

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.3.57-beta"; const VERSION = "0.3.58-beta";
(function(){ (function(){
const tag = document.getElementById("verTag"); const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION; tag.textContent = "v"+VERSION;
@ -204,7 +204,22 @@ const I18N = {
errPwMatch:"Wachtwoorden komen niet overeen.", errPwMatch:"Wachtwoorden komen niet overeen.",
codePh:"koppelcode", codePh:"koppelcode",
roleSuper:"Systeemmanager", roleAdmin:"Schoolbeheerder", roleSuper:"Systeemmanager", roleAdmin:"Schoolbeheerder",
roleTeacher:"Groepsleiding", rolePupil:"Leerling", roleTeacher:"Groepsleiding", rolePupil:"Leerling", roleParent:"Ouder",
tabParentReg:"Ouders",
parentRegIntro:"Maak een ouderaccount en volg je kind - ook als de school (nog) niet meedoet:",
parentRegBtn:"Account maken",
parentTitle:"Mijn kinderen",
parentNoChildren:"Nog geen kinderen gekoppeld.",
parentAddChild:" Kind", parentChildNamePh:"naam van je kind…", parentAddBtn:"Toevoegen",
parentPwNote:"Bewaar dit wachtwoord: je kind logt ermee in (schoolveld leeg laten).",
parentLinkTitle:" Ouder-code", parentLinkPh:"ouder-code van school…", parentLinkBtn:"Koppel",
parentLinkNote:"Vraag de school om een ouder-code voor je kind.",
parentHome:"thuis",
parentApply:"🏫 Aanmelden", parentApplied:"aangemeld bij", parentApplyBtn:"Meld aan",
parentSchoolPh:"zoek je school…",
parentUnlink:"Ontkoppel",
parentUnlinkWarn:"Verwijdert alleen de koppeling; het account van je kind blijft bestaan.",
parentProgress:"📈 Voortgang", parentProgressEmpty:"Nog geen voortgang.",
pupilEmpty:"Je leerkracht heeft nog niets voor je klaargezet.", pupilEmpty:"Je leerkracht heeft nog niets voor je klaargezet.",
pupilWatching:"meekijken", pupilWatching:"meekijken",
amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen", amOverarching:"Systeem", amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen", amOverarching:"Systeem",
@ -219,6 +234,13 @@ const I18N = {
amNewCodeWarn:"Maakt een nieuwe koppelcode aan. Het huidige wachtwoord vervalt direct; de gebruiker moet opnieuw activeren.", amNewCodeWarn:"Maakt een nieuwe koppelcode aan. Het huidige wachtwoord vervalt direct; de gebruiker moet opnieuw activeren.",
amDeleteWarn:"Verwijdert dit account definitief, inclusief borden en woorden.", amDeleteWarn:"Verwijdert dit account definitief, inclusief borden en woorden.",
amResultPw:"Nieuw wachtwoord", amResultCode:"Nieuwe koppelcode", amResultPw:"Nieuw wachtwoord", amResultCode:"Nieuwe koppelcode",
amParents:"Ouders",
amParentCode:"Ouder-code", amResultParentCode:"Ouder-code",
amParentCodeWarn:"Maakt een eenmalige ouder-code aan; een eerdere code vervalt.",
amLinkRequests:"Aanmeldingen", amRequestVia:"via",
amAdopt:"Adopteer", amAdoptUserPh:"andere gebruikersnaam (bij conflict)…",
amMatchPick:"— koppel aan bestaande leerling… —", amMatchBtn:"Koppel",
amReject:"Afwijzen", amRejectWarn:"Wijst de aanmelding af; de ouder ziet het verzoek verdwijnen.",
amSchoolNamePh:"naam van de school…", amNewSchoolConfirm:"Aanmaken:", amSchoolNamePh:"naam van de school…", amNewSchoolConfirm:"Aanmaken:",
amDeleteSchoolWarn:"Verwijdert deze school met alle klassen en gebruikers. Dit kan niet ongedaan worden gemaakt.", amDeleteSchoolWarn:"Verwijdert deze school met alle klassen en gebruikers. Dit kan niet ongedaan worden gemaakt.",
amClassNamePh:"naam van de klas…", amClassNamePh:"naam van de klas…",
@ -429,7 +451,22 @@ const I18N = {
errPwMatch:"Passwords don't match.", errPwMatch:"Passwords don't match.",
codePh:"link code", codePh:"link code",
roleSuper:"System manager", roleAdmin:"School administrator", roleSuper:"System manager", roleAdmin:"School administrator",
roleTeacher:"Group leader", rolePupil:"Pupil", roleTeacher:"Group leader", rolePupil:"Pupil", roleParent:"Parent",
tabParentReg:"Parents",
parentRegIntro:"Create a parent account and follow your child - even if the school doesn't take part (yet):",
parentRegBtn:"Create account",
parentTitle:"My children",
parentNoChildren:"No children linked yet.",
parentAddChild:" Child", parentChildNamePh:"your child's name…", parentAddBtn:"Add",
parentPwNote:"Keep this password: your child logs in with it (leave the school field empty).",
parentLinkTitle:" Parent code", parentLinkPh:"parent code from school…", parentLinkBtn:"Link",
parentLinkNote:"Ask the school for a parent code for your child.",
parentHome:"home",
parentApply:"🏫 Apply", parentApplied:"applied to", parentApplyBtn:"Apply",
parentSchoolPh:"search your school…",
parentUnlink:"Unlink",
parentUnlinkWarn:"Removes only the link; your child's account remains.",
parentProgress:"📈 Progress", parentProgressEmpty:"No progress yet.",
pupilEmpty:"Your teacher hasn't set up anything for you yet.", pupilEmpty:"Your teacher hasn't set up anything for you yet.",
pupilWatching:"watching", pupilWatching:"watching",
amUsers:"Users", amClasses:"Classes", amSchools:"Schools", amOverarching:"System", amUsers:"Users", amClasses:"Classes", amSchools:"Schools", amOverarching:"System",
@ -444,6 +481,13 @@ const I18N = {
amNewCodeWarn:"Generates a new link code. The current password stops working immediately; the user must re-activate.", amNewCodeWarn:"Generates a new link code. The current password stops working immediately; the user must re-activate.",
amDeleteWarn:"Deletes this account permanently, including boards and words.", amDeleteWarn:"Deletes this account permanently, including boards and words.",
amResultPw:"New password", amResultCode:"New link code", amResultPw:"New password", amResultCode:"New link code",
amParents:"Parents",
amParentCode:"Parent code", amResultParentCode:"Parent code",
amParentCodeWarn:"Generates a one-time parent code; a previous code stops working.",
amLinkRequests:"Enrolments", amRequestVia:"via",
amAdopt:"Adopt", amAdoptUserPh:"different username (on conflict)…",
amMatchPick:"— link to existing pupil… —", amMatchBtn:"Link",
amReject:"Reject", amRejectWarn:"Rejects the enrolment; the request disappears for the parent.",
amSchoolNamePh:"name of the school…", amNewSchoolConfirm:"Create:", amSchoolNamePh:"name of the school…", amNewSchoolConfirm:"Create:",
amDeleteSchoolWarn:"Deletes this school with all its classes and users. This cannot be undone.", amDeleteSchoolWarn:"Deletes this school with all its classes and users. This cannot be undone.",
amClassNamePh:"name of the class…", amClassNamePh:"name of the class…",
@ -494,6 +538,9 @@ function applyI18n(){
document.getElementById("liCode").placeholder = T("codePh"); document.getElementById("liCode").placeholder = T("codePh");
document.getElementById("liPassNew").placeholder = T("pwNewPh"); document.getElementById("liPassNew").placeholder = T("pwNewPh");
document.getElementById("liPassNew2").placeholder = T("pwConfirmPh"); document.getElementById("liPassNew2").placeholder = T("pwConfirmPh");
document.getElementById("prName").placeholder = T("namePh");
document.getElementById("prPass").placeholder = T("pwNewPh");
document.getElementById("prPass2").placeholder = T("pwConfirmPh");
document.getElementById("ownPwCurrent").placeholder = T("pwCurrentPh"); document.getElementById("ownPwCurrent").placeholder = T("pwCurrentPh");
document.getElementById("ownPw").placeholder = T("pwNewPh"); document.getElementById("ownPw").placeholder = T("pwNewPh");
document.getElementById("ownPw2").placeholder = T("pwConfirmPh"); document.getElementById("ownPw2").placeholder = T("pwConfirmPh");
@ -652,11 +699,22 @@ async function hydrateFromServer(){
if(currentUser && currentUser.role === "pupil"){ if(currentUser && currentUser.role === "pupil"){
THEMES = {nl:[],en:[]}; THEMES = {nl:[],en:[]};
GENERAL_EXTRA = {nl:[],en:[]}; GENERAL_EXTRA = {nl:[],en:[]};
stopParentView();
startPupilView(); startPupilView();
document.dispatchEvent(new CustomEvent("userchange")); document.dispatchEvent(new CustomEvent("userchange"));
return; return;
} }
/* ouders krijgen het portaal (parent.js) i.p.v. het whiteboard */
if(currentUser && currentUser.role === "parent"){
THEMES = {nl:[],en:[]};
GENERAL_EXTRA = {nl:[],en:[]};
stopPupilView();
startParentView();
document.dispatchEvent(new CustomEvent("userchange"));
return;
}
stopPupilView(); stopPupilView();
stopParentView();
const res = await api("/me/data"); const res = await api("/me/data");
const d = res.data || {}; const d = res.data || {};
DATA_REV = typeof res.rev === "number" ? res.rev : 0; DATA_REV = typeof res.rev === "number" ? res.rev : 0;
@ -671,6 +729,7 @@ async function hydrateFromServer(){
function resetToGuest(){ function resetToGuest(){
currentUser = null; currentUser = null;
stopPupilView(); stopPupilView();
stopParentView();
THEMES = {nl:[],en:[]}; THEMES = {nl:[],en:[]};
GENERAL_EXTRA = {nl:[],en:[]}; GENERAL_EXTRA = {nl:[],en:[]};
ANCHOR_LIB = []; ANCHOR_LIB = [];
@ -686,7 +745,7 @@ const loginWrap = document.getElementById("loginWrap");
const loginMsg = document.getElementById("loginMsg"); const loginMsg = document.getElementById("loginMsg");
const settingsWrap = document.getElementById("settingsWrap"); const settingsWrap = document.getElementById("settingsWrap");
const settingsMsg = document.getElementById("settingsMsg"); const settingsMsg = document.getElementById("settingsMsg");
const roleKey = r => r==="super" ? "roleSuper" : r==="admin" ? "roleAdmin" : r==="teacher" ? "roleTeacher" : "rolePupil"; const roleKey = r => r==="super" ? "roleSuper" : r==="admin" ? "roleAdmin" : r==="teacher" ? "roleTeacher" : r==="parent" ? "roleParent" : "rolePupil";
function updateUserUI(){ function updateUserUI(){
document.getElementById("userLbl").textContent = currentUser ? currentUser.displayName : T("login"); document.getElementById("userLbl").textContent = currentUser ? currentUser.displayName : T("login");
document.getElementById("btnUser").classList.toggle("on", !!currentUser); document.getElementById("btnUser").classList.toggle("on", !!currentUser);
@ -703,11 +762,12 @@ function updateUserUI(){
document.getElementById("ownPwBlock").style.display = can("password.own.change") ? "flex" : "none"; document.getElementById("ownPwBlock").style.display = can("password.own.change") ? "flex" : "none";
} }
} }
/* login-modal tabs: "inloggen" vs "koppelcode activeren" */ /* login-modal tabs: "inloggen" / "koppelcode activeren" / "ouderaccount maken" */
function switchLoginTab(tab){ function switchLoginTab(tab){
document.querySelectorAll("#loginModal .ed-tabs button").forEach(b=>b.classList.toggle("active", b.dataset.tab===tab)); document.querySelectorAll("#loginModal .ed-tabs button").forEach(b=>b.classList.toggle("active", b.dataset.tab===tab));
document.getElementById("loginBox").style.display = tab==="login" ? "block" : "none"; document.getElementById("loginBox").style.display = tab==="login" ? "block" : "none";
document.getElementById("linkBox").style.display = tab==="link" ? "block" : "none"; document.getElementById("linkBox").style.display = tab==="link" ? "block" : "none";
document.getElementById("parentRegBox").style.display = tab==="parentreg" ? "block" : "none";
} }
document.querySelectorAll("#loginModal .ed-tabs button").forEach(b=>{ document.querySelectorAll("#loginModal .ed-tabs button").forEach(b=>{
b.addEventListener("click", ()=>switchLoginTab(b.dataset.tab)); b.addEventListener("click", ()=>switchLoginTab(b.dataset.tab));
@ -851,6 +911,24 @@ function pwToggle(btnId, ...fieldIds){
} }
pwToggle("btnLiPassToggle", "liPassNew", "liPassNew2"); pwToggle("btnLiPassToggle", "liPassNew", "liPassNew2");
pwToggle("btnOwnPwToggle", "ownPwCurrent", "ownPw", "ownPw2"); pwToggle("btnOwnPwToggle", "ownPwCurrent", "ownPw", "ownPw2");
pwToggle("btnPrPassToggle", "prPass", "prPass2");
/* ouderaccount registreren: direct ingelogd, parent.js toont daarna het portaal */
document.getElementById("btnParentReg").addEventListener("click", async ()=>{
const btn = document.getElementById("btnParentReg");
const username = document.getElementById("prName").value.trim();
const password = document.getElementById("prPass").value;
const password2 = document.getElementById("prPass2").value;
loginMsg.classList.remove("ok");
if(password !== password2){ loginMsg.textContent = T("errPwMatch"); return; }
await withBusy(btn, async ()=>{
try{
await afterLogin(await api("/auth/register-parent", { body:{ username, password } }));
document.getElementById("prPass").value = "";
document.getElementById("prPass2").value = "";
}catch(e){ loginMsg.textContent = e.message; }
});
});
/* account activeren met een koppelcode */ /* account activeren met een koppelcode */
document.getElementById("btnLink").addEventListener("click", async ()=>{ document.getElementById("btnLink").addEventListener("click", async ()=>{

233
public/js/parent.js Normal file
View file

@ -0,0 +1,233 @@
/* teach - ouderportaal: ouders (role parent) zien geen whiteboard maar een
portaal met hun gekoppelde kinderen. Per kind: school/klas (of "thuis"),
voortgangstijdlijn, en voor thuiskinderen een aanmelding bij een school
(doorzoekbare lijst; de school handelt het verzoek af in het beheer).
Kinderen komen erbij door ze zelf aan te maken (thuiskind, wachtwoord
eenmalig zichtbaar) of via een eenmalige ouder-code van de school. */
"use strict";
const parentView = document.getElementById("parentView");
/* opengeklapte voortgang/aanmeldformulieren en eenmalige resultaten
(kind-wachtwoord) blijven behouden over her-renders van het portaal */
const parentOpen = new Set();
const parentResults = new Map();
let PARENT_SCHOOLS = [];
function prH(tag, cls, text){
const el = document.createElement(tag);
if(cls) el.className = cls;
if(text != null) el.textContent = text;
return el;
}
/* arm-then-confirm zoals dangerBtn in het beheer: eerste klik wapent ("zeker?"),
tweede klik voert uit, na 4s ontwapent hij vanzelf */
function prDangerBtn(label, warnKey, fn){
const b = prH("button","am-btn am-danger", label);
b.type = "button";
if(warnKey) b.title = T(warnKey);
let timer = null;
b.addEventListener("click", async ()=>{
if(!timer){
b.classList.add("armed");
b.textContent = "⚠ " + T("amSure");
timer = setTimeout(()=>{ timer = null; b.classList.remove("armed"); b.textContent = label; }, 4000);
return;
}
clearTimeout(timer); timer = null;
b.classList.remove("armed"); b.textContent = label;
await fn(b);
});
return b;
}
function prMsg(t){
const m = parentView.querySelector(".am-msg");
if(m) m.textContent = t || "";
}
async function parentReload(){
let children = [];
try{ children = (await api("/parent/children")).children || []; }
catch(e){ /* render toont dan een lege lijst; melding via prMsg hieronder */ }
renderParentView(children);
}
function parentChildRow(c){
const row = prH("div","am-row");
row.appendChild(prH("span","am-name","🧒 " + c.displayName + (c.displayName!==c.username ? ` (${c.username})` : "")));
const where = c.schoolName
? c.schoolName + (c.className ? ` · ${c.className}` : "")
: T("parentHome") + (c.appliedTo ? ` · ${T("parentApplied")} ${c.appliedTo}` : "");
row.appendChild(prH("span","am-role", where));
const tools = prH("div","am-row-tools");
const r = parentResults.get(c.id);
if(r){
const res = prH("div","am-result");
res.appendChild(prH("span","am-result-lbl", r.label + ": "));
const code = prH("code", null, r.value);
res.appendChild(code);
res.appendChild(prH("div","guestnote", T("parentPwNote")));
tools.appendChild(res);
}
/* voortgangstijdlijn (zelfde regelopbouw als het beheer: widget, score, tijd) */
const progHolder = prH("div","am-list");
async function loadProgress(){
progHolder.innerHTML = "";
let events = [];
try{ events = (await api("/progress/pupil/"+c.id)).events || []; }
catch(e){ progHolder.appendChild(prH("div","guestnote", e.message)); return; }
if(!events.length){ progHolder.appendChild(prH("div","guestnote", T("parentProgressEmpty"))); return; }
events.forEach(ev=>{
const line = prH("div","am-row");
line.appendChild(prH("span","am-name", T("wg_"+ev.widgetType) || ev.widgetType));
const scoreParts = [];
if(ev.attempts) scoreParts.push(`${ev.correct}/${ev.attempts} ${T("mGoed")}`);
if(ev.stars) scoreParts.push("★".repeat(ev.stars));
line.appendChild(prH("span","am-code", scoreParts.join(" · ")));
line.appendChild(prH("span","am-code", new Date(ev.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB")));
progHolder.appendChild(line);
});
}
const progBtn = prH("button","am-btn", T("parentProgress"));
progBtn.type = "button";
progBtn.addEventListener("click", ()=>{
const key = "prog:"+c.id;
if(parentOpen.has(key)){ parentOpen.delete(key); progHolder.innerHTML = ""; }
else{ parentOpen.add(key); loadProgress(); }
});
const actions = prH("div","am-actions");
actions.appendChild(progBtn);
/* thuiskind zonder lopende aanmelding: aanmelden bij een school via een
doorzoekbare lijst (input + datalist gevuld uit de publieke /schools) */
const applyHolder = prH("div","am-add");
applyHolder.style.display = "none";
if(!c.schoolId && !c.appliedTo){
const applyBtn = prH("button","am-btn", T("parentApply"));
applyBtn.type = "button";
const inp = prH("input","am-inp");
inp.placeholder = T("parentSchoolPh");
inp.setAttribute("list","parentSchoolList");
const go = prH("button","am-btn", T("parentApplyBtn"));
go.type = "button";
go.addEventListener("click", async ()=>{
const s = PARENT_SCHOOLS.find(x=>x.name.toLowerCase()===inp.value.trim().toLowerCase());
if(!s){ prMsg(T("parentSchoolPh")); return; }
try{ await api(`/parent/children/${c.id}/apply`, { body:{ schoolId: s.id } }); parentOpen.delete("apply:"+c.id); parentReload(); }
catch(e){ prMsg(e.message); }
});
applyHolder.appendChild(inp);
applyHolder.appendChild(go);
applyBtn.addEventListener("click", ()=>{
const key = "apply:"+c.id;
if(parentOpen.has(key)) parentOpen.delete(key); else parentOpen.add(key);
applyHolder.style.display = parentOpen.has(key) ? "" : "none";
if(parentOpen.has(key)) inp.focus();
});
applyHolder.style.display = parentOpen.has("apply:"+c.id) ? "" : "none";
actions.appendChild(applyBtn);
}
actions.appendChild(prDangerBtn(T("parentUnlink"), "parentUnlinkWarn", async ()=>{
try{ await api("/parent/children/"+c.id, { method:"DELETE" }); parentResults.delete(c.id); parentReload(); }
catch(e){ prMsg(e.message); }
}));
tools.appendChild(actions);
tools.appendChild(applyHolder);
tools.appendChild(progHolder);
row.appendChild(tools);
if(parentOpen.has("prog:"+c.id)) loadProgress();
return row;
}
/* ingeklapt formulier (" …"-knop) zoals addForm in het beheer */
function parentCollapsedForm(label, build){
const wrap = prH("div","am-add-wrap");
const toggle = prH("button","tbtn ghost", label);
toggle.type = "button";
const f = prH("div","am-add");
f.style.display = "none";
toggle.addEventListener("click", ()=>{
f.style.display = f.style.display==="none" ? "" : "none";
const first = f.querySelector("input");
if(f.style.display!=="none" && first) first.focus();
});
build(f);
wrap.appendChild(toggle);
wrap.appendChild(f);
return wrap;
}
function renderParentView(children){
parentView.innerHTML = "";
const inner = prH("div","parent-inner");
inner.appendChild(prH("h2","parent-title", T("parentTitle")));
inner.appendChild(prH("div","am-msg"));
const list = prH("div","am-list");
if(!children.length) list.appendChild(prH("div","guestnote", T("parentNoChildren")));
children.forEach(c=>list.appendChild(parentChildRow(c)));
inner.appendChild(list);
/* datalist voor de doorzoekbare schoolkeuze (gedeeld door alle rijen) */
const dl = document.createElement("datalist");
dl.id = "parentSchoolList";
PARENT_SCHOOLS.forEach(s=>dl.appendChild(new Option(s.name)));
inner.appendChild(dl);
/* kind aanmaken (thuiskind): naam -> account zonder school + wachtwoord */
inner.appendChild(parentCollapsedForm(T("parentAddChild"), f=>{
const inp = prH("input","am-inp");
inp.placeholder = T("parentChildNamePh");
inp.maxLength = 30;
const go = prH("button","am-btn", T("parentAddBtn"));
go.type = "button";
go.addEventListener("click", async ()=>{
try{
const r = await api("/parent/children", { body:{ name: inp.value.trim() } });
parentResults.set(r.child.id, { label: T("amFieldPw"), value: r.child.password });
parentReload();
}catch(e){ prMsg(e.message); }
});
inp.addEventListener("keydown", ev=>{ if(ev.key==="Enter") go.click(); });
f.appendChild(inp);
f.appendChild(go);
}));
/* schoolkind koppelen met de eenmalige ouder-code van de school */
inner.appendChild(parentCollapsedForm(T("parentLinkTitle"), f=>{
f.appendChild(prH("div","guestnote", T("parentLinkNote")));
const inp = prH("input","am-inp");
inp.placeholder = T("parentLinkPh");
inp.maxLength = 9;
inp.style.textTransform = "uppercase";
const go = prH("button","am-btn", T("parentLinkBtn"));
go.type = "button";
go.addEventListener("click", async ()=>{
try{
await api("/parent/children/link", { body:{ code: inp.value.trim().toUpperCase() } });
inp.value = "";
parentReload();
}catch(e){ prMsg(e.message); }
});
inp.addEventListener("keydown", ev=>{ if(ev.key==="Enter") go.click(); });
f.appendChild(inp);
f.appendChild(go);
}));
parentView.appendChild(inner);
}
function startParentView(){
document.body.classList.add("parent-mode");
parentOpen.clear();
parentResults.clear();
(async ()=>{
try{ PARENT_SCHOOLS = await api("/schools"); }catch(e){ PARENT_SCHOOLS = []; }
parentReload();
})();
}
function stopParentView(){
document.body.classList.remove("parent-mode");
parentView.innerHTML = "";
}