All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m14s
- @fastify/compress (brotli quality 4 + gzip, global): alle statische assets én API-JSON (zoals /me/data) gaan gecomprimeerd over de lijn - eerste lading van ~715 KB naar grofweg een derde - Nieuw src/frontend.js: index.html wordt bij boot één keer gelezen, ?v=__V__ wordt vervangen door de app-versie en de pagina wordt uit het geheugen geserveerd met no-cache - Cache-beleid per prefix: js/css een jaar immutable (veilig dankzij de geversioneerde URL's), fonts 7 dagen, afbeeldingen 1 dag met zwakke etag als 304-vangnet - herhaalbezoek kost vrijwel geen bytes meer - Alle 31 scripts staan nu met defer in de head: parallel downloaden, uitvoeren in dezelfde dwingende volgorde (core → widgets → board → app); font-preload bewust ongeversioneerd (moet byte-gelijk blijven aan de url() in teach.css) - Compress geregistreerd vóór de api/frontend-registraties (onSend-hook geldt alleen voor latere routes) - geborgd met een teksttest - 6 nieuwe tests (test/frontend.test.js): versie-injectie, cache-headers per prefix, gzip+brotli round-trip, 304 op etag, defer/?v= op elk script Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
1069 lines
78 KiB
JavaScript
1069 lines
78 KiB
JavaScript
/* 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.81-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:"Potlood (zachte, gelijkmatige lijn)", toolFountain:"Vulpen (kalligrafie: dik in de neerhaal, dun in de ophaal)",
|
||
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", cardStart:"Startwoorden", wordThemes:"Woordthema’s", 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", flMission:"Lees het woord voordat het verdwijnt.", flQuestion:"Kon je het woord zelf lezen?", flGotIt:"🌟 Gelukt!", flAgain:"Nog oefenen", flScore:"{score} sterren",
|
||
wg_dice:"Dobbelstenen", wg_dice_d:"Gooi 1, 2 of 3 grote dobbelstenen.",
|
||
diceRoll:"🎲 Gooi!", diceSum:"Samen", diceMission:"Gooi, tel de ogen en controleer de uitkomst.", diceHistory:"Vorige worpen",
|
||
wg_light:"Klassenstoplicht", wg_light_d:"Maak in één oogopslag duidelijk of kinderen luisteren, fluisteren of rustig mogen werken.",
|
||
lightRed:"Rood", lightRedHint:"Stop en luister", lightOrange:"Oranje", lightOrangeHint:"Fluister en rond af", lightGreen:"Groen", lightGreenHint:"Je mag rustig werken", lightStatus:"{color}: {hint}",
|
||
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! 🎉", namesReset:"Opnieuw", namesProgress:"{used} van {total} geweest",
|
||
wg_hak:"Hakken & plakken", wg_hak_d:"Woorden in klanken hakken en weer plakken, met voorlezen.",
|
||
hakChop:"✂️ Hak", hakGlue:"🔗 Plak", hakSay:"🔊 Lees voor", hakHint:"Bekijk en hoor de klankgroepen in het woord.",
|
||
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.", numberlineScore:"{score} sterren · reeks {streak}",
|
||
wg_fractions:"Breukenmuur", wg_fractions_d:"Bouw breuken, vergelijk de gekleurde delen en ontdek gelijkwaardige breuken.",
|
||
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:"Leer achttien Nederlandse boomsoorten met vijf foto-, kenmerk- en koppeloefeningen.",
|
||
wg_birds:"Vogels herkennen", wg_birds_d:"Leer achttien Nederlandse vogels met vier foto-, kenmerk- en naamoefeningen.",
|
||
wg_plants:"Wilde planten", wg_plants_d:"Herken twaalf algemene wilde planten aan echte foto's en duidelijke veldkenmerken.",
|
||
wg_topography:"Topografie", wg_topography_d:"Oefen Nederland én de wereld: provincies, plaatsen, landsdelen, rivieren en werelddelen op nauwkeurige kaarten.",
|
||
worldActivity:"Werkvorm", worldLearn:"Leren", worldQuiz:"Quiz", worldExercise:"Oefening", worldLevel:"Niveau", worldTreeView:"Wat zie je?", worldTreeWhole:"Hele boom", worldTreeLeaf:"Blad", worldTreeMixed:"Boom en blad",
|
||
worldExerciseRecognize:"Herken de soort", worldExercisePair:"Koppel boom en blad", worldExerciseClue:"Raad met kenmerken", worldExercisePhoto:"Kies de juiste foto", worldExerciseLatin:"Wetenschappelijke naam",
|
||
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?", worldQuestionPlant:"Welke wilde plant zie je?",
|
||
worldQuestionPair:"Bij welke boom horen deze boom en dit blad?", worldQuestionClue:"Welke soort hoort bij deze kenmerken?", worldQuestionLatin:"Welke soort hoort bij deze wetenschappelijke naam?", worldFindTreePhoto:"Tik op de foto van de {name}.", worldFindBirdPhoto:"Tik op de foto van de {name}.", worldFindPlantPhoto:"Tik op de foto van de {name}.",
|
||
worldCorrect:"Goed herkend! 🌟", worldTryAgain:"Kijk nog eens goed naar de kenmerken.", worldNext:"Volgende opdracht",
|
||
worldPhotoTree:"Boom", worldPhotoLeaf:"Blad of naalden", worldPhotoBird:"Vogel", worldPhotoPlant:"Wilde plant", worldPhotoCredit:"Foto: {author} · {license}", worldPhotoOption:"Afbeelding {number}",
|
||
topoMode:"Oefenen met", topoProvinces:"Provincies", topoCapitals:"Provinciehoofdsteden", topoCities:"Belangrijke steden", topoRegions:"Landsdelen", topoRivers:"Rivieren", topoContinents:"Werelddelen", topoMapLabel:"Kaart van Nederland", topoWorldMapLabel:"Kaart van de wereld",
|
||
topoExploreHint:"Bekijk de echte provinciegrenzen en vergelijk de ligging van de plaatsen.", topoExploreRegions:"Bekijk welke provincies samen Noord-, Oost-, West- en Zuid-Nederland vormen.", topoExploreRivers:"Volg de loop van de grote Nederlandse rivieren van grens tot delta.", topoExploreContinents:"Vergelijk de vorm en ligging van de zeven werelddelen.",
|
||
topoFindProvince:"Tik op de provincie {name}.", topoFindCapital:"Waar ligt provinciehoofdstad {name}?", topoFindCity:"Waar ligt {name}?", topoFindRegion:"Tik op {name}.", topoFindRiver:"Tik op de rivier {name}.", topoFindContinent:"Tik op het werelddeel {name}.", topoMapSource:"Kaart: Gust Justice · CC BY-SA 4.0", topoWorldMapSource:"Wereldkaart: Natural Earth · publiek domein",
|
||
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", fractionHint:"Tik in een rij tot waar je de breuk wilt kleuren.", fractionSelected:"Gekozen breuk: {fraction}", fractionClear:"Leegmaken", 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.", scheduleCount:"{count} momenten op de planning", pollReset:"Opnieuw", pollQuestion:"Vraag", pollOptions:"Antwoorden (één per regel)", pollTotal:"{count} stemmen", moodTotal:"{count} kinderen hebben gekozen", moodVote:"Kies {face}", 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", sentenceSurprise:"✨ Verrassingszin",
|
||
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", tabLibrary:"🗂 Bibliotheek",
|
||
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…",
|
||
imgOpenLibrary:"Open afbeeldingenbibliotheek", imgLanguageHint:"Hier vind je alle vaste woordplaatjes, eigen woordkaarten en uploads bij elkaar.",
|
||
imgWordThemes:"Taalthema’s",
|
||
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…", notesColor:"Kleur briefje", notesLarge:"Grote letters", notesCount:"{count} tekens",
|
||
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",
|
||
dtLock:"Vastzetten: kan niet meer verschuiven of draaien, alleen nog langs tekenen",
|
||
dtMode:"Basis: grote hoofdhoeken en hele centimeters. Volledig: graden per 2° met dubbele schaal, millimeters en evenwijdige hulplijnen.",
|
||
dtModeBasic:"basis", dtModeFull:"volledig",
|
||
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:"Pencil (soft, even line)", toolFountain:"Fountain pen (calligraphy: thick downstroke, thin upstroke)",
|
||
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", cardStart:"Starter words", wordThemes:"Word themes", 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", flMission:"Read the word before it disappears.", flQuestion:"Could you read the word by yourself?", flGotIt:"🌟 Got it!", flAgain:"Practise again", flScore:"{score} stars",
|
||
wg_dice:"Dice", wg_dice_d:"Roll 1, 2 or 3 big dice.",
|
||
diceRoll:"🎲 Roll!", diceSum:"Total", diceMission:"Roll, count the dots and check the total.", diceHistory:"Previous rolls",
|
||
wg_light:"Class traffic light", wg_light_d:"Show at a glance whether children listen, whisper or work quietly.",
|
||
lightRed:"Red", lightRedHint:"Stop and listen", lightOrange:"Amber", lightOrangeHint:"Whisper and finish", lightGreen:"Green", lightGreenHint:"You may work quietly", lightStatus:"{color}: {hint}",
|
||
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! 🎉", namesReset:"Start over", namesProgress:"{used} of {total} picked",
|
||
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", hakHint:"See and hear the sound groups in the word.",
|
||
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.", numberlineScore:"{score} stars · streak {streak}",
|
||
wg_fractions:"Fraction wall", wg_fractions_d:"Build fractions, compare coloured parts and discover equivalent fractions.",
|
||
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:"Learn eighteen Dutch tree species with five photo, feature and matching exercises.",
|
||
wg_birds:"Bird identification", wg_birds_d:"Learn eighteen Dutch birds with four photo, feature and naming exercises.",
|
||
wg_plants:"Wild plants", wg_plants_d:"Recognise twelve common wild plants from real photographs and clear field marks.",
|
||
wg_topography:"Topography", wg_topography_d:"Practise the Netherlands and the world: provinces, places, regions, rivers and continents on accurate maps.",
|
||
worldActivity:"Activity", worldLearn:"Learn", worldQuiz:"Quiz", worldExercise:"Exercise", worldLevel:"Level", worldTreeView:"What do you see?", worldTreeWhole:"Whole tree", worldTreeLeaf:"Leaf", worldTreeMixed:"Tree and leaf",
|
||
worldExerciseRecognize:"Recognise the species", worldExercisePair:"Match tree and leaf", worldExerciseClue:"Guess from features", worldExercisePhoto:"Choose the correct photo", worldExerciseLatin:"Scientific name",
|
||
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?", worldQuestionPlant:"Which wild plant do you see?",
|
||
worldQuestionPair:"Which species do this tree and leaf belong to?", worldQuestionClue:"Which species matches these features?", worldQuestionLatin:"Which species matches this scientific name?", worldFindTreePhoto:"Tap the photograph of the {name}.", worldFindBirdPhoto:"Tap the photograph of the {name}.", worldFindPlantPhoto:"Tap the photograph of the {name}.",
|
||
worldCorrect:"Well recognised! 🌟", worldTryAgain:"Take another close look at the features.", worldNext:"Next task",
|
||
worldPhotoTree:"Tree", worldPhotoLeaf:"Leaf or needles", worldPhotoBird:"Bird", worldPhotoPlant:"Wild plant", worldPhotoCredit:"Photo: {author} · {license}", worldPhotoOption:"Image {number}",
|
||
topoMode:"Practise", topoProvinces:"Provinces", topoCapitals:"Provincial capitals", topoCities:"Important cities", topoRegions:"Regions", topoRivers:"Rivers", topoContinents:"Continents", topoMapLabel:"Map of the Netherlands", topoWorldMapLabel:"Map of the world",
|
||
topoExploreHint:"Study the real provincial boundaries and compare the locations of the places.", topoExploreRegions:"Study which provinces make up the northern, eastern, western and southern Netherlands.", topoExploreRivers:"Follow the major Dutch rivers from the border to the delta.", topoExploreContinents:"Compare the shapes and locations of the seven continents.",
|
||
topoFindProvince:"Tap the province {name}.", topoFindCapital:"Where is the provincial capital {name}?", topoFindCity:"Where is {name}?", topoFindRegion:"Tap {name}.", topoFindRiver:"Tap the river {name}.", topoFindContinent:"Tap the continent {name}.", topoMapSource:"Map: Gust Justice · CC BY-SA 4.0", topoWorldMapSource:"World map: Natural Earth · public domain",
|
||
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", fractionHint:"Tap a row up to the part you want to colour.", fractionSelected:"Selected fraction: {fraction}", fractionClear:"Clear", 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.", scheduleCount:"{count} moments in the schedule", pollReset:"Reset", pollQuestion:"Question", pollOptions:"Answers (one per line)", pollTotal:"{count} votes", moodTotal:"{count} children have chosen", moodVote:"Choose {face}", 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", sentenceSurprise:"✨ Surprise sentence",
|
||
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", tabLibrary:"🗂 Library",
|
||
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…",
|
||
imgOpenLibrary:"Open image library", imgLanguageHint:"All starter pictures, custom word cards and uploads are collected here.",
|
||
imgWordThemes:"Language themes",
|
||
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…", notesColor:"Note colour", notesLarge:"Large letters", notesCount:"{count} characters",
|
||
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",
|
||
dtLock:"Lock in place: can no longer move or rotate, only draw along it",
|
||
dtMode:"Basic: big main angles and whole centimetres. Full: degrees every 2° with a double scale, millimetres and parallel guide lines.",
|
||
dtModeBasic:"basic", dtModeFull:"full",
|
||
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 = `<option value="">${T("schoolSel")}</option>` +
|
||
list.map(x=>`<option value="${x.id}">${x.name.replace(/</g,"<")}</option>`).join("");
|
||
}catch(e){ sel.innerHTML = `<option value="">${T("schoolSel")}</option>`; }
|
||
}
|
||
/* 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)); };
|