Voeg 28 herkenbare kleurplaten en creatieve niveaus toe (v0.4.66-beta) #4
9 changed files with 305 additions and 2 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.3.06-beta
|
||||
0.3.07-beta
|
||||
|
|
|
|||
|
|
@ -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}}
|
||||
|
|
|
|||
|
|
@ -239,6 +239,9 @@
|
|||
<script src="js/widgets/light.js"></script>
|
||||
<script src="js/widgets/names.js"></script>
|
||||
<script src="js/widgets/mind.js"></script>
|
||||
<script src="js/widgets/birthday.js"></script>
|
||||
<script src="js/widgets/numberline.js"></script>
|
||||
<script src="js/widgets/sentence.js"></script>
|
||||
<script src="js/board.js"></script>
|
||||
<script src="js/pupil.js"></script>
|
||||
<script src="js/admin.js"></script>
|
||||
|
|
|
|||
|
|
@ -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"]];
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
74
public/js/widgets/birthday.js
Normal file
74
public/js/widgets/birthday.js
Normal file
|
|
@ -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 = `
|
||||
<div class="bd">
|
||||
<div class="bd-controls">
|
||||
<input class="bd-name" type="text" maxlength="30">
|
||||
<label><span class="bd-age-label"></span><input class="bd-age" type="number" min="1" max="120"></label>
|
||||
<label><span class="bd-layers-label"></span><input class="bd-layers" type="range" min="1" max="3"></label>
|
||||
<label class="bd-color"><span class="bd-cake-label"></span><input class="bd-cake-color" type="color"></label>
|
||||
<label class="bd-color"><span class="bd-icing-label"></span><input class="bd-icing-color" type="color"></label>
|
||||
<label class="bd-color"><span class="bd-plate-label"></span><input class="bd-plate-color" type="color"></label>
|
||||
<select class="bd-decoration">
|
||||
<option value="sprinkles"></option><option value="fruit"></option><option value="stars"></option><option value="none"></option>
|
||||
</select>
|
||||
<label class="bd-check"><input class="bd-candles" type="checkbox"><span class="bd-candles-label"></span></label>
|
||||
<button class="tbtn bd-party" type="button"></button>
|
||||
</div>
|
||||
<div class="bd-stage">
|
||||
<div class="bd-title"></div>
|
||||
<div class="bd-cake" aria-live="polite"></div>
|
||||
<div class="bd-age-badge"></div>
|
||||
<div class="bd-plate"></div>
|
||||
</div>
|
||||
</div>`;
|
||||
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;i<count;i++){ const c=document.createElement('i'); c.className='bd-candle'; row.appendChild(c); }
|
||||
layer.appendChild(row);
|
||||
}
|
||||
cake.appendChild(layer);
|
||||
}
|
||||
title.textContent=S.name ? T('birthdayFor').replace('{name}',S.name) : T('birthdayTitle');
|
||||
badge.textContent=String(S.age); badge.style.background=S.icing;
|
||||
q('.bd-plate').style.background=S.plate;
|
||||
}
|
||||
name.addEventListener('keydown',e=>e.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}) };
|
||||
}
|
||||
63
public/js/widgets/numberline.js
Normal file
63
public/js/widgets/numberline.js
Normal file
|
|
@ -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=`
|
||||
<div class="nlw">
|
||||
<div class="nlw-settings">
|
||||
<label><span class="nlw-from-label"></span><input class="nlw-min" type="number" min="-100" max="99"></label>
|
||||
<label><span class="nlw-to-label"></span><input class="nlw-max" type="number" min="-99" max="200"></label>
|
||||
<label><span class="nlw-step-label"></span><select class="nlw-step"><option>1</option><option>2</option><option>5</option><option>10</option></select></label>
|
||||
<button class="tbtn ghost nlw-new" type="button"></button>
|
||||
</div>
|
||||
<div class="nlw-task"></div>
|
||||
<div class="nlw-scroll"><div class="nlw-line"></div></div>
|
||||
<div class="nlw-actions">
|
||||
<button class="tbtn ghost nlw-minus" type="button">−</button>
|
||||
<output class="nlw-value"></output>
|
||||
<button class="tbtn ghost nlw-plus" type="button">+</button>
|
||||
<button class="tbtn nlw-check" type="button"></button>
|
||||
</div>
|
||||
<div class="nlw-feedback" aria-live="polite"></div>
|
||||
</div>`;
|
||||
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.value<min||S.value>max||(S.value-min)%step!==0) S.value=min;
|
||||
if(S.target<min||S.target>max||(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})};
|
||||
}
|
||||
57
public/js/widgets/sentence.js
Normal file
57
public/js/widgets/sentence.js
Normal file
|
|
@ -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=`
|
||||
<div class="sb">
|
||||
<div class="sb-sentence" aria-live="polite"></div>
|
||||
<div class="sb-banks"></div>
|
||||
<div class="sb-add">
|
||||
<select class="sb-kind"><option value="who"></option><option value="does"></option><option value="what"></option></select>
|
||||
<input class="sb-word" type="text" maxlength="40">
|
||||
<button class="tbtn ghost sb-add-btn" type="button">+</button>
|
||||
</div>
|
||||
<div class="sb-actions"><button class="tbtn ghost sb-back" type="button">↶</button><button class="tbtn ghost sb-clear" type="button"></button><button class="tbtn sb-speak" type="button"></button></div>
|
||||
</div>`;
|
||||
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})};
|
||||
}
|
||||
32
test/widgets.test.js
Normal file
32
test/widgets.test.js
Normal file
|
|
@ -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<board, `${id} moet vóór board.js laden`);
|
||||
}
|
||||
});
|
||||
|
||||
test('nieuwe widgets zijn geregistreerd en bewaren state', async()=>{
|
||||
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);
|
||||
}
|
||||
});
|
||||
Loading…
Reference in a new issue