/* 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", "creatief"]; 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 = ""; }