/* 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.3.06-beta"; (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", penColor:"Kleur", penExtras:"Hulpmiddelen", recentColors:"Recent gebruikt", paletteColors:"Kleuren", customColor:"Eigen kleur", 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", anchorHintReadonly:"Sleep de achtergrond om te schuiven", anchorColor:"Kleur: klik om te wisselen (verbind woorden met dezelfde kleur)", ballWin:"Geraden! 🎉", ballLose:"Helaas! Het woord was:", bgDots:"Stippen", toolRuler:"Liniaal", toolGeo:"Geodriehoek", toolLens:"Vergrootglas", 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", stView:"Weergave", vwTheme:"Thema", vwLight:"Licht", vwDark:"Donker", vwAuto:"Auto", vwContrast:"Hoog contrast", vwOn:"Aan", vwOff:"Uit", vwLang:"Taal", vwTbar:"Menubalk", vwTop:"Boven", vwLeft:"Links", vwRight:"Rechts", vwMotion:"Animaties", vwGlass:"Glas-effect", vwNote:"Deze voorkeuren gelden voor dit apparaat.", pwChanged:"Wachtwoord gewijzigd ✓", pwCurrentPh:"huidig wachtwoord", 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", pupilEmpty:"Je leerkracht heeft nog niets voor je klaargezet.", amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen", amOverarching:"Overkoepelend", amPickSchoolHint:"Kies eerst een school bij het tabblad “Overkoepelend”.", amNewSchool:"+ School", amNewClass:"+ Klas", amNewTeacher:"+ Groepsleiding", amNewAdmin:"+ Schoolbeheerder", amNewSuper:"+ Overkoepelend beheerder", amNewPupil:"+ Leerling", amUserPh:"gebruikersnaam…", amNamePh:"naam…", amSearchPh:"🔎 zoek op 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)", amAssignments:"Toewijzingen", amAssignClasses:"Per klas (standaard)", amAssignPupils:"Per leerling (uitzondering)", amPickPupil:"— kies een leerling —", amNoAssignment:"— geen toewijzing —", amAssignedAs:"Toegewezen:", amAssign:"Toewijzen", amAssignSaved:"Toewijzing opgeslagen.", amConfirmDel:"Weet je zeker dat je deze gebruiker wilt verwijderen?", amCopy:"kopieer", amCopyAll:"kopieer alles", amResetClass:"reset wachtwoorden klas", amResetSchool:"reset alle leerlingwachtwoorden", 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", saving:"Opslaan…", saveFailed:"⚠ Niet opgeslagen" }, 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", penColor:"Colour", penExtras:"Tools", recentColors:"Recently used", paletteColors:"Colours", customColor:"Custom colour", 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", anchorHintReadonly:"Drag the background to pan", anchorColor:"Colour: click to cycle (connect words with the same colour)", ballWin:"You got it! 🎉", ballLose:"Oh no! The word was:", bgDots:"Dots", toolRuler:"Ruler", toolGeo:"Set square", toolLens:"Magnifier", 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", stView:"Display", vwTheme:"Theme", vwLight:"Light", vwDark:"Dark", vwAuto:"Auto", vwContrast:"High contrast", vwOn:"On", vwOff:"Off", vwLang:"Language", vwTbar:"Menu bar", vwTop:"Top", vwLeft:"Left", vwRight:"Right", vwMotion:"Animations", vwGlass:"Glass effect", vwNote:"These preferences apply to this device.", pwChanged:"Password changed ✓", pwCurrentPh:"current password", 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", pupilEmpty:"Your teacher hasn't set up anything for you yet.", amUsers:"Users", amClasses:"Classes", amSchools:"Schools", amOverarching:"Overarching", amPickSchoolHint:"First pick a school in the “Overarching” tab.", amNewSchool:"+ School", amNewClass:"+ Class", amNewTeacher:"+ Group leader", amNewAdmin:"+ School admin", amNewSuper:"+ Overarching admin", amNewPupil:"+ Pupil", amUserPh:"username…", amNamePh:"name…", amSearchPh:"🔎 search by 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)", amAssignments:"Assignments", amAssignClasses:"Per class (default)", amAssignPupils:"Per pupil (override)", amPickPupil:"— pick a pupil —", amNoAssignment:"— no assignment —", amAssignedAs:"Assigned:", amAssign:"Assign", amAssignSaved:"Assignment saved.", amConfirmDel:"Are you sure you want to delete this user?", amCopy:"copy", amCopyAll:"copy all", amResetClass:"reset class passwords", amResetSchool:"reset all pupil passwords", 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", saving:"Saving…", saveFailed:"⚠ Not saved" } }; 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("ownPwCurrent").placeholder = T("pwCurrentPh"); 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; try{ localStorage.setItem("teach.lang", LANG); }catch(e){} 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. ==========================================================*/ // Oude bearer-tokens worden bewust verwijderd; sessies lopen via een HttpOnly-cookie. try{ localStorage.removeItem("teach.token"); }catch(e){} let currentUser = null; /* {id, username, displayName, role, schoolId, classId} */ async function api(path, opts={}){ const headers = { "Content-Type": "application/json" }; const r = await fetch("/api" + path, { credentials: "same-origin", 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; } /* ========================================================= Opslaan - alles van de gebruiker staat op de server. Autosave: elke wijziging plant (debounced) een save; de status is altijd zichtbaar op de opslaan-knop en fouten worden NIET meer ingeslikt (die stilte verborg de 1MB-limiet-bug). ==========================================================*/ let saveTimer = null; let lastSaveError = null; function canPersist(){ return !!currentUser && currentUser.role !== "pupil"; } function persistUser(){ if(!canPersist()) return Promise.resolve(true); return api("/me/data", { method:"PUT", body:{ themes: THEMES, generalExtra: GENERAL_EXTRA, boards: BS }}).then(()=>{ lastSaveError = null; return true; }) .catch(e=>{ lastSaveError = e.message; return false; }); } /* status op de opslaan-knop: idle / saving / saved / error */ function setSaveStatus(state){ const btn = document.getElementById("btnSave"); const lbl = btn.querySelector("span"); btn.classList.remove("ok","err"); btn.title = ""; if(state==="saving"){ lbl.textContent = T("saving"); } else if(state==="saved"){ lbl.textContent = T("boardSaved"); btn.classList.add("ok"); } else if(state==="error"){ lbl.textContent = T("saveFailed"); btn.classList.add("err"); btn.title = lastSaveError || ""; } else lbl.textContent = T("saveBoard"); } let saveStatusTimer = null; async function doSave(){ if(!canPersist()) return false; stashCurrent(); setSaveStatus("saving"); const ok = await persistUser(); clearTimeout(saveStatusTimer); setSaveStatus(ok ? "saved" : "error"); /* een fout blijft staan tot een geslaagde save; succes ebt weg */ if(ok) saveStatusTimer = setTimeout(()=>setSaveStatus("idle"), 2000); return ok; } /* autosave: door alles aan te roepen wat het bord of de woorden wijzigt */ function scheduleSave(){ if(!canPersist()) return; clearTimeout(saveTimer); saveTimer = setTimeout(doSave, 2500); } function persistWords(){ scheduleSave(); } function generalExtraFromData(data){ return (data && data.generalExtra) ? data.generalExtra : {nl:[], en:[]}; } async function hydrateFromServer(){ /* leerlingen hebben geen eigen borden - zij zien enkel wat de leerkracht heeft toegewezen (zie pupil.js), nooit het whiteboard */ if(currentUser && currentUser.role === "pupil"){ THEMES = {nl:[],en:[]}; GENERAL_EXTRA = {nl:[],en:[]}; startPupilView(); document.dispatchEvent(new CustomEvent("userchange")); return; } stopPupilView(); const d = (await api("/me/data")).data || {}; THEMES = themesFromData(d); GENERAL_EXTRA = generalExtraFromData(d); BS = boardsFromData(d); document.dispatchEvent(new CustomEvent("wordschange")); loadCurrentSlot(); document.dispatchEvent(new CustomEvent("userchange")); } function resetToGuest(){ currentUser = null; stopPupilView(); 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; const roles = [currentUser.role, ...(currentUser.extraRoles||[])]; document.getElementById("helloRole").textContent = roles.map(r=>T(roleKey(r))).join(" + "); 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" / "weergave" / "beheer" - alles in één overzicht */ 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("settingsViewPanel").style.display = tab==="view" ? "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")); document.querySelector('#settingsTabs button[data-tab="view"]').addEventListener("click", ()=>switchSettingsTab("view")); 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"); /* 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"); 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"); }); /* gast-ingang naar de weergave-instellingen vanuit het inlogscherm */ document.getElementById("btnGuestView").addEventListener("click", ()=>{ loginWrap.classList.remove("open"); openSettings(); }); 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){ 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", "ownPwCurrent", "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 currentPassword = document.getElementById("ownPwCurrent").value; 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:{ currentPassword, password: pw } }); document.getElementById("ownPwCurrent").value = ""; 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 lbl = document.getElementById("btnSave").querySelector("span"); if(!currentUser){ lbl.textContent = T("loginFirst"); setTimeout(()=>{ lbl.textContent = T("saveBoard"); }, 2200); loadSchools(); loginWrap.classList.add("open"); return; } clearTimeout(saveTimer); /* expliciet opslaan gaat vóór de geplande autosave */ await doSave(); }); /* ========================================================= 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)); };