Verbeter grafische kwaliteit van kleurplaten (v0.4.67-beta) #5

Open
bes-r wants to merge 208 commits from bes-r/coloring-quality-v0.4.67 into main AGit
8 changed files with 383 additions and 94 deletions
Showing only changes of commit bdb5ef9875 - Show all commits

View file

@ -1 +1 @@
0.3.59-beta
0.3.60-beta

View file

@ -513,7 +513,8 @@
/* instellingen is breder dan het inlogscherm: het beheerpaneel (tabblad) heeft
de ruimte nodig van wat vroeger een eigen, brede modal was (#adminModal) */
#settingsModal{width:min(760px,94vw); max-height:86vh; overflow:auto;}
#settingsAccountPanel{max-width:400px;}
.st-section[data-section="account"]{max-width:420px;}
.st-section[data-section="account"] .am-h3:first-of-type{margin-top:0;}
#loginModal input,#settingsModal input{
width:100%; border:1px solid var(--line); border-radius:var(--radius);
height:var(--control-h); padding:0 12px;
@ -530,15 +531,76 @@
.guestnote{font-size:13px; color:var(--muted); margin-top:12px; line-height:1.45;}
/* instellingen: neutrale kaarten per sectie (.am-reset, hergebruikt van het
beheerpaneel) i.p.v. het speelse bord-uiterlijk - dit scherm is voor volwassenen */
#settingsAccountPanel .am-h3:first-of-type{margin-top:0;}
#settingsModal .am-reset .lrow{margin:0;}
#settingsModal .am-reset .guestnote{margin-top:2px;}
.st-footer{
display:flex; justify-content:space-between; align-items:center; gap:8px;
margin-top:16px; padding-top:12px; border-top:1.5px solid var(--line);
font-size:11px; color:var(--muted);
margin-top:12px; padding-top:12px; border-top:1.5px solid var(--line);
font-size:11px; color:var(--muted); flex:none;
}
#settingsModal .am-name{font-size:16px;}
/* ---------- Instellingen-schil: nav links, inhoud rechts, zoeken boven ----- */
.st-modal{
width:min(980px,96vw); height:min(700px,92vh); max-height:92vh;
display:flex; flex-direction:column; overflow:hidden; padding:18px 22px;
}
.st-head{display:flex; align-items:center; gap:14px; flex:none; padding-bottom:12px;}
.st-head h2{margin:0; font-size:19px;}
#stSearch{
flex:1; max-width:360px; margin-left:auto; min-width:120px;
height:var(--control-h); padding:0 12px; font-size:14px;
border:1.5px solid var(--line); border-radius:10px;
background:var(--surface-2); color:var(--ink); outline:none;
}
#stSearch:focus{border-color:var(--accent);}
.st-body{display:flex; flex:1; min-height:0; border-top:1.5px solid var(--line);}
#stNav{
width:200px; flex:none; overflow-y:auto; padding:12px 12px 12px 0;
border-right:1.5px solid var(--line);
display:flex; flex-direction:column; gap:2px; align-content:flex-start;
}
.st-nav-group{
font-size:10.5px; font-weight:800; color:var(--muted);
text-transform:uppercase; letter-spacing:.5px; margin:12px 6px 4px;
}
.st-nav-group:first-child{margin-top:0;}
.st-nav-btn{
display:flex; gap:9px; align-items:center; border:none; background:transparent;
padding:8px 10px; border-radius:9px; cursor:pointer; text-align:left;
font-size:14px; font-weight:650; color:var(--ink);
}
.st-nav-btn:hover{background:var(--surface-3);}
.st-nav-btn.active{background:var(--accent-soft); color:var(--accent-ink);}
.st-nav-ico{flex:none; width:20px; text-align:center;}
#stContent{flex:1; min-width:0; overflow-y:auto; padding:14px 4px 14px 18px;}
#stSecHead{margin-bottom:12px;}
#stSecHead:empty{display:none;}
.st-sec-title{font-size:17px; font-weight:800;}
.st-sec-sub{font-size:12.5px; color:var(--muted); margin-top:2px;}
/* zoekresultaten: gegroepeerd per sectie, klik = navigeren */
#stSearchResults{display:flex; flex-direction:column; gap:2px;}
.st-result{
display:flex; gap:9px; align-items:center; border:none; background:transparent;
padding:8px 10px; border-radius:9px; cursor:pointer; text-align:left;
font-size:14px; color:var(--ink); width:100%;
}
.st-result:hover{background:var(--surface-3);}
.st-result-lbl{font-weight:650; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
.st-result-sub{margin-left:auto; flex:none; font-size:12px; color:var(--muted);}
/* smal scherm: nav wordt een horizontale strook boven de inhoud */
@media (max-width:720px){
.st-modal{padding:14px;}
.st-body{flex-direction:column;}
#stNav{
width:auto; flex-direction:row; overflow-x:auto; overflow-y:hidden;
border-right:none; border-bottom:1.5px solid var(--line);
padding:8px 0; gap:4px;
}
.st-nav-group{display:none;}
.st-nav-btn{flex:none; padding:7px 10px;}
#stContent{padding:12px 2px;}
#stSearch{max-width:none;}
}
/* =========================================================
Letter game widget
==========================================================*/
@ -719,9 +781,6 @@
.ed-tabs{
display:flex; gap:2px; border-bottom:1px solid var(--line); margin-bottom:14px;
}
/* de instellingen-balk bevat nu ook alle beheer-tabs: laten omlopen op
smalle schermen i.p.v. overlopen */
#settingsTabs{flex-wrap:wrap;}
.ed-tabs button{
border:none; border-radius:0; padding:10px 15px 11px; font:inherit; font-weight:700;
font-size:14px; background:transparent; color:var(--muted); cursor:pointer;

View file

@ -149,19 +149,20 @@
</div>
</div>
<div class="modalwrap" id="settingsWrap">
<div class="modal" id="settingsModal">
<div class="am-head">
<div class="modal st-modal" id="settingsModal">
<div class="st-head">
<h2 data-i18n="settingsTitle"></h2>
<input type="search" id="stSearch" maxlength="60" autocomplete="off">
<button class="wclose" id="btnSettingsClose"></button>
</div>
<div class="ed-tabs" id="settingsTabs">
<button type="button" class="active" data-tab="account" data-i18n="stAccount"></button>
<button type="button" data-tab="view" data-i18n="stView"></button>
<span id="adminTabsSlot" style="display:none;"></span>
</div>
<div class="st-body">
<nav id="stNav"></nav>
<div id="stContent">
<div id="stSecHead"></div>
<div id="stSearchResults" style="display:none;"></div>
<div id="settingsAccountPanel">
<div class="st-section" data-section="account" style="display:none;">
<div class="am-reset">
<div class="am-name" id="helloName"></div>
<div class="am-role" id="helloRole"></div>
@ -196,9 +197,9 @@
</div>
<div class="guestnote" data-i18n="storedNote"></div>
</div>
</div>
</div>
<div id="settingsViewPanel" style="display:none;">
<div class="st-section" data-section="view" style="display:none;">
<div class="vw-row">
<span class="vw-lbl" data-i18n="vwTheme"></span>
<div class="seg" id="segTheme">
@ -244,10 +245,12 @@
</div>
</div>
<div class="guestnote" data-i18n="vwNote"></div>
</div>
</div>
<div id="settingsAdminPanel" style="display:none;">
<div id="adminModal"></div>
<div class="st-section" data-section="admin" style="display:none;">
<div id="adminModal"></div>
</div>
</div>
</div>
<div class="st-footer">
@ -259,6 +262,7 @@
<script src="js/core.js"></script>
<script src="js/permissions.js"></script>
<script src="js/settings.js"></script>
<script src="js/data.js"></script>
<script src="js/explorer.js"></script>
<script src="js/picker.js"></script>

View file

@ -117,46 +117,84 @@
return badge;
}
/* de beheer-tabs staan als losse knoppen in de ene instellingen-tabbalk
(#adminTabsSlot in core.js/index.html) - geen geneste tabbalk meer */
const adminTabDefs = ()=>{
const defs = [];
if(currentUser && currentUser.role==="super"){ defs.push(["scholen", T("amOverarching")]); defs.push(["opslag", T("imgStorageAdmin")]); }
defs.push(["klassen", T("amClasses")]);
defs.push(["gebruikers", T("amUsers")]);
defs.push(["toewijzingen", T("amAssignments")]);
defs.push(["voortgang", T("amProgress")]);
return defs;
};
function buildAdminTabs(){
const slot = document.getElementById("adminTabsSlot");
slot.innerHTML = "";
if(!currentUser || !can("admin.access")) return;
adminTabDefs().forEach(([t, label])=>{
const b = h("button", null, label);
b.type = "button";
b.dataset.tab = "admin";
b.dataset.admintab = t;
b.addEventListener("click", async ()=>{
adminTab = t;
/* data (opnieuw) laden bij binnenkomst in het beheer-deel; wisselen
tussen beheer-tabs onderling hergebruikt de geladen data */
const adminVisible = document.getElementById("settingsAdminPanel").style.display !== "none";
if(!adminVisible){
selSchool = currentUser.role==="super" ? (selSchool ?? "") : currentUser.schoolId;
await reload();
}
switchSettingsTab("admin");
switchAdminTab(t);
});
slot.appendChild(b);
});
/* ---- beheer-secties in het instellingen-register (settings.js) ------------
Elke sectie meldt zichzelf aan met rol-zichtbaarheid, een activate() en
zoekgegevens; de schil bouwt daar navigatie + zoeken uit. Per rol alleen
de essentie: groepsleiding ziet gebruikers/toewijzingen/voortgang,
schoolbeheer ook klassen, de systeemmanager ook scholen en opslag. */
const ADMIN_SECTIONS = [
{ tab:"gebruikers", icon:"👥", group:"school", order:10, labelKey:"amUsers", subKey:"stUsersSub",
show:()=>true,
keywords:()=>[T("amPupils"), T("roleTeacher"), T("roleAdmin"), T("amParents"), T("amLinkRequests"),
T("amNewPw"), T("amParentCode"), "leerling","pupil","ouder","parent","wachtwoord","password",
"koppelcode","aanmelding","enrolment"],
search:q=>USERS
.filter(u=>`${u.displayName} ${u.username}`.toLowerCase().includes(q))
.map(u=>({ icon: u.role==="pupil" ? "🧒" : u.role==="parent" ? "👪" : "🧑‍🏫",
label:u.displayName, sub:T(roleKey(u.role)),
go:()=>goSettingsSection("gebruikers", { filter:u.displayName }) })) },
{ tab:"klassen", icon:"🏷", group:"school", order:11, labelKey:"amClasses", subKey:"stKlassenSub",
show:()=>can("classes.manage"),
keywords:()=>[T("amNewClass"), T("amResetClass"), "klas","class","groep","group"],
search:q=>CLASSES
.filter(c=>c.name.toLowerCase().includes(q))
.map(c=>({ icon:"🏷", label:c.name, sub:T("amClasses"), go:()=>goSettingsSection("klassen") })) },
{ tab:"toewijzingen", icon:"🎯", group:"school", order:12, labelKey:"amAssignments", subKey:"stToewijzSub",
show:()=>can("assignments.manage"),
keywords:()=>[T("amModeWatch"), T("amModeWork"), "bord","board","delen","share","toewijzen","assign","widget"] },
{ tab:"voortgang", icon:"📈", group:"school", order:13, labelKey:"amProgress", subKey:"stVoortgangSub",
show:()=>can("progress.view"),
keywords:()=>["voortgang","progress","score","resultaten","results","tijdlijn","timeline"] },
{ tab:"scholen", icon:"🏫", group:"system", order:30, labelKey:"amOverarching", subKey:"stScholenSub",
show:()=>currentUser.role==="super",
keywords:()=>[T("amNewSchool"), T("amNewSuper"), "school","systeemmanager","system manager"],
search:q=>SCHOOLS
.filter(s=>s.name.toLowerCase().includes(q))
.map(s=>({ icon:"🏫", label:s.name, sub:T("amSchools"), go:()=>goSettingsSection("scholen") })) },
{ tab:"opslag", icon:"🖼", group:"system", order:31, labelKey:"stOpslag", subKey:"stOpslagSub",
show:()=>currentUser.role==="super",
keywords:()=>["opslag","storage","afbeeldingen","images","quota"] },
];
const adminTabDefs = ()=>ADMIN_SECTIONS.filter(d=>d.show()).map(d=>[d.tab, T(d.labelKey)]);
/* data één keer per bezoek aan Instellingen laden; secties onderling
hergebruiken de lading (in-flight promise voorkomt dubbel laden) */
let loadPromise = null;
function ensureLoaded(force){
if(force) loadPromise = null;
if(!loadPromise){
loadPromise = (async ()=>{
selSchool = currentUser.role==="super" ? (selSchool ?? "") : currentUser.schoolId;
await reload();
})();
}
return loadPromise;
}
/* settings.js roept dit bij het openen aan: verse data + zoekindex vullen */
window.adminPrefetch = ()=>{ ensureLoaded(true).catch(()=>{}); };
ADMIN_SECTIONS.forEach(d=>{
registerSettingsSection({
id:d.tab, icon:d.icon, group:d.group, order:d.order,
label:()=>T(d.labelKey), sub:()=>T(d.subKey),
visible:()=>!!currentUser && can("admin.access") && d.show(),
async activate(payload){
await ensureLoaded();
if(d.tab==="gebruikers" && payload && payload.filter !== undefined) userFilter = payload.filter;
showAdminHost();
switchAdminTab(d.tab);
if(d.tab==="gebruikers" && payload && payload.filter !== undefined){
const panel = modal.querySelector('[data-panel="gebruikers"]');
const inp = panel && panel.querySelector(".am-filter");
if(inp){ inp.value = userFilter; applyUserFilter(panel); }
}
},
keywords:d.keywords,
searchItems:d.search,
});
});
document.addEventListener("userchange", ()=>{
if(!currentUser) resetResults = null;
buildAdminTabs();
loadPromise = null;
});
document.addEventListener("langchange", buildAdminTabs);
async function reload(){
try{
@ -1007,7 +1045,6 @@
function switchAdminTab(tab){
adminTab = tab;
document.querySelectorAll("#adminTabsSlot button").forEach(b=>b.classList.toggle("active", b.dataset.admintab===tab));
modal.querySelectorAll(".am-tabpanel").forEach(p=>{ p.style.display = p.dataset.panel===tab ? "block" : "none"; });
/* het Systeem-tabblad is school-onafhankelijk: daar is de schoolkiezer
alleen maar verwarrend, dus die verbergen we er */

View file

@ -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.3.59-beta";
const VERSION = "0.3.60-beta";
(function(){
const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION;
@ -184,6 +184,17 @@ const I18N = {
login:"Inloggen", loginBtn:"Inloggen", registerBtn:"Account maken", logout:"Uitloggen",
settingsTitle:"Instellingen", tabLogin:"Inloggen", tabLinkCode:"Koppelcode", busy:"Bezig…",
stAccount:"Account", stPassword:"Wachtwoord", stSession:"Sessie",
stSearchPh:"🔎 Zoeken in instellingen…", stNoResults:"Niets gevonden.",
stOpenSection:"open dit onderdeel",
stGroupPersonal:"Persoonlijk", stGroupSchool:"School", stGroupSystem:"Systeem",
stAccountSub:"Je gegevens, wachtwoord en reservekopieën.",
stViewSub:"Weergave op dit apparaat: thema, taal en menubalk.",
stUsersSub:"Accounts beheren: leerlingen, staf, ouders en aanmeldingen.",
stKlassenSub:"Klassen aanmaken, koppelen en beheren.",
stToewijzSub:"Bepaal wat een klas of leerling te zien krijgt.",
stVoortgangSub:"Volg per leerling de resultaten in de tijd.",
stScholenSub:"Scholen en systeemmanagers, over alle scholen heen.",
stOpslag:"Opslag", stOpslagSub:"Afbeeldingencatalogus en opslagruimte per school.",
stBackups:"Reservekopieën", backupsNote:"Automatisch bewaarde eerdere versies van je borden en woorden.",
backupsEmpty:"Nog geen reservekopieën.", backupRestore:"herstel", backupRestoreConfirm:"zeker?",
backupRestored:"Versie hersteld ✓",
@ -431,6 +442,17 @@ const I18N = {
login:"Log in", loginBtn:"Log in", registerBtn:"Create account", logout:"Log out",
settingsTitle:"Settings", tabLogin:"Log in", tabLinkCode:"Link code", busy:"Please wait…",
stAccount:"Account", stPassword:"Password", stSession:"Session",
stSearchPh:"🔎 Search settings…", stNoResults:"Nothing found.",
stOpenSection:"open this section",
stGroupPersonal:"Personal", stGroupSchool:"School", stGroupSystem:"System",
stAccountSub:"Your details, password and backups.",
stViewSub:"Display on this device: theme, language and toolbar.",
stUsersSub:"Manage accounts: pupils, staff, parents and enrolments.",
stKlassenSub:"Create, link and manage classes.",
stToewijzSub:"Choose what a class or pupil gets to see.",
stVoortgangSub:"Follow each pupil's results over time.",
stScholenSub:"Schools and system managers, across all schools.",
stOpslag:"Storage", stOpslagSub:"Image catalogue and storage per school.",
stBackups:"Backups", backupsNote:"Automatically kept earlier versions of your boards and words.",
backupsEmpty:"No backups yet.", backupRestore:"restore", backupRestoreConfirm:"sure?",
backupRestored:"Version restored ✓",
@ -601,9 +623,13 @@ let DATA_REV = 0;
let saveConflict = false;
let saveInFlight = false;
function canPersist(){
/* leerlingen en ouders slaan geen eigen borden-data op; het ouderportaal
werkt straks met gekoppelde kind-accounts i.p.v. een eigen blob */
return !!currentUser && currentUser.role !== "pupil" && currentUser.role !== "parent";
/* ouders slaan geen eigen borden-data op (het portaal werkt met gekoppelde
kind-accounts); schoolleerlingen ook niet (zij zien de toewijzing van de
leerkracht). Thuiskinderen (leerling zónder school, ouderportaal) hebben
wél een eigen whiteboard met eigen opslag. */
if(!currentUser || currentUser.role === "parent") return false;
if(currentUser.role === "pupil") return !currentUser.schoolId;
return true;
}
/* welke secties er sinds de laatste geslaagde save zijn gewijzigd; een save
verstuurt alleen die secties i.p.v. altijd de complete blob (die MB's kan
@ -694,9 +720,11 @@ 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"){
/* schoolleerlingen hebben geen eigen borden - zij zien enkel wat de
leerkracht heeft toegewezen (zie pupil.js), nooit het whiteboard.
Thuiskinderen (leerling zonder school, ouderportaal) vallen hier expres
buiten: zij krijgen hieronder het volledige whiteboard met eigen opslag. */
if(currentUser && currentUser.role === "pupil" && currentUser.schoolId){
THEMES = {nl:[],en:[]};
GENERAL_EXTRA = {nl:[],en:[]};
stopParentView();
@ -749,12 +777,8 @@ const roleKey = r => r==="super" ? "roleSuper" : r==="admin" ? "roleAdmin" : r==
function updateUserUI(){
document.getElementById("userLbl").textContent = currentUser ? currentUser.displayName : T("login");
document.getElementById("btnUser").classList.toggle("on", !!currentUser);
/* de beheer-tabs (Systeem/Klassen/Gebruikers/...) staan als losse knoppen
in de ene tabbalk; admin.js vult de slot, hier alleen zichtbaarheid */
const adminSlot = document.getElementById("adminTabsSlot");
const showAdmin = can("admin.access");
adminSlot.style.display = showAdmin ? "contents" : "none";
if(!showAdmin && adminSlot.querySelector("button.active")) switchSettingsTab("account");
/* welke instellingen-secties zichtbaar zijn bepaalt settings.js per rol
(sectieregister); hier alleen nog de accountgegevens zelf */
if(currentUser){
document.getElementById("helloName").textContent = currentUser.displayName;
const roles = [currentUser.role, ...(currentUser.extraRoles||[])];
@ -772,18 +796,6 @@ function switchLoginTab(tab){
document.querySelectorAll("#loginModal .ed-tabs button").forEach(b=>{
b.addEventListener("click", ()=>switchLoginTab(b.dataset.tab));
});
/* instellingen-tabs: één balk met account / weergave / de beheer-tabs
(die laatste worden door admin.js in #adminTabsSlot gezet en zelf
actief gemarkeerd via switchAdminTab) */
function switchSettingsTab(tab){
document.querySelectorAll("#settingsTabs button").forEach(b=>b.classList.toggle("active", b.dataset.tab===tab && !b.dataset.admintab));
document.getElementById("settingsAccountPanel").style.display = tab==="account" ? "block" : "none";
document.getElementById("settingsViewPanel").style.display = tab==="view" ? "block" : "none";
document.getElementById("settingsAdminPanel").style.display = tab==="admin" ? "block" : "none";
}
/* de beheer-tabs hebben hun eigen klik-handlers in admin.js (moeten eerst data laden) */
document.querySelector('#settingsTabs button[data-tab="account"]').addEventListener("click", ()=>switchSettingsTab("account"));
document.querySelector('#settingsTabs button[data-tab="view"]').addEventListener("click", ()=>switchSettingsTab("view"));
async function loadSchools(){
const sel = document.getElementById("liSchool");
try{
@ -792,15 +804,8 @@ async function loadSchools(){
list.map(x=>`<option value="${x.id}">${x.name.replace(/</g,"&lt;")}</option>`).join("");
}catch(e){ sel.innerHTML = `<option value="">${T("schoolSel")}</option>`; }
}
function openSettings(){
settingsMsg.textContent = ""; settingsMsg.classList.remove("ok");
/* zonder account is er geen Account-tab: dan direct naar Weergave
(zo blijven taal/contrast/thema ook als gast bereikbaar) */
document.querySelector('#settingsTabs button[data-tab="account"]').style.display = currentUser ? "" : "none";
switchSettingsTab(currentUser ? "account" : "view");
loadBackups();
settingsWrap.classList.add("open");
}
/* openSettings() zelf woont in settings.js (sectieregister + zoeken);
de knoppen hieronder roepen hem via window aan */
/* reservekopieën (Instellingen Account): automatisch bewaarde eerdere
versies van de borddata, met een arm-then-confirm herstelknop zoals de
verwijderknop in de boardStrip */

171
public/js/settings.js Normal file
View file

@ -0,0 +1,171 @@
/* teach - instellingen-schil: één sectieregister als enige bron van waarheid.
Elke sectie meldt zich aan met zichtbaarheid per rol, een activate() en
zoekgegevens; de schil bouwt daar de navigatie (gegroepeerd: Persoonlijk /
School / Systeem), de sectiekop en de zoekfunctie uit op. Zo bepaalt de rol
van de ingelogde gebruiker vanzelf wat er te zien is: een gast alleen
Weergave, een leerling/ouder Account + Weergave, staf ook de beheerdelen
(admin.js registreert die zelf - de schil kent het beheer niet van binnen).
De zoekbalk zoekt door sectienamen, instelling-trefwoorden én data
(gebruikers/klassen/scholen via searchItems van de secties). */
"use strict";
const ST_SECTIONS = [];
function registerSettingsSection(sec){ ST_SECTIONS.push(sec); }
(function(){
const wrap = document.getElementById("settingsWrap");
const nav = document.getElementById("stNav");
const content = document.getElementById("stContent");
const secHead = document.getElementById("stSecHead");
const searchInp = document.getElementById("stSearch");
const resultsEl = document.getElementById("stSearchResults");
let activeSection = null;
const el = (tag, cls, text)=>{
const e = document.createElement(tag);
if(cls) e.className = cls;
if(text != null) e.textContent = text;
return e;
};
const visibleSections = ()=>
ST_SECTIONS.filter(s=>s.visible()).sort((a,b)=>(a.order||99)-(b.order||99));
/* ---- ingebouwde secties: Account en Weergave ------------------------------ */
registerSettingsSection({
id:"account", icon:"👤", group:"personal", order:1,
label:()=>T("stAccount"), sub:()=>T("stAccountSub"),
visible:()=>!!currentUser,
activate(){ showDomSection("account"); loadBackups(); },
keywords:()=>[T("stPassword"), T("stBackups"), T("stSession"), T("logout"),
"wachtwoord","password","reservekopie","backup","herstel","restore",
"uitloggen","logout","sessie","session","naam","name"],
});
registerSettingsSection({
id:"view", icon:"🎨", group:"personal", order:2,
label:()=>T("stView"), sub:()=>T("stViewSub"),
visible:()=>true,
activate(){ showDomSection("view"); },
keywords:()=>[T("vwTheme"), T("vwContrast"), T("vwLang"), T("vwTbar"), T("vwMotion"), T("vwGlass"),
"thema","theme","donker","dark","licht","light","contrast","taal","language",
"menubalk","toolbar","animaties","animations","glas","glass"],
});
/* de drie DOM-secties (account/view/admin-host); beheer-secties delen de
admin-host, admin.js wisselt daarbinnen zelf van paneel */
function showDomSection(id){
resultsEl.style.display = "none";
content.querySelectorAll(".st-section").forEach(s=>{
s.style.display = s.dataset.section===id ? "" : "none";
});
}
window.showAdminHost = ()=>showDomSection("admin");
const GROUPS = [["personal","stGroupPersonal"], ["school","stGroupSchool"], ["system","stGroupSystem"]];
function buildNav(){
nav.innerHTML = "";
const secs = visibleSections();
GROUPS.forEach(([g, key])=>{
const inGroup = secs.filter(s=>s.group===g);
if(!inGroup.length) return;
/* groepskoppen pas als er echt iets te groeperen valt (staf); een
gast/leerling met 1-2 secties krijgt een kale, rustige lijst */
if(secs.length > 3) nav.appendChild(el("div","st-nav-group", T(key)));
inGroup.forEach(s=>{
const b = el("button","st-nav-btn");
b.type = "button";
b.dataset.section = s.id;
b.appendChild(el("span","st-nav-ico", s.icon));
b.appendChild(el("span","st-nav-lbl", s.label()));
b.addEventListener("click", ()=>goSection(s.id));
nav.appendChild(b);
});
});
}
async function goSection(id, payload){
const s = ST_SECTIONS.find(x=>x.id===id && x.visible());
if(!s) return;
activeSection = id;
searchInp.value = "";
nav.querySelectorAll(".st-nav-btn").forEach(b=>b.classList.toggle("active", b.dataset.section===id));
secHead.innerHTML = "";
secHead.appendChild(el("div","st-sec-title", s.icon + " " + s.label()));
if(s.sub) secHead.appendChild(el("div","st-sec-sub", s.sub()));
content.scrollTop = 0;
await s.activate(payload);
}
window.goSettingsSection = goSection;
/* ---- zoeken ---------------------------------------------------------------
drie lagen per zichtbare sectie: (1) sectienaam, (2) trefwoorden van de
instellingen erin, (3) live data (searchItems: gebruikers/klassen/scholen).
Resultaten vervangen de inhoud; een klik navigeert (en filtert waar kan). */
function doSearch(){
const q = searchInp.value.trim().toLowerCase();
if(!q){
if(activeSection) goSection(activeSection);
return;
}
nav.querySelectorAll(".st-nav-btn").forEach(b=>b.classList.remove("active"));
secHead.innerHTML = "";
resultsEl.innerHTML = "";
content.querySelectorAll(".st-section").forEach(s=>{ s.style.display = "none"; });
let total = 0;
visibleSections().forEach(s=>{
const hits = [];
if(s.label().toLowerCase().includes(q)){
hits.push({ icon:s.icon, label:s.label(), sub:T("stOpenSection"), go:()=>goSection(s.id) });
}else{
const kw = (s.keywords ? s.keywords() : []).find(k=>String(k).toLowerCase().includes(q));
if(kw) hits.push({ icon:s.icon, label:String(kw), sub:s.label(), go:()=>goSection(s.id) });
}
if(s.searchItems) hits.push(...s.searchItems(q));
if(!hits.length) return;
total += hits.length;
resultsEl.appendChild(el("div","st-nav-group", s.label()));
hits.slice(0, 8).forEach(hit=>{
const row = el("button","st-result");
row.type = "button";
row.appendChild(el("span","st-nav-ico", hit.icon || s.icon));
row.appendChild(el("span","st-result-lbl", hit.label));
if(hit.sub) row.appendChild(el("span","st-result-sub", hit.sub));
row.addEventListener("click", ()=>hit.go());
resultsEl.appendChild(row);
});
});
if(!total) resultsEl.appendChild(el("div","guestnote", T("stNoResults")));
resultsEl.style.display = "";
}
searchInp.addEventListener("input", doSearch);
searchInp.addEventListener("keydown", e=>{
e.stopPropagation();
if(e.key==="Escape"){ searchInp.value = ""; doSearch(); }
if(e.key==="Enter"){ const first = resultsEl.querySelector(".st-result"); if(first) first.click(); }
});
/* ---- openen ---------------------------------------------------------------- */
window.openSettings = function(section){
const msg = document.getElementById("settingsMsg");
msg.textContent = ""; msg.classList.remove("ok");
searchInp.value = "";
searchInp.placeholder = T("stSearchPh");
buildNav();
goSection(section || (currentUser ? "account" : "view"));
/* beheerdata alvast op de achtergrond laden zodat zoeken op namen van
gebruikers/klassen/scholen meteen wat oplevert (admin.js bewaakt zelf
dat er maar één keer geladen wordt) */
if(currentUser && can("admin.access") && window.adminPrefetch) window.adminPrefetch();
wrap.classList.add("open");
};
document.addEventListener("langchange", ()=>{
searchInp.placeholder = T("stSearchPh");
if(wrap.classList.contains("open")){ buildNav(); if(activeSection) goSection(activeSection); }
});
document.addEventListener("userchange", ()=>{
if(wrap.classList.contains("open")){
buildNav();
const secs = visibleSections();
if(!secs.some(s=>s.id===activeSection)) goSection(secs[0] ? secs[0].id : "view");
else goSection(activeSection);
}
});
})();

View file

@ -200,9 +200,12 @@ export default async function api(app) {
if (!r.rows[0]) return fail(reply, 409, 'in een ander tabblad bewerkt');
return { ok: true, rev: Number(r.rows[0].data_rev) };
};
// schoolleerlingen hebben geen eigen borden-data; thuiskinderen (pupil
// zonder school, ouderportaal) wél - die mogen ook hun reservekopieën zien
const schoolPupil = (req) => req.user.role === 'pupil' && req.user.school_id != null;
app.get('/me/data/versions', async (req, reply) => {
need(req, reply);
if (req.user.role === 'pupil') return fail(reply, 403, 'geen rechten');
if (schoolPupil(req)) return fail(reply, 403, 'geen rechten');
const rows = (await pool.query(
'SELECT id, created_at FROM user_data_versions WHERE user_id = $1 ORDER BY created_at DESC',
[req.user.id])).rows;
@ -210,7 +213,7 @@ export default async function api(app) {
});
app.post('/me/data/restore', async (req, reply) => {
need(req, reply);
if (req.user.role === 'pupil') return fail(reply, 403, 'geen rechten');
if (schoolPupil(req)) return fail(reply, 403, 'geen rechten');
const { versionId } = req.body ?? {};
if (!Number.isInteger(versionId)) return fail(reply, 400, 'ongeldige versie');
const v = (await pool.query(

View file

@ -323,6 +323,16 @@ test('thuiskind logt voortgang met de gekoppelde ouder als leerkracht', async ()
await app.close();
});
test('thuiskind mag reservekopieën zien, schoolleerling niet', async () => {
const { app } = await makeApp({ user: homeChild });
const res = await app.inject({ method: 'GET', url: '/api/me/data/versions', cookies });
assert.equal(res.statusCode, 200, res.body);
const { app: app2 } = await makeApp({ user: { ...homeChild, school_id: 2 } });
const res2 = await app2.inject({ method: 'GET', url: '/api/me/data/versions', cookies });
assert.equal(res2.statusCode, 403);
await app.close(); await app2.close();
});
test('gekoppelde ouder ziet de voortgang van het kind, een vreemde ouder niet', async () => {
const { app } = await makeApp({ user: parent, respond: (sql) => {
if (sql.startsWith('SELECT * FROM users WHERE id')) return { rows: [homeChild] };