Voeg thematische kleurplaten en creatieve niveaus toe (v0.4.65-beta) #3

Open
bes-r wants to merge 207 commits from bes-r/kleurplaten-v0-4-65 into main AGit
16 changed files with 472 additions and 96 deletions
Showing only changes of commit 043831e1dd - Show all commits

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" "license": "MIT"
}, },
"node_modules/brace-expansion": { "node_modules/brace-expansion": {
"version": "5.0.7", "version": "5.0.8",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.7.tgz", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.8.tgz",
"integrity": "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA==", "integrity": "sha512-JZyDyq3D4AUifKTPOB7DELf6XsB3WdPuNxCtob1vFXPsSXhdAiHBWJ/tJ8HAc9aH84BK+5JFZLNkJKx3G9kzQg==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"balanced-match": "^4.0.2" "balanced-match": "^4.0.2"
}, },
"engines": { "engines": {
"node": "18 || 20 || >=22" "node": "20 || >=22"
} }
}, },
"node_modules/buffer": { "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{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.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-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{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-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))} .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-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{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-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{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-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} .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{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-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-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-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-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-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} .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-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-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-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-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} .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. */ gebeurt niet meer hier - zie cast.js. */
function describeCurrent(current){ function describeCurrent(current){
if(!current) return ""; 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}`; let s = `${T("amAssignedAs")}${current.boardName || current.boardId}`;
if(current.widgetId){ if(current.widgetId){
const wName = current.widgetType ? T("wg_"+current.widgetType) : 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 /* teach - opdrachten samenstellen en delen. Nieuwe opdrachten komen uit de
vanaf de toolbar uitzenden naar een klas of leerling, i.p.v. de omweg via volledige leerlingwidgetbibliotheek en staan los van het geopende bord.
Instellingen > Toewijzingen. Hergebruikt dezelfde PUT/DELETE-routes als Oude bordtoewijzingen blijven via dezelfde API achterwaarts compatibel. */
admin.js's assignmentRow - geen serverwijziging, alleen een korter pad. */
"use strict"; "use strict";
const btnCast = document.getElementById("btnCast"); const btnCast = document.getElementById("btnCast");
btnCast.title = T("cast"); btnCast.title = T("cast");
@ -25,33 +24,60 @@ castBackdrop.addEventListener("click", e=>{ if(e.target===castBackdrop&&!castSav
let castOpen = false; let castOpen = false;
let castLoading = false; let castLoading = false;
let castClasses = [], castPupils = []; let castClasses = [], castPupils = [];
let castSelWidget = "", castSelMode = "werken"; let castSelWidget = "", castSelMode = "werken", castSingleTheme = null;
let castPathTitle = "", castPathSteps = []; let castPathTitle = "", castPathSteps = [];
let castShareKind = "path"; let castShareKind = "path";
let castSelectedTargets = new Set(); let castSelectedTargets = new Set();
let castPupilQuery = ""; let castPupilQuery = "";
let castSaving = false; let castSaving = false;
let castNotice = ""; let castNotice = "";
/* toewijzingen die deze sessie al gelukt zijn ("type:id"), zodat je hetzelfde /* toewijzingen die deze sessie al gelukt zijn ("type:id"), zodat je dezelfde
bord naar meerdere klassen/leerlingen achter elkaar kunt casten zonder dat opdracht naar meerdere klassen/leerlingen achter elkaar kunt sturen zonder dat
de sheet na elke keer sluit */ de sheet na elke keer sluit */
let castActiveKeys = new Set(); let castActiveKeys = new Set();
let castError = ""; let castError = "";
/* widgets van het live-open bord, zelfde bron als serializeBoard() (board.js) */ const CAST_WIDGET_CATEGORIES=new Set(["taal","rekenen"]);
function currentBoardWidgets(){ const CAST_THEME_WIDGETS=new Set(["letters","flits","hak","ball"]);
return [...instances].map(inst=>({ function assignmentLibraryWidgets(){
wid: inst.wid, id: inst.def.id, cat: inst.def.cat, icon: inst.def.icon || "🧩", return REGISTRY.filter(widget=>CAST_WIDGET_CATEGORIES.has(widget.cat));
})).filter(w=>w.wid);
} }
function castPathWidgetAllowed(widgetId){ function castWidgetLabel(widget){ return T("wg_"+widget.id); }
const widget = currentBoardWidgets().find(item=>item.wid===widgetId); function castThemeCatalog(){
return !!widget && ["taal","rekenen"].includes(widget.cat); 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){ function castThemePayload(theme){
const widgets = currentBoardWidgets().filter(item=>item.id===widget.id); if(!theme)return null;
const index = widgets.findIndex(item=>item.wid===widget.wid); return {
return T("wg_"+widget.id) + (widgets.length>1 ? ` ${index+1}` : ""); 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 castTargetKey(target){ return target.type+":"+target.id; }
function castClassSelected(classId){ return castSelectedTargets.has("class:"+classId); } function castClassSelected(classId){ return castSelectedTargets.has("class:"+classId); }
@ -90,13 +116,16 @@ function castAffectedPupilCount(){
return pupilIds.size; return pupilIds.size;
} }
function castPayload(){ function castPayload(){
const body={boardId:curSlot().id}; const body={};
if(castShareKind==="path"){ if(castShareKind==="path"){
body.title=castPathTitle.trim(); 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"){ }else if(castShareKind==="widget"){
body.widgetId=castSelWidget; const widget=assignmentLibraryWidgets().find(item=>item.id===castSelWidget);
body.mode=castSelMode; body.title=widget?castWidgetLabel(widget):T("castSingleAssignment");
body.sequence=[{widgetType:castSelWidget,mode:castSelMode,theme:castThemePayload(castSingleTheme)}];
} }
return body; return body;
} }
@ -137,11 +166,10 @@ async function castSendSelected(){
} }
btnCast.addEventListener("click", async ()=>{ btnCast.addEventListener("click", async ()=>{
stashCurrent(); /* verse widget-state, zelfde als btnFolders doet */
castOpen = true; castOpen = true;
castSelWidget = ""; castSelMode = "werken"; castSelWidget = ""; castSelMode = "werken"; castSingleTheme = null;
castPathTitle = ""; castPathSteps = []; castPathTitle = ""; castPathSteps = [];
castShareKind = currentBoardWidgets().some(w=>castPathWidgetAllowed(w.wid)) ? "path" : "board"; castShareKind = "path";
castSelectedTargets = new Set(); castPupilQuery = ""; castSaving = false; castNotice = ""; castSelectedTargets = new Set(); castPupilQuery = ""; castSaving = false; castNotice = "";
castActiveKeys = new Set(); castError = ""; castActiveKeys = new Set(); castError = "";
castLoading = true; castLoading = true;
@ -193,7 +221,7 @@ function renderCast(preserveScroll=false){
const head = h("div","am-kebab-row"); const head = h("div","am-kebab-row");
const titleWrap = h("div"); const titleWrap = h("div");
titleWrap.appendChild(h("h2","am-nav-title", T("castTitle"))); 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); head.appendChild(titleWrap);
const closeBtn = h("button","am-btn", T("close")); const closeBtn = h("button","am-btn", T("close"));
closeBtn.type = "button"; closeBtn.type = "button";
@ -218,7 +246,7 @@ function renderCast(preserveScroll=false){
const composer=h("section","cast-composer-card"); const composer=h("section","cast-composer-card");
composer.appendChild(h("h3",null,T("castWhat"))); composer.appendChild(h("h3",null,T("castWhat")));
const kindTabs=h("div","cast-kind-tabs"); 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])=>{ .forEach(([kind,labelKey,helpKey])=>{
const button=h("button","cast-kind-button"+(castShareKind===kind?" active":"")); const button=h("button","cast-kind-button"+(castShareKind===kind?" active":""));
button.type="button";button.disabled=castSaving; button.type="button";button.disabled=castSaving;
@ -231,12 +259,12 @@ function renderCast(preserveScroll=false){
composer.appendChild(kindTabs); composer.appendChild(kindTabs);
composer.appendChild(h("p","cast-live-hint",T("castLiveStateHint"))); 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 widgetGrid=(selectOnly=false)=>{
const grid=h("div","cast-widget-grid"); const grid=h("div","cast-widget-grid");
learningWidgets.forEach(widget=>{ learningWidgets.forEach(widget=>{
const selected=selectOnly&&castSelWidget===widget.wid; const selected=selectOnly&&castSelWidget===widget.id;
const added=castPathSteps.filter(step=>step.widgetId===widget.wid).length; const added=castPathSteps.filter(step=>step.widgetType===widget.id).length;
const card=h("button","cast-widget-card"+(selected?" selected":"")); const card=h("button","cast-widget-card"+(selected?" selected":""));
card.type="button";card.disabled=castSaving||(!selectOnly&&castPathSteps.length>=12); card.type="button";card.disabled=castSaving||(!selectOnly&&castPathSteps.length>=12);
card.setAttribute("aria-pressed",String(selected)); 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))); if(added&&!selectOnly)card.appendChild(h("span","cast-widget-count",String(added)));
else card.appendChild(h("span","cast-widget-plus",selected?"✓":"")); else card.appendChild(h("span","cast-widget-plus",selected?"✓":""));
card.addEventListener("click",()=>{ card.addEventListener("click",()=>{
if(selectOnly)castSelWidget=widget.wid; if(selectOnly){
if(castSelWidget!==widget.id)castSingleTheme=null;
castSelWidget=widget.id;
}
else{ 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"); if(!castPathTitle)castPathTitle=T("castPathDefault");
} }
castActiveKeys.clear();castError="";castNotice="";renderCast(true); castActiveKeys.clear();castError="";castNotice="";renderCast(true);
@ -274,12 +305,11 @@ function renderCast(preserveScroll=false){
libraryHead.appendChild(libraryCopy); libraryHead.appendChild(libraryCopy);
const libraryActions=h("div","cast-mini-actions"); const libraryActions=h("div","cast-mini-actions");
const addAll=h("button","am-btn",T("castAddAll"));addAll.type="button"; 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",()=>{ 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({ .slice(0,12-castPathSteps.length).forEach(widget=>castPathSteps.push({
boardId:curSlot().id,widgetId:widget.wid,widgetType:widget.id, widgetType:widget.id,widgetLabel:castWidgetLabel(widget),icon:widget.icon,mode:"werken",theme:null,
widgetLabel:castWidgetLabel(widget),icon:widget.icon,mode:"werken",
})); }));
if(castPathSteps.length&&!castPathTitle)castPathTitle=T("castPathDefault"); if(castPathSteps.length&&!castPathTitle)castPathTitle=T("castPathDefault");
castActiveKeys.clear();castError="";castNotice="";renderCast(true); castActiveKeys.clear();castError="";castNotice="";renderCast(true);
@ -300,12 +330,21 @@ function renderCast(preserveScroll=false){
castPathSteps.forEach((step,index)=>{ castPathSteps.forEach((step,index)=>{
const row=h("div","cast-path-step"); const row=h("div","cast-path-step");
row.append(h("span","cast-path-number",String(index+1)),h("span","cast-step-icon",step.icon||"🧩")); 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); 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"); 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.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.setAttribute("aria-label",T("castStepMode"));mode.disabled=castSaving;
mode.addEventListener("change",()=>{step.mode=mode.value;castActiveKeys.clear();castNotice="";}); mode.addEventListener("change",()=>{step.mode=mode.value;castActiveKeys.clear();castNotice="";});
modeWrap.appendChild(mode);
const controls=h("div","cast-step-controls"); 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"); 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);}); 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"); 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.setAttribute("aria-label",T("castRemoveStep"));
remove.addEventListener("click",()=>{castPathSteps.splice(index,1);castActiveKeys.clear();castNotice="";renderCast(true);}); 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); composer.appendChild(pathList);
} }
const progress=h("progress","cast-path-progress");progress.max=12;progress.value=castPathSteps.length;composer.appendChild(progress); const progress=h("progress","cast-path-progress");progress.max=12;progress.value=castPathSteps.length;composer.appendChild(progress);
}else if(castShareKind==="widget"){ }else if(castShareKind==="widget"){
composer.appendChild(h("p","cast-kind-note",T("castSingleHint"))); composer.appendChild(h("p","cast-kind-note",T("castSingleHint")));
if(learningWidgets.length)composer.appendChild(widgetGrid(true)); composer.appendChild(widgetGrid(true));
else composer.appendChild(h("div","cast-empty",T("castNoLearningWidgets"))); 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 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; 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="";}); mode.addEventListener("change",()=>{castSelMode=mode.value;castActiveKeys.clear();castNotice="";});
modeField.appendChild(mode);composer.appendChild(modeField); modeField.appendChild(mode);composer.appendChild(modeField);
}else composer.appendChild(h("div","cast-board-note",T("castBoardHint"))); }
sheet.appendChild(composer); sheet.appendChild(composer);
const audience=h("section","cast-composer-card cast-audience"); const audience=h("section","cast-composer-card cast-audience");

View file

@ -2,7 +2,7 @@
"use strict"; "use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline). /* 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. */ 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 /* Safari en oudere mobiele browsers behandelen 100vh/100% soms als de hoogte
achter hun adres- en navigatiebalk. visualViewport geeft de werkelijk achter hun adres- en navigatiebalk. visualViewport geeft de werkelijk
zichtbare hoogte; de CSS gebruikt deze pixelwaarde als betrouwbare bron en 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.", castNoClasses:"Geen klassen in deze school.", castNoPupils:"Geen leerlingen in deze school.",
castActive:"✓ Actief", castPathTitle:"Opdrachtenreeks (optioneel)", castPathPlaceholder:"Naam van de reeks…", 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.", 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", castStepChoose:"Kies vorm", castStepBuild:"Stel samen", castStepAudience:"Geef door",
castKindPath:"Opdrachtenreeks", castKindPathHelp:"Meerdere widgets na elkaar", castKindPath:"Opdrachtenreeks", castKindPathHelp:"Meerdere widgets na elkaar",
castKindWidget:"Eén opdracht", castKindWidgetHelp:"Eén gekozen widget", castKindWidget:"Eén opdracht", castKindWidgetHelp:"Eén gekozen widget",
castKindBoard:"Heel bord", castKindBoardHelp:"Alle geschikte widgets", castLiveStateHint:"Kies iedere widget rechtstreeks uit de bibliotheek. Bij woordopdrachten kun je per stap een vast thema kiezen, inclusief je eigen woordthemas.",
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:"Volledige widgetbibliotheek", castWidgetLibraryHint:"Tik op een widget om die als zelfstandige opdracht toe te voegen.",
castPathName:"Naam voor leerlingen", castWidgetLibrary:"Widgets op dit bord", castWidgetLibraryHint:"Tik op een widget om die aan de reeks toe te voegen.", castAddAll:"Vul tot 12", castClearPath:"Reeks leegmaken", castNoLearningWidgets:"Er zijn geen geschikte leerlingwidgets beschikbaar.",
castAddAll:"Alles toevoegen", castClearPath:"Reeks leegmaken", castNoLearningWidgets:"Zet eerst een taal- of rekenwidget op dit bord.",
castSequenceOrder:"Volgorde van de opdrachten", castPathEmpty:"Kies hierboven minimaal één widget. Je kunt een widget ook vaker toevoegen.", 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", 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.", 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", 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)", 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.", 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.", 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", amDashboardTitle:"Klasdashboard", amProgressDetail:"Detail per leerling",
amLiveSessions:"Live leersessies", amLiveConnected:"● Live verbonden", amLiveReconnecting:"○ Verbinden…", 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.", 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.", castNoClasses:"No classes in this school.", castNoPupils:"No pupils in this school.",
castActive:"✓ Active", castPathTitle:"Learning path (optional)", castPathPlaceholder:"Name of the path…", 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.", 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", castStepChoose:"Choose format", castStepBuild:"Build it", castStepAudience:"Assign it",
castKindPath:"Learning path", castKindPathHelp:"Several widgets in sequence", castKindPath:"Learning path", castKindPathHelp:"Several widgets in sequence",
castKindWidget:"One assignment", castKindWidgetHelp:"One selected widget", castKindWidget:"One assignment", castKindWidgetHelp:"One selected widget",
castKindBoard:"Whole board", castKindBoardHelp:"All suitable widgets", castLiveStateHint:"Choose each widget directly from the library. For word assignments, every step can use a fixed theme, including your own word themes.",
castLiveStateHint:"Each widget uses its current content and settings. Later board changes are reflected live for pupils.", castPathName:"Name shown to pupils", castWidgetLibrary:"Full widget library", castWidgetLibraryHint:"Tap a widget to add it as an independent assignment.",
castPathName:"Name shown to pupils", castWidgetLibrary:"Widgets on this board", castWidgetLibraryHint:"Tap a widget to add it to the path.", castAddAll:"Fill up to 12", castClearPath:"Clear path", castNoLearningWidgets:"No suitable pupil widgets are available.",
castAddAll:"Add all", castClearPath:"Clear path", castNoLearningWidgets:"First add a language or maths widget to this board.",
castSequenceOrder:"Assignment order", castPathEmpty:"Choose at least one widget above. You can also add a widget more than once.", 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", 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.", 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", 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)", 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.", 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.", 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", amDashboardTitle:"Class dashboard", amProgressDetail:"Pupil details",
amLiveSessions:"Live learning sessions", amLiveConnected:"● Live connected", amLiveReconnecting:"○ Connecting…", 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.", 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); dots.appendChild(dot);
}); });
path.append(title, progress, dots); 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); pupilView.appendChild(path);
if(sequence.done){ if(sequence.done){
const finish = document.createElement("div"); const finish = document.createElement("div");

View file

@ -4,8 +4,8 @@
==========================================================*/ ==========================================================*/
function mountBall(root, initState, opts){ function mountBall(root, initState, opts){
const onProgress = opts && opts.onProgress; 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 }; 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;} 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 assignedRules=languageTaskRules(S.yearGroup,S.level),maxLives=assignedRules?.lives||6;
const play = f=>{ if(S.snd) f(); }; const play = f=>{ if(S.snd) f(); };
@ -32,10 +32,11 @@ function mountBall(root, initState, opts){
scoreEl = el(".bl-score"), sndBtn = el(".lg-snd"); scoreEl = el(".bl-score"), sndBtn = el(".lg-snd");
function list(){ 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"); 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"); 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=>{ "abcdefghijklmnopqrstuvwxyz".split("").forEach(l=>{
const b = document.createElement("button"); const b = document.createElement("button");
b.textContent = l; b.dataset.l = l; b.textContent = l; b.dataset.l = l;
@ -113,7 +114,7 @@ function mountBall(root, initState, opts){
nextBtn.textContent = T("next"); nextBtn.textContent = T("next");
buildSrc(); 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(); }); document.addEventListener("wordschange", ()=>{ buildSrc(); S.order=[]; newWord(); });
labels(); newWord(); labels(); newWord();
root.classList.toggle("managed-learning-level",S.managedLearningLevel); root.classList.toggle("managed-learning-level",S.managedLearningLevel);

View file

@ -3,8 +3,8 @@
Flash words widget Flash words widget
==========================================================*/ ==========================================================*/
function mountFlits(root, initState){ function mountFlits(root, initState){
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, dur:2, idx:0, order:[], score:0, 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; } 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; const assignedRules=languageTaskRules(S.yearGroup,S.level);if(S.managedLearningLevel&&assignedRules)S.dur=assignedRules.flashSeconds;
let flashTimer = null, assessed=false; 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"); wordEl = el(".fl-word"), picEl = el(".fl-pic"), cntEl = el(".fl-cnt"), reflect=el(".fl-reflect");
function list(){ 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"); 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"); if(selected.length)return selected;S.card="g";return languageWidgetWords(wordSourceList("g"),S.yearGroup,S.level,"flits");
} }
function buildSrc(){ function buildSrc(){
fillWordSourceSel(srcSel, S.card,S.yearGroup,S.level); if(S.assignmentWords?.length){srcSel.innerHTML="";srcSel.appendChild(new Option("🎯 "+(S.assignmentThemeName||T("castTheme")),"assignment"));srcSel.disabled=true;}
S.card=srcSel.value||"g"; else{srcSel.disabled=false;fillWordSourceSel(srcSel, S.card,S.yearGroup,S.level);S.card=srcSel.value||"g";}
durSel.innerHTML = ""; durSel.innerHTML = "";
[0.5,1,2,3,5].forEach(d=>{ [0.5,1,2,3,5].forEach(d=>{
const o = document.createElement("option"); const o = document.createElement("option");
@ -109,7 +110,7 @@ function mountFlits(root, initState){
buildSrc(); buildSrc();
if(S.order.length)score(); 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(); }); document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); hide(); });
labels(); newRound(); labels(); newRound();
root.classList.toggle("managed-learning-level",S.managedLearningLevel); 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 Chop & blend widget words in sounds, with read-aloud
==========================================================*/ ==========================================================*/
function mountHak(root, initState){ function mountHak(root, initState){
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, idx:0, order:[], chopped:false }; 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;} 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; const canSpeak = "speechSynthesis" in window;
root.innerHTML = ` root.innerHTML = `
@ -46,10 +46,11 @@ function mountHak(root, initState){
picEl = el(".hk-pic"), cntEl = el(".hk-cnt"); picEl = el(".hk-pic"), cntEl = el(".hk-cnt");
function list(){ 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"); 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"); 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 cur(){ return list()[S.order[S.idx]]; }
function newRound(){ S.order = shuffle(list().map((_,i)=>i)); S.idx = 0; } function newRound(){ S.order = shuffle(list().map((_,i)=>i)); S.idx = 0; }
function display(){ 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"); 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(); 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(); }); document.addEventListener("wordschange", ()=>{ buildSrc(); newRound(); S.chopped=false; display(); });
labels(); newRound(); display(); labels(); newRound(); display();
root.classList.toggle("managed-learning-level",S.managedLearningLevel); root.classList.toggle("managed-learning-level",S.managedLearningLevel);

View file

@ -2,7 +2,7 @@
function mountLetterGame(root, initState, opts){ function mountLetterGame(root, initState, opts){
const readonly = !!(opts && opts.readonly); const readonly = !!(opts && opts.readonly);
const onProgress = opts && opts.onProgress; 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){ if(initState){
S.level = initState.level||1; S.level = initState.level||1;
S.yearGroup = initState.yearGroup||null; S.yearGroup = initState.yearGroup||null;
@ -11,6 +11,8 @@ function mountLetterGame(root, initState, opts){
S.count = initState.count||6; S.count = initState.count||6;
S.snd = initState.snd !== false; S.snd = initState.snd !== false;
S.amode = initState.amode||"blocks"; 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(); }; const play = f=>{ if(S.snd) f(); };
@ -155,9 +157,14 @@ function mountLetterGame(root, initState, opts){
cardsEl.innerHTML = ""; cardsEl.innerHTML = "";
srcSel = document.createElement("select"); srcSel = document.createElement("select");
srcSel.className = "fl-sel"; 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); fillWordSourceSel(srcSel, S.card,S.yearGroup,S.level);
S.card=srcSel.value||"g"; S.card=srcSel.value||"g";
srcSel.addEventListener("change", ()=>{ S.card = srcSel.value; newRound(); render(); }); srcSel.addEventListener("change", ()=>{ S.card = srcSel.value; newRound(); render(); });
}
cardsEl.appendChild(srcSel); cardsEl.appendChild(srcSel);
if(!readonly){ if(!readonly){
const edit = document.createElement("button"); const edit = document.createElement("button");
@ -177,7 +184,7 @@ function mountLetterGame(root, initState, opts){
if(S.wordIdx >= S.order.length){ showCardDone(); } else render(); if(S.wordIdx >= S.order.length){ showCardDone(); } else render();
}); });
document.addEventListener("langchange", ()=>{ 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", ()=>{ document.addEventListener("wordschange", ()=>{
buildCardButtons(); newRound(); render(); buildCardButtons(); newRound(); render();
@ -185,6 +192,7 @@ function mountLetterGame(root, initState, opts){
}); });
function fullList(){ function fullList(){
if(S.assignmentWords?.length)return languageWordsForYear(S.assignmentWords,S.yearGroup,S.level);
S.card = normWordSource(S.card); S.card = normWordSource(S.card);
const selected=languageWordsForYear(wordSourceList(S.card),S.yearGroup,S.level); const selected=languageWordsForYear(wordSourceList(S.card),S.yearGroup,S.level);
if(selected.length)return selected; if(selected.length)return selected;
@ -774,7 +782,7 @@ function mountLetterGame(root, initState, opts){
document.addEventListener("langchange", updateSndBtn); document.addEventListener("langchange", updateSndBtn);
return { return {
getState: ()=>({ level:S.level, card:S.card, count:S.count, snd:S.snd, amode:S.amode }), 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 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 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) => { 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 stepBoard = t ? findBoardById(t.data, step.boardId) : null;
const stepWidget = (stepBoard?.data?.widgets || []).find((w) => w.wid === step.widgetId); const stepWidget = (stepBoard?.data?.widgets || []).find((w) => w.wid === step.widgetId);
return { ...step, boardName: stepBoard?.name || null, widgetType: stepWidget?.id || null }; 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', 'anchor', 'letters', 'flits', 'hak', 'ball', 'sentence', 'wordtypes',
'madd', 'msub', 'mmul', 'mdiv', 'mpct', 'numberline', 'fractions', 'clock', 'placevalue', 'money', '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 parseAssignmentBody = (req, reply) => {
const raw = req.body ?? {}; const raw = req.body ?? {};
if (!Array.isArray(raw.sequence) || raw.sequence.length === 0) { if (!Array.isArray(raw.sequence) || raw.sequence.length === 0) {
@ -885,6 +927,25 @@ export default async function api(app) {
const sequence = []; const sequence = [];
for (let index = 0; index < raw.sequence.length; index += 1) { for (let index = 0; index < raw.sequence.length; index += 1) {
const item = raw.sequence[index] ?? {}; 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 boardId = item.boardId || raw.boardId;
const board = typeof boardId === 'string' ? findBoardById(req.user.data, boardId) : null; const board = typeof boardId === 'string' ? findBoardById(req.user.data, boardId) : null;
if (!board) return fail(reply, 400, `bord van stap ${index + 1} onbekend`); 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 (!widget) return fail(reply, 400, `widget van stap ${index + 1} staat niet op dit bord`);
if (!PUPIL_ASSIGNMENT_WIDGETS.has(widget.id)) if (!PUPIL_ASSIGNMENT_WIDGETS.has(widget.id))
return fail(reply, 400, `widget van stap ${index + 1} is niet geschikt voor de leerlingomgeving`); 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 }); 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]; const first = sequence[0];
return { boardId: first.boardId, widgetId: first.widgetId, mode: first.mode, title, sequence }; 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; 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 teacher = (await pool.query('SELECT data FROM users WHERE id = $1', [assignment.teacher_id])).rows[0];
const board = teacher ? findBoardById(teacher.data, boardId) : null; const board = teacher ? findBoardById(teacher.data, boardId) : null;
let widgets = (board?.data?.widgets || []).map((widget) => ({ id: widget.id, wid: widget.wid, state: widget.state })); 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 const readySince = previous
? (completedRows.find((row) => row.step_id === previous.id)?.completed_at || assignment.updated_at) ? (completedRows.find((row) => row.step_id === previous.id)?.completed_at || assignment.updated_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 `SELECT 1 FROM progress_events
WHERE pupil_id = $1 AND widget_id = $2 AND created_at >= $3 LIMIT 1`, WHERE pupil_id = $1 AND widget_id = $2 AND created_at >= $3 LIMIT 1`,
[req.user.id, current.widgetId, readySince])).rows[0]; [req.user.id, current.widgetId, readySince])).rows[0];
return { return {
widgets: current ? await assignmentWidgets(assignment, current.boardId, current.widgetId) : [], widgets: current ? await assignmentWidgets(assignment, current.boardId, current.widgetId, current) : [],
mode: current?.mode || 'werken', mode: current?.mode || 'werken',
updatedAt: assignment.updated_at, updatedAt: assignment.updated_at,
sequence: { sequence: {
title: assignment.title || '', 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, currentIndex: currentIndex < 0 ? steps.length : currentIndex,
completed: completedIds.size, completed: completedIds.size,
total: steps.length, total: steps.length,
@ -1052,7 +1128,7 @@ export default async function api(app) {
const currentIndex = steps.findIndex((step) => !completedIds.has(step.id)); const currentIndex = steps.findIndex((step) => !completedIds.has(step.id));
const current = currentIndex >= 0 ? steps[currentIndex] : null; 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 || 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 previous = currentIndex > 0 ? steps[currentIndex - 1] : null;
const readySince = previous const readySince = previous
? (completedRows.find((row) => row.step_id === previous.id)?.completed_at || assignment.updated_at) ? (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 boardId = assignment.board_id;
let assignedWidgetId = assignment.widget_id; let assignedWidgetId = assignment.widget_id;
let assignedMode = assignment.mode || 'werken'; let assignedMode = assignment.mode || 'werken';
let assignedStep = null;
if (steps.length) { if (steps.length) {
const completed = new Set((await pool.query( const completed = new Set((await pool.query(
'SELECT step_id FROM assignment_step_progress WHERE assignment_id = $1 AND pupil_id = $2', '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)); [assignment.id, req.user.id])).rows.map((row) => row.step_id));
const current = steps.find((step) => !completed.has(step.id)); const current = steps.find((step) => !completed.has(step.id));
if (!current) return fail(reply, 409, 'opdrachtenreeks is al afgerond'); if (!current) return fail(reply, 409, 'opdrachtenreeks is al afgerond');
boardId = current.boardId; assignedStep = current;
boardId = current.boardId || assignment.board_id;
assignedWidgetId = current.widgetId; assignedWidgetId = current.widgetId;
assignedMode = current.mode; assignedMode = current.mode;
} }
@ -246,7 +248,7 @@ export default function registerLiveClassroom(app, options) {
} }
if (assignedMode !== mode) return fail(reply, 409, 'de opdrachtmodus is gewijzigd'); 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) => const known = widgets.some((widget) =>
widget.id === widgetType && (widget.wid ? widget.wid === widgetId : true)); widget.id === widgetType && (widget.wid ? widget.wid === widgetId : true));
if (!known) return fail(reply, 409, 'widget staat niet meer in de toewijzing'); 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'; import { readFile } from 'node:fs/promises';
test('leerkracht bouwt visueel een reeks en geeft die in één actie aan meerdere doelgroepen', async () => { 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/cast.js', 'utf8'),
readFile('public/js/admin.js', 'utf8'), readFile('public/js/admin.js', 'utf8'),
readFile('public/css/teach.css', 'utf8'), readFile('public/css/teach.css', 'utf8'),
readFile('public/js/core.js', '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, /castShareKind\s*=\s*"path"/);
assert.match(cast, /castSelectedTargets\s*=\s*new Set/); 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, /cast-widget-card/);
assert.match(cast, /castPathSteps\.push/); assert.match(cast, /castPathSteps\.push/);
assert.match(cast, /slice\(0,12-castPathSteps\.length\)/); 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, /castPupilCoveredByClass/);
assert.match(cast, /Promise\.allSettled\(targets\.map/); assert.match(cast, /Promise\.allSettled\(targets\.map/);
assert.match(cast, /castSendSelected/); 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-widget-grid[\s\S]*cast-path-step[\s\S]*cast-target-grid/);
assert.match(css, /cast-sendbar\{position:sticky/); assert.match(css, /cast-sendbar\{position:sticky/);
assert.match(css, /@media \(max-width:700px\)[\s\S]*cast-composer-sheet/); 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 [ for (const key of [
'castIntro', 'castKindPath', 'castLiveStateHint', 'castWidgetLibrary', 'castSequenceOrder', 'castIntro', 'castLibrarySubtitle', 'castKindPath', 'castLiveStateHint',
'castWidgetLibrary', 'castSequenceOrder', 'castTheme', 'castThemeNone',
'castBuiltInThemes', 'castOwnThemes', 'castIndependentStep',
'castAudienceTitle', 'castSelectVisible', 'castSend', 'castSent', 'castAudienceTitle', 'castSelectVisible', 'castSend', 'castSent',
'castNeedPath', 'castNeedTarget', 'castOpenComposer', 'castNeedPath', 'castNeedTarget', 'castOpenComposer',
]) { ]) {

View file

@ -162,6 +162,174 @@ test('leerkracht kan een gevalideerde reeks van maximaal twaalf stappen opslaan'
await app.close(); 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 () => { test('dashboard markeert klaar, bezig, hulp nodig en nog niet gestart', async () => {
const teacher = { const teacher = {
id: 3, username: 'juf', role: 'teacher', school_id: 2, class_id: null, 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(); 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 () => { test('voortgang houdt dezelfde live sessie direct actueel', async () => {
const pupil = { const pupil = {
id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50, id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50,