teach/public/js/core.js
Ramon 42fc323ab1
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 28s
fix: toon gekoppelde ouders in de gebruikerslijst van de school (v0.3.59-beta)
- Ouders (school_id NULL) vielen buiten elke schoolgescopeerde gebruikerslijst; /admin/users levert nu ook de ouders die aan leerlingen van de school gekoppeld zijn (groepsleiding: alleen ouders van leerlingen uit eigen klassen)
- Ouder-rijen tonen de namen van hun gekoppelde kinderen (childNames)
- Servertest voor de gekoppelde-ouders-lijst

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
2026-07-16 00:23:51 +02:00

1025 lines
68 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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.59-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 <b>beginletter</b> van het woord.",
instr2_mid:"Zoek de <b>middelste letter</b> van het woord.",
instr2_end:"Zoek de <b>eindletter</b> 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",
themeFolder:"Thema's en mappen",
exFixedFolder:"Deze map wordt automatisch beheerd.",
anchorLibTitle:"Ankerdiagrammen", anchorLibSave:"Bewaar dit diagram in de bibliotheek",
anchorLibOpen:"Ankerbibliotheek openen", anchorLibSaved:"Bewaard ✓",
anchorLibEmptyGrid:"Zet eerst woorden in het diagram.",
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", catClass:"Feest & klas", 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)",
exFolder:"Map", exRename:"Hernoem", exDelete:"Verwijder", exConfirm:"zeker?",
exSelectFirst:"Klik eerst een map of bord aan.", exNoSlash:"Een naam mag geen / bevatten.",
exExists:"Die naam bestaat al.", exEmpty:"Deze map is leeg.",
exFolderNotEmpty:"Map is niet leeg - verplaats of verwijder eerst de inhoud.",
exIntoSelf:"Een map kan niet in zichzelf.", exNoRootItems:"Borden horen in een map.",
exLastBoard:"Het laatste bord kan niet weg.",
shGlobal:"Systeem", shSchool:"School",
shPublished:"Gedeeld ✓", shCopied:"Gekopieerd naar je eigen mappen ✓",
shNoCreateHere:"Nieuw maken kan alleen in je eigen mappen - sleep het daarna hierheen om te delen.",
shReadonly:"Alleen lezen - kopieer het naar je eigen mappen om te bewerken.",
exLock:"Alleen lezen", shLockedOk:"Vergrendeld ✓", shUnlockedOk:"Ontgrendeld ✓",
exWrongBranch:"Dat hoort niet in deze map (borden en ankerdiagrammen zijn gescheiden).",
anchorLibCreateHint:"Nieuw diagram maken: gebruik 💾 op een ankerwidget.",
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:"Woordweb voor woordenschatonderwijs: leer woorden in samenhang rond een themawoord (het anker), met plaatjes. Elk woord verankert aan de woorden eromheen.",
fabAnchor:"Ankerdiagram (woordweb)",
wg_birthday:"Verjaardagstaart", wg_birthday_d:"Maak een persoonlijke taart met naam, leeftijd, kleuren, lagen, versiering en kaarsjes.",
birthdayName:"naam van de jarige", birthdayAge:"Leeftijd", birthdayLayers:"Lagen", birthdayCakeColor:"Taart", birthdayIcing:"Glazuur", birthdayPlate:"Bord",
birthdayCandles:"Kaarsjes", birthdayCelebrate:"🎉 Vieren!", birthdayTitle:"Er is er één jarig!", birthdayFor:"Hoera voor {name}!",
birthdaySprinkles:"Spikkels", birthdayFruit:"Fruit", birthdayStars:"Sterren", birthdayNone:"Geen versiering",
wg_numberline:"Getallenlijn", wg_numberline_d:"Oefen getalbegrip met een instelbare, interactieve getallenlijn.",
numberlineFrom:"Van", numberlineTo:"Tot", numberlineStep:"Stap", numberlineNew:"Nieuwe opdracht", numberlineTask:"Zet de stip op {number}",
numberlineCheck:"Controleren", numberlineGood:"Goed gevonden! 🌟", numberlineTry:"Kijk nog eens naar de getallenlijn.",
wg_fractions:"Breukenmuur", wg_fractions_d:"Vergelijk breuken visueel en markeer gelijke delen.",
wg_clock:"Oefenklok", wg_clock_d:"Leer kloktijden op vier niveaus met meerdere leerwijzen en spellen.",
wg_placevalue:"Plaatswaardetabel", wg_placevalue_d:"Ontdek de waarde van cijfers tot honderdduizend.",
wg_money:"Geldrekenen", wg_money_d:"Oefen bedragen maken, geld tellen en wisselgeld op drie niveaus.",
moneyLearningStyle:"Opdracht tonen", moneyStyleAmount:"Als bedrag", moneyStyleWords:"In woorden", moneyStyleBoth:"Bedrag en woorden",
moneyGame:"Spel", moneyGameMake:"Bedrag maken", moneyGameCount:"Geld tellen", moneyGameChange:"Wisselgeld",
moneyCategory:"Koopcategorie", moneyCategoryAll:"Alle producten", moneyCategoryFood:"Eten en drinken", moneyCategorySchool:"Schoolspullen", moneyCategoryPlay:"Spelen en sport",
moneyLevel:"Niveau", moneyLevel1Hint:"Niveau 1: hele euros tot € 9", moneyLevel2Hint:"Niveau 2: tot € 10 in stappen van 5 cent", moneyLevel3Hint:"Niveau 3: tot € 50 met alle centen, munten en biljetten",
moneyScore:"Score: {score} · reeks: {streak}", moneyMakeTitle:"Maak het gevraagde bedrag", moneyCountTitle:"Hoeveel geld zie je?", moneyChangeTitle:"Bereken het wisselgeld",
moneyYourTask:"Jouw opdracht", moneyPrice:"Prijs", moneyPaid:"Betaald", moneyWallet:"Jouw geld", moneyWalletHint:"Sleep geld hierheen of tik op een munt of biljet. Sleep geplaatst geld naar buiten om het weg te halen.", moneyRemove:"Sleep naar buiten om weg te halen", moneyTray:"Munten en biljetten",
moneyBuildHint:"Sleep de cijfers naar de vakken en maak het bedrag.", moneyTypeHint:"Voer het bedrag in met een komma.", moneyIncomplete:"Maak eerst het volledige bedrag af.",
moneyCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", moneyTryAgain:"Bijna! Reken het bedrag nog eens na.", moneyNeed:"Nog {amount} nodig.", moneyTooMuch:"{amount} te veel.",
moneyClear:"Wissen", moneyNextTask:"Volgende opdracht",
wg_wordtypes:"Woordsoorten", wg_wordtypes_d:"Geef woorden kleur per woordsoort.",
wg_schedule:"Dagplanning", wg_schedule_d:"Maak een duidelijke visuele planning voor de klas.",
wg_mood:"Gevoelsmeter", wg_mood_d:"Doe een snelle, anonieme check-in met de klas.",
wg_poll:"Klaspoll", wg_poll_d:"Stem samen op maximaal zes antwoorden.",
clock24Hour:"24-uursklok",
widgetSettings:"Instellingen", clockWelcome:"Oefenen met de klok", clockWelcomeHint:"Kies bovenaan niveau 1, 2 of 3. Kies bij Instellingen hoe de opdracht wordt getoond en start daarna.", clockSetTitle:"Zet de wijzers op de goede tijd", clockReadTitle:"Bekijk de opdracht en maak de tijd", clockChooseTitle:"Kies het juiste tijdblok", clockYourTask:"Jouw opdracht", clockLinkHands:"Grote wijzer draait mee", clockTypeHint:"Voer de tijd in als UU:MM.", clockLevel1Hint:"Niveau 1: hele en halve uren", clockLevel2Hint:"Niveau 2: stappen van vijf minuten", clockLevel3Hint:"Niveau 3: alle minuten, met optioneel 24 uur en dagdelen", clockBuildHint:"Sleep vier cijfers naar de vakken en maak UU:MM.", clockDigitGhost:"?", clockDigit:"Cijfer {digit}", clockIncomplete:"Maak eerst de volledige tijd af.", clockCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", clockTryAgain:"Bijna! Probeer het nog een keer.", clockStartTask:"Start opdracht", clockNextTask:"Volgende opdracht", clockLevel:"Niveau", clockLevelHour:"Hele uren", clockLevelQuarter:"Kwartieren", clockLevelFive:"Vijf minuten", clockLevelMinute:"Iedere minuut", clockLearningStyle:"Opdracht tonen", clockStyleAnalogue:"Analoge klok", clockStyleDigital:"Grote digitale tijd", clockStyleWords:"In woorden", clockStyleBoth:"Klok, cijfers en woorden", clockDragHint:"Sleep de wijzers om de tijd te veranderen.", clockGame:"Spel", clockGameSet:"Klok zetten", clockGameRead:"Tijd aflezen", clockGameChoose:"Kies de tijd", clockStart:"Kies Nieuwe opdracht om te beginnen", clockRead:"Welke tijd zie je? Stel je antwoord hieronder in.", clockChoose:"Welke digitale tijd hoort bij deze klok?", clockTry:"Nog niet. Het goede antwoord is {time}.", clockScore:"Score: {score} · reeks: {streak}", clockWordsHour:"{hour} uur", clockWordsQuarterPast:"kwart over {hour}", clockWordsHalf:"half {hour}", clockWordsQuarterTo:"kwart voor {hour}", clockWordsPast:"{minutes} over {hour}", clockWordsTo:"{minutes} voor {hour}", clockMorning:"in de ochtend", clockAfternoon:"in de middag", clockEvening:"in de avond", clockNight:"in de nacht", fractionRows:"Rijen", fractionLabels:"Breuken tonen", clockTask:"Oefenopdracht", clockSet:"Zet de klok op {time}", clockHour:"Uur", clockMinute:"Minuten", placeNumber:"Getal", moneyMake:"Maak € {amount}", wordtypeSentence:"Zin", wordtypeHint:"Tik: blauw zelfstandig naamwoord · groen werkwoord · geel bijvoeglijk naamwoord", scheduleActivity:"activiteit…", scheduleEmpty:"Voeg de eerste activiteit toe.", pollReset:"Opnieuw", pollQuestion:"Vraag", pollOptions:"Antwoorden (één per regel)", galleryFavorites:"Favorieten", galleryFavorite:"Favoriet maken", galleryEmpty:"Geen widgets gevonden.", gallerySearch:"Zoek widgets…", templateMath:"Rekenles", templateLanguage:"Taalles", templateDay:"Dagstart", exportBoard:"Exporteren", importBoard:"Importeren", importError:"Dit is geen geldig Teach-bordbestand.", widgetDuplicate:"Dupliceren", widgetLock:"Vastzetten", widgetUnlock:"Losmaken", widgetPreview:"Voorbeeld zonder bediening", undo:"Ongedaan maken", redo:"Opnieuw",
wg_sentence:"Zinsbouwer", wg_sentence_d:"Bouw zinnen met kleurgecodeerde zinsdelen en voeg eigen woorden toe.",
sentence_who:"Wie?", sentence_does:"Doet wat?", sentence_what:"Wat of waar?", sentenceHint:"Kies woorden om een zin te maken…",
sentenceAdd:"eigen woord of zinsdeel…", sentenceClear:"Wissen", sentenceSpeak:"🔊 Lees voor", sentenceRemove:"Verwijder woord",
anchorPh:"thema…", anchorHint:"Woordweb: zet het themawoord in het midden en bouw eromheen · tik op een vak en typ · sleep 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 (25 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: <a href='https://arasaac.org' target='_blank'>ARASAAC</a> (CC BY-NC-SA, Gobierno de Aragón). Ook leuk: <a href='https://www.sclera.be' target='_blank'>sclera.be</a> (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.",
save:"Opslaan", cancel:"Annuleren",
imgCatalog:"Afbeeldingscatalogus", imgSearch:"Zoek op naam, map of thema…",
imgFolders:"Afbeeldingen en mappen", imgUpload:"Afbeeldingen uploaden",
imgChooseFiles:"Kies één of meer afbeeldingen…", imgFolder:"Map", imgThemes:"Themas",
imgNewTheme:"Nieuw thema…", imgAddTheme:"Thema toevoegen", imgEdit:"Afbeelding bewerken", imgName:"Naam",
imgAllThemes:"Alle themas", imgAllScopes:"Alle bronnen", imgAllFolders:"Alle mappen", imgFolderRoot:"Hoofdmap",
imgScopeGlobal:"Systeem", imgScopeSchool:"School", imgScopePersonal:"Mijn afbeeldingen",
imgQuotaUsed:"{used} gebruikt", imgQuotaOf:"limiet {limit}", imgSchoolQuota:"school: {used} van {limit}",
imgUnlimited:"Onbeperkt", imgNoResults:"Geen afbeeldingen gevonden.", imgNoManage:"Je mag dit onderdeel niet beheren.",
imgLoginRequired:"Log in om de afbeeldingscatalogus te gebruiken.", imgInvalidFile:"Kies een geldige afbeelding.",
imgFileTooLarge:"Een afbeelding mag maximaal 50 MB groot zijn.", imgUploadFailed:"Uploaden is mislukt.",
imgUploading:"Afbeeldingen uploaden…", imgUploaded:"Afbeeldingen toegevoegd ✓", imgUntitled:"Afbeelding",
imgLoading:"Afbeeldingen laden…", imgSaved:"Afbeelding opgeslagen ✓", imgDeleted:"Afbeelding verwijderd ✓",
imgThemeAdded:"Thema toegevoegd ✓", imgStorageAdmin:"Opslaglimieten afbeeldingen",
imgQuotaUsage:"{used} gebruikt · {limit}", imgQuotaDefault:"Standaard", imgQuotaCustom:"Aangepast",
imgQuotaMb:"limiet in MB", imgQuotaInvalid:"Vul een geldige limiet in.", imgSchools:"Scholen",
imgUsers:"Gebruikers", imgSearchUsers:"Zoek gebruiker…",
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",
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 ✓",
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 = systeem) —",
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",
roleSuper:"Systeemmanager", roleAdmin:"Schoolbeheerder",
roleTeacher:"Groepsleiding", rolePupil:"Leerling", roleParent:"Ouder",
tabParentReg:"Ouders",
parentRegIntro:"Maak een ouderaccount en volg je kind - ook als de school (nog) niet meedoet:",
parentRegBtn:"Account maken",
parentTitle:"Mijn kinderen",
parentNoChildren:"Nog geen kinderen gekoppeld.",
parentAddChild:" Kind", parentChildNamePh:"naam van je kind…", parentAddBtn:"Toevoegen",
parentPwNote:"Bewaar dit wachtwoord: je kind logt ermee in (schoolveld leeg laten).",
parentLinkTitle:" Ouder-code", parentLinkPh:"ouder-code van school…", parentLinkBtn:"Koppel",
parentLinkNote:"Vraag de school om een ouder-code voor je kind.",
parentHome:"thuis",
parentApply:"🏫 Aanmelden", parentApplied:"aangemeld bij", parentApplyBtn:"Meld aan",
parentSchoolPh:"zoek je school…",
parentUnlink:"Ontkoppel",
parentUnlinkWarn:"Verwijdert alleen de koppeling; het account van je kind blijft bestaan.",
parentProgress:"📈 Voortgang", parentProgressEmpty:"Nog geen voortgang.",
pupilEmpty:"Je leerkracht heeft nog niets voor je klaargezet.",
pupilWatching:"meekijken",
amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen", amOverarching:"Systeem",
amPickSchoolHint:"Kies hierboven eerst een school (🏫).", amPickSchool:"Kies een school",
amPupils:"Leerlingen", amPupilOne:"Leerling", amEdit:"Bewerken",
amAdmins:"Schoolbeheerders", amAdminOne:"Schoolbeheerder",
amClassAdd:"— voeg toe aan klas… —", amClassRemove:"Uit deze klas halen",
amSure:"weet je het zeker?", amDelete:"verwijder",
amFieldName:"Naam", amFieldRole:"Functie", amFieldClass:"Klas", amFieldClasses:"Klassen",
amFieldPw:"Wachtwoord", amFieldActions:"Acties", amPwTypePh:"typ een wachtwoord…",
amNewPwWarn:"Maakt een nieuw wachtwoord aan. Het oude wachtwoord werkt daarna niet meer en de gebruiker wordt overal uitgelogd.",
amNewCodeWarn:"Maakt een nieuwe koppelcode aan. Het huidige wachtwoord vervalt direct; de gebruiker moet opnieuw activeren.",
amDeleteWarn:"Verwijdert dit account definitief, inclusief borden en woorden.",
amResultPw:"Nieuw wachtwoord", amResultCode:"Nieuwe koppelcode",
amParents:"Ouders",
amParentCode:"Ouder-code", amResultParentCode:"Ouder-code",
amParentCodeWarn:"Maakt een eenmalige ouder-code aan; een eerdere code vervalt.",
amLinkRequests:"Aanmeldingen", amRequestVia:"via",
amAdopt:"Adopteer", amAdoptUserPh:"andere gebruikersnaam (bij conflict)…",
amMatchPick:"— koppel aan bestaande leerling… —", amMatchBtn:"Koppel",
amReject:"Afwijzen", amRejectWarn:"Wijst de aanmelding af; de ouder ziet het verzoek verdwijnen.",
amSchoolNamePh:"naam van de school…", amNewSchoolConfirm:"Aanmaken:",
amDeleteSchoolWarn:"Verwijdert deze school met alle klassen en gebruikers. Dit kan niet ongedaan worden gemaakt.",
amClassNamePh:"naam van de klas…",
amDeleteClassWarn:"Verwijdert deze klas; leerlingen blijven bestaan maar staan daarna zonder klas.",
amNewSchool:" School", amNewClass:" Klas", amNewTeacher:" Groepsleiding",
amNewAdmin:" Schoolbeheerder", amNewSuper:" Systeemmanager",
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:"Systeem",
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.",
amWholeBoard:"— heel bord —", amModeWatch:"kijken", amModeWork:"werken",
amProgress:"Voortgang", amProgressEmpty:"Nog geen voortgang voor deze leerling.",
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: 220 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",
saveConflict:"⚠ Elders bewerkt", saveConflictHint:"In een ander tabblad bewerkt. Klik om de nieuwste versie te laden (jouw niet-opgeslagen wijzigingen hier vervallen)."
},
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 <b>beginning letter</b> of the word.",
instr2_mid:"Find the <b>middle letter</b> of the word.",
instr2_end:"Find the <b>ending letter</b> 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",
themeFolder:"Themes and folders",
exFixedFolder:"This folder is managed automatically.",
anchorLibTitle:"Anchor diagrams", anchorLibSave:"Save this diagram to the library",
anchorLibOpen:"Open anchor library", anchorLibSaved:"Saved ✓",
anchorLibEmptyGrid:"Put words in the diagram first.",
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", catClass:"Celebration & class", 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 112.",
wg_mdiv:"Division", wg_mdiv_d:"Dividing with the tables 112.",
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)",
exFolder:"Folder", exRename:"Rename", exDelete:"Delete", exConfirm:"sure?",
exSelectFirst:"Select a folder or board first.", exNoSlash:"A name cannot contain /.",
exExists:"That name already exists.", exEmpty:"This folder is empty.",
exFolderNotEmpty:"Folder is not empty - move or delete its contents first.",
exIntoSelf:"A folder cannot go inside itself.", exNoRootItems:"Boards belong in a folder.",
exLastBoard:"The last board cannot be deleted.",
shGlobal:"System", shSchool:"School",
shPublished:"Shared ✓", shCopied:"Copied to your own folders ✓",
shNoCreateHere:"Create it in your own folders first - then drag it here to share.",
shReadonly:"Read-only - copy it to your own folders to edit.",
exLock:"Read-only", shLockedOk:"Locked ✓", shUnlockedOk:"Unlocked ✓",
exWrongBranch:"That doesn't belong in this folder (boards and anchor diagrams are separate).",
anchorLibCreateHint:"To create a new diagram: use 💾 on an anchor widget.",
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:"Word web for vocabulary teaching: learn words in meaningful clusters around a theme word (the anchor), with pictures. Every word anchors to its neighbours.",
fabAnchor:"Anchor diagram (word web)",
wg_birthday:"Birthday cake", wg_birthday_d:"Create a personal cake with a name, age, colours, layers, decorations and candles.",
birthdayName:"birthday person's name", birthdayAge:"Age", birthdayLayers:"Layers", birthdayCakeColor:"Cake", birthdayIcing:"Icing", birthdayPlate:"Plate",
birthdayCandles:"Candles", birthdayCelebrate:"🎉 Celebrate!", birthdayTitle:"Someone has a birthday!", birthdayFor:"Hooray for {name}!",
birthdaySprinkles:"Sprinkles", birthdayFruit:"Fruit", birthdayStars:"Stars", birthdayNone:"No decoration",
wg_numberline:"Number line", wg_numberline_d:"Practise number sense with an adjustable, interactive number line.",
numberlineFrom:"From", numberlineTo:"To", numberlineStep:"Step", numberlineNew:"New task", numberlineTask:"Move the marker to {number}",
numberlineCheck:"Check", numberlineGood:"Well done! 🌟", numberlineTry:"Take another look at the number line.",
wg_fractions:"Fraction wall", wg_fractions_d:"Compare fractions visually and mark equal parts.",
wg_clock:"Practice clock", wg_clock_d:"Learn time at four levels with multiple learning styles and games.",
wg_placevalue:"Place value", wg_placevalue_d:"Explore digit values up to hundred thousands.",
wg_money:"Money", wg_money_d:"Practise making amounts, counting money and change at three levels.",
moneyLearningStyle:"Show task as", moneyStyleAmount:"As an amount", moneyStyleWords:"In words", moneyStyleBoth:"Amount and words",
moneyGame:"Game", moneyGameMake:"Make an amount", moneyGameCount:"Count money", moneyGameChange:"Change",
moneyCategory:"Shopping category", moneyCategoryAll:"All products", moneyCategoryFood:"Food and drinks", moneyCategorySchool:"School supplies", moneyCategoryPlay:"Play and sports",
moneyLevel:"Level", moneyLevel1Hint:"Level 1: whole euros up to €9", moneyLevel2Hint:"Level 2: up to €10 in five-cent steps", moneyLevel3Hint:"Level 3: up to €50 with all cents, coins and notes",
moneyScore:"Score: {score} · streak: {streak}", moneyMakeTitle:"Make the requested amount", moneyCountTitle:"How much money do you see?", moneyChangeTitle:"Work out the change",
moneyYourTask:"Your task", moneyPrice:"Price", moneyPaid:"Paid", moneyWallet:"Your money", moneyWalletHint:"Drag money here or tap a coin or note. Drag placed money outside to remove it.", moneyRemove:"Drag outside to remove", moneyTray:"Coins and notes",
moneyBuildHint:"Drag the digits into the slots to make the amount.", moneyTypeHint:"Enter the amount with a decimal point.", moneyIncomplete:"Complete the full amount first.",
moneyCorrect:"Well done! Choose the next task. 🌟", moneyTryAgain:"Almost! Check the amount again.", moneyNeed:"You still need {amount}.", moneyTooMuch:"{amount} too much.",
moneyClear:"Clear", moneyNextTask:"Next task",
wg_wordtypes:"Word classes", wg_wordtypes_d:"Colour words by their word class.",
wg_schedule:"Day schedule", wg_schedule_d:"Create a clear visual class schedule.",
wg_mood:"Mood check-in", wg_mood_d:"Run a quick anonymous class check-in.",
wg_poll:"Class poll", wg_poll_d:"Vote together on up to six answers.",
clock24Hour:"24-hour clock",
widgetSettings:"Settings", clockWelcome:"Practise telling time", clockWelcomeHint:"Choose level 1, 2 or 3 at the top. In Settings, choose how the task is shown, then start.", clockSetTitle:"Move the hands to the correct time", clockReadTitle:"View the task and make the time", clockChooseTitle:"Choose the correct time block", clockYourTask:"Your task", clockLinkHands:"Move the minute hand along", clockTypeHint:"Enter the time as HH:MM.", clockLevel1Hint:"Level 1: whole and half hours", clockLevel2Hint:"Level 2: five-minute steps", clockLevel3Hint:"Level 3: every minute, with optional 24-hour time and day parts", clockBuildHint:"Drag four digits into the slots to make HH:MM.", clockDigitGhost:"?", clockDigit:"Digit {digit}", clockIncomplete:"Complete the full time first.", clockCorrect:"Well done! Choose the next task. 🌟", clockTryAgain:"Almost! Try again.", clockStartTask:"Start task", clockNextTask:"Next task", clockLevel:"Level", clockLevelHour:"Whole hours", clockLevelQuarter:"Quarter hours", clockLevelFive:"Five minutes", clockLevelMinute:"Every minute", clockLearningStyle:"Show task as", clockStyleAnalogue:"Analogue clock", clockStyleDigital:"Large digital time", clockStyleWords:"In words", clockStyleBoth:"Clock, digits and words", clockDragHint:"Drag the hands to change the time.", clockGame:"Game", clockGameSet:"Set the clock", clockGameRead:"Read the time", clockGameChoose:"Choose the time", clockStart:"Choose New task to begin", clockRead:"What time is it? Set your answer below.", clockChoose:"Which digital time matches this clock?", clockTry:"Not yet. The correct answer is {time}.", clockScore:"Score: {score} · streak: {streak}", clockWordsHour:"{hour} o'clock", clockWordsQuarterPast:"quarter past {hour}", clockWordsHalf:"half past {hour}", clockWordsQuarterTo:"quarter to {hour}", clockWordsPast:"{minutes} past {hour}", clockWordsTo:"{minutes} to {hour}", clockMorning:"in the morning", clockAfternoon:"in the afternoon", clockEvening:"in the evening", clockNight:"at night", fractionRows:"Rows", fractionLabels:"Show fractions", clockTask:"Practice task", clockSet:"Set the clock to {time}", clockHour:"Hour", clockMinute:"Minutes", placeNumber:"Number", moneyMake:"Make € {amount}", wordtypeSentence:"Sentence", wordtypeHint:"Tap: blue noun · green verb · yellow adjective", scheduleActivity:"activity…", scheduleEmpty:"Add the first activity.", pollReset:"Reset", pollQuestion:"Question", pollOptions:"Answers (one per line)", galleryFavorites:"Favourites", galleryFavorite:"Add favourite", galleryEmpty:"No widgets found.", gallerySearch:"Search widgets…", templateMath:"Maths lesson", templateLanguage:"Language lesson", templateDay:"Day start", exportBoard:"Export", importBoard:"Import", importError:"This is not a valid Teach board file.", widgetDuplicate:"Duplicate", widgetLock:"Lock", widgetUnlock:"Unlock", widgetPreview:"Preview without controls", undo:"Undo", redo:"Redo",
wg_sentence:"Sentence builder", wg_sentence_d:"Build sentences with colour-coded parts and add your own words.",
sentence_who:"Who?", sentence_does:"Does what?", sentence_what:"What or where?", sentenceHint:"Choose words to build a sentence…",
sentenceAdd:"your own word or phrase…", sentenceClear:"Clear", sentenceSpeak:"🔊 Read aloud", sentenceRemove:"Remove word",
anchorPh:"theme…", anchorHint:"Word web: put the theme word in the middle and build around it · tap a cell and type · drag 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 (25 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: <a href='https://arasaac.org' target='_blank'>ARASAAC</a> (CC BY-NC-SA, Gobierno de Aragón). Also nice: <a href='https://www.sclera.be' target='_blank'>sclera.be</a> (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.",
save:"Save", cancel:"Cancel",
imgCatalog:"Image catalogue", imgSearch:"Search by name, folder or theme…",
imgFolders:"Images and folders", imgUpload:"Upload images",
imgChooseFiles:"Choose one or more images…", imgFolder:"Folder", imgThemes:"Themes",
imgNewTheme:"New theme…", imgAddTheme:"Add theme", imgEdit:"Edit image", imgName:"Name",
imgAllThemes:"All themes", imgAllScopes:"All sources", imgAllFolders:"All folders", imgFolderRoot:"Root folder",
imgScopeGlobal:"System", imgScopeSchool:"School", imgScopePersonal:"My images",
imgQuotaUsed:"{used} used", imgQuotaOf:"limit {limit}", imgSchoolQuota:"school: {used} of {limit}",
imgUnlimited:"Unlimited", imgNoResults:"No images found.", imgNoManage:"You cannot manage this item.",
imgLoginRequired:"Log in to use the image catalogue.", imgInvalidFile:"Choose a valid image.",
imgFileTooLarge:"An image may be at most 50 MB.", imgUploadFailed:"Upload failed.",
imgUploading:"Uploading images…", imgUploaded:"Images added ✓", imgUntitled:"Image",
imgLoading:"Loading images…", imgSaved:"Image saved ✓", imgDeleted:"Image deleted ✓",
imgThemeAdded:"Theme added ✓", imgStorageAdmin:"Image storage limits",
imgQuotaUsage:"{used} used · {limit}", imgQuotaDefault:"Default", imgQuotaCustom:"Custom",
imgQuotaMb:"limit in MB", imgQuotaInvalid:"Enter a valid limit.", imgSchools:"Schools",
imgUsers:"Users", imgSearchUsers:"Search user…",
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",
stBackups:"Backups", backupsNote:"Automatically kept earlier versions of your boards and words.",
backupsEmpty:"No backups yet.", backupRestore:"restore", backupRestoreConfirm:"sure?",
backupRestored:"Version restored ✓",
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",
roleSuper:"System manager", roleAdmin:"School administrator",
roleTeacher:"Group leader", rolePupil:"Pupil", roleParent:"Parent",
tabParentReg:"Parents",
parentRegIntro:"Create a parent account and follow your child - even if the school doesn't take part (yet):",
parentRegBtn:"Create account",
parentTitle:"My children",
parentNoChildren:"No children linked yet.",
parentAddChild:" Child", parentChildNamePh:"your child's name…", parentAddBtn:"Add",
parentPwNote:"Keep this password: your child logs in with it (leave the school field empty).",
parentLinkTitle:" Parent code", parentLinkPh:"parent code from school…", parentLinkBtn:"Link",
parentLinkNote:"Ask the school for a parent code for your child.",
parentHome:"home",
parentApply:"🏫 Apply", parentApplied:"applied to", parentApplyBtn:"Apply",
parentSchoolPh:"search your school…",
parentUnlink:"Unlink",
parentUnlinkWarn:"Removes only the link; your child's account remains.",
parentProgress:"📈 Progress", parentProgressEmpty:"No progress yet.",
pupilEmpty:"Your teacher hasn't set up anything for you yet.",
pupilWatching:"watching",
amUsers:"Users", amClasses:"Classes", amSchools:"Schools", amOverarching:"System",
amPickSchoolHint:"First pick a school above (🏫).", amPickSchool:"Pick a school",
amPupils:"Pupils", amPupilOne:"Pupil", amEdit:"Edit",
amAdmins:"School admins", amAdminOne:"School admin",
amClassAdd:"— add to class… —", amClassRemove:"Remove from this class",
amSure:"are you sure?", amDelete:"delete",
amFieldName:"Name", amFieldRole:"Role", amFieldClass:"Class", amFieldClasses:"Classes",
amFieldPw:"Password", amFieldActions:"Actions", amPwTypePh:"type a password…",
amNewPwWarn:"Generates a new password. The old one stops working and the user is signed out everywhere.",
amNewCodeWarn:"Generates a new link code. The current password stops working immediately; the user must re-activate.",
amDeleteWarn:"Deletes this account permanently, including boards and words.",
amResultPw:"New password", amResultCode:"New link code",
amParents:"Parents",
amParentCode:"Parent code", amResultParentCode:"Parent code",
amParentCodeWarn:"Generates a one-time parent code; a previous code stops working.",
amLinkRequests:"Enrolments", amRequestVia:"via",
amAdopt:"Adopt", amAdoptUserPh:"different username (on conflict)…",
amMatchPick:"— link to existing pupil… —", amMatchBtn:"Link",
amReject:"Reject", amRejectWarn:"Rejects the enrolment; the request disappears for the parent.",
amSchoolNamePh:"name of the school…", amNewSchoolConfirm:"Create:",
amDeleteSchoolWarn:"Deletes this school with all its classes and users. This cannot be undone.",
amClassNamePh:"name of the class…",
amDeleteClassWarn:"Deletes this class; pupils remain but end up without a class.",
amNewSchool:" School", amNewClass:" Class", amNewTeacher:" Group leader",
amNewAdmin:" School admin", amNewSuper:" System manager",
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:"System",
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.",
amWholeBoard:"— whole board —", amModeWatch:"watch", amModeWork:"work",
amProgress:"Progress", amProgressEmpty:"No progress for this pupil yet.",
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: 220 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",
saveConflict:"⚠ Edited elsewhere", saveConflictHint:"Edited in another tab. Click to load the latest version (your unsaved changes here will be lost)."
}
};
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("prName").placeholder = T("namePh");
document.getElementById("prPass").placeholder = T("pwNewPh");
document.getElementById("prPass2").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 (systeemmanager), 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", ...(opts.headers||{}) };
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){
const err = new Error(j.error || ("fout " + r.status));
err.status = r.status;
throw err;
}
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;
/* rev van de data zoals geladen van de server; gaat als x-data-rev mee met
elke save zodat de server een verouderde schrijver kan weigeren (409) */
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";
}
/* 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
zijn door borden met afbeeldingen) */
const DIRTY = new Set();
const DATA_SECTIONS = { themes: ()=>THEMES, generalExtra: ()=>GENERAL_EXTRA, boards: ()=>BS, anchors: ()=>ANCHOR_LIB };
function persistUser(){
if(!canPersist()) return Promise.resolve(true);
const todo = [...DIRTY].filter(s=>s in DATA_SECTIONS);
const run = async ()=>{
if(!todo.length || todo.length >= Object.keys(DATA_SECTIONS).length){
/* alles (of niets specifieks) gewijzigd: volledige save als terugval */
const res = await api("/me/data", { method:"PUT", headers:{ "x-data-rev": String(DATA_REV) }, body:{
themes: THEMES, generalExtra: GENERAL_EXTRA, boards: BS, anchors: ANCHOR_LIB
}});
if(typeof res.rev === "number") DATA_REV = res.rev;
}else{
/* sequentieel, niet parallel: elke sectie-PUT bumpt de rev en de
volgende moet de nieuwe rev meesturen om geen 409 te krijgen */
for(const s of todo){
const res = await api("/me/data/"+s, { method:"PUT", headers:{ "x-data-rev": String(DATA_REV) }, body: DATA_SECTIONS[s]() });
if(typeof res.rev === "number") DATA_REV = res.rev;
}
}
DIRTY.clear();
lastSaveError = null;
saveConflict = false;
return true;
};
return run().catch(e=>{ lastSaveError = e.message; saveConflict = e.status === 409; return false; });
}
/* status op de opslaan-knop: idle / saving / saved / error / conflict */
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==="conflict"){
lbl.textContent = T("saveConflict");
btn.classList.add("err");
btn.title = T("saveConflictHint");
}
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;
clearTimeout(saveTimer);
saveTimer = null;
stashCurrent();
setSaveStatus("saving");
saveInFlight = true;
const ok = await persistUser();
saveInFlight = false;
clearTimeout(saveStatusTimer);
setSaveStatus(ok ? "saved" : (saveConflict ? "conflict" : "error"));
/* een fout blijft staan tot een geslaagde save; succes ebt weg */
if(ok) saveStatusTimer = setTimeout(()=>setSaveStatus("idle"), 2000);
return ok;
}
/* niet weg-navigeren met onopgeslagen werk: geplande/lopende save of een
mislukte laatste save betekent dat de server nog niet alles heeft */
window.addEventListener("beforeunload", (e)=>{
if(!canPersist()) return;
if(saveTimer || saveInFlight || lastSaveError){ e.preventDefault(); e.returnValue = ""; }
});
/* autosave: door alles aan te roepen wat het bord of de woorden wijzigt;
de optionele sectie markeert wat er precies vuil is (zie DIRTY) */
function scheduleSave(section){
if(section) DIRTY.add(section);
if(!canPersist()) return;
clearTimeout(saveTimer);
saveTimer = setTimeout(doSave, 2500);
}
function persistWords(){
DIRTY.add("themes");
DIRTY.add("generalExtra");
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:[]};
stopParentView();
startPupilView();
document.dispatchEvent(new CustomEvent("userchange"));
return;
}
/* ouders krijgen het portaal (parent.js) i.p.v. het whiteboard */
if(currentUser && currentUser.role === "parent"){
THEMES = {nl:[],en:[]};
GENERAL_EXTRA = {nl:[],en:[]};
stopPupilView();
startParentView();
document.dispatchEvent(new CustomEvent("userchange"));
return;
}
stopPupilView();
stopParentView();
const res = await api("/me/data");
const d = res.data || {};
DATA_REV = typeof res.rev === "number" ? res.rev : 0;
THEMES = themesFromData(d);
GENERAL_EXTRA = generalExtraFromData(d);
ANCHOR_LIB = Array.isArray(d.anchors) ? d.anchors : [];
BS = boardsFromData(d);
document.dispatchEvent(new CustomEvent("wordschange"));
loadCurrentSlot();
document.dispatchEvent(new CustomEvent("userchange"));
}
function resetToGuest(){
currentUser = null;
stopPupilView();
stopParentView();
THEMES = {nl:[],en:[]};
GENERAL_EXTRA = {nl:[],en:[]};
ANCHOR_LIB = [];
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" : r==="parent" ? "roleParent" : "rolePupil";
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");
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" / "koppelcode activeren" / "ouderaccount maken" */
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.getElementById("parentRegBox").style.display = tab==="parentreg" ? "block" : "none";
}
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{
const list = await api("/schools");
sel.innerHTML = `<option value="">${T("schoolSel")}</option>` +
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");
}
/* reservekopieën (Instellingen → Account): automatisch bewaarde eerdere
versies van de borddata, met een arm-then-confirm herstelknop zoals de
verwijderknop in de boardStrip */
async function loadBackups(){
const head = document.getElementById("backupsHead");
const block = document.getElementById("backupsBlock");
const list = document.getElementById("backupsList");
const show = canPersist();
head.style.display = block.style.display = show ? "" : "none";
if(!show) return;
list.textContent = "";
let versions = [];
try{ versions = (await api("/me/data/versions")).versions || []; }
catch(e){ list.textContent = e.message; return; }
if(!versions.length){
const d = document.createElement("div");
d.className = "guestnote"; d.textContent = T("backupsEmpty");
list.appendChild(d);
return;
}
versions.forEach(v=>{
const row = document.createElement("div");
row.className = "lrow";
const when = document.createElement("span");
when.className = "am-code";
when.textContent = new Date(v.createdAt).toLocaleString(LANG==="nl" ? "nl-NL" : "en-GB");
const btn = document.createElement("button");
btn.className = "tbtn ghost";
btn.type = "button";
btn.textContent = T("backupRestore");
let armed = false;
btn.addEventListener("click", async ()=>{
if(!armed){ armed = true; btn.textContent = T("backupRestoreConfirm"); btn.classList.add("danger"); return; }
try{
const res = await api("/me/data/restore", { body:{ versionId: v.id } });
DATA_REV = typeof res.rev === "number" ? res.rev : DATA_REV;
const d = res.data || {};
THEMES = themesFromData(d);
GENERAL_EXTRA = generalExtraFromData(d);
ANCHOR_LIB = Array.isArray(d.anchors) ? d.anchors : [];
BS = boardsFromData(d);
document.dispatchEvent(new CustomEvent("wordschange"));
loadCurrentSlot();
settingsMsg.textContent = T("backupRestored");
settingsMsg.classList.add("ok");
loadBackups();
}catch(e){ settingsMsg.textContent = e.message; }
});
row.appendChild(when);
row.appendChild(btn);
list.appendChild(row);
});
}
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");
pwToggle("btnPrPassToggle", "prPass", "prPass2");
/* ouderaccount registreren: direct ingelogd, parent.js toont daarna het portaal */
document.getElementById("btnParentReg").addEventListener("click", async ()=>{
const btn = document.getElementById("btnParentReg");
const username = document.getElementById("prName").value.trim();
const password = document.getElementById("prPass").value;
const password2 = document.getElementById("prPass2").value;
loginMsg.classList.remove("ok");
if(password !== password2){ loginMsg.textContent = T("errPwMatch"); return; }
await withBusy(btn, async ()=>{
try{
await afterLogin(await api("/auth/register-parent", { body:{ username, password } }));
document.getElementById("prPass").value = "";
document.getElementById("prPass2").value = "";
}catch(e){ loginMsg.textContent = e.message; }
});
});
/* account activeren met een koppelcode */
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;
}
/* na een conflict (409) niet blind overschrijven: de klik haalt eerst de
nieuwste serverversie op, daarna kan er weer gewoon opgeslagen worden */
if(saveConflict){
saveConflict = false;
lastSaveError = null;
clearTimeout(saveTimer);
saveTimer = null;
await hydrateFromServer();
setSaveStatus("idle");
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)); };