All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 8s
392 lines
22 KiB
JavaScript
392 lines
22 KiB
JavaScript
/* 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 <b>beginletter</b> van het woord.",
|
||
instr2_mid:"Zoek de <b>middelste letter</b> van het woord.",
|
||
instr2_end:"Zoek de <b>eindletter</b> van het woord.",
|
||
instr3:"Spel het woord bij het plaatje. De nopjes helpen je!",
|
||
card:"Kaart", cardGeneral:"Algemeen", word:"Woord", welldone:"Goed zo!", next:"Volgende woord ▸",
|
||
cardDone:"Kaart klaar! 🎉", pickCard:"Kies een nieuwe kaart of een ander niveau.",
|
||
myWords:"Mijn woorden", manage:"Thema's en woorden beheren", addWord:"+ Toevoegen",
|
||
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: <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.",
|
||
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 <b>beginning letter</b> of the word.",
|
||
instr2_mid:"Find the <b>middle letter</b> of the word.",
|
||
instr2_end:"Find the <b>ending letter</b> of the word.",
|
||
instr3:"Spell the word for the picture. The little studs will help you!",
|
||
card:"Card", cardGeneral:"General", word:"Word", welldone:"Well done!", next:"Next word ▸",
|
||
cardDone:"Card complete! 🎉", pickCard:"Pick a new card or another level.",
|
||
myWords:"My words", manage:"Manage themes and words", addWord:"+ Add",
|
||
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: <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.",
|
||
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)); };
|
||
|