/* teach - kern: versie, i18n, accounts, opslag, geluid */ "use strict"; /* version — bump on every change (form 0.0.00) */ const VERSION = "0.1.02"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; /* dev branch deploys report a dev-… build via /api/version → mark as beta; main/release builds show only the version number */ fetch("/api/version") .then(r=>r.ok ? r.json() : null) .then(v=>{ if(v && typeof v.version==="string" && v.version.startsWith("dev")){ tag.textContent = "v"+VERSION+" · beta"; tag.classList.add("beta"); } }) .catch(()=>{}); })(); /* ========================================================= i18n ==========================================================*/ const I18N = { nl:{ addWidget:"Widget", chooseWidget:"Kies een widget", saveBoard:"Bord opslaan", emptyBoard:"Het bord is leeg. Klik op “Widget” om te beginnen.", wg_letters:"Letterblokken", wg_letters_d:"Leer lezen en spellen met letterblokken – 3 niveaus, zoals ‘Ik leer lezen’.", wg_notes:"Notities", wg_notes_d:"Een vel om op te schrijven.", wg_timer:"Timer", wg_timer_d:"Afteltimer met kijkklok voor de klas.", wg_write:"Schrijfblad", wg_write_d:"Schrijven en rekenen: pen, gum en papier met schrijflijnen of rekenblokjes.", bgBlank:"Leeg blad", bgLines:"Lijntjes", bgWrite:"Schrijflijnen", bgGridS:"Kleine rekenblokjes", bgGridB:"Grote rekenblokjes", penSize:"Dikte", eraser:"Gum", clearAll:"Alles wissen", toolPen:"Pen", toolFountain:"Vulpen (dun en dik, reageert op druk of snelheid)", toolMark:"Markeerstift", toolText:"Tekst typen (klik op het blad)", wMin:"Minimaliseren", wMax:"Herstellen", lvl1:"Niveau 1", lvl2:"Niveau 2", lvl3:"Niveau 3", instr1:"Leg de letterblokken op de goede plek. Kijk goed naar de letters!", instr2_begin:"Zoek de beginletter van het woord.", instr2_mid:"Zoek de middelste letter van het woord.", instr2_end:"Zoek de eindletter van het woord.", instr3:"Spel het woord bij het plaatje. De nopjes helpen je!", card:"Kaart", cardGeneral:"Algemeen", word:"Woord", welldone:"Goed zo!", next:"Volgende woord ▸", cardDone:"Kaart klaar! 🎉", pickCard:"Kies een nieuwe kaart of een ander niveau.", myWords:"Mijn woorden", manage:"Thema's en woorden beheren", addWord:"+ Toevoegen", newTheme:"Nieuw thema", themeNamePh:"naam van het thema…", delTheme:"Thema verwijderen", numWords:"Woorden per spel", all:"Alle", noThemes:"— nog geen thema's —", muteAll:"Alle geluiden aan/uit", sndWidget:"Geluid van deze widget aan/uit", catTaal:"Taal", catRekenen:"Rekenen", catTools:"Hulpmiddelen", catMedia:"Media", wg_madd:"Plussommen", wg_madd_d:"Optellen tot 10, 20 of 100.", wg_msub:"Minsommen", wg_msub_d:"Aftrekken tot 10, 20 of 100.", wg_mmul:"Keersommen", wg_mmul_d:"De tafels van 1 t/m 12 oefenen.", wg_mdiv:"Deelsommen", wg_mdiv_d:"Delen met de tafels van 1 t/m 12.", wg_mpct:"Procenten", wg_mpct_d:"Procenten uitrekenen met mooie getallen.", mGood:"Goed zo!", mAnsWas:"Het goede antwoord is", mAgain:"Nog een keer ▸", mGoed:"goed", mOf:"van", mDoneT:"Klaar!", amode:"Blokjes slepen of typen", penMode:"Tekenen op het bord", fabWidgets:"Widget toevoegen", fabDraw:"Tekenen & schrijven", fabMind:"Mindmap op het bord", fabImg:"Afbeelding op het bord", nodeColor:"Kleur", nodeFont:"Lettergrootte", lineColor:"Lijnkleur", lineStyle:"Lijnstijl", a11y:"Hoog contrast (voor slechtzienden)", zoomUI:"Beeldgrootte: alles groter of kleiner tonen", lensBig:"Loep groter", lensSmall:"Loep kleiner", lensShape:"Rond of vierkant", toolHand:"Handje: verplaatsen en bedienen", boardsNav:"Borden en mappen", newBoard:"+ Bord", newFolder:"+ Map", folderPh:"naam nieuwe map…", boardName:"Bord", folderName:"Map", delBoard:"Bord verwijderen (2× tikken)", renameTip:"Dubbelklik op een bord om het te hernoemen", hudMin:"Menu inklappen/uitklappen", hudOrient:"Horizontaal of verticaal", wFull:"Maximaliseren", wUnfull:"Terugzetten", wZoom:"Inhoud groter of kleiner (voor slechtzienden)", wg_flits:"Flitswoorden", wg_flits_d:"Een woord flitst kort in beeld – lezen voordat het verdwijnt!", flFlash:"⚡ Flits", flShow:"👁 Toon", flNext:"Volgende ▸", flSec:"sec", wg_dice:"Dobbelstenen", wg_dice_d:"Gooi 1, 2 of 3 grote dobbelstenen.", diceRoll:"🎲 Gooi!", diceSum:"Samen", wg_light:"Stoplicht", wg_light_d:"Rood, oranje of groen – bijvoorbeeld voor het werkrumoer.", wg_names:"Namenkiezer", wg_names_d:"Kies willekeurig een naam uit de klas.", namesPh:"Eén naam per regel…", namesPick:"🎯 Kies!", namesNoRep:"Zonder herhaling", namesDone:"Iedereen is geweest! 🎉", wg_hak:"Hakken & plakken", wg_hak_d:"Woorden in klanken hakken en weer plakken, met voorlezen.", hakChop:"✂️ Hak", hakGlue:"🔗 Plak", hakSay:"🔊 Lees voor", wg_ball:"Ballonnenwoord", wg_ball_d:"Raad het woord letter voor letter voordat de ballonnen knappen.", wg_anchor:"Ankerwoorden", wg_anchor_d:"Onbeperkt woordraster rond een thema: elk woord hoort bij de woorden eromheen.", anchorPh:"thema…", anchorHint:"Tik op een vak en typ · sleep de achtergrond om te schuiven", anchorColor:"Kleur: klik om te wisselen (verbind woorden met dezelfde kleur)", ballWin:"Geraden! 🎉", ballLose:"Helaas! Het woord was:", bgDots:"Stippen", toolRuler:"Liniaal", toolGeo:"Geodriehoek", toolLens:"Vergrootglas", wg_mind:"Mindmap", wg_mind_d:"Woordweb, spin of boomdiagram om samen ideeën te ordenen.", mmSpider:"Spin", mmTree:"Boom", mmWeb:"Woordweb", mmRoot:"Onderwerp", mmHint:"Klik op tekst om te typen · sleep aan de rand", wg_media:"Foto & video", wg_media_d:"Toon foto's en video's van internet, zoals YouTube of Vimeo.", mediaPh:"Plak een link (YouTube, Vimeo, foto of video)…", mediaShow:"Tonen", mediaEmpty:"Plak hierboven een link en klik op Tonen.", mediaErr:"Deze link kan niet worden getoond. Probeer een YouTube-, Vimeo-, foto- of videolink, of open hem met ↗ in een nieuw tabblad.", mediaOpen:"Open in nieuw tabblad", wordPh:"woord (2–5 letters)", close:"Sluiten", badWord:"Gebruik 2 t/m 5 letters (a-z).", needPic:"Kies eerst een plaatje.", noWords:"Nog geen eigen woorden. Voeg er hierboven een toe!", tabEmoji:"😀 Emoji", tabPicto:"🖼️ Picto's", tabUpload:"📷 Upload", pictoSearch:"Zoeken", pictoPh:"zoekwoord…", pictoNone:"Niets gevonden. Probeer een ander woord.", pictoLoad:"Zoeken…", pictoErr:"Kon picto's niet laden (internet nodig).", pictoCredit:"Picto's: ARASAAC (CC BY-NC-SA, Gobierno de Aragón). Ook leuk: sclera.be (gratis download, dan via Upload toevoegen).", upload:"📷 Kies een afbeelding…", upHint:"Tip: vierkant plaatje, witte of doorzichtige achtergrond, één duidelijk voorwerp, géén tekst in het plaatje.", cats:["Dieren","Eten","Natuur","Vervoer","Spullen","Lichaam"], notesPh:"Schrijf hier…", start:"Start", pause:"Pauze", reset:"Reset", min:"min", tmDigits:"Cijfers", tmVisual:"Kijkklok", tmBoth:"Allebei", tmSound:"Geluid aan/uit", tmHint:"Sleep aan de rode schijf om de tijd in te stellen.", login:"Inloggen", loginBtn:"Inloggen", registerBtn:"Account maken", logout:"Uitloggen", namePh:"naam", passPh:"wachtwoord", guestNote:"Zonder inloggen wordt niets bewaard: eigen woorden en het bord zijn weg als je de pagina sluit. Log in om alles op dit apparaat te bewaren.", storedNote:"Je eigen woorden worden automatisch bewaard. Met “Bord opslaan” bewaar je ook de widgets op het bord.", errLogin:"Onbekende naam of verkeerd wachtwoord.", errExists:"Deze naam bestaat al.", errName:"Naam: 2–20 letters of cijfers.", errPass:"Wachtwoord: minimaal 4 tekens.", errCrypto:"Inloggen werkt niet in deze browser.", hello:"Hoi", boardSaved:"Opgeslagen ✓", loginFirst:"Eerst inloggen" }, en:{ addWidget:"Widget", chooseWidget:"Choose a widget", saveBoard:"Save board", emptyBoard:"The board is empty. Click “Widget” to get started.", wg_letters:"Letter blocks", wg_letters_d:"Learn to read and spell with letter blocks – 3 levels, like ‘Ik leer lezen’.", wg_notes:"Notes", wg_notes_d:"A sheet to write on.", wg_timer:"Timer", wg_timer_d:"Countdown timer with a visual clock for class.", wg_write:"Writing pad", wg_write_d:"Writing and maths: pen, eraser and paper with handwriting lines or maths squares.", bgBlank:"Blank sheet", bgLines:"Lines", bgWrite:"Handwriting lines", bgGridS:"Small maths squares", bgGridB:"Big maths squares", penSize:"Thickness", eraser:"Eraser", clearAll:"Clear all", toolPen:"Pen", toolFountain:"Fountain pen (thin and thick, follows pressure or speed)", toolMark:"Highlighter", toolText:"Type text (click the sheet)", wMin:"Minimize", wMax:"Restore", lvl1:"Level 1", lvl2:"Level 2", lvl3:"Level 3", instr1:"Place the letter blocks in the right spot. Look closely at the letters!", instr2_begin:"Find the beginning letter of the word.", instr2_mid:"Find the middle letter of the word.", instr2_end:"Find the ending letter of the word.", instr3:"Spell the word for the picture. The little studs will help you!", card:"Card", cardGeneral:"General", word:"Word", welldone:"Well done!", next:"Next word ▸", cardDone:"Card complete! 🎉", pickCard:"Pick a new card or another level.", myWords:"My words", manage:"Manage themes and words", addWord:"+ Add", newTheme:"New theme", themeNamePh:"name of the theme…", delTheme:"Delete theme", numWords:"Words per game", all:"All", noThemes:"— no themes yet —", muteAll:"All sounds on/off", sndWidget:"This widget's sound on/off", catTaal:"Language", catRekenen:"Maths", catTools:"Tools", catMedia:"Media", wg_madd:"Addition", wg_madd_d:"Adding up to 10, 20 or 100.", wg_msub:"Subtraction", wg_msub_d:"Subtracting up to 10, 20 or 100.", wg_mmul:"Multiplication", wg_mmul_d:"Practise the times tables 1–12.", wg_mdiv:"Division", wg_mdiv_d:"Dividing with the tables 1–12.", wg_mpct:"Percentages", wg_mpct_d:"Working out percentages with friendly numbers.", mGood:"Well done!", mAnsWas:"The right answer is", mAgain:"Again ▸", mGoed:"correct", mOf:"of", mDoneT:"Done!", amode:"Drag blocks or type", penMode:"Draw on the board", fabWidgets:"Add widget", fabDraw:"Draw & write", fabMind:"Mindmap on the board", fabImg:"Picture on the board", nodeColor:"Colour", nodeFont:"Font size", lineColor:"Line colour", lineStyle:"Line style", a11y:"High contrast (for low vision)", zoomUI:"Display size: show everything bigger or smaller", lensBig:"Larger magnifier", lensSmall:"Smaller magnifier", lensShape:"Round or square", toolHand:"Hand: move and interact", boardsNav:"Boards and folders", newBoard:"+ Board", newFolder:"+ Folder", folderPh:"name of new folder…", boardName:"Board", folderName:"Folder", delBoard:"Delete board (tap twice)", renameTip:"Double-click a board to rename it", hudMin:"Collapse/expand menu", hudOrient:"Horizontal or vertical", wFull:"Maximize", wUnfull:"Restore", wZoom:"Content bigger or smaller (low vision)", wg_flits:"Flash words", wg_flits_d:"A word flashes briefly – read it before it disappears!", flFlash:"⚡ Flash", flShow:"👁 Show", flNext:"Next ▸", flSec:"sec", wg_dice:"Dice", wg_dice_d:"Roll 1, 2 or 3 big dice.", diceRoll:"🎲 Roll!", diceSum:"Total", wg_light:"Traffic light", wg_light_d:"Red, orange or green – e.g. for classroom noise.", wg_names:"Name picker", wg_names_d:"Pick a random name from the class.", namesPh:"One name per line…", namesPick:"🎯 Pick!", namesNoRep:"No repeats", namesDone:"Everyone has had a turn! 🎉", wg_hak:"Chop & blend", wg_hak_d:"Chop words into sounds and blend them back, with read-aloud.", hakChop:"✂️ Chop", hakGlue:"🔗 Blend", hakSay:"🔊 Read aloud", wg_ball:"Balloon word", wg_ball_d:"Guess the word letter by letter before the balloons pop.", wg_anchor:"Anchor words", wg_anchor_d:"Unlimited word grid around a theme: every word relates to its neighbours.", anchorPh:"theme…", anchorHint:"Tap a cell and type · drag the background to pan", anchorColor:"Colour: click to cycle (connect words with the same colour)", ballWin:"You got it! 🎉", ballLose:"Oh no! The word was:", bgDots:"Dots", toolRuler:"Ruler", toolGeo:"Set square", toolLens:"Magnifier", wg_mind:"Mind map", wg_mind_d:"Word web, spider or tree diagram to organise ideas together.", mmSpider:"Spider", mmTree:"Tree", mmWeb:"Word web", mmRoot:"Topic", mmHint:"Click text to type · drag by the edge", wg_media:"Photo & video", wg_media_d:"Show photos and videos from the internet, such as YouTube or Vimeo.", mediaPh:"Paste a link (YouTube, Vimeo, photo or video)…", mediaShow:"Show", mediaEmpty:"Paste a link above and click Show.", mediaErr:"This link cannot be shown. Try a YouTube, Vimeo, photo or video link, or open it with ↗ in a new tab.", mediaOpen:"Open in new tab", wordPh:"word (2–5 letters)", close:"Close", badWord:"Use 2 to 5 letters (a-z).", needPic:"Pick a picture first.", noWords:"No custom words yet. Add one above!", tabEmoji:"😀 Emoji", tabPicto:"🖼️ Pictos", tabUpload:"📷 Upload", pictoSearch:"Search", pictoPh:"search word…", pictoNone:"Nothing found. Try another word.", pictoLoad:"Searching…", pictoErr:"Could not load pictos (internet required).", pictoCredit:"Pictos: ARASAAC (CC BY-NC-SA, Gobierno de Aragón). Also nice: sclera.be (free download, then add via Upload).", upload:"📷 Choose an image…", upHint:"Tip: square picture, white or transparent background, one clear object, no text in the picture.", cats:["Animals","Food","Nature","Transport","Things","Body"], notesPh:"Write here…", start:"Start", pause:"Pause", reset:"Reset", min:"min", tmDigits:"Digits", tmVisual:"Visual clock", tmBoth:"Both", tmSound:"Sound on/off", tmHint:"Drag the red disc to set the time.", login:"Log in", loginBtn:"Log in", registerBtn:"Create account", logout:"Log out", namePh:"name", passPh:"password", guestNote:"Without logging in nothing is saved: custom words and the board are gone when you close the page. Log in to keep everything on this device.", storedNote:"Your custom words are saved automatically. “Save board” also stores the widgets on the board.", errLogin:"Unknown name or wrong password.", errExists:"This name already exists.", errName:"Name: 2–20 letters or digits.", errPass:"Password: at least 4 characters.", errCrypto:"Login does not work in this browser.", hello:"Hi", boardSaved:"Saved ✓", loginFirst:"Log in first" } }; let LANG = "nl"; const T = k => I18N[LANG][k]; /* effective CSS zoom of the app (display-size setting); pointer coordinates are visual pixels, absolute positions are layout pixels → divide by VZ() */ const VZ = ()=>{ const z = parseFloat(document.body.style.zoom); return z>0 ? z : 1; }; function applyI18n(){ document.querySelectorAll("[data-i18n]").forEach(el=>{ el.textContent = T(el.dataset.i18n); }); document.querySelectorAll("#langToggle button").forEach(b=>b.classList.toggle("active", b.dataset.lang===LANG)); document.getElementById("liName").placeholder = T("namePh"); document.getElementById("liPass").placeholder = T("passPh"); document.getElementById("btnMute").title = T("muteAll"); updateUserUI(); document.dispatchEvent(new CustomEvent("langchange")); } document.querySelectorAll("#langToggle button").forEach(b=>{ b.addEventListener("click", ()=>{ LANG=b.dataset.lang; applyI18n(); }); }); document.getElementById("btnFs").addEventListener("click", ()=>{ if(document.fullscreenElement) document.exitFullscreen(); else document.documentElement.requestFullscreen(); }); /* ========================================================= Accounts & storage - Guest: everything in memory only, gone on close. - Logged in: PBKDF2-SHA256 hashed password (Web Crypto), per-user data (custom words + saved board) in localStorage. Note: this protects the password itself; anyone with physical access to this device/browser profile can still clear or read stored data. For real multi-device security a server is needed. ==========================================================*/ const USERS_KEY = "teach.users"; let currentUser = null; // {name, data} function loadUsers(){ try{ return JSON.parse(localStorage.getItem(USERS_KEY)) || {}; }catch(e){ return {}; } } function saveUsers(u){ try{ localStorage.setItem(USERS_KEY, JSON.stringify(u)); }catch(e){} } const bufToHex = b => [...new Uint8Array(b)].map(x=>x.toString(16).padStart(2,"0")).join(""); const hexToBuf = h => new Uint8Array(h.match(/.{2}/g).map(x=>parseInt(x,16))); async function hashPw(pw, saltHex, iterations){ const key = await crypto.subtle.importKey("raw", new TextEncoder().encode(pw), "PBKDF2", false, ["deriveBits"]); const bits = await crypto.subtle.deriveBits( {name:"PBKDF2", hash:"SHA-256", salt:hexToBuf(saltHex), iterations}, key, 256); return bufToHex(bits); } const cryptoOK = !!(window.crypto && crypto.subtle && crypto.getRandomValues); function persistUser(){ if(!currentUser) return; const users = loadUsers(); if(users[currentUser.name]){ users[currentUser.name].data = currentUser.data; saveUsers(users); } } function persistWords(){ if(currentUser){ currentUser.data.themes = THEMES; currentUser.data.generalExtra = GENERAL_EXTRA; persistUser(); } } function generalExtraFromData(data){ return (data && data.generalExtra) ? data.generalExtra : {nl:[], en:[]}; } /* login UI */ const loginWrap = document.getElementById("loginWrap"); const loginMsg = document.getElementById("loginMsg"); function updateUserUI(){ document.getElementById("userLbl").textContent = currentUser ? currentUser.name : T("login"); document.getElementById("btnUser").classList.toggle("on", !!currentUser); document.getElementById("loginBox").style.display = currentUser ? "none" : "block"; document.getElementById("loggedBox").style.display = currentUser ? "block" : "none"; if(currentUser) document.getElementById("helloLbl").textContent = `${T("hello")} ${currentUser.name}! 👋`; } document.getElementById("btnUser").addEventListener("click", ()=>{ loginMsg.textContent = ""; loginMsg.classList.remove("ok"); loginWrap.classList.add("open"); }); loginWrap.addEventListener("click", e=>{ if(e.target===loginWrap) loginWrap.classList.remove("open"); }); async function doLogin(register){ const name = document.getElementById("liName").value.trim(); const pass = document.getElementById("liPass").value; loginMsg.classList.remove("ok"); if(!cryptoOK){ loginMsg.textContent = T("errCrypto"); return; } if(!/^[a-zA-Z0-9_-]{2,20}$/.test(name)){ loginMsg.textContent = T("errName"); return; } if(pass.length < 4){ loginMsg.textContent = T("errPass"); return; } const users = loadUsers(); try{ if(register){ if(users[name]){ loginMsg.textContent = T("errExists"); return; } const salt = bufToHex(crypto.getRandomValues(new Uint8Array(16))); const iterations = 150000; const hash = await hashPw(pass, salt, iterations); users[name] = { salt, iterations, hash, data:{ themes:{nl:[],en:[]}, board:null } }; saveUsers(users); }else{ const u = users[name]; if(!u){ loginMsg.textContent = T("errLogin"); return; } const hash = await hashPw(pass, u.salt, u.iterations); if(hash !== u.hash){ loginMsg.textContent = T("errLogin"); return; } } }catch(e){ loginMsg.textContent = T("errCrypto"); return; } currentUser = { name, data: loadUsers()[name].data }; THEMES = themesFromData(currentUser.data); GENERAL_EXTRA = generalExtraFromData(currentUser.data); document.getElementById("liPass").value = ""; try{ sessionStorage.setItem("teach.session", name); }catch(e){} updateUserUI(); loginWrap.classList.remove("open"); document.dispatchEvent(new CustomEvent("wordschange")); BS = boardsFromData(currentUser.data); loadCurrentSlot(); } document.getElementById("btnLogin").addEventListener("click", ()=>doLogin(false)); document.getElementById("btnRegister").addEventListener("click", ()=>doLogin(true)); document.getElementById("liPass").addEventListener("keydown", e=>{ if(e.key==="Enter") doLogin(false); }); document.getElementById("btnLogout").addEventListener("click", ()=>{ persistUser(); currentUser = null; THEMES = {nl:[],en:[]}; GENERAL_EXTRA = {nl:[],en:[]}; try{ sessionStorage.removeItem("teach.session"); }catch(e){} updateUserUI(); loginWrap.classList.remove("open"); document.dispatchEvent(new CustomEvent("wordschange")); BS = freshBS(); loadCurrentSlot(); }); /* save board */ document.getElementById("btnSave").addEventListener("click", ()=>{ const btn = document.getElementById("btnSave"); const lbl = btn.querySelector("span"); if(!currentUser){ lbl.textContent = T("loginFirst"); setTimeout(()=>{ lbl.textContent = T("saveBoard"); }, 2200); loginWrap.classList.add("open"); return; } stashCurrent(); currentUser.data.boards = BS; delete currentUser.data.board; persistUser(); lbl.textContent = T("boardSaved"); btn.classList.add("ok"); setTimeout(()=>{ lbl.textContent = T("saveBoard"); btn.classList.remove("ok"); }, 1800); }); /* ========================================================= Sounds ==========================================================*/ let AC=null, MUTED=false; const btnMute = document.getElementById("btnMute"); btnMute.addEventListener("click", ()=>{ MUTED = !MUTED; btnMute.textContent = MUTED ? "🔇" : "🔊"; btnMute.title = T("muteAll"); }); function tone(freq, dur=0.12, delay=0, type="sine", vol=0.18){ if(MUTED) return; try{ AC = AC || new (window.AudioContext||window.webkitAudioContext)(); const o=AC.createOscillator(), g=AC.createGain(); o.type=type; o.frequency.value=freq; g.gain.setValueAtTime(vol, AC.currentTime+delay); g.gain.exponentialRampToValueAtTime(0.001, AC.currentTime+delay+dur); o.connect(g); g.connect(AC.destination); o.start(AC.currentTime+delay); o.stop(AC.currentTime+delay+dur+0.02); }catch(e){} } const sndGood = ()=> tone(880,0.1); const sndBad = ()=> tone(180,0.18,0,"square",0.10); const sndWin = ()=>{ [523,659,784,1047].forEach((f,i)=>tone(f,0.16,i*0.12,"triangle",0.2)); };