teach/public/js/cast.js
Ramon 05f3dbe56c
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 55s
feat: simplify assignment sequence sharing
2026-07-24 23:08:44 +02:00

389 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* 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. */
"use strict";
const btnCast = document.getElementById("btnCast");
btnCast.title = T("cast");
document.addEventListener("langchange", ()=>{
btnCast.title = T("cast");
if(castOpen) renderCast();
});
function h(tag, cls, text){
const el = document.createElement(tag);
if(cls) el.className = cls;
if(text != null) el.textContent = text;
return el;
}
const castBackdrop = h("div","am-sheet-backdrop");
castBackdrop.id = "castBackdrop";
document.body.appendChild(castBackdrop);
castBackdrop.addEventListener("click", e=>{ if(e.target===castBackdrop&&!castSaving) closeCast(); });
let castOpen = false;
let castLoading = false;
let castClasses = [], castPupils = [];
let castSelWidget = "", castSelMode = "werken";
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
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);
}
function castPathWidgetAllowed(widgetId){
const widget = currentBoardWidgets().find(item=>item.wid===widgetId);
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 ()=>{
stashCurrent(); /* verse widget-state, zelfde als btnFolders doet */
castOpen = true;
castSelWidget = ""; castSelMode = "werken";
castPathTitle = ""; castPathSteps = [];
castShareKind = currentBoardWidgets().some(w=>castPathWidgetAllowed(w.wid)) ? "path" : "board";
castSelectedTargets = new Set(); castPupilQuery = ""; castSaving = false; castNotice = "";
castActiveKeys = new Set(); castError = "";
castLoading = true;
renderCast();
try{
const [cRes, uRes] = await Promise.all([ api("/admin/classes"), api("/admin/users") ]);
castClasses = cRes.classes || [];
castPupils = (uRes.users || []).filter(u=>u.role==="pupil");
}catch(e){
castClasses = []; castPupils = []; castError = e.message;
}
castLoading = false;
renderCast();
});
function closeCast(){ castOpen = false; renderCast(); }
function castTargetPicker(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");
info.appendChild(h("span","am-name", target.label));
if(target.sub) info.appendChild(h("span","am-role", target.sub));
row.appendChild(info);
if(covered)row.appendChild(h("span","cast-target-via",T("castViaClass")));
else if(castActiveKeys.has(castTargetKey(target)))row.appendChild(h("span","am-chip active",T("castActive")));
return row;
}
function renderCast(preserveScroll=false){
const previousScroll=preserveScroll ? castBackdrop.querySelector(".am-sheet")?.scrollTop || 0 : 0;
castBackdrop.classList.toggle("open", castOpen);
castBackdrop.innerHTML = "";
if(!castOpen) return;
const sheet = h("div","am-sheet cast-composer-sheet");
castBackdrop.appendChild(sheet);
if(preserveScroll)requestAnimationFrame(()=>{sheet.scrollTop=previousScroll;});
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}`));
head.appendChild(titleWrap);
const closeBtn = h("button","am-btn", T("close"));
closeBtn.type = "button";
closeBtn.disabled = castSaving;
closeBtn.addEventListener("click", closeCast);
head.appendChild(closeBtn);
sheet.appendChild(head);
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);
const guide=h("ol","cast-guide");
[T("castStepChoose"),T("castStepBuild"),T("castStepAudience")].forEach((label,index)=>{
const item=h("li");item.append(h("span",null,String(index+1)),h("strong",null,label));guide.appendChild(item);
});
sheet.appendChild(guide);
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"]]
.forEach(([kind,labelKey,helpKey])=>{
const button=h("button","cast-kind-button"+(castShareKind===kind?" active":""));
button.type="button";button.disabled=castSaving;
button.append(h("strong",null,T(labelKey)),h("span",null,T(helpKey)));
button.addEventListener("click",()=>{
castShareKind=kind;castActiveKeys.clear();castError="";castNotice="";renderCast(true);
});
kindTabs.appendChild(button);
});
composer.appendChild(kindTabs);
composer.appendChild(h("p","cast-live-hint",T("castLiveStateHint")));
const learningWidgets=currentBoardWidgets().filter(widget=>castPathWidgetAllowed(widget.wid));
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 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)}));
});
audience.appendChild(classList);
const pupilHead=h("div","cast-pupil-head");pupilHead.appendChild(h("div","am-group",T("castPupils")));
const selectVisible=h("button","am-btn",T("castSelectVisible"));selectVisible.type="button";selectVisible.disabled=castSaving||!castPupils.length;
pupilHead.appendChild(selectVisible);audience.appendChild(pupilHead);
const search=h("input","am-inp cast-pupil-search");search.type="search";search.placeholder=T("castAudienceSearch");search.value=castPupilQuery;
audience.appendChild(search);
const pupilList=h("div","cast-target-grid cast-pupil-grid");
const pupilRows=[];
if(castLoading)pupilList.appendChild(h("div","cast-empty","…"));
else if(!castPupils.length)pupilList.appendChild(h("div","cast-empty",T("castNoPupils")));
else castPupils.forEach(pupil=>{
const className=castClasses.find(c=>String(c.id)===String(pupil.classId))?.name||T("amUnassigned");
const target={type:"pupil",id:pupil.id,label:pupil.displayName,classId:pupil.classId,sub:className};
const row=castTargetPicker(target);row.dataset.search=(pupil.displayName+" "+className).toLocaleLowerCase();
pupilRows.push({row,target});pupilList.appendChild(row);
});
const applyPupilFilter=()=>{
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));
});
castError="";castNotice="";renderCast(true);
});
audience.appendChild(pupilList);sheet.appendChild(audience);
const sendbar=h("footer","cast-sendbar");
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);
}