diff --git a/VERSION b/VERSION index bcd548c..a9ce227 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.61-beta +0.4.62-beta diff --git a/package-lock.json b/package-lock.json index c81b432..fe25d0c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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": { diff --git a/public/css/teach.css b/public/css/teach.css index 60c3cd3..c901283 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -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} } diff --git a/public/js/admin.js b/public/js/admin.js index 48190d3..82571e6 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -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; diff --git a/public/js/cast.js b/public/js/cast.js index cc92c05..1ecf78a 100644 --- a/public/js/cast.js +++ b/public/js/cast.js @@ -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"); diff --git a/public/js/core.js b/public/js/core.js index 0d3dc54..9178383 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.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.", diff --git a/public/js/pupil.js b/public/js/pupil.js index 21d1761..30394c5 100644 --- a/public/js/pupil.js +++ b/public/js/pupil.js @@ -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"); diff --git a/public/js/widgets/ball.js b/public/js/widgets/ball.js index 597fb6f..a46d0b6 100644 --- a/public/js/widgets/ball.js +++ b/public/js/widgets/ball.js @@ -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); diff --git a/public/js/widgets/flits.js b/public/js/widgets/flits.js index 700acb6..fd0e8f7 100644 --- a/public/js/widgets/flits.js +++ b/public/js/widgets/flits.js @@ -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); diff --git a/public/js/widgets/hak.js b/public/js/widgets/hak.js index 1fd603a..2620166 100644 --- a/public/js/widgets/hak.js +++ b/public/js/widgets/hak.js @@ -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); diff --git a/public/js/widgets/letters.js b/public/js/widgets/letters.js index b3ca7fd..666ca92 100644 --- a/public/js/widgets/letters.js +++ b/public/js/widgets/letters.js @@ -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(); } } }; } diff --git a/src/api.js b/src/api.js index a9ce3ea..a2bea4d 100644 --- a/src/api.js +++ b/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) diff --git a/src/live-classroom.js b/src/live-classroom.js index b3144fd..ba747c4 100644 --- a/src/live-classroom.js +++ b/src/live-classroom.js @@ -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'); diff --git a/test/assignment-composer.test.js b/test/assignment-composer.test.js index 4e44cad..9742ec7 100644 --- a/test/assignment-composer.test.js +++ b/test/assignment-composer.test.js @@ -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', ]) { diff --git a/test/learning-paths.test.js b/test/learning-paths.test.js index 5a7eeff..338eccf 100644 --- a/test/learning-paths.test.js +++ b/test/learning-paths.test.js @@ -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, diff --git a/test/live-sessions.test.js b/test/live-sessions.test.js index e21a8e4..9686c6d 100644 --- a/test/live-sessions.test.js +++ b/test/live-sessions.test.js @@ -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,