diff --git a/VERSION b/VERSION
index c638d14..5f54142 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-0.3.07-beta
+0.3.08-beta
diff --git a/public/css/teach.css b/public/css/teach.css
index 9dad3fa..f94074a 100644
--- a/public/css/teach.css
+++ b/public/css/teach.css
@@ -1361,3 +1361,14 @@ body.hc #penbar .pen-current,body.hc #penbar .pen-popover{border:2px solid #000;
.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}}
+
+/* Educatieve widgets en compacte widgetbibliotheek */
+.gallery-head,.gallery-tools,.gallery-data{display:flex;align-items:center;gap:8px}.gallery-head h2{flex:1}.gallery-tools{position:sticky;top:-24px;z-index:2;background:var(--surface);padding:6px 0 10px}.gallery-tools input{flex:1;height:42px;border:1px solid var(--line);border-radius:12px;padding:0 13px;background:var(--surface);color:var(--ink);font:inherit}.gallery-data{margin:8px 0 14px;flex-wrap:wrap}#galleryTemplates{display:flex;gap:7px;overflow-x:auto;padding:2px 0 8px}.gcat-title{margin:18px 0 8px;font-size:13px;font-weight:900;color:var(--muted);text-transform:uppercase}.gcard{position:relative}.gstar{position:absolute;right:7px;top:7px;border:0;background:transparent;color:var(--orange);font-size:22px;cursor:pointer}.gallery-empty,.edu-empty{text-align:center;color:var(--muted);padding:28px}.widget.locked .widget-rs{display:none}.widget.locked{outline:2px solid var(--accent-soft)}.widget.preview .widget-head{position:absolute;z-index:4;width:100%;opacity:0}.widget.preview:hover .widget-head,.widget.preview:focus-within .widget-head{opacity:1}.widget.preview .widget-rs{display:none}.widget.preview{border-color:transparent;box-shadow:none}.wpreview.on{background:var(--accent-soft);color:var(--accent-ink)}
+.edu{height:100%;display:flex;flex-direction:column;background:var(--surface);color:var(--ink)}.edu-settings{flex:none;background:var(--surface-2);border-bottom:1px solid var(--line)}.edu-settings summary{cursor:pointer;padding:9px 12px;font-weight:800;font-size:13px}.edu-controls{display:flex;align-items:end;gap:8px;flex-wrap:wrap;padding:0 12px 10px}.edu-controls label{display:flex;flex-direction:column;gap:3px;font-size:11px;font-weight:800;color:var(--muted)}.edu-controls input,.edu-controls select,.edu-controls textarea{min-height:34px;border:1px solid var(--line);border-radius:9px;padding:5px 8px;background:var(--surface);color:var(--ink);font:inherit}.edu-controls textarea{height:65px;resize:vertical}.edu-stage{flex:1;min-height:0;overflow:auto;padding:16px;display:flex;align-items:center;justify-content:center}
+.fraction-wall{width:100%;display:flex;flex-direction:column;gap:5px}.fraction-wall>div{display:flex;gap:4px}.fraction-piece{flex:1;min-height:34px;border:2px solid hsl(var(--fraction-hue) 55% 43%);border-radius:5px;background:hsl(var(--fraction-hue) 75% 90%);font-weight:800;color:#20304a}.fraction-piece.selected{background:hsl(var(--fraction-hue) 70% 55%);color:#fff}
+.clock-wrap{text-align:center}.clock{width:min(270px,65vw);aspect-ratio:1;border:12px solid var(--ink);border-radius:50%;position:relative;background:var(--surface)}.clock i{position:absolute;width:7px;left:calc(50% - 3px);bottom:50%;background:var(--ink);border-radius:8px;transform-origin:50% 100%}.clock-hour{height:29%}.clock-min{height:40%;background:var(--pink)!important}.clock b{position:absolute}.clock b:nth-of-type(1){top:5px;left:47%}.clock b:nth-of-type(2){right:10px;top:47%}.clock b:nth-of-type(3){bottom:5px;left:49%}.clock b:nth-of-type(4){left:10px;top:47%}.clock-wrap output{display:block;font-size:28px;font-weight:900;margin-top:10px}
+.place-grid{display:grid;grid-template-columns:repeat(6,1fr);width:100%;border:1px solid var(--line);border-radius:14px;overflow:hidden}.place-grid div{text-align:center;border-right:1px solid var(--line)}.place-grid b{display:block;padding:8px;background:var(--surface-2);font-size:12px}.place-grid output{display:block;padding:18px 4px;font-size:clamp(25px,5vw,48px);font-weight:900;color:var(--blue)}
+.money{text-align:center;display:flex;align-items:center;justify-content:center;gap:9px;flex-wrap:wrap}.money h3{flex:1 0 100%;font-size:25px}.money .tbtn{border-radius:50%;min-width:62px}.money output{flex:1 0 100%;font-size:32px;font-weight:900;color:var(--green)}
+.wordtypes{display:flex;gap:9px;flex-wrap:wrap;justify-content:center}.wordtypes button{border:0;border-radius:10px;padding:10px 13px;font:inherit;font-weight:900;background:var(--surface-2)}.wordtypes button[data-type=noun]{background:#dce9ff;color:#174c9b}.wordtypes button[data-type=verb]{background:#dcf7e7;color:#176b3a}.wordtypes button[data-type=adjective]{background:#fff0cf;color:#855b00}.wordtypes p{flex-basis:100%;text-align:center;color:var(--muted);font-size:12px}
+.schedule{width:min(100%,450px);display:flex;flex-direction:column;gap:7px}.schedule>div{display:flex;align-items:center;gap:12px;padding:9px;background:var(--surface-2);border-radius:10px}.schedule time{font-weight:900;color:var(--blue)}.schedule span{flex:1;font-weight:800}.schedule .tbtn{height:30px;min-height:30px;padding:0 9px}.mood{display:flex;gap:8px;flex-wrap:wrap}.mood .tbtn{font-size:36px;height:auto;padding:12px}.mood span{display:block;font-size:14px}.poll{width:min(100%,440px);display:flex;flex-direction:column;gap:8px}.poll h3{text-align:center}.poll .tbtn{position:relative;overflow:hidden;text-align:left}.poll .tbtn i{position:absolute;inset:0 auto 0 0;background:var(--accent-soft);z-index:0}.poll .tbtn span{position:relative;float:right;font-weight:900}.poll .tbtn{isolation:isolate}
+@media(max-width:600px){.gallery{grid-template-columns:repeat(2,minmax(0,1fr))}.gcard{min-height:120px;padding:13px 8px}.gcard .gd{display:none}.widget .wdup,.widget .wpreview{display:none}.edu-stage{padding:10px}.place-grid b{font-size:9px}.place-grid output{padding:12px 2px}}
diff --git a/public/index.html b/public/index.html
index 9df8cd5..7593920 100644
--- a/public/index.html
+++ b/public/index.html
@@ -76,9 +76,12 @@
+
@@ -242,6 +245,7 @@
+
diff --git a/public/js/board.js b/public/js/board.js
index 5f22666..5b5bfaf 100644
--- a/public/js/board.js
+++ b/public/js/board.js
@@ -4,7 +4,14 @@
==========================================================*/
const board = document.getElementById("board");
/* autosave-haakje: board.js meldt wijzigingen bij core.js, behalve tijdens restore */
-const markBoardChange = ()=>{ if(!RESTORING) scheduleSave(); };
+let BOARD_HISTORY=[], BOARD_FUTURE=[], HISTORY_TIMER=null, HISTORY_BUSY=false;
+function recordBoardHistory(){
+ if(RESTORING||HISTORY_BUSY) return; clearTimeout(HISTORY_TIMER); HISTORY_TIMER=setTimeout(()=>{
+ const snap=serializeBoard(), sig=JSON.stringify(snap);
+ if(!BOARD_HISTORY.length||JSON.stringify(BOARD_HISTORY.at(-1))!==sig){BOARD_HISTORY.push(snap);if(BOARD_HISTORY.length>30)BOARD_HISTORY.shift();BOARD_FUTURE=[];}
+ },250);
+}
+const markBoardChange = ()=>{ if(!RESTORING){ scheduleSave(); recordBoardHistory(); } };
/* vangnet voor widget-INTERNE wijzigingen (notities typen, mindmap slepen,
schrijfblad, spelvoortgang): elke interactie in een widget-body plant een
(debounced) save - goedkoop, en dekt alle widgets zonder ze stuk voor stuk
@@ -32,16 +39,24 @@ const REGISTRY = [
{ 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:"fractions", cat:"rekenen", icon:"🍫", w:600, h:480, minW:380, minH:320, mount:mountFractions },
+ { id:"clock", cat:"rekenen", icon:"🕙", w:500, h:500, minW:340, minH:340, mount:mountClock },
+ { id:"placevalue", cat:"rekenen", icon:"🔢", w:610, h:390, minW:390, minH:280, mount:mountPlacevalue },
+ { id:"money", cat:"rekenen", icon:"🪙", w:520, h:430, minW:360, minH:300, mount:mountMoney },
+ { id:"wordtypes", cat:"taal", icon:"🎨", w:600, h:410, minW:380, minH:290, mount:mountWordtypes },
{ 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:"birthday",cat:"class", icon:"🎂", w:620, h:560, minW:400, minH:400, mount:mountBirthday },
+ { id:"schedule", cat:"class", icon:"📅", w:500, h:520, minW:340, minH:340, mount:mountSchedule },
+ { id:"mood", cat:"class", icon:"🌡️", w:530, h:360, minW:350, minH:260, mount:mountMood },
+ { id:"poll", cat:"class", icon:"📊", w:540, h:500, minW:360, minH:330, mount:mountPoll },
{ 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"]];
+const WIDGET_CATS = [["favorites","galleryFavorites"],["taal","catTaal"],["rekenen","catRekenen"],["class","catClass"],["tools","catTools"],["media","catMedia"]];
/* houdt een (opgeslagen of standaard) widgetgrootte binnen het bord - zonder
dit passen desktop-formaten (tot 760px breed) niet op een telefoon/tablet */
@@ -66,10 +81,14 @@ function makeWidget(def, saved){
win.style.zIndex = ++zTop;
widgetCount++;
if(saved && saved.bare){ win.classList.add("bare"); win.dataset.bare = "1"; }
+ if(saved && saved.locked){ win.dataset.locked="1"; }
const head = document.createElement("div");
head.className = "widget-head";
head.innerHTML = `
${def.icon}`;
+ const dupBtn = document.createElement("button"); dupBtn.className="wmin wdup"; dupBtn.textContent="⧉"; head.appendChild(dupBtn);
+ const lockBtn = document.createElement("button"); lockBtn.className="wmin wlock"; lockBtn.textContent="🔓"; head.appendChild(lockBtn);
+ const previewBtn = document.createElement("button"); previewBtn.className="wmin wpreview"; previewBtn.textContent="◉"; head.appendChild(previewBtn);
const zBtn = document.createElement("button");
zBtn.className = "wmin wz"; zBtn.textContent = "A+";
head.appendChild(zBtn);
@@ -91,6 +110,7 @@ function makeWidget(def, saved){
const rs = document.createElement("div");
rs.className = "widget-rs";
rs.addEventListener("pointerdown", e=>{
+ if(win.dataset.locked==="1") return;
e.preventDefault(); e.stopPropagation();
win.style.zIndex = ++zTop;
const zf = VZ();
@@ -107,6 +127,10 @@ function makeWidget(def, saved){
document.addEventListener("pointermove",mv); document.addEventListener("pointerup",up);
});
win.appendChild(rs);
+ const applyLock=()=>{const locked=win.dataset.locked==="1";win.classList.toggle("locked",locked);lockBtn.textContent=locked?"🔒":"🔓";lockBtn.title=T(locked?"widgetUnlock":"widgetLock");lockBtn.setAttribute("aria-label",lockBtn.title);lockBtn.setAttribute("aria-pressed",String(locked))};
+ lockBtn.addEventListener("click",()=>{win.dataset.locked=win.dataset.locked==="1"?"":"1";applyLock();markBoardChange()});
+ previewBtn.addEventListener("click",()=>{win.classList.toggle("preview");previewBtn.classList.toggle("on",win.classList.contains("preview"));previewBtn.title=T("widgetPreview");previewBtn.setAttribute("aria-pressed",String(win.classList.contains("preview"))) });
+ applyLock();
let pill = null;
function setMin(m){
@@ -182,7 +206,7 @@ function makeWidget(def, saved){
win.addEventListener("pointerdown", ()=>{ win.style.zIndex = ++zTop; });
head.addEventListener("pointerdown", e=>{
- if(e.target===close) return;
+ if(win.dataset.locked==="1" || e.target.closest("button")) return;
e.preventDefault();
const zf = VZ();
const sx = e.clientX/zf - win.offsetLeft, sy = e.clientY/zf - win.offsetTop;
@@ -199,6 +223,8 @@ function makeWidget(def, saved){
if(saved && saved.min) setMin(true);
if(saved && saved.max) setMax(true);
const inst = { def, win, api };
+ dupBtn.title=T("widgetDuplicate");dupBtn.setAttribute("aria-label",dupBtn.title);previewBtn.title=T("widgetPreview");previewBtn.setAttribute("aria-label",previewBtn.title);
+ dupBtn.addEventListener("click",()=>makeWidget(def,{x:win.offsetLeft+28,y:win.offsetTop+28,w:win.offsetWidth,h:win.offsetHeight,wz:+win.dataset.wz||1,state:api.getState?structuredClone(api.getState()):null}));
instances.add(inst);
close.addEventListener("click", ()=>{
markBoardChange();
@@ -230,6 +256,7 @@ function serializeBoard(){
min, max,
wz: +w.dataset.wz || 1,
bare: w.dataset.bare === "1",
+ locked: w.dataset.locked === "1",
state: inst.api.getState ? inst.api.getState() : null
};
}),
@@ -258,6 +285,7 @@ function restoreBoard(b){
(b.imgs||[]).forEach(im=>addBoardImage(im.src, im.x, im.y, im.w));
updateEmptyHint();
RESTORING = false;
+ if(!HISTORY_BUSY){BOARD_HISTORY=[serializeBoard()];BOARD_FUTURE=[];}
}
/* ---------- board drawing layer (pen mode) ---------- */
@@ -772,26 +800,37 @@ const galleryWrap = document.getElementById("galleryWrap");
const gallery = document.getElementById("gallery");
function renderGallery(){
gallery.innerHTML = "";
+ const query=(document.getElementById("gallerySearch").value||"").trim().toLowerCase();
+ const favOnly=document.getElementById("galleryFavOnly").classList.contains("on");
+ let favorites=[];try{favorites=JSON.parse(localStorage.getItem("teachWidgetFavorites")||"[]")}catch(e){}
WIDGET_CATS.forEach(([cat,key])=>{
- const defs = REGISTRY.filter(d=>d.cat===cat);
- if(!defs.length) return;
- const h = document.createElement("div");
- h.className = "gcat-title";
- h.textContent = T(key);
- gallery.appendChild(h);
- const grid = document.createElement("div");
- grid.className = "gallery";
- defs.forEach(def=>{
- const c = document.createElement("div");
- c.className = "gcard";
- c.innerHTML = `
${def.icon}
${T("wg_"+def.id)}
${T("wg_"+def.id+"_d")}
`;
- c.addEventListener("click", ()=>{ galleryWrap.classList.remove("open"); makeWidget(def); });
- grid.appendChild(c);
- });
- gallery.appendChild(grid);
+ if(cat==="favorites"&&!favorites.length)return;
+ if(cat!=="favorites"&&favOnly)return;
+ let defs=cat==="favorites"?REGISTRY.filter(d=>favorites.includes(d.id)):REGISTRY.filter(d=>d.cat===cat);
+ defs=defs.filter(d=>(T("wg_"+d.id)+" "+T("wg_"+d.id+"_d")).toLowerCase().includes(query));
+ if(!defs.length)return;
+ const h=document.createElement("div");h.className="gcat-title";h.textContent=T(key);gallery.appendChild(h);
+ const grid=document.createElement("div");grid.className="gallery";
+ defs.forEach(def=>{const c=document.createElement("div");c.className="gcard";c.tabIndex=0;
+ const icon=document.createElement("div");icon.className="gi";icon.textContent=def.icon;
+ const name=document.createElement("div");name.className="gn";name.textContent=T("wg_"+def.id);
+ const desc=document.createElement("div");desc.className="gd";desc.textContent=T("wg_"+def.id+"_d");
+ const star=document.createElement("button");star.className="gstar";star.type="button";star.textContent=favorites.includes(def.id)?"★":"☆";star.title=T("galleryFavorite");
+ star.onclick=e=>{e.stopPropagation();favorites=favorites.includes(def.id)?favorites.filter(x=>x!==def.id):[...favorites,def.id];localStorage.setItem("teachWidgetFavorites",JSON.stringify(favorites));renderGallery()};
+ const add=()=>{galleryWrap.classList.remove("open");makeWidget(def)};c.onclick=add;c.onkeydown=e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();add()}};c.append(star,icon,name,desc);grid.appendChild(c)});gallery.appendChild(grid);
});
+ if(!gallery.children.length){const e=document.createElement("p");e.className="gallery-empty";e.textContent=T("galleryEmpty");gallery.append(e)}
}
galleryWrap.addEventListener("click", e=>{ if(e.target===galleryWrap) galleryWrap.classList.remove("open"); });
+document.querySelector(".gallery-close").onclick=()=>galleryWrap.classList.remove("open");
+const gallerySearch=document.getElementById("gallerySearch"),favOnlyBtn=document.getElementById("galleryFavOnly");gallerySearch.addEventListener("input",renderGallery);favOnlyBtn.onclick=()=>{favOnlyBtn.classList.toggle("on");renderGallery()};
+const templates=[["templateMath","clock","numberline","fractions"],["templateLanguage","sentence","wordtypes","flits"],["templateDay","schedule","timer","mood"]];
+function renderTemplates(){const host=document.getElementById("galleryTemplates");host.innerHTML="";templates.forEach(([key,...ids])=>host.append(btn(T(key),()=>{galleryWrap.classList.remove("open");ids.forEach((id,i)=>makeWidget(REGISTRY.find(d=>d.id===id),{x:24+i*45,y:24+i*38}))})));gallerySearch.placeholder=T("gallerySearch");gallerySearch.setAttribute("aria-label",T("gallerySearch"));favOnlyBtn.title=T("galleryFavorites");favOnlyBtn.setAttribute("aria-label",favOnlyBtn.title);document.getElementById("boardUndo").title=T("undo");document.getElementById("boardRedo").title=T("redo");document.getElementById("boardExport").textContent="⇩ "+T("exportBoard");document.getElementById("boardImport").textContent="⇧ "+T("importBoard");}
+const importInput=document.createElement("input");importInput.type="file";importInput.accept="application/json";importInput.hidden=true;document.body.append(importInput);
+document.getElementById("boardExport").onclick=()=>{const blob=new Blob([JSON.stringify(serializeBoard(),null,2)],{type:"application/json"}),a=document.createElement("a");a.href=URL.createObjectURL(blob);a.download="teach-bord.json";a.click();setTimeout(()=>URL.revokeObjectURL(a.href),500)};
+document.getElementById("boardImport").onclick=()=>importInput.click();importInput.onchange=()=>{const f=importInput.files[0];if(!f)return;if(f.size>2*1024*1024){alert(T("importError"));return}const r=new FileReader();r.onload=()=>{try{const data=JSON.parse(r.result);if(!data||!Array.isArray(data.widgets)||data.widgets.length>100||(data.imgs||[]).length>50)throw Error();restoreBoard(data);markBoardChange()}catch(e){alert(T("importError"))}};r.readAsText(f);importInput.value=""};
+function historyMove(redo){clearTimeout(HISTORY_TIMER);const from=redo?BOARD_FUTURE:BOARD_HISTORY,to=redo?BOARD_HISTORY:BOARD_FUTURE;if((redo&&from.length)||(!redo&&from.length>1)){const current=from.pop();to.push(current);const target=redo?current:from.at(-1);HISTORY_BUSY=true;restoreBoard(structuredClone(target));HISTORY_BUSY=false;scheduleSave()}}
+document.getElementById("boardUndo").onclick=()=>historyMove(false);document.getElementById("boardRedo").onclick=()=>historyMove(true);document.addEventListener("keydown",e=>{if((e.ctrlKey||e.metaKey)&&e.key.toLowerCase()==="z"&&!e.target.matches("input,textarea,[contenteditable]")){e.preventDefault();historyMove(e.shiftKey)}});
/* ---------- floating add-button (FAB) ---------- */
const fab = document.getElementById("fab");
@@ -818,7 +857,7 @@ document.addEventListener("pointerdown", e=>{
});
fabMenu.querySelector('[data-f="widgets"]').addEventListener("click", ()=>{
fabMenu.classList.remove("open"); fab.classList.remove("open");
- renderGallery(); galleryWrap.classList.add("open");
+ renderTemplates(); renderGallery(); galleryWrap.classList.add("open");
});
fabMenu.querySelector('[data-f="draw"]').addEventListener("click", ()=>{
fabMenu.classList.remove("open"); fab.classList.remove("open");
diff --git a/public/js/core.js b/public/js/core.js
index 336a215..e7f111b 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.07-beta";
+const VERSION = "0.3.08-beta";
(function(){
const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION;
@@ -55,7 +55,7 @@ const I18N = {
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",
+ catTaal:"Taal", catRekenen:"Rekenen", catClass:"Feest & klas", catTools:"Hulpmiddelen", catMedia:"Media",
wg_madd:"Plussommen", wg_madd_d:"Optellen tot 10, 20 of 100.",
wg_msub:"Minsommen", wg_msub_d:"Aftrekken tot 10, 20 of 100.",
wg_mmul:"Keersommen", wg_mmul_d:"De tafels van 1 t/m 12 oefenen.",
@@ -97,6 +97,15 @@ const I18N = {
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_fractions:"Breukenmuur", wg_fractions_d:"Vergelijk breuken visueel en markeer gelijke delen.",
+ wg_clock:"Oefenklok", wg_clock_d:"Leer analoge kloktijden instellen en aflezen.",
+ wg_placevalue:"Plaatswaardetabel", wg_placevalue_d:"Ontdek de waarde van cijfers tot honderdduizend.",
+ wg_money:"Geldrekenen", wg_money_d:"Stel bedragen samen met euromunten.",
+ wg_wordtypes:"Woordsoorten", wg_wordtypes_d:"Geef woorden kleur per woordsoort.",
+ wg_schedule:"Dagplanning", wg_schedule_d:"Maak een duidelijke visuele planning voor de klas.",
+ wg_mood:"Gevoelsmeter", wg_mood_d:"Doe een snelle, anonieme check-in met de klas.",
+ wg_poll:"Klaspoll", wg_poll_d:"Stem samen op maximaal zes antwoorden.",
+ widgetSettings:"Instellingen", fractionRows:"Rijen", fractionLabels:"Breuken tonen", clockTask:"Oefenopdracht", clockSet:"Zet de klok op {time}", clockHour:"Uur", clockMinute:"Minuten", placeNumber:"Getal", moneyMake:"Maak € {amount}", wordtypeSentence:"Zin", wordtypeHint:"Tik: blauw zelfstandig naamwoord · groen werkwoord · geel bijvoeglijk naamwoord", scheduleActivity:"activiteit…", scheduleEmpty:"Voeg de eerste activiteit toe.", pollReset:"Opnieuw", pollQuestion:"Vraag", pollOptions:"Antwoorden (één per regel)", galleryFavorites:"Favorieten", galleryFavorite:"Favoriet maken", galleryEmpty:"Geen widgets gevonden.", gallerySearch:"Zoek widgets…", templateMath:"Rekenles", templateLanguage:"Taalles", templateDay:"Dagstart", exportBoard:"Exporteren", importBoard:"Importeren", importError:"Dit is geen geldig Teach-bordbestand.", widgetDuplicate:"Dupliceren", widgetLock:"Vastzetten", widgetUnlock:"Losmaken", widgetPreview:"Voorbeeld zonder bediening", undo:"Ongedaan maken", redo:"Opnieuw",
wg_sentence:"Zinsbouwer", wg_sentence_d:"Bouw zinnen met kleurgecodeerde zinsdelen en voeg eigen woorden toe.",
sentence_who:"Wie?", sentence_does:"Doet wat?", sentence_what:"Wat of waar?", sentenceHint:"Kies woorden om een zin te maken…",
sentenceAdd:"eigen woord of zinsdeel…", sentenceClear:"Wissen", sentenceSpeak:"🔊 Lees voor", sentenceRemove:"Verwijder woord",
@@ -205,7 +214,7 @@ const I18N = {
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",
+ catTaal:"Language", catRekenen:"Maths", catClass:"Celebration & class", catTools:"Tools", catMedia:"Media",
wg_madd:"Addition", wg_madd_d:"Adding up to 10, 20 or 100.",
wg_msub:"Subtraction", wg_msub_d:"Subtracting up to 10, 20 or 100.",
wg_mmul:"Multiplication", wg_mmul_d:"Practise the times tables 1–12.",
@@ -247,6 +256,15 @@ const I18N = {
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_fractions:"Fraction wall", wg_fractions_d:"Compare fractions visually and mark equal parts.",
+ wg_clock:"Practice clock", wg_clock_d:"Learn to set and read analogue times.",
+ wg_placevalue:"Place value", wg_placevalue_d:"Explore digit values up to hundred thousands.",
+ wg_money:"Money", wg_money_d:"Build amounts using euro coins.",
+ wg_wordtypes:"Word classes", wg_wordtypes_d:"Colour words by their word class.",
+ wg_schedule:"Day schedule", wg_schedule_d:"Create a clear visual class schedule.",
+ wg_mood:"Mood check-in", wg_mood_d:"Run a quick anonymous class check-in.",
+ wg_poll:"Class poll", wg_poll_d:"Vote together on up to six answers.",
+ widgetSettings:"Settings", fractionRows:"Rows", fractionLabels:"Show fractions", clockTask:"Practice task", clockSet:"Set the clock to {time}", clockHour:"Hour", clockMinute:"Minutes", placeNumber:"Number", moneyMake:"Make € {amount}", wordtypeSentence:"Sentence", wordtypeHint:"Tap: blue noun · green verb · yellow adjective", scheduleActivity:"activity…", scheduleEmpty:"Add the first activity.", pollReset:"Reset", pollQuestion:"Question", pollOptions:"Answers (one per line)", galleryFavorites:"Favourites", galleryFavorite:"Add favourite", galleryEmpty:"No widgets found.", gallerySearch:"Search widgets…", templateMath:"Maths lesson", templateLanguage:"Language lesson", templateDay:"Day start", exportBoard:"Export", importBoard:"Import", importError:"This is not a valid Teach board file.", widgetDuplicate:"Duplicate", widgetLock:"Lock", widgetUnlock:"Unlock", widgetPreview:"Preview without controls", undo:"Undo", redo:"Redo",
wg_sentence:"Sentence builder", wg_sentence_d:"Build sentences with colour-coded parts and add your own words.",
sentence_who:"Who?", sentence_does:"Does what?", sentence_what:"What or where?", sentenceHint:"Choose words to build a sentence…",
sentenceAdd:"your own word or phrase…", sentenceClear:"Clear", sentenceSpeak:"🔊 Read aloud", sentenceRemove:"Remove word",
diff --git a/public/js/widgets/learning.js b/public/js/widgets/learning.js
new file mode 100644
index 0000000..84bbb56
--- /dev/null
+++ b/public/js/widgets/learning.js
@@ -0,0 +1,47 @@
+/* Acht compacte educatieve en klaswidgets. Alle invoer blijft tekstContent-safe. */
+function learningShell(root, settings, stage){
+ root.innerHTML='
';
+ root.querySelector('summary span').textContent=T('widgetSettings');
+ root.querySelector('.edu-controls').append(...settings);
+ root.querySelector('.edu-stage').append(stage);
+}
+function field(label, el){const w=document.createElement('label');const s=document.createElement('span');s.textContent=label;w.append(s,el);return w}
+function btn(text,fn,kind='ghost'){const b=document.createElement('button');b.type='button';b.className='tbtn '+kind;b.textContent=text;b.onclick=fn;return b}
+function num(value,min,max){const i=document.createElement('input');i.type='number';i.value=value;i.min=min;i.max=max;return i}
+
+function mountFractions(root,init){
+ const S=Object.assign({den:4,show:true},init||{}), den=num(S.den,2,12), show=document.createElement('input'), stage=document.createElement('div');show.type='checkbox';show.checked=S.show;
+ const draw=()=>{S.den=Math.max(2,Math.min(12,+den.value||4));S.show=show.checked;stage.className='fraction-wall';stage.innerHTML='';for(let d=1;d<=S.den;d++){const row=document.createElement('div');for(let n=0;n
c.classList.toggle('selected');row.append(c)}stage.append(row)}};
+ den.onchange=show.onchange=draw;learningShell(root,[field(T('fractionRows'),den),field(T('fractionLabels'),show)],stage);draw();return{getState:()=>({...S})};
+}
+function mountClock(root,init){
+ const S=Object.assign({h:10,m:10,task:false,targetH:3,targetM:30},init||{}), h=num(S.h,1,12),m=document.createElement('select'),stage=document.createElement('div');[0,5,10,15,20,25,30,35,40,45,50,55].forEach(v=>m.add(new Option(String(v).padStart(2,'0'),v)));m.value=S.m;
+ const draw=()=>{S.h=+h.value;S.m=+m.value;stage.className='clock-wrap';stage.innerHTML=`12369
`;stage.querySelector('.clock-hour').style.transform=`rotate(${S.h*30+S.m/2}deg)`;stage.querySelector('.clock-min').style.transform=`rotate(${S.m*6}deg)`};
+ const task=btn(T('clockTask'),()=>{S.targetH=1+Math.floor(Math.random()*12);S.targetM=[0,15,30,45][Math.floor(Math.random()*4)];S.task=true;alert(T('clockSet').replace('{time}',`${S.targetH}:${String(S.targetM).padStart(2,'0')}`))});
+ const check=btn(T('numberlineCheck'),()=>{if(!S.task)return;const ok=S.h===S.targetH&&S.m===S.targetM;ok?sndGood():sndBad();stage.querySelector('output').textContent=T(ok?'numberlineGood':'numberlineTry')},'');h.oninput=m.onchange=draw;learningShell(root,[field(T('clockHour'),h),field(T('clockMinute'),m),task,check],stage);draw();return{getState:()=>({...S})};
+}
+function mountPlacevalue(root,init){
+ const S=Object.assign({number:1342},init||{}),input=num(S.number,0,999999),stage=document.createElement('div');
+ const draw=()=>{S.number=Math.max(0,Math.min(999999,+input.value||0));const digits=String(S.number).padStart(6,'0').split('');stage.className='place-grid';stage.innerHTML='';['HD','TD','D','H','T','E'].forEach((x,i)=>{const d=document.createElement('div');const b=document.createElement('b');b.textContent=x;const o=document.createElement('output');o.textContent=digits[i];d.append(b,o);stage.append(d)})};input.oninput=draw;learningShell(root,[field(T('placeNumber'),input),btn('🎲',()=>{input.value=Math.floor(Math.random()*999999);draw()})],stage);draw();return{getState:()=>({...S})};
+}
+function mountMoney(root,init){
+ const S=Object.assign({target:3.75,total:0},init||{}),stage=document.createElement('div'),out=document.createElement('output');
+ const draw=()=>{stage.className='money';stage.innerHTML='';const task=document.createElement('h3');task.textContent=T('moneyMake').replace('{amount}',S.target.toFixed(2));stage.append(task);[2,1,.5,.2,.1,.05].forEach(v=>stage.append(btn('€ '+v.toFixed(2),()=>{S.total=Math.round((S.total+v)*100)/100;renderTotal()})));stage.append(out,btn(T('sentenceClear'),()=>{S.total=0;renderTotal()}),btn(T('numberlineCheck'),()=>{S.total===S.target?sndGood():sndBad()},''));renderTotal()};const renderTotal=()=>out.textContent='€ '+S.total.toFixed(2);learningShell(root,[btn(T('numberlineNew'),()=>{S.target=(50+Math.floor(Math.random()*950))/100;S.total=0;draw()})],stage);draw();return{getState:()=>({...S})};
+}
+function mountWordtypes(root,init){
+ const S=Object.assign({sentence:'De vrolijke hond rent snel',answers:{}},init||{}),input=document.createElement('input'),stage=document.createElement('div');input.value=S.sentence;
+ const types=['noun','verb','adjective','other'],draw=()=>{S.sentence=input.value.slice(0,120);stage.className='wordtypes';stage.innerHTML='';S.sentence.split(/\s+/).filter(Boolean).forEach((word,i)=>{const w=document.createElement('button');w.type='button';w.textContent=word;const type=S.answers[i]||'other';w.dataset.type=type;w.onclick=()=>{S.answers[i]=types[(types.indexOf(w.dataset.type)+1)%types.length];draw()};stage.append(w)});const legend=document.createElement('p');legend.textContent=T('wordtypeHint');stage.append(legend)};input.oninput=draw;learningShell(root,[field(T('wordtypeSentence'),input)],stage);draw();return{getState:()=>({...S})};
+}
+function mountSchedule(root,init){
+ const S=Object.assign({items:[{time:'08:30',text:'Start'},{time:'10:15',text:'Pauze'}]},init||{}),time=document.createElement('input'),text=document.createElement('input'),stage=document.createElement('div');time.type='time';time.value='09:00';text.placeholder=T('scheduleActivity');
+ const draw=()=>{stage.className='schedule';stage.innerHTML='';S.items.sort((a,b)=>a.time.localeCompare(b.time)).forEach((it,i)=>{const row=document.createElement('div');const t=document.createElement('time');t.textContent=it.time;const s=document.createElement('span');s.textContent=it.text;row.append(t,s,btn('×',()=>{S.items.splice(i,1);draw()}));stage.append(row)});if(!S.items.length){const e=document.createElement('p');e.className='edu-empty';e.textContent=T('scheduleEmpty');stage.append(e)}};
+ learningShell(root,[time,text,btn(T('addWord'),()=>{if(text.value.trim()){S.items.push({time:time.value,text:text.value.trim().slice(0,50)});text.value='';draw()}})],stage);draw();return{getState:()=>({...S,items:S.items.map(x=>({...x}))})};
+}
+function mountMood(root,init){
+ const S=Object.assign({votes:[0,0,0,0,0]},init||{}),stage=document.createElement('div'),faces=['😢','😕','😐','🙂','🤩'];
+ const draw=()=>{stage.className='mood';stage.innerHTML='';faces.forEach((f,i)=>{const b=btn(f,()=>{S.votes[i]++;draw()});const n=document.createElement('span');n.textContent=S.votes[i];b.append(n);stage.append(b)})};learningShell(root,[btn(T('pollReset'),()=>{S.votes=[0,0,0,0,0];draw()})],stage);draw();return{getState:()=>({...S,votes:[...S.votes]})};
+}
+function mountPoll(root,init){
+ const S=Object.assign({question:'Wat kies jij?',options:[{text:'A',votes:0},{text:'B',votes:0}]},init||{}),question=document.createElement('input'),options=document.createElement('textarea'),stage=document.createElement('div');question.value=S.question;options.value=S.options.map(x=>x.text).join('\n');
+ const sync=()=>{S.question=question.value.slice(0,100);const old=new Map(S.options.map(x=>[x.text,x.votes]));S.options=options.value.split('\n').map(x=>x.trim()).filter(Boolean).slice(0,6).map(text=>({text,votes:old.get(text)||0}));draw()},draw=()=>{stage.className='poll';stage.innerHTML='';const h=document.createElement('h3');h.textContent=S.question;stage.append(h);const max=Math.max(1,...S.options.map(x=>x.votes));S.options.forEach(o=>{const b=btn(o.text,()=>{o.votes++;draw()});const bar=document.createElement('i');bar.style.width=(o.votes/max*100)+'%';const n=document.createElement('span');n.textContent=o.votes;b.append(bar,n);stage.append(b)})};question.oninput=options.oninput=sync;learningShell(root,[field(T('pollQuestion'),question),field(T('pollOptions'),options),btn(T('pollReset'),()=>{S.options.forEach(x=>x.votes=0);draw()})],stage);draw();return{getState:()=>({...S,options:S.options.map(x=>({...x}))})};
+}
diff --git a/test/widgets.test.js b/test/widgets.test.js
index c04937e..f95c7f7 100644
--- a/test/widgets.test.js
+++ b/test/widgets.test.js
@@ -3,6 +3,7 @@ import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
const ids=['birthday','numberline','sentence'];
+const learningIds=['fractions','clock','placevalue','money','wordtypes','schedule','mood','poll'];
test('nieuwe widgets zijn geladen vóór het widgetregister', async()=>{
const html=await readFile('public/index.html','utf8');
@@ -30,3 +31,30 @@ test('nieuwe widgetnamen en beschrijvingen zijn tweetalig', async()=>{
assert.equal((core.match(new RegExp(`wg_${id}_d:`,'g'))||[]).length,2);
}
});
+
+
+test('educatieve widgetmodule laadt en registreert alle acht widgets', async()=>{
+ const html=await readFile('public/index.html','utf8');
+ assert.ok(html.indexOf('js/widgets/learning.js'){
+ const core=await readFile('public/js/core.js','utf8');
+ for(const id of learningIds){
+ assert.equal((core.match(new RegExp('wg_'+id+':','g'))||[]).length,2,id);
+ assert.equal((core.match(new RegExp('wg_'+id+'_d:','g'))||[]).length,2,id);
+ }
+});
+
+test('widgetbibliotheek bevat zoeken favorieten sjablonen en borddata-acties', async()=>{
+ const [html,board]=await Promise.all([readFile('public/index.html','utf8'),readFile('public/js/board.js','utf8')]);
+ for(const id of ['gallerySearch','galleryFavOnly','galleryTemplates','boardUndo','boardRedo','boardExport','boardImport'])assert.match(html,new RegExp('id="'+id+'"'));
+ for(const feature of ['teachWidgetFavorites','structuredClone','application/json','widgetDuplicate','widgetLock','widgetPreview'])assert.ok(board.includes(feature),feature);
+});