Voeg thematische kleurplaten en creatieve niveaus toe (v0.4.65-beta) #3
16 changed files with 472 additions and 96 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
|||
0.4.61-beta
|
||||
0.4.62-beta
|
||||
|
|
|
|||
8
package-lock.json
generated
8
package-lock.json
generated
|
|
@ -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": {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -662,6 +662,9 @@
|
|||
gebeurt niet meer hier - zie cast.js. */
|
||||
function describeCurrent(current){
|
||||
if(!current) return "";
|
||||
if(Array.isArray(current.sequence)&¤t.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;
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
|
|
@ -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 woordthema’s.",
|
||||
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 thema’s", castOwnThemes:"Mijn thema’s", 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.",
|
||||
|
|
|
|||
|
|
@ -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&¤tStep?.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");
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
fillWordSourceSel(srcSel, S.card,S.yearGroup,S.level);
|
||||
S.card=srcSel.value||"g";
|
||||
srcSel.addEventListener("change", ()=>{ S.card = srcSel.value; newRound(); render(); });
|
||||
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(); } }
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
90
src/api.js
90
src/api.js
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
]) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue