diff --git a/VERSION b/VERSION index 1cb92ea..c638d14 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.06-beta +0.3.07-beta diff --git a/public/css/teach.css b/public/css/teach.css index ccd6e36..9dad3fa 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -1310,3 +1310,54 @@ body.hc #penbar .pen-current,body.hc #penbar .pen-popover{border:2px solid #000; .pen-current{width:40px; height:36px;} #penbar .pen-popover{position:fixed; left:50%; bottom:70px; transform:translateX(-50%);} } + + +/* ============================================================ + Nieuwe widgets: verjaardagstaart, getallenlijn en zinsbouwer + ============================================================ */ +/* Verjaardagstaart */ +.bd{height:100%; min-height:0; display:grid; grid-template-columns:minmax(180px,220px) 1fr; background:var(--surface);} +.bd-controls{padding:12px; overflow:auto; display:flex; flex-direction:column; gap:8px; background:var(--surface-2); border-right:1px solid var(--line);} +.bd-controls input[type=text],.bd-controls input[type=number],.bd-controls select{ + width:100%; height:36px; border:1px solid var(--line); border-radius:9px; padding:0 9px; background:var(--surface); color:var(--ink); font:inherit; font-size:13px; +} +.bd-controls label{display:flex; align-items:center; justify-content:space-between; gap:8px; font-size:12px; font-weight:800; color:var(--muted);} +.bd-controls input[type=range]{width:96px;}.bd-color input{width:42px; height:30px; padding:1px; border:0; background:transparent; cursor:pointer;} +.bd-check{justify-content:flex-start !important;}.bd-party{margin-top:auto; min-height:38px;} +.bd-stage{min-width:0; position:relative; overflow:hidden; display:flex; flex-direction:column; align-items:center; justify-content:center; padding:54px 16px 34px; background:radial-gradient(circle at 50% 20%,#fff 0,var(--surface-2) 72%);} +.bd-title{position:absolute; top:16px; left:12px; right:12px; text-align:center; font-size:clamp(20px,4vw,34px); font-weight:900; color:var(--pink); white-space:nowrap; overflow:hidden; text-overflow:ellipsis;} +.bd-cake{width:min(100%,390px); display:flex; flex-direction:column; align-items:center; filter:drop-shadow(0 10px 10px rgba(29,52,97,.18)); z-index:2;} +.bd-layer{height:72px; position:relative; background:var(--cake); border-radius:20px 20px 12px 12px; border-bottom:8px solid color-mix(in srgb,var(--cake),#8b4b2b 18%); box-shadow:inset 0 13px 0 var(--icing); flex:none;} +.bd-layer-1{width:94%;}.bd-layer-2{width:76%;}.bd-layer-3{width:58%;} +.bd-deco{position:absolute; inset:16px 8px auto; text-align:center; color:#fff; font-weight:900; letter-spacing:5px; white-space:nowrap; overflow:hidden; text-shadow:0 1px 2px rgba(0,0,0,.25);} +.bd-deco.sprinkles{color:#fff5a5;}.bd-deco.stars{color:#fff0a8;font-size:18px}.bd-deco.fruit{font-size:17px;letter-spacing:2px;} +.bd-candle-row{position:absolute; left:8%; right:8%; top:-31px; height:34px; display:flex; justify-content:center; align-items:flex-end; gap:3px;} +.bd-candle{display:block; width:5px; height:25px; border-radius:2px; background:repeating-linear-gradient(45deg,#fff 0 3px,var(--blue) 3px 6px); position:relative;} +.bd-candle::before{content:""; position:absolute; width:8px; height:12px; left:-1.5px; top:-12px; border-radius:70% 30% 60% 40%; background:linear-gradient(#fff7a0,var(--orange)); box-shadow:0 0 8px var(--orange);} +.bd-age-badge{position:absolute; right:8%; top:20%; z-index:4; min-width:58px; height:58px; padding:0 10px; border-radius:20px; display:grid; place-items:center; color:#fff; font-size:29px; font-weight:900; transform:rotate(7deg); box-shadow:0 5px 12px rgba(0,0,0,.2); border:3px solid #fff;} +.bd-plate{width:min(94%,420px); height:30px; border-radius:50%; margin-top:-12px; box-shadow:0 8px 12px rgba(29,52,97,.22); z-index:1;} +/* Getallenlijn */ +.nlw{height:100%; min-height:0; display:flex; flex-direction:column; gap:11px; padding:14px; background:var(--surface);} +.nlw-settings{display:flex; align-items:end; gap:8px; flex-wrap:wrap;} +.nlw-settings label{font-size:11px; font-weight:800; color:var(--muted); display:flex; flex-direction:column; gap:3px;} +.nlw-settings input,.nlw-settings select{width:72px; height:34px; border:1px solid var(--line); border-radius:9px; padding:0 8px; background:var(--surface); color:var(--ink); font:inherit;} +.nlw-new{margin-left:auto; min-height:34px;}.nlw-task{text-align:center; font-size:clamp(18px,3vw,27px); font-weight:900; color:var(--blue);} +.nlw-scroll{flex:1; min-height:115px; overflow-x:auto; overflow-y:hidden; border-radius:14px; background:var(--surface-2); padding:20px 18px 8px;} +.nlw-line{position:relative; min-width:max-content; height:88px; display:flex; align-items:center; padding:0 18px;} +.nlw-line::before{content:""; position:absolute; height:4px; left:18px; right:18px; top:38px; border-radius:4px; background:var(--ink);} +.nlw-tick{width:52px; height:76px; flex:none; border:0; background:transparent; color:var(--ink); position:relative; cursor:pointer; font:inherit; font-weight:800; padding:0;} +.nlw-tick i{position:absolute; width:3px; height:18px; background:var(--ink); top:31px; left:calc(50% - 1.5px);} +.nlw-tick span{position:absolute; top:53px; left:0; right:0; text-align:center; font-size:12px;} +.nlw-tick.selected::before{content:""; position:absolute; width:23px; height:23px; border-radius:50%; background:var(--pink); border:3px solid #fff; left:calc(50% - 14px); top:27px; z-index:2; box-shadow:0 2px 6px rgba(0,0,0,.25);} +.nlw-actions{display:flex; align-items:center; justify-content:center; gap:8px;}.nlw-value{min-width:76px; text-align:center; font-size:28px; font-weight:900; color:var(--pink);} +.nlw-feedback{min-height:24px; text-align:center; font-weight:800;}.nlw-feedback.ok{color:var(--green);}.nlw-feedback.bad{color:var(--red);} +/* Zinsbouwer */ +.sb{height:100%; min-height:0; display:flex; flex-direction:column; gap:10px; padding:12px; background:var(--surface);} +.sb-sentence{min-height:72px; border:2px dashed var(--line); border-radius:14px; padding:12px; display:flex; align-items:center; justify-content:center; flex-wrap:wrap; gap:7px; font-size:clamp(17px,3vw,25px); font-weight:900;} +.sb-sentence.empty{color:var(--muted); font-size:14px; font-weight:700;}.sb-part{padding:7px 11px; border-radius:10px;}.sb-part.who{background:#dce9ff;color:#174c9b}.sb-part.does{background:#dcf7e7;color:#176b3a}.sb-part.what{background:#fff0cf;color:#855b00} +.sb-banks{flex:1; min-height:0; overflow:auto; display:grid; grid-template-columns:repeat(3,1fr); gap:8px;}.sb-bank{min-width:0; border-radius:12px; padding:9px; background:var(--surface-2);}.sb-bank h3{margin:0 0 7px; font-size:12px; color:var(--muted); text-transform:uppercase; letter-spacing:.4px;} +.sb-chips{display:flex; flex-wrap:wrap; gap:6px;}.sb-chip-wrap{display:inline-flex;align-items:stretch}.sb-chip{border:0; border-radius:999px; padding:7px 10px; font:inherit; font-size:12.5px; font-weight:800; cursor:pointer; text-align:left;}.sb-bank.who .sb-chip{background:#dce9ff;color:#174c9b}.sb-bank.does .sb-chip{background:#dcf7e7;color:#176b3a}.sb-bank.what .sb-chip{background:#fff0cf;color:#855b00} +.sb-chip-wrap .sb-chip{border-radius:999px 0 0 999px}.sb-chip-remove{border:0;border-radius:0 999px 999px 0;padding:0 7px;background:var(--surface-3);color:var(--muted);font-weight:900;cursor:pointer}.sb-chip-remove:hover{background:var(--red-soft);color:var(--red)} +.sb-add,.sb-actions{display:flex; align-items:center; gap:7px; flex-wrap:wrap;}.sb-add select,.sb-add input{height:36px; border:1px solid var(--line); border-radius:9px; padding:0 9px; background:var(--surface); color:var(--ink); font:inherit;}.sb-add input{flex:1; min-width:150px;}.sb-actions{justify-content:flex-end;}.sb-back{font-size:20px;} +body.dark .bd-stage{background:radial-gradient(circle at 50% 20%,var(--surface-2),var(--surface));} +@media(max-width:600px){.bd{grid-template-columns:1fr}.bd-controls{max-height:155px;border-right:0;border-bottom:1px solid var(--line);display:grid;grid-template-columns:repeat(2,1fr)}.bd-party{margin-top:0}.sb-banks{grid-template-columns:1fr}.nlw-settings{gap:5px}} diff --git a/public/index.html b/public/index.html index ccb0301..9df8cd5 100644 --- a/public/index.html +++ b/public/index.html @@ -239,6 +239,9 @@ + + + diff --git a/public/js/board.js b/public/js/board.js index fb18c83..5f22666 100644 --- a/public/js/board.js +++ b/public/js/board.js @@ -25,17 +25,20 @@ const REGISTRY = [ { id:"hak", cat:"taal", icon:"✂️", w:540, h:440, minW:420, minH:340, mount:mountHak }, { id:"ball", cat:"taal", icon:"🎈", w:620, h:560, minW:480, minH:440, mount:mountBall }, { id:"anchor", cat:"taal", icon:"⚓", w:680, h:500, minW:440, minH:340, mount:mountAnchor }, + { id:"sentence", cat:"taal", icon:"🧩", w:620, h:520, minW:400, minH:360, mount:mountSentence }, { id:"madd", cat:"rekenen", icon:"➕", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"add") }, { id:"msub", cat:"rekenen", icon:"➖", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"sub") }, { id:"mmul", cat:"rekenen", icon:"✖️", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"mul") }, { id:"mdiv", cat:"rekenen", icon:"➗", w:460, h:620, minW:380, minH:520, mount:(r,s)=>mountMath(r,s,"div") }, { id:"mpct", cat:"rekenen", icon:"💯", w:480, h:620, minW:400, minH:520, mount:(r,s)=>mountMath(r,s,"pct") }, + { id:"numberline", cat:"rekenen", icon:"↔️", w:660, h:430, minW:420, minH:320, mount:mountNumberline }, { id:"write", cat:"tools", icon:"✏️", w:560, h:440, minW:360, minH:280, mount:mountWrite }, { id:"mind", cat:"tools", icon:"🕸️", w:640, h:480, minW:420, minH:320, mount:mountMind }, { id:"notes", cat:"tools", icon:"📝", w:340, h:300, minW:240, minH:180, mount:mountNotes }, { id:"timer", cat:"tools", icon:"⏱️", w:350, h:470, minW:300, minH:420, mount:mountTimer }, { id:"dice", cat:"tools", icon:"🎲", w:400, h:340, minW:300, minH:270, mount:mountDice }, { id:"names", cat:"tools", icon:"🎯", w:420, h:430, minW:320, minH:330, mount:mountNames }, + { id:"birthday",cat:"tools", icon:"🎂", w:620, h:560, minW:400, minH:400, mount:mountBirthday }, { id:"media", cat:"media", icon:"🎬", w:560, h:420, minW:320, minH:240, mount:mountMedia } ]; const WIDGET_CATS = [["taal","catTaal"],["rekenen","catRekenen"],["tools","catTools"],["media","catMedia"]]; diff --git a/public/js/core.js b/public/js/core.js index 64e9032..336a215 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "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.06-beta"; +const VERSION = "0.3.07-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -90,6 +90,16 @@ const I18N = { 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.", + wg_birthday:"Verjaardagstaart", wg_birthday_d:"Maak een persoonlijke taart met naam, leeftijd, kleuren, lagen, versiering en kaarsjes.", + birthdayName:"naam van de jarige", birthdayAge:"Leeftijd", birthdayLayers:"Lagen", birthdayCakeColor:"Taart", birthdayIcing:"Glazuur", birthdayPlate:"Bord", + birthdayCandles:"Kaarsjes", birthdayCelebrate:"🎉 Vieren!", birthdayTitle:"Er is er één jarig!", birthdayFor:"Hoera voor {name}!", + birthdaySprinkles:"Spikkels", birthdayFruit:"Fruit", birthdayStars:"Sterren", birthdayNone:"Geen versiering", + wg_numberline:"Getallenlijn", wg_numberline_d:"Oefen getalbegrip met een instelbare, interactieve getallenlijn.", + numberlineFrom:"Van", numberlineTo:"Tot", numberlineStep:"Stap", numberlineNew:"Nieuwe opdracht", numberlineTask:"Zet de stip op {number}", + numberlineCheck:"Controleren", numberlineGood:"Goed gevonden! 🌟", numberlineTry:"Kijk nog eens naar de getallenlijn.", + wg_sentence:"Zinsbouwer", wg_sentence_d:"Bouw zinnen met kleurgecodeerde zinsdelen en voeg eigen woorden toe.", + sentence_who:"Wie?", sentence_does:"Doet wat?", sentence_what:"Wat of waar?", sentenceHint:"Kies woorden om een zin te maken…", + sentenceAdd:"eigen woord of zinsdeel…", sentenceClear:"Wissen", sentenceSpeak:"🔊 Lees voor", sentenceRemove:"Verwijder woord", anchorPh:"thema…", anchorHint:"Tik op een vak en typ · sleep de achtergrond om te schuiven", anchorHintReadonly:"Sleep de achtergrond om te schuiven", anchorColor:"Kleur: klik om te wisselen (verbind woorden met dezelfde kleur)", @@ -230,6 +240,16 @@ const I18N = { 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.", + wg_birthday:"Birthday cake", wg_birthday_d:"Create a personal cake with a name, age, colours, layers, decorations and candles.", + birthdayName:"birthday person's name", birthdayAge:"Age", birthdayLayers:"Layers", birthdayCakeColor:"Cake", birthdayIcing:"Icing", birthdayPlate:"Plate", + birthdayCandles:"Candles", birthdayCelebrate:"🎉 Celebrate!", birthdayTitle:"Someone has a birthday!", birthdayFor:"Hooray for {name}!", + birthdaySprinkles:"Sprinkles", birthdayFruit:"Fruit", birthdayStars:"Stars", birthdayNone:"No decoration", + wg_numberline:"Number line", wg_numberline_d:"Practise number sense with an adjustable, interactive number line.", + numberlineFrom:"From", numberlineTo:"To", numberlineStep:"Step", numberlineNew:"New task", numberlineTask:"Move the marker to {number}", + numberlineCheck:"Check", numberlineGood:"Well done! 🌟", numberlineTry:"Take another look at the number line.", + wg_sentence:"Sentence builder", wg_sentence_d:"Build sentences with colour-coded parts and add your own words.", + sentence_who:"Who?", sentence_does:"Does what?", sentence_what:"What or where?", sentenceHint:"Choose words to build a sentence…", + sentenceAdd:"your own word or phrase…", sentenceClear:"Clear", sentenceSpeak:"🔊 Read aloud", sentenceRemove:"Remove word", anchorPh:"theme…", anchorHint:"Tap a cell and type · drag the background to pan", anchorHintReadonly:"Drag the background to pan", anchorColor:"Colour: click to cycle (connect words with the same colour)", diff --git a/public/js/widgets/birthday.js b/public/js/widgets/birthday.js new file mode 100644 index 0000000..b833d13 --- /dev/null +++ b/public/js/widgets/birthday.js @@ -0,0 +1,74 @@ +/* widget: volledig aanpasbare verjaardagstaart */ +function mountBirthday(root, initState){ + const S = Object.assign({ + name:"", age:8, layers:2, cake:"#f2b880", icing:"#ff78a8", plate:"#57c7d4", + decoration:"sprinkles", candles:true, + }, initState || {}); + root.innerHTML = ` +
+
+ + + + + + + + + +
+
+
+
+
+
+
+
`; + const q=s=>root.querySelector(s), name=q('.bd-name'), age=q('.bd-age'), layers=q('.bd-layers'), + cakeColor=q('.bd-cake-color'), icingColor=q('.bd-icing-color'), plateColor=q('.bd-plate-color'), decoration=q('.bd-decoration'), + candles=q('.bd-candles'), cake=q('.bd-cake'), title=q('.bd-title'), badge=q('.bd-age-badge'); + name.value=S.name; age.value=S.age; layers.value=S.layers; cakeColor.value=S.cake; + icingColor.value=S.icing; plateColor.value=S.plate; decoration.value=S.decoration; candles.checked=S.candles; + function sync(){ + S.name=name.value.trim(); S.age=Math.max(1,Math.min(120,+age.value||1)); S.layers=+layers.value; + S.cake=cakeColor.value; S.icing=icingColor.value; S.plate=plateColor.value; S.decoration=decoration.value; S.candles=candles.checked; + render(); + } + function render(){ + cake.innerHTML=""; + for(let n=S.layers;n>=1;n--){ + const layer=document.createElement('div'); layer.className='bd-layer bd-layer-'+n; + layer.style.setProperty('--cake',S.cake); layer.style.setProperty('--icing',S.icing); + if(S.decoration!=="none"){ + const deco=document.createElement('div'); deco.className='bd-deco '+S.decoration; + deco.textContent=S.decoration==='fruit'?'🍓 🍒 🍓':S.decoration==='stars'?'★ ✦ ★ ✦ ★':'• • • • • •'; + layer.appendChild(deco); + } + if(n===S.layers && S.candles){ + const row=document.createElement('div'); row.className='bd-candle-row'; + const count=Math.min(S.age,20); + for(let i=0;ie.stopPropagation()); + [name,age,layers,cakeColor,icingColor,plateColor,decoration,candles].forEach(el=>el.addEventListener('input',sync)); + q('.bd-party').addEventListener('click',()=>{ confetti(q('.bd-stage')); sndWin(); }); + function labels(){ + name.placeholder=T('birthdayName'); q('.bd-age-label').textContent=T('birthdayAge'); + q('.bd-layers-label').textContent=T('birthdayLayers'); q('.bd-cake-label').textContent=T('birthdayCakeColor'); + q('.bd-icing-label').textContent=T('birthdayIcing'); q('.bd-plate-label').textContent=T('birthdayPlate'); q('.bd-candles-label').textContent=T('birthdayCandles'); + q('.bd-party').textContent=T('birthdayCelebrate'); + const keys=['birthdaySprinkles','birthdayFruit','birthdayStars','birthdayNone']; + [...decoration.options].forEach((o,i)=>o.textContent=T(keys[i])); render(); + } + document.addEventListener('langchange',labels); labels(); + return { getState:()=>({...S}) }; +} diff --git a/public/js/widgets/numberline.js b/public/js/widgets/numberline.js new file mode 100644 index 0000000..f00ab9f --- /dev/null +++ b/public/js/widgets/numberline.js @@ -0,0 +1,63 @@ +/* widget: interactieve getallenlijn */ +function mountNumberline(root, initState){ + const S=Object.assign({min:0,max:20,step:1,value:0,target:10},initState||{}); + root.innerHTML=` +
+
+ + + + +
+
+
+
+ + + + +
+
+
`; + const q=s=>root.querySelector(s), minEl=q('.nlw-min'), maxEl=q('.nlw-max'), stepEl=q('.nlw-step'), + line=q('.nlw-line'), valueEl=q('.nlw-value'), task=q('.nlw-task'), feedback=q('.nlw-feedback'); + minEl.value=S.min; maxEl.value=S.max; stepEl.value=S.step; + function normalize(){ + let min=Math.max(-100,Math.min(99,+minEl.value||0)); let max=Math.max(-99,Math.min(200,+maxEl.value||20)); + if(max<=min) max=min+1; const step=+stepEl.value||1; + if((max-min)/step>60) max=min+60*step; + S.min=min; S.max=max; S.step=step; + if(S.valuemax||(S.value-min)%step!==0) S.value=min; + if(S.targetmax||(S.target-min)%step!==0) S.target=min; + minEl.value=min; maxEl.value=max; render(); + } + function render(){ + line.innerHTML=""; + for(let v=S.min;v<=S.max;v+=S.step){ + const b=document.createElement('button'); b.type='button'; b.className='nlw-tick'; b.dataset.v=v; + const mark=document.createElement('i'), label=document.createElement('span'); label.textContent=v; + b.append(mark,label); b.classList.toggle('selected',v===S.value); + b.addEventListener('click',()=>{S.value=v; feedback.textContent=""; render();}); line.appendChild(b); + } + valueEl.textContent=S.value; task.textContent=T('numberlineTask').replace('{number}',S.target); + } + function newTarget(){ + const count=Math.floor((S.max-S.min)/S.step); S.target=S.min+Math.floor(Math.random()*(count+1))*S.step; + feedback.textContent=""; render(); + } + [minEl,maxEl,stepEl].forEach(el=>el.addEventListener('change',normalize)); + q('.nlw-minus').addEventListener('click',()=>{S.value=Math.max(S.min,S.value-S.step);render();}); + q('.nlw-plus').addEventListener('click',()=>{S.value=Math.min(S.max,S.value+S.step);render();}); + q('.nlw-new').addEventListener('click',newTarget); + q('.nlw-check').addEventListener('click',()=>{ + const ok=S.value===S.target; feedback.textContent=T(ok?'numberlineGood':'numberlineTry'); + feedback.className='nlw-feedback '+(ok?'ok':'bad'); if(ok){sndGood();confetti(root);}else sndBad(); + }); + function labels(){ + q('.nlw-from-label').textContent=T('numberlineFrom'); q('.nlw-to-label').textContent=T('numberlineTo'); + q('.nlw-step-label').textContent=T('numberlineStep'); q('.nlw-new').textContent=T('numberlineNew'); + q('.nlw-check').textContent=T('numberlineCheck'); render(); + } + document.addEventListener('langchange',labels); labels(); normalize(); + return {getState:()=>({...S})}; +} diff --git a/public/js/widgets/sentence.js b/public/js/widgets/sentence.js new file mode 100644 index 0000000..af163ca --- /dev/null +++ b/public/js/widgets/sentence.js @@ -0,0 +1,57 @@ +/* widget: zinsbouwer met aanpasbare woordbanken */ +function mountSentence(root, initState){ + const defaults=LANG==='nl' ? { + who:['De hond','Mijn vriend','De juf','Sara'], does:['lees','speelt','maakt','ziet'], + what:['een boek','op het plein','een mooie tekening','de rode bal'], + } : { + who:['The dog','My friend','The teacher','Sarah'], does:['read','plays','makes','sees'], + what:['a book','in the playground','a beautiful drawing','the red ball'], + }; + const S={banks:JSON.parse(JSON.stringify(initState?.banks||defaults)),sentence:[...(initState?.sentence||[])]}; + root.innerHTML=` +
+
+
+
+ + + +
+
+
`; + const q=s=>root.querySelector(s), sentenceEl=q('.sb-sentence'), banksEl=q('.sb-banks'), kind=q('.sb-kind'), word=q('.sb-word'); + function sentenceText(){ return S.sentence.map(x=>x.word).join(' ').replace(/s+([,.!?])/g,'$1'); } + function renderSentence(){ + sentenceEl.innerHTML=""; + if(!S.sentence.length){ sentenceEl.textContent=T('sentenceHint'); sentenceEl.classList.add('empty'); return; } + sentenceEl.classList.remove('empty'); + S.sentence.forEach(part=>{const span=document.createElement('span');span.className='sb-part '+part.kind;span.textContent=part.word;sentenceEl.appendChild(span);}); + } + function renderBanks(){ + banksEl.innerHTML=""; + ['who','does','what'].forEach(k=>{ + const group=document.createElement('section'); group.className='sb-bank '+k; + const h=document.createElement('h3'); h.textContent=T('sentence_'+k); group.appendChild(h); + const chips=document.createElement('div'); chips.className='sb-chips'; + S.banks[k].forEach((w,i)=>{ + const wrap=document.createElement('span'); wrap.className='sb-chip-wrap'; + const chip=document.createElement('button'); chip.type='button'; chip.className='sb-chip'; chip.textContent=w; + chip.addEventListener('click',()=>{S.sentence.push({kind:k,word:w});renderSentence();}); + const remove=document.createElement('button'); remove.type='button'; remove.className='sb-chip-remove'; remove.textContent='×'; remove.title=T('sentenceRemove'); + remove.addEventListener('click',()=>{S.banks[k].splice(i,1);renderBanks();}); wrap.append(chip,remove); chips.appendChild(wrap); + }); + group.appendChild(chips); banksEl.appendChild(group); + }); + } + function addWord(){const w=word.value.trim();if(!w)return;S.banks[kind.value].push(w);word.value='';renderBanks();} + q('.sb-add-btn').addEventListener('click',addWord); word.addEventListener('keydown',e=>{e.stopPropagation();if(e.key==='Enter')addWord();}); + q('.sb-back').addEventListener('click',()=>{S.sentence.pop();renderSentence();}); + q('.sb-clear').addEventListener('click',()=>{S.sentence=[];renderSentence();}); + q('.sb-speak').addEventListener('click',()=>{const text=sentenceText();if(text&&'speechSynthesis'in window){speechSynthesis.cancel();const u=new SpeechSynthesisUtterance(text);u.lang=LANG==='nl'?'nl-NL':'en-GB';speechSynthesis.speak(u);}}); + function labels(){ + [...kind.options].forEach(o=>o.textContent=T('sentence_'+o.value)); word.placeholder=T('sentenceAdd'); + q('.sb-clear').textContent=T('sentenceClear'); q('.sb-speak').textContent=T('sentenceSpeak'); renderSentence();renderBanks(); + } + document.addEventListener('langchange',labels);labels(); + return {getState:()=>({banks:S.banks,sentence:S.sentence})}; +} diff --git a/test/widgets.test.js b/test/widgets.test.js new file mode 100644 index 0000000..c04937e --- /dev/null +++ b/test/widgets.test.js @@ -0,0 +1,32 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import {readFile} from 'node:fs/promises'; + +const ids=['birthday','numberline','sentence']; + +test('nieuwe widgets zijn geladen vóór het widgetregister', async()=>{ + const html=await readFile('public/index.html','utf8'); + const board=html.indexOf('js/board.js'); + for(const id of ids){ + const pos=html.indexOf(`js/widgets/${id}.js`); + assert.ok(pos>0 && pos{ + const registry=await readFile('public/js/board.js','utf8'); + for(const id of ids){ + assert.match(registry,new RegExp(`id:"${id}"`)); + const source=await readFile(`public/js/widgets/${id}.js`,'utf8'); + assert.match(source,/getState/); + assert.doesNotMatch(source,/innerHTML\s*=.*(?:name|word\.value)/); + } +}); + +test('nieuwe widgetnamen en beschrijvingen zijn tweetalig', async()=>{ + const core=await readFile('public/js/core.js','utf8'); + for(const id of ids){ + assert.equal((core.match(new RegExp(`wg_${id}:`,'g'))||[]).length,2); + assert.equal((core.match(new RegExp(`wg_${id}_d:`,'g'))||[]).length,2); + } +});