/* 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.70-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", 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", catWorld:"Wereldoriëntatie", 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.", wg_trees:"Bomen & bladeren", wg_trees_d:"Herken acht Nederlandse boomsoorten op echte foto's van bomen en bladeren.", wg_birds:"Vogels herkennen", wg_birds_d:"Herken acht veelvoorkomende Nederlandse vogels op echte natuurfoto's.", wg_topography:"Topografie Nederland", wg_topography_d:"Oefen provincies, provinciehoofdsteden en belangrijke steden op een nauwkeurige kaart.", worldActivity:"Werkvorm", worldLearn:"Leren", worldQuiz:"Quiz", worldLevel:"Niveau", worldTreeView:"Wat zie je?", worldTreeWhole:"Hele boom", worldTreeLeaf:"Blad", worldTreeMixed:"Boom en blad", worldScore:"Score: {score} · reeks: {streak}", worldExploreHint:"Bekijk de kenmerken en vergelijk de soorten.", worldQuestionTree:"Welke boom zie je?", worldQuestionLeaf:"Bij welke boom hoort dit blad?", worldQuestionBird:"Welke vogel zie je?", worldCorrect:"Goed herkend! 🌟", worldTryAgain:"Kijk nog eens goed naar de kenmerken.", worldNext:"Volgende opdracht", worldPhotoTree:"Boom", worldPhotoLeaf:"Blad of naalden", worldPhotoBird:"Vogel", worldPhotoCredit:"Foto: {author} · {license}", topoMode:"Oefenen met", topoProvinces:"Provincies", topoCapitals:"Provinciehoofdsteden", topoCities:"Belangrijke steden", topoMapLabel:"Kaart van Nederland", topoExploreHint:"Bekijk de echte provinciegrenzen en vergelijk de ligging van de plaatsen.", topoFindProvince:"Tik op de provincie {name}.", topoFindCapital:"Waar ligt provinciehoofdstad {name}?", topoFindCity:"Waar ligt {name}?", topoMapSource:"Kaart: Gust Justice · CC BY-SA 4.0", 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 euro’s 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 (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.", 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:"Thema’s", imgNewTheme:"Nieuw thema…", imgAddTheme:"Thema toevoegen", imgEdit:"Afbeelding bewerken", imgName:"Naam", imgAllThemes:"Alle thema’s", 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", stSearchPh:"🔎 Zoeken in instellingen…", stNoResults:"Niets gevonden.", stOpenSection:"open dit onderdeel", stGroupPersonal:"Persoonlijk", stGroupSchool:"School", stGroupSystem:"Systeem", stAccountSub:"Je gegevens, wachtwoord en reservekopieën.", stViewSub:"Weergave op dit apparaat: thema, taal en menubalk.", stUsersSub:"Accounts beheren: leerlingen, staf, ouders en aanmeldingen.", stKlassenSub:"Klassen aanmaken, koppelen en beheren.", stToewijzSub:"Bepaal wat een klas of leerling te zien krijgt.", stVoortgangSub:"Volg per leerling de resultaten in de tijd.", stScholenSub:"Scholen en systeemmanagers, over alle scholen heen.", stOpslag:"Opslag", stOpslagSub:"Afbeeldingencatalogus en opslagruimte per school.", stBackups:"Reservekopieën", backupsNote:"Automatisch bewaarde eerdere versies van je borden en woorden.", backupsEmpty:"Nog geen reservekopieën.", backupRestore:"herstel", backupRestoreConfirm:"zeker?", backupRestored:"Versie hersteld ✓", 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.", dtLen:"Lengte", dtContrast:"Hoog contrast", 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: 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", 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 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", 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", catWorld:"World studies", 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 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)", 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.", wg_trees:"Trees & leaves", wg_trees_d:"Recognise eight Dutch tree species from real photographs of trees and leaves.", wg_birds:"Bird identification", wg_birds_d:"Recognise eight common Dutch birds from real nature photographs.", wg_topography:"Map of the Netherlands", wg_topography_d:"Practise provinces, provincial capitals and important cities on an accurate map.", worldActivity:"Activity", worldLearn:"Learn", worldQuiz:"Quiz", worldLevel:"Level", worldTreeView:"What do you see?", worldTreeWhole:"Whole tree", worldTreeLeaf:"Leaf", worldTreeMixed:"Tree and leaf", worldScore:"Score: {score} · streak: {streak}", worldExploreHint:"Study the features and compare the species.", worldQuestionTree:"Which tree do you see?", worldQuestionLeaf:"Which tree does this leaf belong to?", worldQuestionBird:"Which bird do you see?", worldCorrect:"Well recognised! 🌟", worldTryAgain:"Take another close look at the features.", worldNext:"Next task", worldPhotoTree:"Tree", worldPhotoLeaf:"Leaf or needles", worldPhotoBird:"Bird", worldPhotoCredit:"Photo: {author} · {license}", topoMode:"Practise", topoProvinces:"Provinces", topoCapitals:"Provincial capitals", topoCities:"Important cities", topoMapLabel:"Map of the Netherlands", topoExploreHint:"Study the real provincial boundaries and compare the locations of the places.", topoFindProvince:"Tap the province {name}.", topoFindCapital:"Where is the provincial capital {name}?", topoFindCity:"Where is {name}?", topoMapSource:"Map: Gust Justice · CC BY-SA 4.0", 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 (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.", 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", stSearchPh:"🔎 Search settings…", stNoResults:"Nothing found.", stOpenSection:"open this section", stGroupPersonal:"Personal", stGroupSchool:"School", stGroupSystem:"System", stAccountSub:"Your details, password and backups.", stViewSub:"Display on this device: theme, language and toolbar.", stUsersSub:"Manage accounts: pupils, staff, parents and enrolments.", stKlassenSub:"Create, link and manage classes.", stToewijzSub:"Choose what a class or pupil gets to see.", stVoortgangSub:"Follow each pupil's results over time.", stScholenSub:"Schools and system managers, across all schools.", stOpslag:"Storage", stOpslagSub:"Image catalogue and storage per school.", stBackups:"Backups", backupsNote:"Automatically kept earlier versions of your boards and words.", backupsEmpty:"No backups yet.", backupRestore:"restore", backupRestoreConfirm:"sure?", backupRestored:"Version restored ✓", 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.", dtLen:"Length", dtContrast:"High contrast", 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: 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", 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(){ /* ouders slaan geen eigen borden-data op (het portaal werkt met gekoppelde kind-accounts); schoolleerlingen ook niet (zij zien de toewijzing van de leerkracht). Thuiskinderen (leerling zónder school, ouderportaal) hebben wél een eigen whiteboard met eigen opslag. */ if(!currentUser || currentUser.role === "parent") return false; if(currentUser.role === "pupil") return !currentUser.schoolId; return true; } /* welke secties er sinds de laatste geslaagde save zijn gewijzigd; een save verstuurt alleen die secties i.p.v. altijd de complete blob (die MB's kan 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(){ /* schoolleerlingen hebben geen eigen borden - zij zien enkel wat de leerkracht heeft toegewezen (zie pupil.js), nooit het whiteboard. Thuiskinderen (leerling zonder school, ouderportaal) vallen hier expres buiten: zij krijgen hieronder het volledige whiteboard met eigen opslag. */ if(currentUser && currentUser.role === "pupil" && currentUser.schoolId){ THEMES = {nl:[],en:[]}; GENERAL_EXTRA = {nl:[],en:[]}; stopParentView(); 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); /* welke instellingen-secties zichtbaar zijn bepaalt settings.js per rol (sectieregister); hier alleen nog de accountgegevens zelf */ if(currentUser){ document.getElementById("helloName").textContent = currentUser.displayName; const roles = [currentUser.role, ...(currentUser.extraRoles||[])]; 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)); }); async function loadSchools(){ const sel = document.getElementById("liSchool"); try{ const list = await api("/schools"); sel.innerHTML = `` + list.map(x=>``; } } /* openSettings() zelf woont in settings.js (sectieregister + zoeken); de knoppen hieronder roepen hem via window aan */ /* reservekopieën (Instellingen → Account): automatisch bewaarde eerdere versies van de borddata, met een arm-then-confirm herstelknop zoals de verwijderknop in de boardStrip */ 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)); };