teach/public/js/cast.js
Ramon 043831e1dd
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m28s
feat: make assignments independent from boards
2026-07-25 22:15:52 +02:00

434 lines
22 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 - 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");
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", 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 dezelfde
opdracht naar meerdere klassen/leerlingen achter elkaar kunt sturen zonder dat
de sheet na elke keer sluit */
let castActiveKeys = new Set();
let castError = "";
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 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 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); }
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={};
if(castShareKind==="path"){
body.title=castPathTitle.trim();
body.sequence=castPathSteps.map(step=>({
widgetType:step.widgetType,mode:step.mode,theme:castThemePayload(step.theme),
}));
}else if(castShareKind==="widget"){
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;
}
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 ()=>{
castOpen = true;
castSelWidget = ""; castSelMode = "werken"; castSingleTheme = null;
castPathTitle = ""; castPathSteps = [];
castShareKind = "path";
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",T("castLibrarySubtitle")));
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"]]
.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=assignmentLibraryWidgets();
const widgetGrid=(selectOnly=false)=>{
const grid=h("div","cast-widget-grid");
learningWidgets.forEach(widget=>{
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));
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){
if(castSelWidget!==widget.id)castSingleTheme=null;
castSelWidget=widget.id;
}
else{
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);
});
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.widgetType===widget.id))||castPathSteps.length>=12;
addAll.addEventListener("click",()=>{
learningWidgets.filter(widget=>!castPathSteps.some(step=>step.widgetType===widget.id))
.slice(0,12-castPathSteps.length).forEach(widget=>castPathSteps.push({
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);
});
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("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);});
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(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")));
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);
}
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);
}