feat: simplify assignment sequence sharing
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 55s

This commit is contained in:
Ramon 2026-07-24 23:08:44 +02:00
parent b8e9e46819
commit 05f3dbe56c
6 changed files with 421 additions and 118 deletions

View file

@ -1 +1 @@
0.4.60-beta 0.4.61-beta

View file

@ -2425,12 +2425,33 @@ body.hc #settingsModal .am-inp, body.hc #settingsModal .am-sel{border-color:#000
#settingsModal .am-card-sub{font-size:12.5px; padding:5px 12px 5px 30px; flex-wrap:wrap;} #settingsModal .am-card-sub{font-size:12.5px; padding:5px 12px 5px 30px; flex-wrap:wrap;}
#settingsModal .am-card-sub .am-name{font-size:12.5px; font-weight:550; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;} #settingsModal .am-card-sub .am-name{font-size:12.5px; font-weight:550; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;}
/* Opdrachtenreeks in het cast-venster */ /* Visuele opdracht- en reeksbouwer voor groepsleiding */
.cast-path-list{display:flex;flex-direction:column;gap:6px;margin-top:8px} #castBackdrop .cast-composer-sheet{width:min(960px,calc(100vw - 32px));max-width:960px;max-height:min(90dvh,900px);padding:18px 20px 0;overscroll-behavior:contain}
.cast-path-step{display:grid;grid-template-columns:28px minmax(0,1fr) auto auto auto;align-items:center;gap:6px;padding:7px;border:1px solid var(--line);border-radius:10px;background:var(--surface-2)} .cast-composer-intro{max-width:72ch;margin:0 0 10px;color:var(--muted);font-size:13px;line-height:1.45}
.cast-path-number{width:24px;height:24px;display:grid;place-items:center;border-radius:50%;background:var(--accent);color:#fff;font-size:12px;font-weight:900} .cast-status{min-height:0;margin:0 0 10px;padding:0;font-size:13px;font-weight:850}
.cast-path-step .am-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap} .cast-status:empty{display:none}.cast-status.error{padding:9px 11px;border-radius:10px;background:var(--red-soft);color:var(--red)}.cast-status.success{padding:9px 11px;border-radius:10px;background:color-mix(in srgb,var(--green) 14%,var(--surface));color:var(--green)}
.cast-path-step .am-btn{min-width:30px;padding:4px 7px} .cast-guide{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:8px;margin:0 0 14px;padding:0;list-style:none}
.cast-guide li{display:flex;align-items:center;gap:8px;padding:8px 10px;border-radius:12px;background:var(--surface-2);color:var(--muted);font-size:11px}
.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-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}
.cast-field{display:flex;flex-direction:column;gap:5px;color:var(--muted);font-size:11.5px;font-weight:850}.cast-field .am-inp,.cast-field .am-sel{width:100%}.cast-mode-field{max-width:360px}
.cast-section-head,.cast-pupil-head{display:flex;align-items:center;justify-content:space-between;gap:10px}.cast-section-head>div:first-child{display:flex;min-width:0;flex-direction:column;gap:2px}.cast-section-head small{color:var(--muted);font-size:11px;font-weight:650;line-height:1.35}.cast-mini-actions{display:flex!important;flex-direction:row!important;flex:none;gap:6px}
.cast-widget-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(178px,1fr));gap:8px}
.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-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-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}
body.hc .cast-kind-button,body.hc .cast-widget-card,body.hc .cast-target-card,body.hc .cast-composer-card{border-width:2px}
/* Leerkrachtdashboard: scanbare status per leerling */ /* Leerkrachtdashboard: scanbare status per leerling */
#settingsModal .teacher-dashboard{margin:10px 0 20px} #settingsModal .teacher-dashboard{margin:10px 0 20px}
@ -2452,7 +2473,18 @@ body.hc #settingsModal .am-inp, body.hc #settingsModal .am-sel{border-color:#000
#settingsModal .dashboard-path-bar{display:block;height:5px;border-radius:99px;background:var(--surface-3);overflow:hidden} #settingsModal .dashboard-path-bar{display:block;height:5px;border-radius:99px;background:var(--surface-3);overflow:hidden}
#settingsModal .dashboard-path-bar i{display:block;height:100%;border-radius:inherit;background:var(--green)} #settingsModal .dashboard-path-bar i{display:block;height:100%;border-radius:inherit;background:var(--green)}
#settingsModal .dashboard-score{font-size:11.5px;font-weight:800;color:var(--muted)} #settingsModal .dashboard-score{font-size:11.5px;font-weight:800;color:var(--muted)}
@media (max-width:600px){#settingsModal .dashboard-summary{grid-template-columns:1fr 1fr}.cast-path-step{grid-template-columns:26px minmax(0,1fr) auto auto auto}} @media (max-width:700px){
#castBackdrop .cast-composer-sheet{width:100%;max-width:none;max-height:94dvh;padding:14px 14px 0;border-radius:18px 18px 0 0}
.cast-guide li{justify-content:center;padding:7px}.cast-guide li strong{display:none}
.cast-kind-tabs{grid-template-columns:1fr}.cast-kind-button{min-height:54px}.cast-kind-button span{display:none}
.cast-section-head{align-items:flex-start;flex-direction:column}.cast-section-head .cast-mini-actions{align-self:stretch}.cast-mini-actions .am-btn{flex:1}
.cast-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-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}
}
@media (max-width:420px){.cast-widget-grid{grid-template-columns:1fr}.cast-send-summary small{max-width:170px}.cast-sequence-count{white-space:normal}}
@media (max-width:600px){#settingsModal .dashboard-summary{grid-template-columns:1fr 1fr}}
/* Voortgang-tab: klassenmanagement-koppeling + grafiek (v0.4.35-beta e.v.) */ /* Voortgang-tab: klassenmanagement-koppeling + grafiek (v0.4.35-beta e.v.) */
/* Realtime leersessies bovenin het klasdashboard */ /* Realtime leersessies bovenin het klasdashboard */
@ -2494,8 +2526,10 @@ body.hc #settingsModal .am-inp, body.hc #settingsModal .am-sel{border-color:#000
#settingsModal .am-combo-pane-title{font-size:13px;font-weight:900;text-transform:uppercase;letter-spacing:.35px;color:var(--accent-ink)} #settingsModal .am-combo-pane-title{font-size:13px;font-weight:900;text-transform:uppercase;letter-spacing:.35px;color:var(--accent-ink)}
#settingsModal .am-combo-pane .teacher-dashboard{margin-top:0} #settingsModal .am-combo-pane .teacher-dashboard{margin-top:0}
#settingsModal .am-combo-pane .am-group:first-child{margin-top:0} #settingsModal .am-combo-pane .am-group:first-child{margin-top:0}
#settingsModal .assignment-composer-cta{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:12px;border:1px solid color-mix(in srgb,var(--accent) 35%,var(--line));border-radius:14px;background:var(--accent-soft)}
#settingsModal .assignment-composer-cta>div{display:flex;min-width:0;flex-direction:column;gap:3px}#settingsModal .assignment-composer-cta strong{font-size:13px;color:var(--accent-ink)}#settingsModal .assignment-composer-cta span{color:var(--muted);font-size:11px;line-height:1.35}#settingsModal .assignment-composer-cta .tbtn{flex:none;background:var(--accent);color:#fff}
@media (max-width:920px){#settingsModal .am-combo-hero{grid-template-columns:1fr}#settingsModal .am-combo-layout{grid-template-columns:1fr}} @media (max-width:920px){#settingsModal .am-combo-hero{grid-template-columns:1fr}#settingsModal .am-combo-layout{grid-template-columns:1fr}}
@media (max-width:600px){#settingsModal .am-combo-stats{grid-template-columns:1fr 1fr}} @media (max-width:600px){#settingsModal .am-combo-stats{grid-template-columns:1fr 1fr}#settingsModal .assignment-composer-cta{align-items:stretch;flex-direction:column}#settingsModal .assignment-composer-cta .tbtn{width:100%}}
#settingsModal .progress-level-badge{ #settingsModal .progress-level-badge{
display:inline-flex; align-self:flex-start; padding:4px 10px; margin-bottom:10px; display:inline-flex; align-self:flex-start; padding:4px 10px; margin-bottom:10px;

View file

@ -792,6 +792,17 @@
panel.appendChild(h("div","guestnote", T("amPickSchoolHint"))); panel.appendChild(h("div","guestnote", T("amPickSchoolHint")));
return; return;
} }
const composerCta=h("section","assignment-composer-cta");
const composerCopy=h("div");
composerCopy.append(h("strong",null,T("castOpenComposer")),h("span",null,T("castOpenComposerHint")));
const composerButton=h("button","tbtn",T("castOpenComposer"));
composerButton.type="button";
composerButton.addEventListener("click",()=>{
document.getElementById("settingsWrap").classList.remove("open");
document.getElementById("btnCast").click();
});
composerCta.append(composerCopy,composerButton);
panel.appendChild(composerCta);
panel.appendChild(h("div","am-group", T("amAssignClasses"))); panel.appendChild(h("div","am-group", T("amAssignClasses")));
const classList = h("div","am-list"); const classList = h("div","am-list");
if(!CLASSES.length) classList.appendChild(h("div","guestnote", T("amNoClass"))); if(!CLASSES.length) classList.appendChild(h("div","guestnote", T("amNoClass")));

View file

@ -20,13 +20,18 @@ function h(tag, cls, text){
const castBackdrop = h("div","am-sheet-backdrop"); const castBackdrop = h("div","am-sheet-backdrop");
castBackdrop.id = "castBackdrop"; castBackdrop.id = "castBackdrop";
document.body.appendChild(castBackdrop); document.body.appendChild(castBackdrop);
castBackdrop.addEventListener("click", e=>{ if(e.target===castBackdrop) closeCast(); }); castBackdrop.addEventListener("click", e=>{ if(e.target===castBackdrop&&!castSaving) closeCast(); });
let castOpen = false; let castOpen = false;
let castLoading = false; let castLoading = false;
let castClasses = [], castPupils = []; let castClasses = [], castPupils = [];
let castSelWidget = "", castSelMode = "werken"; let castSelWidget = "", castSelMode = "werken";
let castPathTitle = "", castPathSteps = []; 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 /* toewijzingen die deze sessie al gelukt zijn ("type:id"), zodat je hetzelfde
bord naar meerdere klassen/leerlingen achter elkaar kunt casten zonder dat bord naar meerdere klassen/leerlingen achter elkaar kunt casten zonder dat
de sheet na elke keer sluit */ de sheet na elke keer sluit */
@ -35,18 +40,109 @@ let castError = "";
/* widgets van het live-open bord, zelfde bron als serializeBoard() (board.js) */ /* widgets van het live-open bord, zelfde bron als serializeBoard() (board.js) */
function currentBoardWidgets(){ function currentBoardWidgets(){
return [...instances].map(inst=>({ wid: inst.wid, id: inst.def.id, cat: inst.def.cat })).filter(w=>w.wid); return [...instances].map(inst=>({
wid: inst.wid, id: inst.def.id, cat: inst.def.cat, icon: inst.def.icon || "🧩",
})).filter(w=>w.wid);
} }
function castPathWidgetAllowed(widgetId){ function castPathWidgetAllowed(widgetId){
const widget = currentBoardWidgets().find(item=>item.wid===widgetId); const widget = currentBoardWidgets().find(item=>item.wid===widgetId);
return !!widget && ["taal","rekenen"].includes(widget.cat); return !!widget && ["taal","rekenen"].includes(widget.cat);
} }
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 castTargetKey(target){ return target.type+":"+target.id; }
function castClassSelected(classId){ return castSelectedTargets.has("class:"+classId); }
function castPupilCoveredByClass(target){
return target.type==="pupil" && target.classId && castClassSelected(target.classId);
}
function castToggleTarget(target, checked){
const key=castTargetKey(target);
if(checked){
castSelectedTargets.add(key);
if(target.type==="class"){
castPupils.filter(p=>String(p.classId)===String(target.id))
.forEach(p=>castSelectedTargets.delete("pupil:"+p.id));
}
}else castSelectedTargets.delete(key);
castNotice="";castError="";
}
function castSelectedTargetObjects(){
const targets=[];
castClasses.forEach(c=>{
const target={type:"class",id:c.id,label:c.name};
if(castSelectedTargets.has(castTargetKey(target)))targets.push(target);
});
castPupils.forEach(p=>{
const target={type:"pupil",id:p.id,label:p.displayName,classId:p.classId};
if(castSelectedTargets.has(castTargetKey(target)))targets.push(target);
});
return targets;
}
function castAffectedPupilCount(){
const pupilIds=new Set();
castSelectedTargetObjects().forEach(target=>{
if(target.type==="pupil")pupilIds.add(String(target.id));
else castPupils.filter(p=>String(p.classId)===String(target.id)).forEach(p=>pupilIds.add(String(p.id)));
});
return pupilIds.size;
}
function castPayload(){
const body={boardId:curSlot().id};
if(castShareKind==="path"){
body.title=castPathTitle.trim();
body.sequence=castPathSteps.map(step=>({boardId:step.boardId,widgetId:step.widgetId,mode:step.mode}));
}else if(castShareKind==="widget"){
body.widgetId=castSelWidget;
body.mode=castSelMode;
}
return body;
}
function castValidationKey(){
if(castShareKind==="path"&&!castPathSteps.length)return "castNeedPath";
if(castShareKind==="path"&&!castPathTitle.trim())return "castNeedTitle";
if(castShareKind==="widget"&&!castSelWidget)return "castNeedWidget";
if(!castSelectedTargetObjects().length)return "castNeedTarget";
return "";
}
function castRefreshSendValidation(){
const invalid=castValidationKey();
const validation=castBackdrop.querySelector(".cast-validation");
const send=castBackdrop.querySelector(".cast-send-button");
if(validation){validation.textContent=invalid?T(invalid):"";validation.hidden=!invalid;}
if(send)send.disabled=castSaving||!!invalid;
}
async function castSendSelected(){
const invalid=castValidationKey();
if(invalid){castError=T(invalid);renderCast(true);return;}
const targets=castSelectedTargetObjects();
const payload=castPayload();
castSaving=true;castError="";castNotice="";renderCast(true);
const results=await Promise.allSettled(targets.map(target=>
api(`/assignments/${target.type}/${target.id}`,{method:"PUT",body:payload})
));
let saved=0,failed=0;
results.forEach((result,index)=>{
const key=castTargetKey(targets[index]);
if(result.status==="fulfilled"){
saved+=1;castActiveKeys.add(key);castSelectedTargets.delete(key);
}else failed+=1;
});
castSaving=false;
if(saved)castNotice=T("castSent").replace("{count}",saved);
if(failed)castError=T("castPartial").replace("{count}",failed);
renderCast(true);
}
btnCast.addEventListener("click", async ()=>{ btnCast.addEventListener("click", async ()=>{
stashCurrent(); /* verse widget-state, zelfde als btnFolders doet */ stashCurrent(); /* verse widget-state, zelfde als btnFolders doet */
castOpen = true; castOpen = true;
castSelWidget = ""; castSelMode = "werken"; castSelWidget = ""; castSelMode = "werken";
castPathTitle = ""; castPathSteps = []; castPathTitle = ""; castPathSteps = [];
castShareKind = currentBoardWidgets().some(w=>castPathWidgetAllowed(w.wid)) ? "path" : "board";
castSelectedTargets = new Set(); castPupilQuery = ""; castSaving = false; castNotice = "";
castActiveKeys = new Set(); castError = ""; castActiveKeys = new Set(); castError = "";
castLoading = true; castLoading = true;
renderCast(); renderCast();
@ -63,37 +159,36 @@ btnCast.addEventListener("click", async ()=>{
function closeCast(){ castOpen = false; renderCast(); } function closeCast(){ castOpen = false; renderCast(); }
function castTargetRow(target){ function castTargetPicker(target){
const row = h("div","am-row am-target"); const row = h("label","cast-target-card");
const covered=castPupilCoveredByClass(target);
const checkbox=h("input","cast-target-check");
checkbox.type="checkbox";
checkbox.checked=covered||castSelectedTargets.has(castTargetKey(target));
checkbox.disabled=covered||castSaving;
checkbox.addEventListener("change",()=>{
castToggleTarget(target,checkbox.checked);
renderCast(true);
});
row.appendChild(checkbox);
const info = h("div","am-person-info"); const info = h("div","am-person-info");
info.appendChild(h("span","am-name", target.label)); info.appendChild(h("span","am-name", target.label));
if(target.sub) info.appendChild(h("span","am-role", target.sub)); if(target.sub) info.appendChild(h("span","am-role", target.sub));
row.appendChild(info); row.appendChild(info);
const key = target.type+":"+target.id; if(covered)row.appendChild(h("span","cast-target-via",T("castViaClass")));
if(castActiveKeys.has(key)) row.appendChild(h("span","am-chip active", T("castActive"))); else if(castActiveKeys.has(castTargetKey(target)))row.appendChild(h("span","am-chip active",T("castActive")));
row.addEventListener("click", async ()=>{
try{
const body = { boardId: curSlot().id };
if(castPathSteps.length){
body.title = castPathTitle.trim();
body.sequence = castPathSteps.map(step=>({boardId:step.boardId, widgetId:step.widgetId, mode:step.mode}));
}else if(castSelWidget){ body.widgetId = castSelWidget; body.mode = castSelMode; }
await api(`/assignments/${target.type}/${target.id}`, { method:"PUT", body });
castActiveKeys.add(key);
castError = "";
}catch(e){ castError = e.message; }
renderCast();
});
return row; return row;
} }
function renderCast(){ function renderCast(preserveScroll=false){
const previousScroll=preserveScroll ? castBackdrop.querySelector(".am-sheet")?.scrollTop || 0 : 0;
castBackdrop.classList.toggle("open", castOpen); castBackdrop.classList.toggle("open", castOpen);
castBackdrop.innerHTML = ""; castBackdrop.innerHTML = "";
if(!castOpen) return; if(!castOpen) return;
const sheet = h("div","am-sheet"); const sheet = h("div","am-sheet cast-composer-sheet");
castBackdrop.appendChild(sheet); castBackdrop.appendChild(sheet);
if(preserveScroll)requestAnimationFrame(()=>{sheet.scrollTop=previousScroll;});
const head = h("div","am-kebab-row"); const head = h("div","am-kebab-row");
const titleWrap = h("div"); const titleWrap = h("div");
@ -102,98 +197,193 @@ function renderCast(){
head.appendChild(titleWrap); head.appendChild(titleWrap);
const closeBtn = h("button","am-btn", T("close")); const closeBtn = h("button","am-btn", T("close"));
closeBtn.type = "button"; closeBtn.type = "button";
closeBtn.disabled = castSaving;
closeBtn.addEventListener("click", closeCast); closeBtn.addEventListener("click", closeCast);
head.appendChild(closeBtn); head.appendChild(closeBtn);
sheet.appendChild(head); sheet.appendChild(head);
sheet.appendChild(h("div","am-msg", castError)); sheet.appendChild(h("p","cast-composer-intro",T("castIntro")));
const status=h("div","cast-status");
status.setAttribute("aria-live","polite");
if(castError){status.classList.add("error");status.textContent=castError;}
else if(castNotice){status.classList.add("success");status.textContent=castNotice;}
sheet.appendChild(status);
sheet.appendChild(h("div","am-group", T("castWhat"))); const guide=h("ol","cast-guide");
const widgetSel = h("select","am-sel"); [T("castStepChoose"),T("castStepBuild"),T("castStepAudience")].forEach((label,index)=>{
widgetSel.appendChild(new Option(T("amWholeBoard"), "")); const item=h("li");item.append(h("span",null,String(index+1)),h("strong",null,label));guide.appendChild(item);
currentBoardWidgets().forEach(w=>widgetSel.appendChild(new Option(T("wg_"+w.id), w.wid)));
widgetSel.value = castSelWidget;
const modeSel = h("select","am-sel");
modeSel.appendChild(new Option(T("amModeWork"), "werken"));
modeSel.appendChild(new Option(T("amModeWatch"), "kijken"));
modeSel.value = castSelMode;
modeSel.style.display = castSelWidget ? "" : "none";
widgetSel.addEventListener("change", ()=>{
castSelWidget = widgetSel.value;
modeSel.style.display = castSelWidget ? "" : "none";
addStep.disabled = !castPathWidgetAllowed(castSelWidget) || castPathSteps.length >= 12;
}); });
modeSel.addEventListener("change", ()=>{ castSelMode = modeSel.value; }); sheet.appendChild(guide);
const whatRow = h("div","am-inline");
whatRow.appendChild(widgetSel);
whatRow.appendChild(modeSel);
sheet.appendChild(whatRow);
sheet.appendChild(h("div","am-group", T("castPathTitle"))); const composer=h("section","cast-composer-card");
const pathTitle = h("input","am-inp"); composer.appendChild(h("h3",null,T("castWhat")));
pathTitle.type = "text"; const kindTabs=h("div","cast-kind-tabs");
pathTitle.maxLength = 80; [["path","castKindPath","castKindPathHelp"],["widget","castKindWidget","castKindWidgetHelp"],["board","castKindBoard","castKindBoardHelp"]]
pathTitle.placeholder = T("castPathPlaceholder"); .forEach(([kind,labelKey,helpKey])=>{
pathTitle.value = castPathTitle; const button=h("button","cast-kind-button"+(castShareKind===kind?" active":""));
pathTitle.addEventListener("input", ()=>{ castPathTitle = pathTitle.value; castActiveKeys.clear(); }); button.type="button";button.disabled=castSaving;
sheet.appendChild(pathTitle); button.append(h("strong",null,T(labelKey)),h("span",null,T(helpKey)));
const addStep = h("button","am-btn", T("castPathAdd")); button.addEventListener("click",()=>{
addStep.type = "button"; castShareKind=kind;castActiveKeys.clear();castError="";castNotice="";renderCast(true);
addStep.disabled = !castPathWidgetAllowed(castSelWidget) || castPathSteps.length >= 12; });
addStep.title = T("castPathOnlyLearning"); kindTabs.appendChild(button);
addStep.addEventListener("click", ()=>{ });
if(!castPathWidgetAllowed(castSelWidget) || castPathSteps.length >= 12) return; composer.appendChild(kindTabs);
const widget = currentBoardWidgets().find(item=>item.wid===castSelWidget); composer.appendChild(h("p","cast-live-hint",T("castLiveStateHint")));
castPathSteps.push({boardId:curSlot().id, widgetId:castSelWidget, widgetType:widget?.id || "", mode:castSelMode});
if(!castPathTitle) castPathTitle = T("castPathDefault"); const learningWidgets=currentBoardWidgets().filter(widget=>castPathWidgetAllowed(widget.wid));
castActiveKeys.clear(); const widgetGrid=(selectOnly=false)=>{
renderCast(); 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 card=h("button","cast-widget-card"+(selected?" selected":""));
card.type="button";card.disabled=castSaving||(!selectOnly&&castPathSteps.length>=12);
card.setAttribute("aria-pressed",String(selected));
card.appendChild(h("span","cast-widget-icon",widget.icon));
const copy=h("span","cast-widget-copy");
copy.append(h("strong",null,castWidgetLabel(widget)),h("small",null,T(widget.cat==="taal"?"catTaal":"catRekenen")));
card.appendChild(copy);
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;
else{
castPathSteps.push({boardId:curSlot().id,widgetId:widget.wid,widgetType:widget.id,widgetLabel:castWidgetLabel(widget),icon:widget.icon,mode:"werken"});
if(!castPathTitle)castPathTitle=T("castPathDefault");
}
castActiveKeys.clear();castError="";castNotice="";renderCast(true);
});
grid.appendChild(card);
});
return grid;
};
if(castShareKind==="path"){
const titleField=h("label","cast-field");
titleField.appendChild(h("span",null,T("castPathName")));
const pathTitle=h("input","am-inp");
pathTitle.type="text";pathTitle.maxLength=80;pathTitle.placeholder=T("castPathPlaceholder");pathTitle.value=castPathTitle;
pathTitle.addEventListener("input",()=>{
castPathTitle=pathTitle.value;castActiveKeys.clear();castNotice="";castRefreshSendValidation();
});
titleField.appendChild(pathTitle);composer.appendChild(titleField);
const libraryHead=h("div","cast-section-head");
const libraryCopy=h("div");libraryCopy.append(h("strong",null,T("castWidgetLibrary")),h("small",null,T("castWidgetLibraryHint")));
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.addEventListener("click",()=>{
learningWidgets.filter(widget=>!castPathSteps.some(step=>step.widgetId===widget.wid))
.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",
}));
if(castPathSteps.length&&!castPathTitle)castPathTitle=T("castPathDefault");
castActiveKeys.clear();castError="";castNotice="";renderCast(true);
});
const clearPath=h("button","am-btn",T("castClearPath"));clearPath.type="button";clearPath.disabled=castSaving||!castPathSteps.length;
clearPath.addEventListener("click",()=>{castPathSteps=[];castActiveKeys.clear();castError="";castNotice="";renderCast(true);});
libraryActions.append(addAll,clearPath);libraryHead.appendChild(libraryActions);composer.appendChild(libraryHead);
if(learningWidgets.length)composer.appendChild(widgetGrid());
else composer.appendChild(h("div","cast-empty",T("castNoLearningWidgets")));
const sequenceHead=h("div","cast-section-head");
sequenceHead.appendChild(h("strong",null,T("castSequenceOrder")));
const count=h("span","cast-sequence-count",T("castPathHint").replace("{count}",castPathSteps.length));
sequenceHead.appendChild(count);composer.appendChild(sequenceHead);
if(!castPathSteps.length)composer.appendChild(h("div","cast-path-empty",T("castPathEmpty")));
else{
const pathList=h("div","cast-path-list");
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")));
row.appendChild(label);
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="";});
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);});
const down=h("button","am-btn","↓");down.type="button";down.disabled=castSaving||index===castPathSteps.length-1;down.title=T("castMoveDown");
down.addEventListener("click",()=>{[castPathSteps[index+1],castPathSteps[index]]=[castPathSteps[index],castPathSteps[index+1]];castActiveKeys.clear();renderCast(true);});
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);
});
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")));
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");
const audienceHead=h("div","cast-section-head");
const audienceCopy=h("div");audienceCopy.append(h("h3",null,T("castAudienceTitle")),h("small",null,T("castAudienceHint")));
const clearTargets=h("button","am-btn",T("castClearTargets"));clearTargets.type="button";clearTargets.disabled=castSaving||!castSelectedTargets.size;
clearTargets.addEventListener("click",()=>{castSelectedTargets.clear();castError="";castNotice="";renderCast(true);});
audienceHead.append(audienceCopy,clearTargets);audience.appendChild(audienceHead);
audience.appendChild(h("div","am-group",T("castClasses")));
const classList=h("div","cast-target-grid cast-class-grid");
if(castLoading)classList.appendChild(h("div","cast-empty","…"));
else if(!castClasses.length)classList.appendChild(h("div","cast-empty",T("castNoClasses")));
else castClasses.forEach(c=>{
const count=castPupils.filter(p=>String(p.classId)===String(c.id)).length;
classList.appendChild(castTargetPicker({type:"class",id:c.id,label:c.name,sub:T("castPupilCount").replace("{count}",count)}));
}); });
sheet.appendChild(addStep); audience.appendChild(classList);
if(castPathSteps.length){
const pathList = h("div","cast-path-list");
castPathSteps.forEach((step,index)=>{
const row = h("div","cast-path-step");
row.appendChild(h("span","cast-path-number", String(index+1)));
const label = h("span","am-name", T("wg_"+step.widgetType));
label.appendChild(h("small","am-role", " · "+T(step.mode==="kijken"?"amModeWatch":"amModeWork")));
row.appendChild(label);
const up = h("button","am-btn","↑"); up.type="button"; up.disabled=index===0;
up.addEventListener("click",()=>{ [castPathSteps[index-1],castPathSteps[index]]=[castPathSteps[index],castPathSteps[index-1]]; castActiveKeys.clear(); renderCast(); });
const down = h("button","am-btn","↓"); down.type="button"; down.disabled=index===castPathSteps.length-1;
down.addEventListener("click",()=>{ [castPathSteps[index+1],castPathSteps[index]]=[castPathSteps[index],castPathSteps[index+1]]; castActiveKeys.clear(); renderCast(); });
const remove = h("button","am-btn","×"); remove.type="button";
remove.setAttribute("aria-label",T("amDelete"));
remove.addEventListener("click",()=>{ castPathSteps.splice(index,1); castActiveKeys.clear(); renderCast(); });
row.append(up,down,remove);
pathList.appendChild(row);
});
sheet.appendChild(pathList);
sheet.appendChild(h("div","guestnote", T("castPathHint").replace("{count}",castPathSteps.length)));
}
sheet.appendChild(h("div","am-group", T("castClasses"))); const pupilHead=h("div","cast-pupil-head");pupilHead.appendChild(h("div","am-group",T("castPupils")));
const classList = h("div","am-list"); const selectVisible=h("button","am-btn",T("castSelectVisible"));selectVisible.type="button";selectVisible.disabled=castSaving||!castPupils.length;
if(castLoading) classList.appendChild(h("div","guestnote", "…")); pupilHead.appendChild(selectVisible);audience.appendChild(pupilHead);
else if(!castClasses.length) classList.appendChild(h("div","guestnote", T("castNoClasses"))); const search=h("input","am-inp cast-pupil-search");search.type="search";search.placeholder=T("castAudienceSearch");search.value=castPupilQuery;
else castClasses.forEach(c=>classList.appendChild(castTargetRow({type:"class", id:c.id, label:c.name}))); audience.appendChild(search);
sheet.appendChild(classList); const pupilList=h("div","cast-target-grid cast-pupil-grid");
const pupilRows=[];
sheet.appendChild(h("div","am-group", T("castPupils"))); if(castLoading)pupilList.appendChild(h("div","cast-empty","…"));
const pupilList = h("div","am-list"); else if(!castPupils.length)pupilList.appendChild(h("div","cast-empty",T("castNoPupils")));
if(castLoading) pupilList.appendChild(h("div","guestnote", "…")); else castPupils.forEach(pupil=>{
else if(!castPupils.length) pupilList.appendChild(h("div","guestnote", T("castNoPupils"))); const className=castClasses.find(c=>String(c.id)===String(pupil.classId))?.name||T("amUnassigned");
else{ const target={type:"pupil",id:pupil.id,label:pupil.displayName,classId:pupil.classId,sub:className};
const byClass = new Map(); const row=castTargetPicker(target);row.dataset.search=(pupil.displayName+" "+className).toLocaleLowerCase();
castPupils.forEach(u=>{ pupilRows.push({row,target});pupilList.appendChild(row);
const key = u.classId || "none"; });
if(!byClass.has(key)) byClass.set(key, []); const applyPupilFilter=()=>{
byClass.get(key).push(u); castPupilQuery=search.value;
const query=castPupilQuery.trim().toLocaleLowerCase();
pupilRows.forEach(({row})=>{row.hidden=!!query&&!row.dataset.search.includes(query);});
};
search.addEventListener("input",applyPupilFilter);applyPupilFilter();
selectVisible.addEventListener("click",()=>{
pupilRows.filter(({row})=>!row.hidden).forEach(({target})=>{
if(!castPupilCoveredByClass(target))castSelectedTargets.add(castTargetKey(target));
}); });
castClasses.forEach(c=>(byClass.get(c.id) || []).forEach(u=> castError="";castNotice="";renderCast(true);
pupilList.appendChild(castTargetRow({type:"pupil", id:u.id, label:u.displayName, sub:c.name})))); });
(byClass.get("none") || []).forEach(u=> audience.appendChild(pupilList);sheet.appendChild(audience);
pupilList.appendChild(castTargetRow({type:"pupil", id:u.id, label:u.displayName, sub:T("amUnassigned")})));
} const sendbar=h("footer","cast-sendbar");
sheet.appendChild(pupilList); const summary=h("div","cast-send-summary");
const targetCount=castSelectedTargetObjects().length,affected=castAffectedPupilCount();
summary.append(h("strong",null,T("castTargetCount").replace("{count}",targetCount)),
h("span",null,T("castAffectedPupils").replace("{count}",affected)));
const invalid=castValidationKey();
const validation=h("small","cast-validation",invalid?T(invalid):"");validation.hidden=!invalid;summary.appendChild(validation);
const send=h("button","cast-send-button",castSaving?T("castSending"):T("castSend").replace("{count}",targetCount));
send.type="button";send.disabled=castSaving||!!invalid;send.addEventListener("click",castSendSelected);
sendbar.append(summary,send);sheet.appendChild(sendbar);
} }

View file

@ -2,7 +2,7 @@
"use strict"; "use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline). /* version shown until /api/version resolves (or if the fetch fails, e.g. offline).
Kept in sync by hand with the VERSION file at the repo root on every release. */ Kept in sync by hand with the VERSION file at the repo root on every release. */
const VERSION = "0.4.60-beta"; const VERSION = "0.4.61-beta";
/* Safari en oudere mobiele browsers behandelen 100vh/100% soms als de hoogte /* Safari en oudere mobiele browsers behandelen 100vh/100% soms als de hoogte
achter hun adres- en navigatiebalk. visualViewport geeft de werkelijk achter hun adres- en navigatiebalk. visualViewport geeft de werkelijk
zichtbare hoogte; de CSS gebruikt deze pixelwaarde als betrouwbare bron en zichtbare hoogte; de CSS gebruikt deze pixelwaarde als betrouwbare bron en
@ -404,11 +404,28 @@ const I18N = {
amAssignedAs:"Toegewezen:", amAssign:"Toewijzen", amAssignSaved:"Toewijzing opgeslagen.", amAssignedAs:"Toegewezen:", amAssign:"Toewijzen", amAssignSaved:"Toewijzing opgeslagen.",
amUnlink:"Loskoppelen", amUnlink:"Loskoppelen",
amWholeBoard:"— heel bord —", amModeWatch:"kijken", amModeWork:"werken", amWholeBoard:"— heel bord —", amModeWatch:"kijken", amModeWork:"werken",
cast:"Cast", castTitle:"Cast bord", castWhat:"Wat cast je?", cast:"Cast", castTitle:"Opdrachten klaarzetten", castWhat:"Wat wil je doorgeven?",
castClasses:"Klassen", castPupils:"Leerlingen", castClasses:"Klassen", castPupils:"Leerlingen",
castNoClasses:"Geen klassen in deze school.", castNoPupils:"Geen leerlingen in deze school.", castNoClasses:"Geen klassen in deze school.", castNoPupils:"Geen leerlingen in deze school.",
castActive:"✓ Actief", castPathTitle:"Opdrachtenreeks (optioneel)", castPathPlaceholder:"Naam van de reeks…", castActive:"✓ Actief", castPathTitle:"Opdrachtenreeks (optioneel)", castPathPlaceholder:"Naam van de reeks…",
castPathAdd:" Geselecteerde widget als stap", castPathOnlyLearning:"Reeksen ondersteunen taal- en rekenwidgets.", castPathDefault:"Mijn opdrachtenreeks", castPathHint:"{count} van maximaal 12 stappen. Sleepvolgorde wordt met de pijlen bepaald.", 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.",
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.",
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.",
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)",
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.",
amDashboardTitle:"Klasdashboard", amProgressDetail:"Detail per leerling", amDashboardTitle:"Klasdashboard", amProgressDetail:"Detail per leerling",
amLiveSessions:"Live leersessies", amLiveConnected:"● Live verbonden", amLiveReconnecting:"○ Verbinden…", amLiveSessions:"Live leersessies", amLiveConnected:"● Live verbonden", amLiveReconnecting:"○ Verbinden…",
amLiveNow:"Nu actief", amLiveRecent:"Meest recente sessies", amLiveNone:"Nog geen leerling actief.", amLiveRecentNone:"Nog geen recente sessies.", amLiveNow:"Nu actief", amLiveRecent:"Meest recente sessies", amLiveNone:"Nog geen leerling actief.", amLiveRecentNone:"Nog geen recente sessies.",
@ -807,11 +824,28 @@ const I18N = {
amAssignedAs:"Assigned:", amAssign:"Assign", amAssignSaved:"Assignment saved.", amAssignedAs:"Assigned:", amAssign:"Assign", amAssignSaved:"Assignment saved.",
amUnlink:"Unlink", amUnlink:"Unlink",
amWholeBoard:"— whole board —", amModeWatch:"watch", amModeWork:"work", amWholeBoard:"— whole board —", amModeWatch:"watch", amModeWork:"work",
cast:"Cast", castTitle:"Cast board", castWhat:"What are you casting?", cast:"Cast", castTitle:"Prepare assignments", castWhat:"What would you like to assign?",
castClasses:"Classes", castPupils:"Pupils", castClasses:"Classes", castPupils:"Pupils",
castNoClasses:"No classes in this school.", castNoPupils:"No pupils in this school.", castNoClasses:"No classes in this school.", castNoPupils:"No pupils in this school.",
castActive:"✓ Active", castPathTitle:"Learning path (optional)", castPathPlaceholder:"Name of the path…", castActive:"✓ Active", castPathTitle:"Learning path (optional)", castPathPlaceholder:"Name of the path…",
castPathAdd:" Add selected widget as step", castPathOnlyLearning:"Paths support language and maths widgets.", castPathDefault:"My learning path", castPathHint:"{count} of up to 12 steps. Use the arrows to set the order.", castPathAdd:" Add selected widget as step", castPathOnlyLearning:"Paths support language and maths widgets.", castPathDefault:"My learning path", castPathHint:"{count} of up to 12 steps. Use the arrows to set the order.",
castIntro:"Build an assignment or path from the widgets on this board, then share it in one go.",
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.",
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.",
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)",
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.",
amDashboardTitle:"Class dashboard", amProgressDetail:"Pupil details", amDashboardTitle:"Class dashboard", amProgressDetail:"Pupil details",
amLiveSessions:"Live learning sessions", amLiveConnected:"● Live connected", amLiveReconnecting:"○ Connecting…", amLiveSessions:"Live learning sessions", amLiveConnected:"● Live connected", amLiveReconnecting:"○ Connecting…",
amLiveNow:"Active now", amLiveRecent:"Most recent sessions", amLiveNone:"No pupil is active yet.", amLiveRecentNone:"No recent sessions yet.", amLiveNow:"Active now", amLiveRecent:"Most recent sessions", amLiveNone:"No pupil is active yet.", amLiveRecentNone:"No recent sessions yet.",

View file

@ -0,0 +1,34 @@
import test from 'node:test';
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([
readFile('public/js/cast.js', 'utf8'),
readFile('public/js/admin.js', 'utf8'),
readFile('public/css/teach.css', 'utf8'),
readFile('public/js/core.js', 'utf8'),
]);
assert.match(cast, /castShareKind\s*=\s*"path"/);
assert.match(cast, /castSelectedTargets\s*=\s*new Set/);
assert.match(cast, /cast-widget-card/);
assert.match(cast, /castPathSteps\.push/);
assert.match(cast, /slice\(0,12-castPathSteps\.length\)/);
assert.match(cast, /castPupilCoveredByClass/);
assert.match(cast, /Promise\.allSettled\(targets\.map/);
assert.match(cast, /castSendSelected/);
assert.match(admin, /assignment-composer-cta[\s\S]*btnCast/);
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 key of [
'castIntro', 'castKindPath', 'castLiveStateHint', 'castWidgetLibrary', 'castSequenceOrder',
'castAudienceTitle', 'castSelectVisible', 'castSend', 'castSent',
'castNeedPath', 'castNeedTarget', 'castOpenComposer',
]) {
assert.equal(core.split(`${key}:`).length - 1, 2, `${key} moet tweetalig zijn`);
}
});