feat: make assignments independent from boards
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m28s

This commit is contained in:
Ramon 2026-07-25 22:15:52 +02:00
parent 05f3dbe56c
commit 043831e1dd
16 changed files with 472 additions and 96 deletions

View file

@ -1 +1 @@
0.4.61-beta
0.4.62-beta

8
package-lock.json generated
View file

@ -436,15 +436,15 @@
"license": "MIT"
},
"node_modules/brace-expansion": {
"version": "5.0.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==",
"version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/buffer": {

View file

@ -171,6 +171,7 @@
.pupil-path-dots span{width:28px;height:28px;flex:none;display:grid;place-items:center;border-radius:50%;background:var(--surface-2);border:2px solid var(--line);color:var(--muted);font-weight:900;font-size:12px}
.pupil-path-dots span.complete{background:var(--green);border-color:var(--green);color:#fff}
.pupil-path-dots span.current{border-color:var(--accent);color:var(--accent-ink);background:var(--accent-soft)}
.pupil-path-current{grid-column:1/-1;padding:7px 10px;border-radius:10px;background:var(--accent-soft);color:var(--accent-ink);font-size:12px;font-weight:850}
.pupil-path-actions{flex:1 0 100%;display:flex;justify-content:center;padding:4px 0 24px}
.pupil-path-actions button{min-height:44px;padding:0 22px}
.pupil-path-finish{position:relative;overflow:hidden;min-height:280px;flex:1 0 100%;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;text-align:center;border-radius:20px;background:linear-gradient(145deg,var(--accent-soft),var(--surface))}
@ -2435,7 +2436,7 @@ body.hc #settingsModal .am-inp, body.hc #settingsModal .am-sel{border-color:#000
.cast-guide li span{width:24px;height:24px;display:grid;place-items:center;flex:none;border-radius:50%;background:var(--accent);color:#fff;font-weight:950}.cast-guide li strong{color:var(--ink)}
.cast-composer-card{display:flex;flex-direction:column;gap:10px;margin-bottom:14px;padding:15px;border:1px solid var(--line);border-radius:18px;background:var(--surface);box-shadow:0 3px 14px rgba(20,35,60,.05)}
.cast-composer-card>h3,.cast-section-head h3{margin:0;font-size:16px}.cast-composer-card>.am-group,.cast-pupil-head>.am-group{margin:4px 0 0}
.cast-kind-tabs{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px}
.cast-kind-tabs{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}
.cast-kind-button{display:flex;min-width:0;min-height:66px;flex-direction:column;align-items:flex-start;justify-content:center;gap:3px;padding:10px 12px;border:1.5px solid var(--line);border-radius:13px;background:var(--surface-2);color:var(--ink);font:inherit;text-align:left;cursor:pointer}
.cast-kind-button:hover{border-color:var(--accent)}.cast-kind-button.active{border-color:var(--accent);background:var(--accent-soft);box-shadow:inset 0 0 0 1px var(--accent)}.cast-kind-button span{color:var(--muted);font-size:11px;line-height:1.25}.cast-kind-button:disabled{opacity:.55;cursor:default}
.cast-live-hint{margin:0;padding:8px 10px;border-left:3px solid var(--blue);border-radius:0 9px 9px 0;background:color-mix(in srgb,var(--blue) 7%,var(--surface));color:var(--muted);font-size:11px;line-height:1.4}
@ -2445,9 +2446,10 @@ body.hc #settingsModal .am-inp, body.hc #settingsModal .am-sel{border-color:#000
.cast-widget-card{display:grid;grid-template-columns:38px minmax(0,1fr) 28px;align-items:center;gap:8px;min-height:62px;padding:9px 10px;border:1.5px solid var(--line);border-radius:13px;background:var(--surface-2);color:var(--ink);font:inherit;text-align:left;cursor:pointer}
.cast-widget-card:hover{border-color:var(--accent);transform:translateY(-1px)}.cast-widget-card.selected{border-color:var(--accent);background:var(--accent-soft);box-shadow:inset 0 0 0 1px var(--accent)}.cast-widget-card:disabled{opacity:.5;cursor:default;transform:none}
.cast-widget-icon{width:38px;height:38px;display:grid;place-items:center;border-radius:11px;background:var(--surface);font-size:22px}.cast-widget-copy{display:flex;min-width:0;flex-direction:column;gap:2px}.cast-widget-copy strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px}.cast-widget-copy small{color:var(--muted);font-size:10.5px;font-weight:750}.cast-widget-plus,.cast-widget-count{width:25px;height:25px;display:grid;place-items:center;border-radius:50%;background:var(--accent);color:#fff;font-size:15px;font-weight:950}.cast-widget-count{background:var(--green)}
.cast-path-list{display:flex;flex-direction:column;gap:7px}.cast-path-step{display:grid;grid-template-columns:28px 34px minmax(120px,1fr) minmax(135px,180px) auto;align-items:center;gap:8px;padding:8px;border:1px solid var(--line);border-radius:12px;background:var(--surface-2)}
.cast-path-list{display:flex;flex-direction:column;gap:7px}.cast-path-step{display:grid;grid-template-columns:28px 34px minmax(105px,1fr) minmax(155px,1fr) minmax(130px,165px) auto;align-items:center;gap:8px;padding:8px;border:1px solid var(--line);border-radius:12px;background:var(--surface-2)}
.cast-path-number{width:26px;height:26px;display:grid;place-items:center;border-radius:50%;background:var(--accent);color:#fff;font-size:12px;font-weight:900}.cast-step-icon{font-size:22px;text-align:center}.cast-step-copy{display:flex;min-width:0;flex-direction:column;gap:2px}.cast-step-copy strong{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px}.cast-step-copy small{color:var(--muted);font-size:10px}.cast-step-mode{min-width:0;width:100%}.cast-step-controls{display:flex;gap:4px}.cast-path-step .am-btn{min-width:32px;min-height:34px;padding:4px 7px}.cast-step-remove{color:var(--red)!important}.cast-sequence-count{flex:none;padding:4px 8px;border-radius:999px;background:var(--surface-2);color:var(--muted);font-size:10.5px;font-weight:850}.cast-path-progress{width:100%;height:7px;accent-color:var(--green)}
.cast-path-empty,.cast-empty,.cast-board-note,.cast-kind-note{margin:0;padding:12px;border:1px dashed var(--line);border-radius:12px;background:var(--surface-2);color:var(--muted);font-size:12px;line-height:1.45}.cast-board-note{border-color:color-mix(in srgb,var(--accent) 35%,var(--line));background:var(--accent-soft);color:var(--accent-ink)}
.cast-step-option{display:flex;min-width:0;flex-direction:column;gap:3px}.cast-step-option>span:first-child{overflow:hidden;color:var(--muted);font-size:9.5px;font-weight:800;text-overflow:ellipsis;white-space:nowrap}.cast-step-theme{min-width:0;width:100%}.cast-theme-field{max-width:420px}.cast-theme-na{display:flex;min-height:35px;align-items:center;padding:0 9px;border:1px dashed var(--line);border-radius:9px;color:var(--muted);font-size:11px;font-weight:700}
.cast-path-empty,.cast-empty,.cast-kind-note{margin:0;padding:12px;border:1px dashed var(--line);border-radius:12px;background:var(--surface-2);color:var(--muted);font-size:12px;line-height:1.45}
.cast-audience{gap:8px}.cast-target-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(205px,1fr));gap:7px}.cast-target-card{display:flex;min-width:0;align-items:center;gap:9px;padding:9px 10px;border:1px solid var(--line);border-radius:12px;background:var(--surface-2);cursor:pointer}.cast-target-card:hover{border-color:var(--accent)}.cast-target-card:has(.cast-target-check:checked){border-color:var(--accent);background:var(--accent-soft)}.cast-target-check{width:19px;height:19px;flex:none;accent-color:var(--accent)}.cast-target-via{flex:none;padding:3px 7px;border-radius:999px;background:var(--accent);color:#fff;font-size:9.5px;font-weight:900}.cast-pupil-search{width:100%}.cast-pupil-grid{max-height:250px;overflow-y:auto;padding:2px}.cast-target-card[hidden]{display:none}
.cast-sendbar{position:sticky;z-index:2;bottom:0;display:flex;align-items:center;justify-content:space-between;gap:12px;margin:8px -20px 0;padding:13px 20px calc(13px + env(safe-area-inset-bottom));border-top:1px solid var(--line);background:color-mix(in srgb,var(--surface) 94%,transparent);backdrop-filter:blur(14px)}
.cast-send-summary{display:flex;min-width:0;flex-direction:column;gap:2px}.cast-send-summary strong{font-size:13px}.cast-send-summary span{color:var(--muted);font-size:11px}.cast-validation{color:var(--orange);font-size:10.5px;font-weight:800}.cast-send-button{min-height:44px;flex:none;padding:0 18px;border:0;border-radius:12px;background:var(--accent);color:#fff;font:inherit;font-weight:900;cursor:pointer;box-shadow:0 4px 12px color-mix(in srgb,var(--accent) 30%,transparent)}.cast-send-button:hover{filter:brightness(.94)}.cast-send-button:disabled{opacity:.5;cursor:default;box-shadow:none}
@ -2479,7 +2481,7 @@ body.hc .cast-kind-button,body.hc .cast-widget-card,body.hc .cast-target-card,bo
.cast-kind-tabs{grid-template-columns:1fr}.cast-kind-button{min-height:54px}.cast-kind-button span{display:none}
.cast-section-head{align-items:flex-start;flex-direction:column}.cast-section-head .cast-mini-actions{align-self:stretch}.cast-mini-actions .am-btn{flex:1}
.cast-widget-grid{grid-template-columns:1fr 1fr}.cast-widget-card{grid-template-columns:34px minmax(0,1fr) 24px;min-height:58px;padding:7px}.cast-widget-icon{width:34px;height:34px;font-size:19px}
.cast-path-step{grid-template-columns:26px 30px minmax(0,1fr) auto}.cast-step-mode{grid-column:2 / -1;grid-row:2}.cast-step-controls{grid-column:4;grid-row:1}.cast-step-copy small{display:none}
.cast-path-step{grid-template-columns:26px 30px minmax(0,1fr) auto}.cast-step-theme-option{grid-column:2 / -1;grid-row:2}.cast-step-option:not(.cast-step-theme-option){grid-column:2 / -1;grid-row:3}.cast-step-controls{grid-column:4;grid-row:1}.cast-step-copy small{display:none}
.cast-target-grid{grid-template-columns:1fr}.cast-pupil-head{align-items:flex-end}.cast-pupil-head .am-btn{font-size:11px}
.cast-sendbar{margin-left:-14px;margin-right:-14px;padding-left:14px;padding-right:14px}.cast-send-summary span{display:none}.cast-send-button{padding:0 13px}
}

View file

@ -662,6 +662,9 @@
gebeurt niet meer hier - zie cast.js. */
function describeCurrent(current){
if(!current) return "";
if(Array.isArray(current.sequence)&&current.sequence.length){
return `${current.title||T("castPathDefault")} · ${T("castAssignmentsCount").replace("{count}",current.sequence.length)}`;
}
let s = `${T("amAssignedAs")}${current.boardName || current.boardId}`;
if(current.widgetId){
const wName = current.widgetType ? T("wg_"+current.widgetType) : current.widgetId;

View file

@ -1,7 +1,6 @@
/* teach - cast (📡): het geopende bord (of één widget erop) rechtstreeks
vanaf de toolbar uitzenden naar een klas of leerling, i.p.v. de omweg via
Instellingen > Toewijzingen. Hergebruikt dezelfde PUT/DELETE-routes als
admin.js's assignmentRow - geen serverwijziging, alleen een korter pad. */
/* teach - opdrachten samenstellen en delen. Nieuwe opdrachten komen uit de
volledige leerlingwidgetbibliotheek en staan los van het geopende bord.
Oude bordtoewijzingen blijven via dezelfde API achterwaarts compatibel. */
"use strict";
const btnCast = document.getElementById("btnCast");
btnCast.title = T("cast");
@ -25,33 +24,60 @@ castBackdrop.addEventListener("click", e=>{ if(e.target===castBackdrop&&!castSav
let castOpen = false;
let castLoading = false;
let castClasses = [], castPupils = [];
let castSelWidget = "", castSelMode = "werken";
let castSelWidget = "", castSelMode = "werken", castSingleTheme = null;
let castPathTitle = "", castPathSteps = [];
let castShareKind = "path";
let castSelectedTargets = new Set();
let castPupilQuery = "";
let castSaving = false;
let castNotice = "";
/* toewijzingen die deze sessie al gelukt zijn ("type:id"), zodat je hetzelfde
bord naar meerdere klassen/leerlingen achter elkaar kunt casten zonder dat
/* toewijzingen die deze sessie al gelukt zijn ("type:id"), zodat je dezelfde
opdracht naar meerdere klassen/leerlingen achter elkaar kunt sturen zonder dat
de sheet na elke keer sluit */
let castActiveKeys = new Set();
let castError = "";
/* widgets van het live-open bord, zelfde bron als serializeBoard() (board.js) */
function currentBoardWidgets(){
return [...instances].map(inst=>({
wid: inst.wid, id: inst.def.id, cat: inst.def.cat, icon: inst.def.icon || "🧩",
})).filter(w=>w.wid);
const CAST_WIDGET_CATEGORIES=new Set(["taal","rekenen"]);
const CAST_THEME_WIDGETS=new Set(["letters","flits","hak","ball"]);
function assignmentLibraryWidgets(){
return REGISTRY.filter(widget=>CAST_WIDGET_CATEGORIES.has(widget.cat));
}
function castPathWidgetAllowed(widgetId){
const widget = currentBoardWidgets().find(item=>item.wid===widgetId);
return !!widget && ["taal","rekenen"].includes(widget.cat);
function castWidgetLabel(widget){ return T("wg_"+widget.id); }
function castThemeCatalog(){
const builtIn=typeof languageThemeList==="function"?languageThemeList(LANG).map(theme=>({
id:"builtin:"+theme.id,name:theme.name,icon:theme.icon||"📚",words:theme.words||[],source:"builtin",
})):[];
const personal=(THEMES[LANG]||[]).filter(theme=>Array.isArray(theme.words)&&theme.words.length).map((theme,index)=>({
id:"personal:"+index,name:theme.name,icon:"★",words:theme.words,source:"personal",
}));
return builtIn.concat(personal);
}
function castWidgetLabel(widget){
const widgets = currentBoardWidgets().filter(item=>item.id===widget.id);
const index = widgets.findIndex(item=>item.wid===widget.wid);
return T("wg_"+widget.id) + (widgets.length>1 ? ` ${index+1}` : "");
function castThemePayload(theme){
if(!theme)return null;
return {
id:theme.id,name:theme.name,
words:(theme.words||[]).slice(0,120).map(word=>Array.isArray(word)?word.slice(0,4):word),
};
}
function castThemeSelect(theme,onChange){
const select=h("select","am-sel cast-step-theme");
select.setAttribute("aria-label",T("castTheme"));
select.appendChild(new Option(T("castThemeNone"),""));
const catalog=castThemeCatalog();
[["builtin","castBuiltInThemes"],["personal","castOwnThemes"]].forEach(([source,labelKey])=>{
const items=catalog.filter(item=>item.source===source);
if(!items.length)return;
const group=document.createElement("optgroup");group.label=T(labelKey);
items.forEach(item=>group.appendChild(new Option(`${item.icon} ${item.name} (${item.words.length})`,item.id)));
select.appendChild(group);
});
select.value=theme?.id||"";
select.disabled=castSaving;
select.addEventListener("change",()=>{
const selected=catalog.find(item=>item.id===select.value)||null;
onChange(selected);castActiveKeys.clear();castNotice="";
});
return select;
}
function castTargetKey(target){ return target.type+":"+target.id; }
function castClassSelected(classId){ return castSelectedTargets.has("class:"+classId); }
@ -90,13 +116,16 @@ function castAffectedPupilCount(){
return pupilIds.size;
}
function castPayload(){
const body={boardId:curSlot().id};
const body={};
if(castShareKind==="path"){
body.title=castPathTitle.trim();
body.sequence=castPathSteps.map(step=>({boardId:step.boardId,widgetId:step.widgetId,mode:step.mode}));
body.sequence=castPathSteps.map(step=>({
widgetType:step.widgetType,mode:step.mode,theme:castThemePayload(step.theme),
}));
}else if(castShareKind==="widget"){
body.widgetId=castSelWidget;
body.mode=castSelMode;
const widget=assignmentLibraryWidgets().find(item=>item.id===castSelWidget);
body.title=widget?castWidgetLabel(widget):T("castSingleAssignment");
body.sequence=[{widgetType:castSelWidget,mode:castSelMode,theme:castThemePayload(castSingleTheme)}];
}
return body;
}
@ -137,11 +166,10 @@ async function castSendSelected(){
}
btnCast.addEventListener("click", async ()=>{
stashCurrent(); /* verse widget-state, zelfde als btnFolders doet */
castOpen = true;
castSelWidget = ""; castSelMode = "werken";
castSelWidget = ""; castSelMode = "werken"; castSingleTheme = null;
castPathTitle = ""; castPathSteps = [];
castShareKind = currentBoardWidgets().some(w=>castPathWidgetAllowed(w.wid)) ? "path" : "board";
castShareKind = "path";
castSelectedTargets = new Set(); castPupilQuery = ""; castSaving = false; castNotice = "";
castActiveKeys = new Set(); castError = "";
castLoading = true;
@ -193,7 +221,7 @@ function renderCast(preserveScroll=false){
const head = h("div","am-kebab-row");
const titleWrap = h("div");
titleWrap.appendChild(h("h2","am-nav-title", T("castTitle")));
titleWrap.appendChild(h("div","am-role", `${BS.folders[BS.f].name} / ${curSlot().name}`));
titleWrap.appendChild(h("div","am-role",T("castLibrarySubtitle")));
head.appendChild(titleWrap);
const closeBtn = h("button","am-btn", T("close"));
closeBtn.type = "button";
@ -218,7 +246,7 @@ function renderCast(preserveScroll=false){
const composer=h("section","cast-composer-card");
composer.appendChild(h("h3",null,T("castWhat")));
const kindTabs=h("div","cast-kind-tabs");
[["path","castKindPath","castKindPathHelp"],["widget","castKindWidget","castKindWidgetHelp"],["board","castKindBoard","castKindBoardHelp"]]
[["path","castKindPath","castKindPathHelp"],["widget","castKindWidget","castKindWidgetHelp"]]
.forEach(([kind,labelKey,helpKey])=>{
const button=h("button","cast-kind-button"+(castShareKind===kind?" active":""));
button.type="button";button.disabled=castSaving;
@ -231,12 +259,12 @@ function renderCast(preserveScroll=false){
composer.appendChild(kindTabs);
composer.appendChild(h("p","cast-live-hint",T("castLiveStateHint")));
const learningWidgets=currentBoardWidgets().filter(widget=>castPathWidgetAllowed(widget.wid));
const learningWidgets=assignmentLibraryWidgets();
const widgetGrid=(selectOnly=false)=>{
const grid=h("div","cast-widget-grid");
learningWidgets.forEach(widget=>{
const selected=selectOnly&&castSelWidget===widget.wid;
const added=castPathSteps.filter(step=>step.widgetId===widget.wid).length;
const selected=selectOnly&&castSelWidget===widget.id;
const added=castPathSteps.filter(step=>step.widgetType===widget.id).length;
const card=h("button","cast-widget-card"+(selected?" selected":""));
card.type="button";card.disabled=castSaving||(!selectOnly&&castPathSteps.length>=12);
card.setAttribute("aria-pressed",String(selected));
@ -247,9 +275,12 @@ function renderCast(preserveScroll=false){
if(added&&!selectOnly)card.appendChild(h("span","cast-widget-count",String(added)));
else card.appendChild(h("span","cast-widget-plus",selected?"✓":""));
card.addEventListener("click",()=>{
if(selectOnly)castSelWidget=widget.wid;
if(selectOnly){
if(castSelWidget!==widget.id)castSingleTheme=null;
castSelWidget=widget.id;
}
else{
castPathSteps.push({boardId:curSlot().id,widgetId:widget.wid,widgetType:widget.id,widgetLabel:castWidgetLabel(widget),icon:widget.icon,mode:"werken"});
castPathSteps.push({widgetType:widget.id,widgetLabel:castWidgetLabel(widget),icon:widget.icon,mode:"werken",theme:null});
if(!castPathTitle)castPathTitle=T("castPathDefault");
}
castActiveKeys.clear();castError="";castNotice="";renderCast(true);
@ -274,12 +305,11 @@ function renderCast(preserveScroll=false){
libraryHead.appendChild(libraryCopy);
const libraryActions=h("div","cast-mini-actions");
const addAll=h("button","am-btn",T("castAddAll"));addAll.type="button";
addAll.disabled=castSaving||!learningWidgets.some(widget=>!castPathSteps.some(step=>step.widgetId===widget.wid))||castPathSteps.length>=12;
addAll.disabled=castSaving||!learningWidgets.some(widget=>!castPathSteps.some(step=>step.widgetType===widget.id))||castPathSteps.length>=12;
addAll.addEventListener("click",()=>{
learningWidgets.filter(widget=>!castPathSteps.some(step=>step.widgetId===widget.wid))
learningWidgets.filter(widget=>!castPathSteps.some(step=>step.widgetType===widget.id))
.slice(0,12-castPathSteps.length).forEach(widget=>castPathSteps.push({
boardId:curSlot().id,widgetId:widget.wid,widgetType:widget.id,
widgetLabel:castWidgetLabel(widget),icon:widget.icon,mode:"werken",
widgetType:widget.id,widgetLabel:castWidgetLabel(widget),icon:widget.icon,mode:"werken",theme:null,
}));
if(castPathSteps.length&&!castPathTitle)castPathTitle=T("castPathDefault");
castActiveKeys.clear();castError="";castNotice="";renderCast(true);
@ -300,12 +330,21 @@ function renderCast(preserveScroll=false){
castPathSteps.forEach((step,index)=>{
const row=h("div","cast-path-step");
row.append(h("span","cast-path-number",String(index+1)),h("span","cast-step-icon",step.icon||"🧩"));
const label=h("span","cast-step-copy");label.append(h("strong",null,step.widgetLabel||T("wg_"+step.widgetType)),h("small",null,T("castStepMode")));
const label=h("span","cast-step-copy");label.append(h("strong",null,step.widgetLabel||T("wg_"+step.widgetType)),h("small",null,T("castIndependentStep")));
row.appendChild(label);
const themeWrap=h("label","cast-step-option cast-step-theme-option");
themeWrap.appendChild(h("span",null,T("castTheme")));
if(CAST_THEME_WIDGETS.has(step.widgetType))themeWrap.appendChild(castThemeSelect(step.theme,value=>{step.theme=value;}));
else{
const unavailable=h("span","cast-theme-na",T("castThemeNotApplicable"));themeWrap.appendChild(unavailable);
}
const modeWrap=h("label","cast-step-option");
modeWrap.appendChild(h("span",null,T("castStepMode")));
const mode=h("select","am-sel cast-step-mode");
mode.append(new Option(T("amModeWork"),"werken"),new Option(T("amModeWatch"),"kijken"));mode.value=step.mode;
mode.setAttribute("aria-label",T("castStepMode"));mode.disabled=castSaving;
mode.addEventListener("change",()=>{step.mode=mode.value;castActiveKeys.clear();castNotice="";});
modeWrap.appendChild(mode);
const controls=h("div","cast-step-controls");
const up=h("button","am-btn","↑");up.type="button";up.disabled=castSaving||index===0;up.title=T("castMoveUp");
up.addEventListener("click",()=>{[castPathSteps[index-1],castPathSteps[index]]=[castPathSteps[index],castPathSteps[index-1]];castActiveKeys.clear();renderCast(true);});
@ -314,20 +353,26 @@ function renderCast(preserveScroll=false){
const remove=h("button","am-btn cast-step-remove","×");remove.type="button";remove.disabled=castSaving;remove.title=T("castRemoveStep");
remove.setAttribute("aria-label",T("castRemoveStep"));
remove.addEventListener("click",()=>{castPathSteps.splice(index,1);castActiveKeys.clear();castNotice="";renderCast(true);});
controls.append(up,down,remove);row.append(mode,controls);pathList.appendChild(row);
controls.append(up,down,remove);row.append(themeWrap,modeWrap,controls);pathList.appendChild(row);
});
composer.appendChild(pathList);
}
const progress=h("progress","cast-path-progress");progress.max=12;progress.value=castPathSteps.length;composer.appendChild(progress);
}else if(castShareKind==="widget"){
composer.appendChild(h("p","cast-kind-note",T("castSingleHint")));
if(learningWidgets.length)composer.appendChild(widgetGrid(true));
else composer.appendChild(h("div","cast-empty",T("castNoLearningWidgets")));
composer.appendChild(widgetGrid(true));
if(castSelWidget){
const themeField=h("label","cast-field cast-theme-field");
themeField.appendChild(h("span",null,T("castTheme")));
if(CAST_THEME_WIDGETS.has(castSelWidget))themeField.appendChild(castThemeSelect(castSingleTheme,value=>{castSingleTheme=value;}));
else themeField.appendChild(h("span","cast-theme-na",T("castThemeNotApplicable")));
composer.appendChild(themeField);
}
const modeField=h("label","cast-field cast-mode-field");modeField.appendChild(h("span",null,T("castStepMode")));
const mode=h("select","am-sel");mode.append(new Option(T("amModeWork"),"werken"),new Option(T("amModeWatch"),"kijken"));mode.value=castSelMode;mode.disabled=castSaving;
mode.addEventListener("change",()=>{castSelMode=mode.value;castActiveKeys.clear();castNotice="";});
modeField.appendChild(mode);composer.appendChild(modeField);
}else composer.appendChild(h("div","cast-board-note",T("castBoardHint")));
}
sheet.appendChild(composer);
const audience=h("section","cast-composer-card cast-audience");

View file

@ -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.4.61-beta";
const VERSION = "0.4.62-beta";
/* Safari en oudere mobiele browsers behandelen 100vh/100% soms als de hoogte
achter hun adres- en navigatiebalk. visualViewport geeft de werkelijk
zichtbare hoogte; de CSS gebruikt deze pixelwaarde als betrouwbare bron en
@ -409,23 +409,25 @@ const I18N = {
castNoClasses:"Geen klassen in deze school.", castNoPupils:"Geen leerlingen in deze school.",
castActive:"✓ Actief", castPathTitle:"Opdrachtenreeks (optioneel)", castPathPlaceholder:"Naam van de reeks…",
castPathAdd:" Geselecteerde widget als stap", castPathOnlyLearning:"Reeksen ondersteunen taal- en rekenwidgets.", castPathDefault:"Mijn opdrachtenreeks", castPathHint:"{count} van maximaal 12 stappen. Gebruik de pijlen om de volgorde te bepalen.",
castIntro:"Stel een opdracht of reeks samen vanuit de widgets op dit bord en geef deze daarna in één keer door.",
castIntro:"Stel zelfstandig een opdracht of reeks samen uit alle leerlingwidgets en geef deze daarna in één keer door.",
castLibrarySubtitle:"Zelfstandige leerlingopdrachten · geen bord nodig",
castStepChoose:"Kies vorm", castStepBuild:"Stel samen", castStepAudience:"Geef door",
castKindPath:"Opdrachtenreeks", castKindPathHelp:"Meerdere widgets na elkaar",
castKindWidget:"Eén opdracht", castKindWidgetHelp:"Eén gekozen widget",
castKindBoard:"Heel bord", castKindBoardHelp:"Alle geschikte widgets",
castLiveStateHint:"De huidige inhoud en instellingen van iedere widget worden gebruikt. Latere aanpassingen op het bord komen live bij leerlingen door.",
castPathName:"Naam voor leerlingen", castWidgetLibrary:"Widgets op dit bord", castWidgetLibraryHint:"Tik op een widget om die aan de reeks toe te voegen.",
castAddAll:"Alles toevoegen", castClearPath:"Reeks leegmaken", castNoLearningWidgets:"Zet eerst een taal- of rekenwidget op dit bord.",
castLiveStateHint:"Kies iedere widget rechtstreeks uit de bibliotheek. Bij woordopdrachten kun je per stap een vast thema kiezen, inclusief je eigen woordthemas.",
castPathName:"Naam voor leerlingen", castWidgetLibrary:"Volledige widgetbibliotheek", castWidgetLibraryHint:"Tik op een widget om die als zelfstandige opdracht toe te voegen.",
castAddAll:"Vul tot 12", castClearPath:"Reeks leegmaken", castNoLearningWidgets:"Er zijn geen geschikte leerlingwidgets beschikbaar.",
castSequenceOrder:"Volgorde van de opdrachten", castPathEmpty:"Kies hierboven minimaal één widget. Je kunt een widget ook vaker toevoegen.",
castStepMode:"Wat doet de leerling?", castMoveUp:"Eerder in de reeks", castMoveDown:"Later in de reeks", castRemoveStep:"Verwijder deze stap",
castSingleHint:"Kies de widget die iedere leerling direct opent.", castBoardHint:"Leerlingen openen alle taal- en rekenwidgets van dit bord. Andere bordhulpmiddelen blijven verborgen.",
castTheme:"Thema", castThemeNone:"Geen specifiek thema", castBuiltInThemes:"Vaste themas", castOwnThemes:"Mijn themas", castThemeNotApplicable:"Geen themakeuze nodig", castIndependentStep:"Zelfstandige opdracht", castSingleAssignment:"Losse opdracht",
castSingleHint:"Kies de widget die iedere leerling direct opent.",
castAudienceTitle:"Aan wie geef je dit?", castAudienceHint:"Kies één of meer klassen en eventueel losse leerlingen. Een klaskeuze omvat automatisch alle leerlingen uit die klas.",
castAudienceSearch:"Zoek leerling of klas…", castSelectVisible:"Selecteer zichtbare leerlingen", castClearTargets:"Selectie wissen", castViaClass:"via klas",
castPupilCount:"{count} leerlingen", castTargetCount:"{count} doelgroep(en) gekozen", castAffectedPupils:"Bereikt ongeveer {count} leerling(en)",
castAssignmentsCount:"{count} opdrachten",
castSend:"Doorgeven aan {count}", castSending:"Bezig met doorgeven…", castSent:"Opdracht succesvol doorgegeven aan {count} doelgroep(en).", castPartial:"Doorgeven mislukte voor {count} doelgroep(en); deze blijven geselecteerd.",
castNeedPath:"Voeg minimaal één widget aan de reeks toe.", castNeedTitle:"Geef de reeks een herkenbare naam.", castNeedWidget:"Kies één widget.", castNeedTarget:"Kies minimaal één klas of leerling.",
castOpenComposer:" Nieuwe opdracht of reeks", castOpenComposerHint:"Gebruik de widgets van het geopende bord en geef ze in één keer door aan meerdere doelgroepen.",
castOpenComposer:" Nieuwe opdracht of reeks", castOpenComposerHint:"Kies vrij uit alle leerlingwidgets en geef ze in één keer door aan meerdere doelgroepen.",
amDashboardTitle:"Klasdashboard", amProgressDetail:"Detail per leerling",
amLiveSessions:"Live leersessies", amLiveConnected:"● Live verbonden", amLiveReconnecting:"○ Verbinden…",
amLiveNow:"Nu actief", amLiveRecent:"Meest recente sessies", amLiveNone:"Nog geen leerling actief.", amLiveRecentNone:"Nog geen recente sessies.",
@ -829,23 +831,25 @@ const I18N = {
castNoClasses:"No classes in this school.", castNoPupils:"No pupils in this school.",
castActive:"✓ Active", castPathTitle:"Learning path (optional)", castPathPlaceholder:"Name of the path…",
castPathAdd:" Add selected widget as step", castPathOnlyLearning:"Paths support language and maths widgets.", castPathDefault:"My learning path", castPathHint:"{count} of up to 12 steps. Use the arrows to set the order.",
castIntro:"Build an assignment or path from the widgets on this board, then share it in one go.",
castIntro:"Build an independent assignment or path from every pupil widget, then share it in one go.",
castLibrarySubtitle:"Independent pupil assignments · no board required",
castStepChoose:"Choose format", castStepBuild:"Build it", castStepAudience:"Assign it",
castKindPath:"Learning path", castKindPathHelp:"Several widgets in sequence",
castKindWidget:"One assignment", castKindWidgetHelp:"One selected widget",
castKindBoard:"Whole board", castKindBoardHelp:"All suitable widgets",
castLiveStateHint:"Each widget uses its current content and settings. Later board changes are reflected live for pupils.",
castPathName:"Name shown to pupils", castWidgetLibrary:"Widgets on this board", castWidgetLibraryHint:"Tap a widget to add it to the path.",
castAddAll:"Add all", castClearPath:"Clear path", castNoLearningWidgets:"First add a language or maths widget to this board.",
castLiveStateHint:"Choose each widget directly from the library. For word assignments, every step can use a fixed theme, including your own word themes.",
castPathName:"Name shown to pupils", castWidgetLibrary:"Full widget library", castWidgetLibraryHint:"Tap a widget to add it as an independent assignment.",
castAddAll:"Fill up to 12", castClearPath:"Clear path", castNoLearningWidgets:"No suitable pupil widgets are available.",
castSequenceOrder:"Assignment order", castPathEmpty:"Choose at least one widget above. You can also add a widget more than once.",
castStepMode:"What does the pupil do?", castMoveUp:"Move earlier", castMoveDown:"Move later", castRemoveStep:"Remove this step",
castSingleHint:"Choose the widget every pupil should open directly.", castBoardHint:"Pupils open all language and maths widgets on this board. Other board tools remain hidden.",
castTheme:"Theme", castThemeNone:"No specific theme", castBuiltInThemes:"Built-in themes", castOwnThemes:"My themes", castThemeNotApplicable:"No theme needed", castIndependentStep:"Independent assignment", castSingleAssignment:"Single assignment",
castSingleHint:"Choose the widget every pupil should open directly.",
castAudienceTitle:"Who receives this?", castAudienceHint:"Choose one or more classes and, if needed, individual pupils. Selecting a class automatically covers every pupil in it.",
castAudienceSearch:"Search pupil or class…", castSelectVisible:"Select visible pupils", castClearTargets:"Clear selection", castViaClass:"via class",
castPupilCount:"{count} pupils", castTargetCount:"{count} target(s) selected", castAffectedPupils:"Reaches about {count} pupil(s)",
castAssignmentsCount:"{count} assignments",
castSend:"Assign to {count}", castSending:"Assigning…", castSent:"Assignment successfully shared with {count} target(s).", castPartial:"Sharing failed for {count} target(s); they remain selected.",
castNeedPath:"Add at least one widget to the path.", castNeedTitle:"Give the path a recognisable name.", castNeedWidget:"Choose one widget.", castNeedTarget:"Choose at least one class or pupil.",
castOpenComposer:" New assignment or path", castOpenComposerHint:"Use widgets from the open board and share them with several targets in one go.",
castOpenComposer:" New assignment or path", castOpenComposerHint:"Choose freely from every pupil widget and share them with several targets in one go.",
amDashboardTitle:"Class dashboard", amProgressDetail:"Pupil details",
amLiveSessions:"Live learning sessions", amLiveConnected:"● Live connected", amLiveReconnecting:"○ Connecting…",
amLiveNow:"Active now", amLiveRecent:"Most recent sessions", amLiveNone:"No pupil is active yet.", amLiveRecentNone:"No recent sessions yet.",

View file

@ -86,6 +86,14 @@ async function renderPupilWidgets(widgets, mode, preview=false, sequence=null){
dots.appendChild(dot);
});
path.append(title, progress, dots);
const currentStep=sequence.steps?.[sequence.currentIndex];
if(!sequence.done&&currentStep?.widgetType){
const context=document.createElement("div");
context.className="pupil-path-current";
context.textContent=T("wg_"+currentStep.widgetType)
+(currentStep.themeName?" · "+T("castTheme")+": "+currentStep.themeName:"");
path.appendChild(context);
}
pupilView.appendChild(path);
if(sequence.done){
const finish = document.createElement("div");

View file

@ -4,8 +4,8 @@
==========================================================*/
function mountBall(root, initState, opts){
const onProgress = opts && opts.onProgress;
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, snd:true, lives:6, guessed:new Set(), over:false, order:[], idx:0, stars:0 };
if(initState){S.level=initState.level||1;S.yearGroup=initState.yearGroup||null;S.managedLearningLevel=!!initState.managedLearningLevel;S.card = initState.card ?? 0; S.snd = initState.snd !== false;}
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, snd:true, lives:6, guessed:new Set(), over:false, order:[], idx:0, stars:0, assignmentWords:null, assignmentThemeName:"" };
if(initState){S.level=initState.level||1;S.yearGroup=initState.yearGroup||null;S.managedLearningLevel=!!initState.managedLearningLevel;S.card = initState.card ?? 0; S.snd = initState.snd !== false;S.assignmentWords=Array.isArray(initState.assignmentWords)?initState.assignmentWords:null;S.assignmentThemeName=typeof initState.assignmentThemeName==="string"?initState.assignmentThemeName:"";}
const assignedRules=languageTaskRules(S.yearGroup,S.level),maxLives=assignedRules?.lives||6;
const play = f=>{ if(S.snd) f(); };
@ -32,10 +32,11 @@ function mountBall(root, initState, opts){
scoreEl = el(".bl-score"), sndBtn = el(".lg-snd");
function list(){
if(S.assignmentWords?.length)return languageWidgetWords(S.assignmentWords,S.yearGroup,S.level,"ball");
S.card = normWordSource(S.card);const selected=languageWidgetWords(wordSourceList(S.card),S.yearGroup,S.level,"ball");
if(selected.length)return selected;S.card="g";return languageWidgetWords(wordSourceList("g"),S.yearGroup,S.level,"ball");
}
function buildSrc(){ fillWordSourceSel(srcSel, S.card,S.yearGroup,S.level);S.card=srcSel.value||"g"; }
function buildSrc(){if(S.assignmentWords?.length){srcSel.innerHTML="";srcSel.appendChild(new Option("🎯 "+(S.assignmentThemeName||T("castTheme")),"assignment"));srcSel.disabled=true;}else{srcSel.disabled=false;fillWordSourceSel(srcSel,S.card,S.yearGroup,S.level);S.card=srcSel.value||"g";}}
"abcdefghijklmnopqrstuvwxyz".split("").forEach(l=>{
const b = document.createElement("button");
b.textContent = l; b.dataset.l = l;
@ -113,7 +114,7 @@ function mountBall(root, initState, opts){
nextBtn.textContent = T("next");
buildSrc();
}
document.addEventListener("langchange", ()=>{ S.card=0; S.order=[]; labels(); newWord(); });
document.addEventListener("langchange", ()=>{ if(!S.assignmentWords)S.card=0; S.order=[]; labels(); newWord(); });
document.addEventListener("wordschange", ()=>{ buildSrc(); S.order=[]; newWord(); });
labels(); newWord();
root.classList.toggle("managed-learning-level",S.managedLearningLevel);

View file

@ -3,8 +3,8 @@
Flash words widget
==========================================================*/
function mountFlits(root, initState){
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, dur:2, idx:0, order:[], score:0, tries:0 };
if(initState){ S.level=initState.level||1;S.yearGroup=initState.yearGroup||null;S.managedLearningLevel=!!initState.managedLearningLevel;S.card = initState.card ?? 0; S.dur = initState.dur || 2; S.score=+initState.score||0; S.tries=+initState.tries||0; }
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, dur:2, idx:0, order:[], score:0, tries:0, assignmentWords:null, assignmentThemeName:"" };
if(initState){ S.level=initState.level||1;S.yearGroup=initState.yearGroup||null;S.managedLearningLevel=!!initState.managedLearningLevel;S.card = initState.card ?? 0; S.dur = initState.dur || 2; S.score=+initState.score||0; S.tries=+initState.tries||0;S.assignmentWords=Array.isArray(initState.assignmentWords)?initState.assignmentWords:null;S.assignmentThemeName=typeof initState.assignmentThemeName==="string"?initState.assignmentThemeName:""; }
const assignedRules=languageTaskRules(S.yearGroup,S.level);if(S.managedLearningLevel&&assignedRules)S.dur=assignedRules.flashSeconds;
let flashTimer = null, assessed=false;
@ -35,12 +35,13 @@ function mountFlits(root, initState){
wordEl = el(".fl-word"), picEl = el(".fl-pic"), cntEl = el(".fl-cnt"), reflect=el(".fl-reflect");
function list(){
if(S.assignmentWords?.length)return languageWidgetWords(S.assignmentWords,S.yearGroup,S.level,"flits");
S.card = normWordSource(S.card);const selected=languageWidgetWords(wordSourceList(S.card),S.yearGroup,S.level,"flits");
if(selected.length)return selected;S.card="g";return languageWidgetWords(wordSourceList("g"),S.yearGroup,S.level,"flits");
}
function buildSrc(){
fillWordSourceSel(srcSel, S.card,S.yearGroup,S.level);
S.card=srcSel.value||"g";
if(S.assignmentWords?.length){srcSel.innerHTML="";srcSel.appendChild(new Option("🎯 "+(S.assignmentThemeName||T("castTheme")),"assignment"));srcSel.disabled=true;}
else{srcSel.disabled=false;fillWordSourceSel(srcSel, S.card,S.yearGroup,S.level);S.card=srcSel.value||"g";}
durSel.innerHTML = "";
[0.5,1,2,3,5].forEach(d=>{
const o = document.createElement("option");
@ -109,7 +110,7 @@ function mountFlits(root, initState){
buildSrc();
if(S.order.length)score();
}
document.addEventListener("langchange", ()=>{ S.card=0; labels(); newRound(); hide(); });
document.addEventListener("langchange", ()=>{ if(!S.assignmentWords)S.card=0; labels(); newRound(); hide(); });
document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); hide(); });
labels(); newRound();
root.classList.toggle("managed-learning-level",S.managedLearningLevel);

View file

@ -19,8 +19,8 @@ function soundChunks(word, lang=LANG){
Chop & blend widget words in sounds, with read-aloud
==========================================================*/
function mountHak(root, initState){
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, idx:0, order:[], chopped:false };
if(initState){S.level=initState.level||1;S.yearGroup=initState.yearGroup||null;S.managedLearningLevel=!!initState.managedLearningLevel;S.card = initState.card ?? 0;}
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, idx:0, order:[], chopped:false, assignmentWords:null, assignmentThemeName:"" };
if(initState){S.level=initState.level||1;S.yearGroup=initState.yearGroup||null;S.managedLearningLevel=!!initState.managedLearningLevel;S.card = initState.card ?? 0;S.assignmentWords=Array.isArray(initState.assignmentWords)?initState.assignmentWords:null;S.assignmentThemeName=typeof initState.assignmentThemeName==="string"?initState.assignmentThemeName:"";}
const canSpeak = "speechSynthesis" in window;
root.innerHTML = `
@ -46,10 +46,11 @@ function mountHak(root, initState){
picEl = el(".hk-pic"), cntEl = el(".hk-cnt");
function list(){
if(S.assignmentWords?.length)return languageWidgetWords(S.assignmentWords,S.yearGroup,S.level,"hak");
S.card = normWordSource(S.card);const selected=languageWidgetWords(wordSourceList(S.card),S.yearGroup,S.level,"hak");
if(selected.length)return selected;S.card="g";return languageWidgetWords(wordSourceList("g"),S.yearGroup,S.level,"hak");
}
function buildSrc(){ fillWordSourceSel(srcSel, S.card,S.yearGroup,S.level);S.card=srcSel.value||"g"; }
function buildSrc(){if(S.assignmentWords?.length){srcSel.innerHTML="";srcSel.appendChild(new Option("🎯 "+(S.assignmentThemeName||T("castTheme")),"assignment"));srcSel.disabled=true;}else{srcSel.disabled=false;fillWordSourceSel(srcSel,S.card,S.yearGroup,S.level);S.card=srcSel.value||"g";}}
function cur(){ return list()[S.order[S.idx]]; }
function newRound(){ S.order = shuffle(list().map((_,i)=>i)); S.idx = 0; }
function display(){
@ -114,7 +115,7 @@ function mountHak(root, initState){
el(".hk-hint").textContent = letters?(LANG==="nl"?"Luister naar het woord. Welke letter hoor je vooraan?":"Listen to the word. Which letter do you hear first?"):T("hakHint");
buildSrc();
}
document.addEventListener("langchange", ()=>{ S.card=0; labels(); newRound(); S.chopped=false; display(); });
document.addEventListener("langchange", ()=>{ if(!S.assignmentWords)S.card=0; labels(); newRound(); S.chopped=false; display(); });
document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); S.chopped=false; display(); });
labels(); newRound(); display();
root.classList.toggle("managed-learning-level",S.managedLearningLevel);

View file

@ -2,7 +2,7 @@
function mountLetterGame(root, initState, opts){
const readonly = !!(opts && opts.readonly);
const onProgress = opts && opts.onProgress;
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, wordIdx:0, stars:0, lang:LANG, count:6, order:[], snd:true, amode:"blocks" };
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, wordIdx:0, stars:0, lang:LANG, count:6, order:[], snd:true, amode:"blocks", assignmentWords:null, assignmentThemeName:"" };
if(initState){
S.level = initState.level||1;
S.yearGroup = initState.yearGroup||null;
@ -11,6 +11,8 @@ function mountLetterGame(root, initState, opts){
S.count = initState.count||6;
S.snd = initState.snd !== false;
S.amode = initState.amode||"blocks";
S.assignmentWords = Array.isArray(initState.assignmentWords) ? initState.assignmentWords : null;
S.assignmentThemeName = typeof initState.assignmentThemeName==="string" ? initState.assignmentThemeName : "";
}
const play = f=>{ if(S.snd) f(); };
@ -155,9 +157,14 @@ function mountLetterGame(root, initState, opts){
cardsEl.innerHTML = "";
srcSel = document.createElement("select");
srcSel.className = "fl-sel";
if(S.assignmentWords?.length){
srcSel.appendChild(new Option("🎯 "+(S.assignmentThemeName||T("castTheme")),"assignment"));
srcSel.disabled=true;
}else{
fillWordSourceSel(srcSel, S.card,S.yearGroup,S.level);
S.card=srcSel.value||"g";
srcSel.addEventListener("change", ()=>{ S.card = srcSel.value; newRound(); render(); });
}
cardsEl.appendChild(srcSel);
if(!readonly){
const edit = document.createElement("button");
@ -177,7 +184,7 @@ function mountLetterGame(root, initState, opts){
if(S.wordIdx >= S.order.length){ showCardDone(); } else render();
});
document.addEventListener("langchange", ()=>{
S.lang=LANG; S.card="g"; buildCardButtons(); buildNumSel(); newRound(); render();
S.lang=LANG; if(!S.assignmentWords)S.card="g"; buildCardButtons(); buildNumSel(); newRound(); render();
});
document.addEventListener("wordschange", ()=>{
buildCardButtons(); newRound(); render();
@ -185,6 +192,7 @@ function mountLetterGame(root, initState, opts){
});
function fullList(){
if(S.assignmentWords?.length)return languageWordsForYear(S.assignmentWords,S.yearGroup,S.level);
S.card = normWordSource(S.card);
const selected=languageWordsForYear(wordSourceList(S.card),S.yearGroup,S.level);
if(selected.length)return selected;
@ -774,7 +782,7 @@ function mountLetterGame(root, initState, opts){
document.addEventListener("langchange", updateSndBtn);
return {
getState: ()=>({ level:S.level, card:S.card, count:S.count, snd:S.snd, amode:S.amode }),
setState: st=>{ if(st){ S.level=st.level||1; S.yearGroup=st.yearGroup||null; S.managedLearningLevel=!!st.managedLearningLevel; const rules=languageTaskRules(S.yearGroup,S.level);if(S.managedLearningLevel&&rules)S.level=rules.letterLevel; S.card=st.card??0; S.count=st.count||6; S.snd=st.snd!==false; S.amode=st.amode||"blocks"; buildNumSel(); updateSndBtn(); newRound(); render(); } }
setState: st=>{ if(st){ S.level=st.level||1; S.yearGroup=st.yearGroup||null; S.managedLearningLevel=!!st.managedLearningLevel; const rules=languageTaskRules(S.yearGroup,S.level);if(S.managedLearningLevel&&rules)S.level=rules.letterLevel; S.card=st.card??0; S.count=st.count||6; S.snd=st.snd!==false; S.amode=st.amode||"blocks"; S.assignmentWords=Array.isArray(st.assignmentWords)?st.assignmentWords:null; S.assignmentThemeName=typeof st.assignmentThemeName==="string"?st.assignmentThemeName:""; buildCardButtons(); buildNumSel(); updateSndBtn(); newRound(); render(); } }
};
}

View file

@ -835,6 +835,13 @@ export default async function api(app) {
const board = t ? findBoardById(t.data, a.board_id) : null;
const widget = a.widget_id ? (board?.data?.widgets || []).find((w) => w.wid === a.widget_id) : null;
const sequence = Array.isArray(a.sequence) ? a.sequence.map((step) => {
if (step.source === 'library' && step.widgetType) {
return {
...step,
boardName: null,
themeName: step.theme?.name || null,
};
}
const stepBoard = t ? findBoardById(t.data, step.boardId) : null;
const stepWidget = (stepBoard?.data?.widgets || []).find((w) => w.wid === step.widgetId);
return { ...step, boardName: stepBoard?.name || null, widgetType: stepWidget?.id || null };
@ -872,6 +879,41 @@ export default async function api(app) {
'anchor', 'letters', 'flits', 'hak', 'ball', 'sentence', 'wordtypes',
'madd', 'msub', 'mmul', 'mdiv', 'mpct', 'numberline', 'fractions', 'clock', 'placevalue', 'money',
]);
const ASSIGNMENT_LIBRARY_BOARD = 'assignment-library';
const THEMED_ASSIGNMENT_WIDGETS = new Set(['letters', 'flits', 'hak', 'ball']);
const PROGRESS_ASSIGNMENT_WIDGETS = new Set(['letters', 'ball', 'madd', 'msub', 'mmul', 'mdiv', 'mpct']);
const parseAssignmentTheme = (rawTheme, widgetType, index, reply) => {
if (rawTheme == null) return { theme: null };
if (!THEMED_ASSIGNMENT_WIDGETS.has(widgetType))
return fail(reply, 400, `thema is niet beschikbaar voor stap ${index + 1}`);
if (!rawTheme || typeof rawTheme !== 'object' || Array.isArray(rawTheme))
return fail(reply, 400, `ongeldig thema in stap ${index + 1}`);
const id = typeof rawTheme.id === 'string' ? rawTheme.id.trim() : '';
const name = typeof rawTheme.name === 'string' ? rawTheme.name.trim() : '';
if (!id || id.length > 80 || !name || name.length > 80 || !Array.isArray(rawTheme.words)
|| !rawTheme.words.length || rawTheme.words.length > 120)
return fail(reply, 400, `ongeldig thema in stap ${index + 1}`);
const words = [];
for (const rawWord of rawTheme.words) {
if (!Array.isArray(rawWord) || typeof rawWord[0] !== 'string') {
return fail(reply, 400, `ongeldig themawoord in stap ${index + 1}`);
}
const word = rawWord[0].trim();
const picture = rawWord[1] == null ? '' : String(rawWord[1]);
const yearGroup = rawWord[2] == null ? null : Number(rawWord[2]);
const anchor = rawWord[3] == null ? null : String(rawWord[3]).trim();
if (!word || word.length > 80 || picture.length > 20_000
|| (yearGroup != null && (!Number.isInteger(yearGroup) || yearGroup < 1 || yearGroup > 8))
|| (anchor != null && anchor.length > 80)) {
return fail(reply, 400, `ongeldig themawoord in stap ${index + 1}`);
}
words.push([word, picture, yearGroup, anchor]);
}
const theme = { id, name, words };
if (JSON.stringify(theme).length > 160_000)
return fail(reply, 400, `thema in stap ${index + 1} is te groot`);
return { theme };
};
const parseAssignmentBody = (req, reply) => {
const raw = req.body ?? {};
if (!Array.isArray(raw.sequence) || raw.sequence.length === 0) {
@ -885,6 +927,25 @@ export default async function api(app) {
const sequence = [];
for (let index = 0; index < raw.sequence.length; index += 1) {
const item = raw.sequence[index] ?? {};
const mode = item.mode ?? 'werken';
if (mode !== 'kijken' && mode !== 'werken') return fail(reply, 400, `ongeldige modus in stap ${index + 1}`);
if (item.widgetType != null) {
if (typeof item.widgetType !== 'string' || !PUPIL_ASSIGNMENT_WIDGETS.has(item.widgetType))
return fail(reply, 400, `widget van stap ${index + 1} is niet geschikt voor de leerlingomgeving`);
const parsedTheme = parseAssignmentTheme(item.theme, item.widgetType, index, reply);
if (parsedTheme.error) return parsedTheme;
sequence.push({
id: `step-${index + 1}`,
source: 'library',
boardId: ASSIGNMENT_LIBRARY_BOARD,
widgetId: `assignment-step-${index + 1}`,
widgetType: item.widgetType,
mode,
requiresProgress: PROGRESS_ASSIGNMENT_WIDGETS.has(item.widgetType),
theme: parsedTheme.theme,
});
continue;
}
const boardId = item.boardId || raw.boardId;
const board = typeof boardId === 'string' ? findBoardById(req.user.data, boardId) : null;
if (!board) return fail(reply, 400, `bord van stap ${index + 1} onbekend`);
@ -894,10 +955,10 @@ export default async function api(app) {
if (!widget) return fail(reply, 400, `widget van stap ${index + 1} staat niet op dit bord`);
if (!PUPIL_ASSIGNMENT_WIDGETS.has(widget.id))
return fail(reply, 400, `widget van stap ${index + 1} is niet geschikt voor de leerlingomgeving`);
const mode = item.mode ?? 'werken';
if (mode !== 'kijken' && mode !== 'werken') return fail(reply, 400, `ongeldige modus in stap ${index + 1}`);
sequence.push({ id: `step-${index + 1}`, boardId, widgetId: item.widgetId, mode });
}
if (JSON.stringify(sequence).length > 512_000)
return fail(reply, 400, 'opdrachtenreeks is te groot');
const first = sequence[0];
return { boardId: first.boardId, widgetId: first.widgetId, mode: first.mode, title, sequence };
};
@ -985,7 +1046,15 @@ export default async function api(app) {
}
return assignment || null;
};
const assignmentWidgets = async (assignment, boardId, widgetId) => {
const assignmentWidgets = async (assignment, boardId, widgetId, step = null) => {
if (step?.source === 'library' && step.widgetType) {
const state = step.theme ? {
assignmentThemeId: step.theme.id,
assignmentThemeName: step.theme.name,
assignmentWords: step.theme.words,
} : {};
return [{ id: step.widgetType, wid: step.widgetId, state }];
}
const teacher = (await pool.query('SELECT data FROM users WHERE id = $1', [assignment.teacher_id])).rows[0];
const board = teacher ? findBoardById(teacher.data, boardId) : null;
let widgets = (board?.data?.widgets || []).map((widget) => ({ id: widget.id, wid: widget.wid, state: widget.state }));
@ -1019,17 +1088,24 @@ export default async function api(app) {
const readySince = previous
? (completedRows.find((row) => row.step_id === previous.id)?.completed_at || assignment.updated_at)
: assignment.updated_at;
const ready = !current || current.mode === 'kijken' || !!(await pool.query(
const ready = !current || current.mode === 'kijken' || current.requiresProgress === false || !!(await pool.query(
`SELECT 1 FROM progress_events
WHERE pupil_id = $1 AND widget_id = $2 AND created_at >= $3 LIMIT 1`,
[req.user.id, current.widgetId, readySince])).rows[0];
return {
widgets: current ? await assignmentWidgets(assignment, current.boardId, current.widgetId) : [],
widgets: current ? await assignmentWidgets(assignment, current.boardId, current.widgetId, current) : [],
mode: current?.mode || 'werken',
updatedAt: assignment.updated_at,
sequence: {
title: assignment.title || '',
steps: steps.map((step) => ({ id: step.id, mode: step.mode, completed: completedIds.has(step.id) })),
steps: steps.map((step) => {
const described = { id: step.id, mode: step.mode, completed: completedIds.has(step.id) };
if (step.source === 'library' && step.widgetType) {
described.widgetType = step.widgetType;
described.themeName = step.theme?.name || null;
}
return described;
}),
currentIndex: currentIndex < 0 ? steps.length : currentIndex,
completed: completedIds.size,
total: steps.length,
@ -1052,7 +1128,7 @@ export default async function api(app) {
const currentIndex = steps.findIndex((step) => !completedIds.has(step.id));
const current = currentIndex >= 0 ? steps[currentIndex] : null;
if (!current || current.id !== req.params.stepId) return fail(reply, 409, 'deze stap is niet de huidige opdracht');
if (current.mode !== 'kijken') {
if (current.mode !== 'kijken' && current.requiresProgress !== false) {
const previous = currentIndex > 0 ? steps[currentIndex - 1] : null;
const readySince = previous
? (completedRows.find((row) => row.step_id === previous.id)?.completed_at || assignment.updated_at)

View file

@ -231,13 +231,15 @@ export default function registerLiveClassroom(app, options) {
let boardId = assignment.board_id;
let assignedWidgetId = assignment.widget_id;
let assignedMode = assignment.mode || 'werken';
let assignedStep = null;
if (steps.length) {
const completed = new Set((await pool.query(
'SELECT step_id FROM assignment_step_progress WHERE assignment_id = $1 AND pupil_id = $2',
[assignment.id, req.user.id])).rows.map((row) => row.step_id));
const current = steps.find((step) => !completed.has(step.id));
if (!current) return fail(reply, 409, 'opdrachtenreeks is al afgerond');
boardId = current.boardId;
assignedStep = current;
boardId = current.boardId || assignment.board_id;
assignedWidgetId = current.widgetId;
assignedMode = current.mode;
}
@ -246,7 +248,7 @@ export default function registerLiveClassroom(app, options) {
}
if (assignedMode !== mode) return fail(reply, 409, 'de opdrachtmodus is gewijzigd');
const widgets = await assignmentWidgets(assignment, boardId, assignedWidgetId);
const widgets = await assignmentWidgets(assignment, boardId, assignedWidgetId, assignedStep);
const known = widgets.some((widget) =>
widget.id === widgetType && (widget.wid ? widget.wid === widgetId : true));
if (!known) return fail(reply, 409, 'widget staat niet meer in de toewijzing');

View file

@ -3,18 +3,26 @@ import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
test('leerkracht bouwt visueel een reeks en geeft die in één actie aan meerdere doelgroepen', async () => {
const [cast, admin, css, core] = await Promise.all([
const [cast, admin, css, core, ...wordWidgets] = await Promise.all([
readFile('public/js/cast.js', 'utf8'),
readFile('public/js/admin.js', 'utf8'),
readFile('public/css/teach.css', 'utf8'),
readFile('public/js/core.js', 'utf8'),
readFile('public/js/widgets/letters.js', 'utf8'),
readFile('public/js/widgets/flits.js', 'utf8'),
readFile('public/js/widgets/hak.js', 'utf8'),
readFile('public/js/widgets/ball.js', 'utf8'),
]);
assert.match(cast, /castShareKind\s*=\s*"path"/);
assert.match(cast, /castSelectedTargets\s*=\s*new Set/);
assert.match(cast, /assignmentLibraryWidgets\(\)[\s\S]*REGISTRY\.filter/);
assert.match(cast, /castThemeCatalog[\s\S]*languageThemeList[\s\S]*THEMES\[LANG\]/);
assert.match(cast, /cast-widget-card/);
assert.match(cast, /castPathSteps\.push/);
assert.match(cast, /slice\(0,12-castPathSteps\.length\)/);
assert.match(cast, /widgetType:step\.widgetType[\s\S]*theme:castThemePayload/);
assert.doesNotMatch(cast, /currentBoardWidgets/);
assert.match(cast, /castPupilCoveredByClass/);
assert.match(cast, /Promise\.allSettled\(targets\.map/);
assert.match(cast, /castSendSelected/);
@ -23,9 +31,16 @@ test('leerkracht bouwt visueel een reeks en geeft die in één actie aan meerder
assert.match(css, /cast-widget-grid[\s\S]*cast-path-step[\s\S]*cast-target-grid/);
assert.match(css, /cast-sendbar\{position:sticky/);
assert.match(css, /@media \(max-width:700px\)[\s\S]*cast-composer-sheet/);
for (const widget of wordWidgets) {
assert.match(widget, /assignmentWords/);
assert.match(widget, /assignmentThemeName/);
assert.match(widget, /srcSel\.disabled=true/);
}
for (const key of [
'castIntro', 'castKindPath', 'castLiveStateHint', 'castWidgetLibrary', 'castSequenceOrder',
'castIntro', 'castLibrarySubtitle', 'castKindPath', 'castLiveStateHint',
'castWidgetLibrary', 'castSequenceOrder', 'castTheme', 'castThemeNone',
'castBuiltInThemes', 'castOwnThemes', 'castIndependentStep',
'castAudienceTitle', 'castSelectVisible', 'castSend', 'castSent',
'castNeedPath', 'castNeedTarget', 'castOpenComposer',
]) {

View file

@ -162,6 +162,174 @@ test('leerkracht kan een gevalideerde reeks van maximaal twaalf stappen opslaan'
await app.close();
});
test('leerkracht maakt zonder bord een zelfstandige reeks met een thema per opdracht', async () => {
const teacher = {
id: 3, username: 'juf', display_name: 'Juf', role: 'teacher',
school_id: 2, class_id: null, data: {}, data_rev: 0,
};
const calls = [];
const query = async (sql, params = []) => {
calls.push({ sql, params });
const auth = authRows(teacher, sql);
if (auth) return auth;
if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 50, school_id: 2 }] };
if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [{ '?column?': 1 }] };
if (sql.includes('INSERT INTO assignments')) return { rows: [{ id: 81 }] };
return { rows: [] };
};
const app = await fastifyWith(teacher, query);
const theme = {
id: 'builtin:animals', name: 'Dieren',
words: [['kat', '🐈', 1], ['hond', '🐕', 2]],
};
const response = await app.inject({
method: 'PUT', url: '/api/assignments/class/50', cookies,
payload: {
title: 'Dierenroute',
sequence: [
{ widgetType: 'letters', mode: 'werken', theme },
{ widgetType: 'madd', mode: 'werken' },
],
},
});
assert.equal(response.statusCode, 200, response.body);
const insert = calls.find((call) => call.sql.includes('INSERT INTO assignments'));
assert.equal(insert.params[2], 'assignment-library');
assert.equal(insert.params[3], 'assignment-step-1');
assert.deepEqual(JSON.parse(insert.params[6]), [
{
id: 'step-1', source: 'library', boardId: 'assignment-library',
widgetId: 'assignment-step-1', widgetType: 'letters', mode: 'werken',
requiresProgress: true,
theme: {
id: 'builtin:animals', name: 'Dieren',
words: [['kat', '🐈', 1, null], ['hond', '🐕', 2, null]],
},
},
{
id: 'step-2', source: 'library', boardId: 'assignment-library',
widgetId: 'assignment-step-2', widgetType: 'madd', mode: 'werken',
requiresProgress: true, theme: null,
},
]);
assert.ok(!calls.some((call) => call.sql.includes('SELECT data FROM users')),
'zelfstandige opdrachten zoeken geen auteursbord op');
await app.close();
});
test('leerling ontvangt de opgeslagen thematische widget zonder auteursbord', async () => {
const pupil = {
id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50,
data: {}, data_rev: 0, reward_points: 0,
};
const assignment = {
id: 81, class_id: 50, teacher_id: 3, board_id: 'assignment-library',
widget_id: 'assignment-step-1', mode: 'werken', title: 'Dierenroute',
updated_at: new Date('2026-07-25T08:00:00Z'),
sequence: [{
id: 'step-1', source: 'library', boardId: 'assignment-library',
widgetId: 'assignment-step-1', widgetType: 'letters', mode: 'werken',
requiresProgress: true,
theme: { id: 'builtin:animals', name: 'Dieren', words: [['kat', '🐈', 1, null]] },
}],
};
const calls = [];
const query = async (sql, params = []) => {
calls.push({ sql, params });
const auth = authRows(pupil, sql);
if (auth) return auth;
if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') return { rows: [assignment] };
if (sql.startsWith('SELECT step_id') && sql.includes('FROM assignment_step_progress')) return { rows: [] };
if (sql.startsWith('SELECT 1 FROM progress_events')) return { rows: [] };
return { rows: [] };
};
const app = await fastifyWith(pupil, query);
const response = await app.inject({ method: 'GET', url: '/api/my/assignment', cookies });
assert.equal(response.statusCode, 200, response.body);
const body = response.json();
assert.deepEqual(body.widgets, [{
id: 'letters', wid: 'assignment-step-1',
state: {
assignmentThemeId: 'builtin:animals',
assignmentThemeName: 'Dieren',
assignmentWords: [['kat', '🐈', 1, null]],
},
}]);
assert.deepEqual(body.sequence.steps, [{
id: 'step-1', mode: 'werken', widgetType: 'letters',
themeName: 'Dieren', completed: false,
}]);
assert.ok(!calls.some((call) => call.sql.includes('SELECT data FROM users')),
'de leerlingweergave is niet afhankelijk van het bord van de leerkracht');
await app.close();
});
test('thema wordt alleen begrensd en bij geschikte woordwidgets geaccepteerd', async () => {
const teacher = {
id: 3, username: 'juf', role: 'teacher', school_id: 2, class_id: null,
data: {}, data_rev: 0,
};
const query = async (sql) => {
const auth = authRows(teacher, sql);
if (auth) return auth;
return { rows: [] };
};
const app = await fastifyWith(teacher, query);
const response = await app.inject({
method: 'PUT', url: '/api/assignments/class/50', cookies,
payload: {
title: 'Onjuiste route',
sequence: [{
widgetType: 'madd', mode: 'werken',
theme: { id: 'builtin:animals', name: 'Dieren', words: [['kat', '🐈']] },
}],
},
});
assert.equal(response.statusCode, 400);
assert.match(response.json().error, /thema is niet beschikbaar/);
await app.close();
});
test('zelfstandige widget zonder voortgangsevent kan handmatig worden afgerond', async () => {
const pupil = {
id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50,
data: {}, data_rev: 0, reward_points: 0,
};
const assignment = {
id: 82, class_id: 50, teacher_id: 3, board_id: 'assignment-library',
widget_id: 'assignment-step-1', mode: 'werken', title: 'Flitsroute',
updated_at: new Date('2026-07-25T08:00:00Z'),
sequence: [{
id: 'step-1', source: 'library', boardId: 'assignment-library',
widgetId: 'assignment-step-1', widgetType: 'flits', mode: 'werken',
requiresProgress: false, theme: null,
}],
};
const calls = [];
const query = async (sql, params = []) => {
calls.push({ sql, params });
const auth = authRows(pupil, sql);
if (auth) return auth;
if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') return { rows: [assignment] };
if (sql.startsWith('SELECT step_id') && sql.includes('FROM assignment_step_progress')) return { rows: [] };
if (sql.startsWith('INSERT INTO assignment_step_progress')) return { rows: [{ step_id: 'step-1' }] };
if (sql.startsWith('UPDATE users SET reward_points')) return { rows: [{ reward_points: 1 }] };
if (sql.startsWith('INSERT INTO user_avatar_rewards')) return { rows: [] };
return { rows: [] };
};
const app = await fastifyWith(pupil, query);
const current = await app.inject({ method: 'GET', url: '/api/my/assignment', cookies });
assert.equal(current.statusCode, 200, current.body);
assert.equal(current.json().sequence.ready, true);
const completed = await app.inject({
method: 'POST', url: '/api/my/assignment/steps/step-1/complete', cookies,
});
assert.equal(completed.statusCode, 200, completed.body);
assert.ok(!calls.some((call) => call.sql.startsWith('SELECT 1 FROM progress_events')),
'handmatig af te ronden widgets vragen geen niet-bestaand voortgangsevent');
await app.close();
});
test('dashboard markeert klaar, bezig, hulp nodig en nog niet gestart', async () => {
const teacher = {
id: 3, username: 'juf', role: 'teacher', school_id: 2, class_id: null,

View file

@ -67,6 +67,48 @@ test('echte widgetinteractie start een beveiligde live leersessie', async () =>
await app.close();
});
test('zelfstandige bibliotheekopdracht start live zonder bord van de leerkracht', async () => {
const pupil = {
id: 9, username: 'lena', display_name: 'Lena', role: 'pupil',
school_id: 2, class_id: 50, data: {}, data_rev: 0,
};
const assignment = {
id: 81, class_id: 50, teacher_id: 3, board_id: 'assignment-library',
widget_id: 'assignment-step-1', mode: 'werken',
sequence: [{
id: 'step-1', source: 'library', boardId: 'assignment-library',
widgetId: 'assignment-step-1', widgetType: 'letters', mode: 'werken',
requiresProgress: true, theme: null,
}],
};
const calls = [];
const query = async (sql, params = []) => {
calls.push({ sql, params });
const a = auth(pupil, sql);
if (a) return a;
if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') return { rows: [assignment] };
if (sql.startsWith('SELECT step_id') && sql.includes('FROM assignment_step_progress')) return { rows: [] };
if (sql.includes('INSERT INTO live_learning_sessions')) return { rows: [{
id: 102, pupil_id: 9, teacher_id: 3, class_id: 50, assignment_id: 81,
board_id: 'assignment-library', widget_id: 'assignment-step-1', widget_type: 'letters',
mode: 'werken', started_at: new Date(), last_seen_at: new Date(),
}] };
return { rows: [] };
};
const app = await makeApp(pupil, query);
const response = await app.inject({
method: 'POST', url: '/api/my/live-session/start', cookies,
payload: { widgetId: 'assignment-step-1', widgetType: 'letters', mode: 'werken' },
});
assert.equal(response.statusCode, 200, response.body);
const insert = calls.find((call) => call.sql.includes('INSERT INTO live_learning_sessions'));
assert.deepEqual(insert.params, [
9, 3, 50, 81, 'assignment-library', 'assignment-step-1', 'letters', 'werken',
]);
assert.ok(!calls.some((call) => call.sql.includes('SELECT data FROM users')));
await app.close();
});
test('voortgang houdt dezelfde live sessie direct actueel', async () => {
const pupil = {
id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50,