/* teach - kern: versie, i18n, accounts, opslag, geluid */
"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.20";
(function(){
const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION;
/* the real deployed version always wins once known: CI reads the VERSION file
and passes it as APP_VERSION ("0.2.10-beta" on dev, the release tag on prod)
- never a raw git sha, that's only kept as a hover-title for support/debug */
fetch("/api/version")
.then(r=>r.ok ? r.json() : null)
.then(v=>{
if(!v || typeof v.version!=="string") return;
let text = v.version.startsWith("v") ? v.version : "v"+v.version;
const isBeta = text.includes("beta");
/* on dev the sha is shown inline (not just as a hover-title) so a deploy is always
visibly different, even if the VERSION file itself was forgotten in a commit */
if(isBeta && v.sha) text += " ("+v.sha+")";
tag.textContent = text;
tag.classList.toggle("beta", isBeta);
if(v.sha) tag.title = v.sha;
})
.catch(()=>{});
})();
document.getElementById("copyright").textContent = "© "+new Date().getFullYear()+" Ramon Besselink";
/* =========================================================
i18n
==========================================================*/
const I18N = {
nl:{
addWidget:"Widget", chooseWidget:"Kies een widget", saveBoard:"Bord opslaan",
emptyBoard:"Het bord is leeg. Klik op “Widget” om te beginnen.",
wg_letters:"Letterblokken", wg_letters_d:"Leer lezen en spellen met letterblokken – 3 niveaus, zoals ‘Ik leer lezen’.",
wg_notes:"Notities", wg_notes_d:"Een vel om op te schrijven.",
wg_timer:"Timer", wg_timer_d:"Afteltimer met kijkklok voor de klas.",
wg_write:"Schrijfblad", wg_write_d:"Schrijven en rekenen: pen, gum en papier met schrijflijnen of rekenblokjes.",
bgBlank:"Leeg blad", bgLines:"Lijntjes", bgWrite:"Schrijflijnen",
bgGridS:"Kleine rekenblokjes", bgGridB:"Grote rekenblokjes",
penSize:"Dikte", eraser:"Gum", clearAll:"Alles wissen",
toolPen:"Pen", toolFountain:"Vulpen (dun en dik, reageert op druk of snelheid)",
toolMark:"Markeerstift", toolText:"Tekst typen (klik op het blad)",
wMin:"Minimaliseren", wMax:"Herstellen",
lvl1:"Niveau 1", lvl2:"Niveau 2", lvl3:"Niveau 3",
instr1:"Leg de letterblokken op de goede plek. Kijk goed naar de letters!",
instr2_begin:"Zoek de beginletter van het woord.",
instr2_mid:"Zoek de middelste letter van het woord.",
instr2_end:"Zoek de eindletter van het woord.",
instr3:"Spel het woord bij het plaatje. De nopjes helpen je!",
card:"Kaart", cardGeneral:"Algemeen", word:"Woord", welldone:"Goed zo!", next:"Volgende woord ▸",
cardDone:"Kaart klaar! 🎉", pickCard:"Kies een nieuwe kaart of een ander niveau.",
myWords:"Mijn woorden", manage:"Thema's en woorden beheren", addWord:"+ Toevoegen",
pickPic:"Kies een plaatje",
newTheme:"Nieuw thema", themeNamePh:"naam van het thema…", delTheme:"Thema verwijderen",
numWords:"Woorden per spel", all:"Alle", noThemes:"— nog geen thema's —",
muteAll:"Alle geluiden aan/uit", sndWidget:"Geluid van deze widget aan/uit",
catTaal:"Taal", catRekenen:"Rekenen", catTools:"Hulpmiddelen", catMedia:"Media",
wg_madd:"Plussommen", wg_madd_d:"Optellen tot 10, 20 of 100.",
wg_msub:"Minsommen", wg_msub_d:"Aftrekken tot 10, 20 of 100.",
wg_mmul:"Keersommen", wg_mmul_d:"De tafels van 1 t/m 12 oefenen.",
wg_mdiv:"Deelsommen", wg_mdiv_d:"Delen met de tafels van 1 t/m 12.",
wg_mpct:"Procenten", wg_mpct_d:"Procenten uitrekenen met mooie getallen.",
mGood:"Goed zo!", mAnsWas:"Het goede antwoord is", mAgain:"Nog een keer ▸",
mGoed:"goed", mOf:"van", mDoneT:"Klaar!",
amode:"Blokjes slepen of typen", penMode:"Tekenen op het bord",
fabWidgets:"Widget toevoegen", fabDraw:"Tekenen & schrijven", fabMind:"Mindmap op het bord",
fabImg:"Afbeelding op het bord",
nodeColor:"Kleur", nodeFont:"Lettergrootte", lineColor:"Lijnkleur", lineStyle:"Lijnstijl",
a11y:"Hoog contrast (voor slechtzienden)",
zoomUI:"Beeldgrootte: alles groter of kleiner tonen",
lensBig:"Loep groter", lensSmall:"Loep kleiner", lensShape:"Rond of vierkant",
toolHand:"Handje: verplaatsen en bedienen",
boardsNav:"Borden en mappen", newBoard:"+ Bord", newFolder:"+ Map",
folderPh:"naam nieuwe map…", boardName:"Bord", folderName:"Map",
delBoard:"Bord verwijderen (2× tikken)",
renameTip:"Dubbelklik op een bord om het te hernoemen",
hudMin:"Menu inklappen/uitklappen", hudOrient:"Horizontaal of verticaal",
wFull:"Maximaliseren", wUnfull:"Terugzetten",
wZoom:"Inhoud groter of kleiner (voor slechtzienden)",
wg_flits:"Flitswoorden", wg_flits_d:"Een woord flitst kort in beeld – lezen voordat het verdwijnt!",
flFlash:"⚡ Flits", flShow:"👁 Toon", flNext:"Volgende ▸", flSec:"sec",
wg_dice:"Dobbelstenen", wg_dice_d:"Gooi 1, 2 of 3 grote dobbelstenen.",
diceRoll:"🎲 Gooi!", diceSum:"Samen",
wg_light:"Stoplicht", wg_light_d:"Rood, oranje of groen – bijvoorbeeld voor het werkrumoer.",
wg_names:"Namenkiezer", wg_names_d:"Kies willekeurig een naam uit de klas.",
namesPh:"Eén naam per regel…", namesPick:"🎯 Kies!", namesNoRep:"Zonder herhaling",
namesDone:"Iedereen is geweest! 🎉",
wg_hak:"Hakken & plakken", wg_hak_d:"Woorden in klanken hakken en weer plakken, met voorlezen.",
hakChop:"✂️ Hak", hakGlue:"🔗 Plak", hakSay:"🔊 Lees voor",
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",
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",
wg_mind:"Mindmap", wg_mind_d:"Woordweb, spin of boomdiagram om samen ideeën te ordenen.",
mmSpider:"Spin", mmTree:"Boom", mmWeb:"Woordweb", mmRoot:"Onderwerp",
mmHint:"Klik op tekst om te typen · sleep aan de rand",
wg_media:"Foto & video", wg_media_d:"Toon foto's en video's van internet, zoals YouTube of Vimeo.",
mediaPh:"Plak een link (YouTube, Vimeo, foto of video)…", mediaShow:"Tonen",
mediaEmpty:"Plak hierboven een link en klik op Tonen.",
mediaErr:"Deze link kan niet worden getoond. Probeer een YouTube-, Vimeo-, foto- of videolink, of open hem met ↗ in een nieuw tabblad.",
mediaOpen:"Open in nieuw tabblad",
wordPh:"woord (2–5 letters)", close:"Sluiten",
badWord:"Gebruik 2 t/m 5 letters (a-z).", needPic:"Kies eerst een plaatje.",
noWords:"Nog geen eigen woorden. Voeg er hierboven een toe!",
tabEmoji:"😀 Emoji", tabPicto:"🖼️ Picto's", tabUpload:"📷 Upload",
pictoSearch:"Zoeken", pictoPh:"zoekwoord…", pictoNone:"Niets gevonden. Probeer een ander woord.",
pictoLoad:"Zoeken…", pictoErr:"Kon picto's niet laden (internet nodig).",
pictoCredit:"Picto's: ARASAAC (CC BY-NC-SA, Gobierno de Aragón). Ook leuk: sclera.be (gratis download, dan via Upload toevoegen).",
upload:"📷 Kies een afbeelding…",
upHint:"Tip: vierkant plaatje, witte of doorzichtige achtergrond, één duidelijk voorwerp, géén tekst in het plaatje.",
cats:["Dieren","Eten","Natuur","Vervoer","Spullen","Lichaam"],
notesPh:"Schrijf hier…",
start:"Start", pause:"Pauze", reset:"Reset", min:"min",
tmDigits:"Cijfers", tmVisual:"Kijkklok", tmBoth:"Allebei", tmSound:"Geluid aan/uit",
tmHint:"Sleep aan de rode schijf om de tijd in te stellen.",
login:"Inloggen", loginBtn:"Inloggen", registerBtn:"Account maken", logout:"Uitloggen",
settingsTitle:"Instellingen", tabLogin:"Inloggen", tabLinkCode:"Koppelcode", busy:"Bezig…",
stAccount:"Account", stPassword:"Wachtwoord", stSession:"Sessie",
pwChanged:"Wachtwoord gewijzigd ✓",
namePh:"naam", passPh:"wachtwoord",
guestNote:"Zonder inloggen wordt niets bewaard. Log in met je schoolaccount om alles op de server te bewaren.",
storedNote:"Je werk staat op de school-server en is op elk digibord beschikbaar.",
errLogin:"Onbekende naam of verkeerd wachtwoord.",
schoolSel:"— kies je school (leeg = overkoepelend) —",
linkIntro:"Nieuw account? Activeer het met je koppelcode:",
linkBtn:"Account activeren", pwChange:"Wijzig", pwNewPh:"nieuw wachtwoord (min. 8, met cijfer)",
pwConfirmPh:"herhaal wachtwoord", pwShow:"👁 Toon", pwHide:"🙈 Verberg",
errPwMatch:"Wachtwoorden komen niet overeen.",
codePh:"koppelcode", beheer:"Beheer",
roleSuper:"Overkoepelend beheerder", roleAdmin:"Schoolbeheerder",
roleTeacher:"Groepsleiding", rolePupil:"Leerling",
amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen", amOverarching:"Overkoepelend",
amPickSchoolHint:"Kies eerst een school bij het tabblad “Overkoepelend”.",
amNewSchool:"+ School", amNewClass:"+ Klas", amNewTeacher:"+ Groepsleiding",
amNewAdmin:"+ Schoolbeheerder", amNewSuper:"+ Overkoepelend beheerder",
amNewPupil:"+ Leerling", amUserPh:"gebruikersnaam…", amNamePh:"naam…",
amCode:"koppelcode", amNewCode:"nieuwe code", amPw:"wachtwoord",
amNoClass:"— geen klas —", amPending:"nog niet geactiveerd",
amCouple:"koppel leerkracht", amAllSchools:"— alle scholen —", amSuperGroup:"Overkoepelend",
amCreated:"Aangemaakt! Geef deze koppelcode door:",
amPupilMade:"Leerling aangemaakt. Wachtwoord:",
amPwHidden:"al gebruikt", amGenerate:"genereer wachtwoord",
amNewPw:"nieuw wachtwoord", amPwSet:"Wachtwoord ingesteld:",
amPwOptional:"wachtwoord (leeg = koppelcode)",
amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?",
amCopy:"kopieer", amCopyAll:"kopieer alles",
amResetClass:"reset wachtwoorden klas", amResetSchool:"reset alle leerlingwachtwoorden",
amConfirmReset:"Alle leerlingwachtwoorden van deze klas vernieuwen?",
amConfirmResetSchool:"Alle leerlingwachtwoorden van deze hele school vernieuwen? Dit kan niet ongedaan worden gemaakt.",
amResetNone:"Geen leerlingen om te resetten.",
errExists:"Deze naam bestaat al.",
errName:"Naam: 2–20 letters of cijfers.",
errPass:"Wachtwoord: minimaal 4 tekens.",
errCrypto:"Inloggen werkt niet in deze browser.",
hello:"Hoi", boardSaved:"Opgeslagen ✓", loginFirst:"Eerst inloggen"
},
en:{
addWidget:"Widget", chooseWidget:"Choose a widget", saveBoard:"Save board",
emptyBoard:"The board is empty. Click “Widget” to get started.",
wg_letters:"Letter blocks", wg_letters_d:"Learn to read and spell with letter blocks – 3 levels, like ‘Ik leer lezen’.",
wg_notes:"Notes", wg_notes_d:"A sheet to write on.",
wg_timer:"Timer", wg_timer_d:"Countdown timer with a visual clock for class.",
wg_write:"Writing pad", wg_write_d:"Writing and maths: pen, eraser and paper with handwriting lines or maths squares.",
bgBlank:"Blank sheet", bgLines:"Lines", bgWrite:"Handwriting lines",
bgGridS:"Small maths squares", bgGridB:"Big maths squares",
penSize:"Thickness", eraser:"Eraser", clearAll:"Clear all",
toolPen:"Pen", toolFountain:"Fountain pen (thin and thick, follows pressure or speed)",
toolMark:"Highlighter", toolText:"Type text (click the sheet)",
wMin:"Minimize", wMax:"Restore",
lvl1:"Level 1", lvl2:"Level 2", lvl3:"Level 3",
instr1:"Place the letter blocks in the right spot. Look closely at the letters!",
instr2_begin:"Find the beginning letter of the word.",
instr2_mid:"Find the middle letter of the word.",
instr2_end:"Find the ending letter of the word.",
instr3:"Spell the word for the picture. The little studs will help you!",
card:"Card", cardGeneral:"General", word:"Word", welldone:"Well done!", next:"Next word ▸",
cardDone:"Card complete! 🎉", pickCard:"Pick a new card or another level.",
myWords:"My words", manage:"Manage themes and words", addWord:"+ Add",
pickPic:"Choose a picture",
newTheme:"New theme", themeNamePh:"name of the theme…", delTheme:"Delete theme",
numWords:"Words per game", all:"All", noThemes:"— no themes yet —",
muteAll:"All sounds on/off", sndWidget:"This widget's sound on/off",
catTaal:"Language", catRekenen:"Maths", catTools:"Tools", catMedia:"Media",
wg_madd:"Addition", wg_madd_d:"Adding up to 10, 20 or 100.",
wg_msub:"Subtraction", wg_msub_d:"Subtracting up to 10, 20 or 100.",
wg_mmul:"Multiplication", wg_mmul_d:"Practise the times tables 1–12.",
wg_mdiv:"Division", wg_mdiv_d:"Dividing with the tables 1–12.",
wg_mpct:"Percentages", wg_mpct_d:"Working out percentages with friendly numbers.",
mGood:"Well done!", mAnsWas:"The right answer is", mAgain:"Again ▸",
mGoed:"correct", mOf:"of", mDoneT:"Done!",
amode:"Drag blocks or type", penMode:"Draw on the board",
fabWidgets:"Add widget", fabDraw:"Draw & write", fabMind:"Mindmap on the board",
fabImg:"Picture on the board",
nodeColor:"Colour", nodeFont:"Font size", lineColor:"Line colour", lineStyle:"Line style",
a11y:"High contrast (for low vision)",
zoomUI:"Display size: show everything bigger or smaller",
lensBig:"Larger magnifier", lensSmall:"Smaller magnifier", lensShape:"Round or square",
toolHand:"Hand: move and interact",
boardsNav:"Boards and folders", newBoard:"+ Board", newFolder:"+ Folder",
folderPh:"name of new folder…", boardName:"Board", folderName:"Folder",
delBoard:"Delete board (tap twice)",
renameTip:"Double-click a board to rename it",
hudMin:"Collapse/expand menu", hudOrient:"Horizontal or vertical",
wFull:"Maximize", wUnfull:"Restore",
wZoom:"Content bigger or smaller (low vision)",
wg_flits:"Flash words", wg_flits_d:"A word flashes briefly – read it before it disappears!",
flFlash:"⚡ Flash", flShow:"👁 Show", flNext:"Next ▸", flSec:"sec",
wg_dice:"Dice", wg_dice_d:"Roll 1, 2 or 3 big dice.",
diceRoll:"🎲 Roll!", diceSum:"Total",
wg_light:"Traffic light", wg_light_d:"Red, orange or green – e.g. for classroom noise.",
wg_names:"Name picker", wg_names_d:"Pick a random name from the class.",
namesPh:"One name per line…", namesPick:"🎯 Pick!", namesNoRep:"No repeats",
namesDone:"Everyone has had a turn! 🎉",
wg_hak:"Chop & blend", wg_hak_d:"Chop words into sounds and blend them back, with read-aloud.",
hakChop:"✂️ Chop", hakGlue:"🔗 Blend", hakSay:"🔊 Read aloud",
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",
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",
wg_mind:"Mind map", wg_mind_d:"Word web, spider or tree diagram to organise ideas together.",
mmSpider:"Spider", mmTree:"Tree", mmWeb:"Word web", mmRoot:"Topic",
mmHint:"Click text to type · drag by the edge",
wg_media:"Photo & video", wg_media_d:"Show photos and videos from the internet, such as YouTube or Vimeo.",
mediaPh:"Paste a link (YouTube, Vimeo, photo or video)…", mediaShow:"Show",
mediaEmpty:"Paste a link above and click Show.",
mediaErr:"This link cannot be shown. Try a YouTube, Vimeo, photo or video link, or open it with ↗ in a new tab.",
mediaOpen:"Open in new tab",
wordPh:"word (2–5 letters)", close:"Close",
badWord:"Use 2 to 5 letters (a-z).", needPic:"Pick a picture first.",
noWords:"No custom words yet. Add one above!",
tabEmoji:"😀 Emoji", tabPicto:"🖼️ Pictos", tabUpload:"📷 Upload",
pictoSearch:"Search", pictoPh:"search word…", pictoNone:"Nothing found. Try another word.",
pictoLoad:"Searching…", pictoErr:"Could not load pictos (internet required).",
pictoCredit:"Pictos: ARASAAC (CC BY-NC-SA, Gobierno de Aragón). Also nice: sclera.be (free download, then add via Upload).",
upload:"📷 Choose an image…",
upHint:"Tip: square picture, white or transparent background, one clear object, no text in the picture.",
cats:["Animals","Food","Nature","Transport","Things","Body"],
notesPh:"Write here…",
start:"Start", pause:"Pause", reset:"Reset", min:"min",
tmDigits:"Digits", tmVisual:"Visual clock", tmBoth:"Both", tmSound:"Sound on/off",
tmHint:"Drag the red disc to set the time.",
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",
pwChanged:"Password changed ✓",
namePh:"name", passPh:"password",
guestNote:"Without logging in nothing is saved. Log in with your school account to keep everything on the server.",
storedNote:"Your work is stored on the school server and available on any board.",
errLogin:"Unknown name or wrong password.",
schoolSel:"— choose your school (empty = overarching) —",
linkIntro:"New account? Activate it with your link code:",
linkBtn:"Activate account", pwChange:"Change", pwNewPh:"new password (min. 8, incl. number)",
pwConfirmPh:"repeat password", pwShow:"👁 Show", pwHide:"🙈 Hide",
errPwMatch:"Passwords don't match.",
codePh:"link code", beheer:"Management",
roleSuper:"Overarching administrator", roleAdmin:"School administrator",
roleTeacher:"Group leader", rolePupil:"Pupil",
amUsers:"Users", amClasses:"Classes", amSchools:"Schools", amOverarching:"Overarching",
amPickSchoolHint:"First pick a school in the “Overarching” tab.",
amNewSchool:"+ School", amNewClass:"+ Class", amNewTeacher:"+ Group leader",
amNewAdmin:"+ School admin", amNewSuper:"+ Overarching admin",
amNewPupil:"+ Pupil", amUserPh:"username…", amNamePh:"name…",
amCode:"link code", amNewCode:"new code", amPw:"password",
amNoClass:"— no class —", amPending:"not yet activated",
amCouple:"link teacher", amAllSchools:"— all schools —", amSuperGroup:"Overarching",
amCreated:"Created! Pass on this link code:",
amPupilMade:"Pupil created. Password:",
amPwHidden:"already used", amGenerate:"generate password",
amNewPw:"new password", amPwSet:"Password set:",
amPwOptional:"password (empty = link code)",
amConfirmDel:"Are you sure you want to delete this user?",
amCopy:"copy", amCopyAll:"copy all",
amResetClass:"reset class passwords", amResetSchool:"reset all pupil passwords",
amConfirmReset:"Renew all pupil passwords for this class?",
amConfirmResetSchool:"Renew all pupil passwords for this whole school? This cannot be undone.",
amResetNone:"No pupils to reset.",
errExists:"This name already exists.",
errName:"Name: 2–20 letters or digits.",
errPass:"Password: at least 4 characters.",
errCrypto:"Login does not work in this browser.",
hello:"Hi", boardSaved:"Saved ✓", loginFirst:"Log in first"
}
};
let LANG = "nl";
const T = k => I18N[LANG][k];
/* effective CSS zoom of the app (display-size setting); pointer coordinates
are visual pixels, absolute positions are layout pixels → divide by VZ() */
const VZ = ()=>{ const z = parseFloat(document.body.style.zoom); return z>0 ? z : 1; };
function applyI18n(){
document.querySelectorAll("[data-i18n]").forEach(el=>{ el.textContent = T(el.dataset.i18n); });
document.querySelectorAll("#langToggle button").forEach(b=>b.classList.toggle("active", b.dataset.lang===LANG));
document.getElementById("liName").placeholder = T("namePh");
document.getElementById("liPass").placeholder = T("passPh");
document.getElementById("liCode").placeholder = T("codePh");
document.getElementById("liPassNew").placeholder = T("pwNewPh");
document.getElementById("liPassNew2").placeholder = T("pwConfirmPh");
document.getElementById("ownPw").placeholder = T("pwNewPh");
document.getElementById("ownPw2").placeholder = T("pwConfirmPh");
document.getElementById("btnLiPassToggle").textContent = T("pwShow");
document.getElementById("btnOwnPwToggle").textContent = T("pwShow");
document.getElementById("btnMute").title = T("muteAll");
updateUserUI();
document.dispatchEvent(new CustomEvent("langchange"));
}
document.querySelectorAll("#langToggle button").forEach(b=>{
b.addEventListener("click", ()=>{ LANG=b.dataset.lang; applyI18n(); });
});
document.getElementById("btnFs").addEventListener("click", ()=>{
if(document.fullscreenElement) document.exitFullscreen(); else document.documentElement.requestFullscreen();
});
/* =========================================================
Accounts & sessies — via de server-API (src/api.js)
Rollen: super (overkoepelend), admin (school), teacher (groepsleiding),
pupil (leerling). Zonder inloggen wordt niets bewaard.
==========================================================*/
let TOKEN = null;
try{ TOKEN = localStorage.getItem("teach.token"); }catch(e){}
let currentUser = null; /* {id, username, displayName, role, schoolId, classId} */
async function api(path, opts={}){
const headers = { "Content-Type": "application/json" };
if(TOKEN) headers.Authorization = "Bearer " + TOKEN;
const r = await fetch("/api" + path, {
method: opts.method || (opts.body ? "POST" : "GET"),
headers,
body: opts.body ? JSON.stringify(opts.body) : undefined
});
const j = await r.json().catch(()=>({}));
if(!r.ok) throw new Error(j.error || ("fout " + r.status));
return j;
}
/* alles van de gebruiker (borden, thema's, eigen woorden) staat op de server */
let saveTimer = null;
function persistUser(){
if(!currentUser) return Promise.resolve();
return api("/me/data", { method:"PUT", body:{
themes: THEMES, generalExtra: GENERAL_EXTRA, boards: BS
}}).catch(()=>{});
}
function persistWords(){
if(!currentUser) return;
clearTimeout(saveTimer);
saveTimer = setTimeout(()=>{ stashCurrent(); persistUser(); }, 800);
}
function generalExtraFromData(data){
return (data && data.generalExtra) ? data.generalExtra : {nl:[], en:[]};
}
async function hydrateFromServer(){
const d = (await api("/me/data")).data || {};
THEMES = themesFromData(d);
GENERAL_EXTRA = generalExtraFromData(d);
BS = boardsFromData(d);
document.dispatchEvent(new CustomEvent("wordschange"));
loadCurrentSlot();
document.dispatchEvent(new CustomEvent("userchange"));
}
function resetToGuest(){
currentUser = null;
TOKEN = null;
try{ localStorage.removeItem("teach.token"); }catch(e){}
THEMES = {nl:[],en:[]};
GENERAL_EXTRA = {nl:[],en:[]};
BS = freshBS();
loadCurrentSlot();
updateUserUI();
document.dispatchEvent(new CustomEvent("wordschange"));
document.dispatchEvent(new CustomEvent("userchange"));
}
/* login UI */
const loginWrap = document.getElementById("loginWrap");
const loginMsg = document.getElementById("loginMsg");
const settingsWrap = document.getElementById("settingsWrap");
const settingsMsg = document.getElementById("settingsMsg");
const roleKey = r => r==="super" ? "roleSuper" : r==="admin" ? "roleAdmin" : r==="teacher" ? "roleTeacher" : "rolePupil";
function updateUserUI(){
document.getElementById("userLbl").textContent = currentUser ? currentUser.displayName : T("login");
document.getElementById("btnUser").classList.toggle("on", !!currentUser);
const adminTabBtn = document.getElementById("btnAdmin");
const showAdmin = can("admin.access");
adminTabBtn.style.display = showAdmin ? "" : "none";
if(!showAdmin && adminTabBtn.classList.contains("active")) switchSettingsTab("account");
if(currentUser){
document.getElementById("helloName").textContent = currentUser.displayName;
document.getElementById("helloRole").textContent = T(roleKey(currentUser.role));
document.getElementById("ownPwBlock").style.display = can("password.own.change") ? "flex" : "none";
}
}
/* login-modal tabs: "inloggen" vs "koppelcode activeren" */
function switchLoginTab(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("linkBox").style.display = tab==="link" ? "block" : "none";
}
document.querySelectorAll("#loginModal .ed-tabs button").forEach(b=>{
b.addEventListener("click", ()=>switchLoginTab(b.dataset.tab));
});
/* instellingen-tabs: "account" vs "beheer" - alles in één overzicht i.p.v. een los venster */
function switchSettingsTab(tab){
document.querySelectorAll("#settingsTabs button").forEach(b=>b.classList.toggle("active", b.dataset.tab===tab));
document.getElementById("settingsAccountPanel").style.display = tab==="account" ? "block" : "none";
document.getElementById("settingsAdminPanel").style.display = tab==="admin" ? "block" : "none";
}
/* het "beheer"-tabblad heeft zijn eigen klik-handler in admin.js (moet eerst data laden) */
document.querySelector('#settingsTabs button[data-tab="account"]').addEventListener("click", ()=>switchSettingsTab("account"));
async function loadSchools(){
const sel = document.getElementById("liSchool");
try{
const list = await api("/schools");
sel.innerHTML = `` +
list.map(x=>``; }
}
function openSettings(){
settingsMsg.textContent = ""; settingsMsg.classList.remove("ok");
switchSettingsTab("account");
settingsWrap.classList.add("open");
}
document.getElementById("btnUser").addEventListener("click", ()=>{
if(currentUser){ openSettings(); return; }
loginMsg.textContent = ""; loginMsg.classList.remove("ok");
switchLoginTab("login");
loadSchools();
loginWrap.classList.add("open");
});
document.getElementById("btnSettingsClose").addEventListener("click", ()=>settingsWrap.classList.remove("open"));
loginWrap.addEventListener("click", e=>{ if(e.target===loginWrap) loginWrap.classList.remove("open"); });
settingsWrap.addEventListener("click", e=>{ if(e.target===settingsWrap) settingsWrap.classList.remove("open"); });
async function afterLogin(res){
TOKEN = res.token;
try{ localStorage.setItem("teach.token", TOKEN); }catch(e){}
currentUser = res.user;
updateUserUI();
loginWrap.classList.remove("open");
await hydrateFromServer();
}
/* laadstatus op een knop tijdens een async actie, altijd hersteld na afloop */
async function withBusy(btn, fn){
const orig = btn.textContent;
btn.disabled = true; btn.textContent = T("busy");
try{ await fn(); }
finally{ btn.disabled = false; btn.textContent = orig; }
}
document.getElementById("btnLogin").addEventListener("click", async ()=>{
const btn = document.getElementById("btnLogin");
const schoolId = document.getElementById("liSchool").value || null;
const username = document.getElementById("liName").value.trim();
const password = document.getElementById("liPass").value;
loginMsg.classList.remove("ok");
await withBusy(btn, async ()=>{
try{
await afterLogin(await api("/auth/login", { body:{ schoolId: schoolId ? +schoolId : null, username, password } }));
document.getElementById("liPass").value = "";
}catch(e){ loginMsg.textContent = e.message || T("errLogin"); }
});
});
document.getElementById("liPass").addEventListener("keydown", e=>{
if(e.key==="Enter") document.getElementById("btnLogin").click();
});
/* wachtwoord tonen/verbergen bij activeren en eigen wachtwoord wijzigen */
function pwToggle(btnId, ...fieldIds){
const btn = document.getElementById(btnId);
let shown = false;
btn.addEventListener("click", ()=>{
shown = !shown;
fieldIds.forEach(id=>{ document.getElementById(id).type = shown ? "text" : "password"; });
btn.textContent = T(shown ? "pwHide" : "pwShow");
});
}
pwToggle("btnLiPassToggle", "liPassNew", "liPassNew2");
pwToggle("btnOwnPwToggle", "ownPw", "ownPw2");
/* account activeren met een koppelcode */
document.getElementById("btnLink").addEventListener("click", async ()=>{
const btn = document.getElementById("btnLink");
const code = document.getElementById("liCode").value.trim().toUpperCase();
const password = document.getElementById("liPassNew").value;
const password2 = document.getElementById("liPassNew2").value;
loginMsg.classList.remove("ok");
if(password !== password2){ loginMsg.textContent = T("errPwMatch"); return; }
await withBusy(btn, async ()=>{
try{
await afterLogin(await api("/auth/link", { body:{ code, password } }));
document.getElementById("liCode").value = "";
document.getElementById("liPassNew").value = "";
document.getElementById("liPassNew2").value = "";
}catch(e){ loginMsg.textContent = e.message; }
});
});
document.getElementById("btnLogout").addEventListener("click", async ()=>{
try{ await api("/auth/logout", { method:"POST", body:{} }); }catch(e){}
resetToGuest();
loginWrap.classList.remove("open");
settingsWrap.classList.remove("open");
});
/* eigen wachtwoord wijzigen (niet voor leerlingen) */
document.getElementById("btnOwnPw").addEventListener("click", async ()=>{
const btn = document.getElementById("btnOwnPw");
const pw = document.getElementById("ownPw").value;
const pw2 = document.getElementById("ownPw2").value;
settingsMsg.classList.remove("ok");
if(pw !== pw2){ settingsMsg.textContent = T("errPwMatch"); return; }
await withBusy(btn, async ()=>{
try{
await api("/auth/password", { method:"PATCH", body:{ password: pw } });
document.getElementById("ownPw").value = "";
document.getElementById("ownPw2").value = "";
settingsMsg.textContent = T("pwChanged"); settingsMsg.classList.add("ok");
}catch(e){ settingsMsg.textContent = e.message; }
});
});
/* save board */
document.getElementById("btnSave").addEventListener("click", async ()=>{
const btn = document.getElementById("btnSave");
const lbl = btn.querySelector("span");
if(!currentUser){
lbl.textContent = T("loginFirst");
setTimeout(()=>{ lbl.textContent = T("saveBoard"); }, 2200);
loadSchools();
loginWrap.classList.add("open");
return;
}
stashCurrent();
await persistUser();
lbl.textContent = T("boardSaved");
btn.classList.add("ok");
setTimeout(()=>{ lbl.textContent = T("saveBoard"); btn.classList.remove("ok"); }, 1800);
});
/* =========================================================
Sounds
==========================================================*/
let AC=null, MUTED=false;
const btnMute = document.getElementById("btnMute");
btnMute.addEventListener("click", ()=>{
MUTED = !MUTED;
btnMute.textContent = MUTED ? "🔇" : "🔊";
btnMute.title = T("muteAll");
});
function tone(freq, dur=0.12, delay=0, type="sine", vol=0.18){
if(MUTED) return;
try{
AC = AC || new (window.AudioContext||window.webkitAudioContext)();
const o=AC.createOscillator(), g=AC.createGain();
o.type=type; o.frequency.value=freq;
g.gain.setValueAtTime(vol, AC.currentTime+delay);
g.gain.exponentialRampToValueAtTime(0.001, AC.currentTime+delay+dur);
o.connect(g); g.connect(AC.destination);
o.start(AC.currentTime+delay); o.stop(AC.currentTime+delay+dur+0.02);
}catch(e){}
}
const sndGood = ()=> tone(880,0.1);
const sndBad = ()=> tone(180,0.18,0,"square",0.10);
const sndWin = ()=>{ [523,659,784,1047].forEach((f,i)=>tone(f,0.16,i*0.12,"triangle",0.2)); };