All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m28s
263 lines
12 KiB
JavaScript
263 lines
12 KiB
JavaScript
/* teach - leerling-omgeving: toont enkel de widgets die de leerkracht heeft
|
|
klaargezet (via een toewijzing aan de klas of de leerling zelf), in
|
|
speelmodus (readonly) en zonder de sleep/vergroot/sluit-chrome van het bord.
|
|
Geen momentopname: /my/assignment zoekt live in het bord van de leerkracht,
|
|
dus deze pagina pollt periodiek zodat nieuwe woorden vanzelf doorkomen.
|
|
Modus per toewijzing: "werken" (default, leerling werkt in de widget en
|
|
voortgang wordt gelogd) of "kijken" (alleen meekijken: alle interactie
|
|
geblokkeerd via een overlay, snellere poll zodat wijzigingen van de
|
|
leerkracht vlot doorkomen). */
|
|
const PUPIL_WIDGET_CATS = ["taal", "rekenen"];
|
|
const PUPIL_POLL_MS = { kijken: 5000, werken: 20000 };
|
|
const PUPIL_LIVE_HEARTBEAT_MS = 12000;
|
|
const pupilView = document.getElementById("pupilView");
|
|
let pupilPollTimer = null;
|
|
let pupilLastSig = null;
|
|
let pupilMode = "werken";
|
|
let pupilPendingRewards = [];
|
|
let pupilLiveSession = null;
|
|
let pupilLiveStart = null;
|
|
let pupilLiveHeartbeatTimer = null;
|
|
|
|
async function endPupilLiveSession(keepalive=false){
|
|
const current=pupilLiveSession;
|
|
pupilLiveSession=null;pupilLiveStart=null;
|
|
if(!current)return;
|
|
const path="/api/my/live-session/"+encodeURIComponent(current.id)+"/end";
|
|
if(keepalive){
|
|
fetch(path,{method:"POST",credentials:"same-origin",keepalive:true}).catch(()=>{});
|
|
return;
|
|
}
|
|
try{await api("/my/live-session/"+encodeURIComponent(current.id)+"/end",{method:"POST"});}
|
|
catch(e){ /* een verlopen sessie is al effectief afgesloten */ }
|
|
}
|
|
async function ensurePupilLiveSession(widgetId,widgetType,mode){
|
|
const key=widgetId+"|"+widgetType+"|"+mode;
|
|
if(pupilLiveSession?.key===key)return pupilLiveSession.id;
|
|
if(pupilLiveStart?.key===key)return pupilLiveStart.promise;
|
|
if(pupilLiveSession)await endPupilLiveSession();
|
|
const promise=api("/my/live-session/start",{method:"POST",body:{widgetId,widgetType,mode}})
|
|
.then(result=>{
|
|
pupilLiveSession={id:result.session.id,key};
|
|
pupilLiveStart=null;
|
|
return pupilLiveSession.id;
|
|
})
|
|
.catch(error=>{
|
|
pupilLiveStart=null;
|
|
console.warn("live sessie starten mislukt:",error.message);
|
|
return null;
|
|
});
|
|
pupilLiveStart={key,promise};
|
|
return promise;
|
|
}
|
|
async function heartbeatPupilLiveSession(){
|
|
const current=pupilLiveSession;
|
|
if(!current||document.visibilityState==="hidden")return;
|
|
try{await api("/my/live-session/"+encodeURIComponent(current.id)+"/heartbeat",{method:"POST"});}
|
|
catch(e){if(pupilLiveSession?.id===current.id)pupilLiveSession=null;}
|
|
}
|
|
window.addEventListener("pagehide",()=>endPupilLiveSession(true));
|
|
|
|
function pupilAllowedDefs(){
|
|
return REGISTRY.filter(d => PUPIL_WIDGET_CATS.includes(d.cat));
|
|
}
|
|
|
|
async function renderPupilWidgets(widgets, mode, preview=false, sequence=null){
|
|
if(!preview)await endPupilLiveSession();
|
|
pupilView.innerHTML = "";
|
|
const defs = pupilAllowedDefs();
|
|
const watch = mode === "kijken";
|
|
if(sequence){
|
|
const path = document.createElement("section");
|
|
path.className = "pupil-path" + (sequence.done ? " done" : "");
|
|
const title = document.createElement("div");
|
|
title.className = "pupil-path-title";
|
|
title.textContent = sequence.title || T("pupilPath");
|
|
const progress = document.createElement("div");
|
|
progress.className = "pupil-path-progress";
|
|
progress.textContent = sequence.done ? T("pupilPathComplete")
|
|
: T("pupilPathStep").replace("{current}", sequence.currentIndex + 1).replace("{total}", sequence.total);
|
|
const dots = document.createElement("div");
|
|
dots.className = "pupil-path-dots";
|
|
(sequence.steps || []).forEach((step, index)=>{
|
|
const dot = document.createElement("span");
|
|
dot.className = step.completed ? "complete" : index === sequence.currentIndex ? "current" : "";
|
|
dot.textContent = step.completed ? "✓" : String(index + 1);
|
|
dots.appendChild(dot);
|
|
});
|
|
path.append(title, progress, dots);
|
|
const currentStep=sequence.steps?.[sequence.currentIndex];
|
|
if(!sequence.done&¤tStep?.widgetType){
|
|
const context=document.createElement("div");
|
|
context.className="pupil-path-current";
|
|
context.textContent=T("wg_"+currentStep.widgetType)
|
|
+(currentStep.themeName?" · "+T("castTheme")+": "+currentStep.themeName:"");
|
|
path.appendChild(context);
|
|
}
|
|
pupilView.appendChild(path);
|
|
if(sequence.done){
|
|
const finish = document.createElement("div");
|
|
finish.className = "pupil-path-finish";
|
|
const heading = document.createElement("strong"); heading.textContent = T("pupilPathWellDone");
|
|
const rewardText = document.createElement("span"); rewardText.textContent = T("pupilPathReward");
|
|
finish.append(heading, rewardText);
|
|
if(pupilPendingRewards.length){
|
|
const reward = document.createElement("span");
|
|
reward.className = "pupil-path-unlocked";
|
|
reward.textContent = T("pupilRewardUnlocked").replace("{count}", pupilPendingRewards.length);
|
|
finish.appendChild(reward);
|
|
pupilPendingRewards = [];
|
|
}
|
|
pupilView.appendChild(finish);
|
|
if(typeof celebrateAvatar === "function") celebrateAvatar(finish);
|
|
return;
|
|
}
|
|
}
|
|
const usable = widgets
|
|
.map(w => ({ w, def: defs.find(d => d.id === w.id) }))
|
|
.filter(x => x.def);
|
|
if(!usable.length){
|
|
const hint = document.createElement("div");
|
|
hint.className = "pupil-empty";
|
|
hint.textContent = T("pupilEmpty");
|
|
pupilView.appendChild(hint);
|
|
return;
|
|
}
|
|
/* klassenmanagement: groep/niveau per vakgebied ophalen vóór het mounten,
|
|
zodat een widget zonder eigen opgeslagen niveau met de door de
|
|
groepsleiding ingestelde waarde start i.p.v. altijd stilzwijgend niveau 1
|
|
- één keer per vakgebied dat hier daadwerkelijk voorkomt, niet per widget. */
|
|
const subjects = [...new Set(usable.map(x => x.def.cat))];
|
|
const levels = {};
|
|
if(!preview)await Promise.all(subjects.map(async subject=>{
|
|
try{ levels[subject] = await api("/my/level?subject="+subject); }
|
|
catch(e){ levels[subject] = null; }
|
|
}));
|
|
let stepButton = null;
|
|
let stepReady = watch || !!sequence?.ready;
|
|
usable.forEach(({ w, def }, idx)=>{
|
|
const card = document.createElement("div");
|
|
card.className = "widget pupil-card" + (watch ? " watch" : "");
|
|
card.dataset.widget = def.id;
|
|
card.style.width = `min(100%, ${def.w}px)`;
|
|
card.style.minWidth = "0";
|
|
card.style.minHeight = def.h + "px";
|
|
const body = document.createElement("div");
|
|
body.className = "widget-body";
|
|
card.appendChild(body);
|
|
if(watch){
|
|
const badge = document.createElement("div");
|
|
badge.className = "pupil-watch-badge";
|
|
badge.textContent = "👁 " + T("pupilWatching");
|
|
card.appendChild(badge);
|
|
}
|
|
pupilView.appendChild(card);
|
|
/* borden die vóór de invoering van stabiele widget-ids zijn opgeslagen
|
|
hebben nog geen wid - dan is positie in de lijst de terugval-identiteit,
|
|
stabiel zolang de leerkracht de widgets op het bord niet herschikt */
|
|
const widgetId = w.wid || (def.id + ":" + idx);
|
|
const startLive=()=>ensurePupilLiveSession(widgetId,def.id,mode);
|
|
if(!preview){
|
|
card.addEventListener("pointerdown",startLive);
|
|
card.addEventListener("focusin",startLive);
|
|
card.addEventListener("keydown",startLive);
|
|
if(watch&&idx===0)startLive();
|
|
}
|
|
/* in kijken-modus geen voortgang: de leerling doet zelf niets. In
|
|
werken-modus moet de leerling juist wél kunnen werken - readonly:true
|
|
stond hier sinds v0.3.15-beta abusievelijk ook in de werken-tak (een
|
|
kopieerfout toen kijken/werken werd toegevoegd). De twee widgets die
|
|
opts.readonly echt lezen (letters.js/anchor.js) gebruiken het alleen om
|
|
hun eigen bewerk-gereedschap (woordenlijst-editor, opslaan/bibliotheek)
|
|
te verbergen, niet om de kernoefening te blokkeren - maar dat gereedschap
|
|
hoort een leerling in werken-modus wél te zien, dus dit was hoe dan ook
|
|
verkeerd om, los van de vraag of dit "voortgang werkt niet" verklaart. */
|
|
const opts = watch ? { readonly:true } : { onProgress: (p)=>{
|
|
/* eerder een stille .catch(()=>{}) - een mislukte log (verlopen sessie,
|
|
geen toewijzing meer, servergolf) verdween daarmee spoorloos, wat een
|
|
"voortgang werkt niet"-klacht onmogelijk te onderzoeken maakt. Nu
|
|
zichtbaar in de console (geen storende UI voor de leerling, wél een
|
|
spoor voor wie het probleem moet natrekken). */
|
|
ensurePupilLiveSession(widgetId,def.id,mode)
|
|
.then(liveSessionId=>api("/my/progress", { method:"POST",
|
|
body: { widgetId, widgetType: def.id, liveSessionId, ...p } }))
|
|
.then(()=>{
|
|
stepReady = true;
|
|
if(stepButton){ stepButton.disabled = false; stepButton.textContent = T("pupilPathNext"); }
|
|
})
|
|
.catch(e=>console.warn("voortgang loggen mislukt:", e.message));
|
|
}};
|
|
/* De vakinstelling van de leerkracht is per leerling en dus leidend boven
|
|
de algemene widgetstate van het bord. Zo krijgt dezelfde opdracht voor
|
|
twee leerlingen toch passende woorden en getalgrenzen. */
|
|
const lvl = levels[def.cat];
|
|
const state = typeof applyLearningLevelToState === "function"
|
|
? applyLearningLevelToState(def, w.state, lvl, true)
|
|
: (w.state ? { ...w.state } : {});
|
|
def.mount(body, Object.keys(state).length ? state : null, opts);
|
|
if(typeof appendLearningProfileBadge === "function")
|
|
appendLearningProfileBadge(body, def.cat, state, def.id);
|
|
});
|
|
if(sequence && !preview){
|
|
const actions = document.createElement("div");
|
|
actions.className = "pupil-path-actions";
|
|
stepButton = document.createElement("button");
|
|
stepButton.type = "button";
|
|
stepButton.className = "primary";
|
|
stepButton.disabled = !stepReady;
|
|
stepButton.textContent = stepReady ? T("pupilPathNext") : T("pupilPathFirst");
|
|
stepButton.addEventListener("click", async ()=>{
|
|
const step = sequence.steps[sequence.currentIndex];
|
|
if(!step) return;
|
|
stepButton.disabled = true;
|
|
try{
|
|
const result = await api("/my/assignment/steps/"+encodeURIComponent(step.id)+"/complete", {method:"POST"});
|
|
pupilPendingRewards = result.unlocked || [];
|
|
if(typeof avatarAcceptRewards === "function") avatarAcceptRewards(pupilPendingRewards);
|
|
pupilLastSig = null;
|
|
await pupilPoll();
|
|
}catch(e){ stepButton.disabled = false; stepButton.textContent = e.message; }
|
|
});
|
|
actions.appendChild(stepButton);
|
|
pupilView.appendChild(actions);
|
|
}
|
|
}
|
|
|
|
async function pupilPoll(){
|
|
try{
|
|
const res = await api("/my/assignment");
|
|
const mode = res.mode === "kijken" ? "kijken" : "werken";
|
|
/* modus in de signature zodat een moduswissel ook zonder widget-wijziging
|
|
een her-render (en dus de juiste overlay) oplevert */
|
|
const sig = mode + "|" + JSON.stringify(res.widgets || []) + "|" + JSON.stringify(res.sequence || null);
|
|
if(sig !== pupilLastSig){
|
|
pupilLastSig = sig;
|
|
await renderPupilWidgets(res.widgets || [], mode, false, res.sequence || null);
|
|
}
|
|
if(mode !== pupilMode){
|
|
pupilMode = mode;
|
|
clearInterval(pupilPollTimer);
|
|
pupilPollTimer = setInterval(pupilPoll, PUPIL_POLL_MS[mode]);
|
|
}
|
|
}catch(e){ /* stille mislukking - volgende poll probeert opnieuw */ }
|
|
}
|
|
|
|
function startPupilView(){
|
|
document.body.classList.add("pupil-mode");
|
|
pupilLastSig = null;
|
|
pupilMode = "werken";
|
|
pupilPoll();
|
|
clearInterval(pupilPollTimer);
|
|
pupilPollTimer = setInterval(pupilPoll, PUPIL_POLL_MS[pupilMode]);
|
|
clearInterval(pupilLiveHeartbeatTimer);
|
|
pupilLiveHeartbeatTimer=setInterval(heartbeatPupilLiveSession,PUPIL_LIVE_HEARTBEAT_MS);
|
|
}
|
|
function stopPupilView(){
|
|
document.body.classList.remove("pupil-mode");
|
|
clearInterval(pupilPollTimer);
|
|
pupilPollTimer = null;
|
|
clearInterval(pupilLiveHeartbeatTimer);
|
|
pupilLiveHeartbeatTimer=null;
|
|
endPupilLiveSession();
|
|
pupilView.innerHTML = "";
|
|
}
|