Verbeter grafische kwaliteit van kleurplaten (v0.4.67-beta) #5
13 changed files with 800 additions and 68 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.4.54-beta
|
0.4.55-beta
|
||||||
|
|
|
||||||
24
db/021_learning_paths.sql
Normal file
24
db/021_learning_paths.sql
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
ALTER TABLE assignments
|
||||||
|
ADD COLUMN IF NOT EXISTS title TEXT,
|
||||||
|
ADD COLUMN IF NOT EXISTS sequence JSONB NOT NULL DEFAULT '[]'::jsonb;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS assignment_step_progress (
|
||||||
|
assignment_id BIGINT NOT NULL REFERENCES assignments(id) ON DELETE CASCADE,
|
||||||
|
pupil_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
step_id TEXT NOT NULL,
|
||||||
|
completed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (assignment_id, pupil_id, step_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_assignment_step_progress_pupil
|
||||||
|
ON assignment_step_progress (pupil_id, assignment_id);
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS reward_points INTEGER NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS user_avatar_rewards (
|
||||||
|
user_id BIGINT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||||
|
reward_key TEXT NOT NULL,
|
||||||
|
unlocked_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
PRIMARY KEY (user_id, reward_key)
|
||||||
|
);
|
||||||
|
|
@ -164,6 +164,18 @@
|
||||||
box-shadow:0 2px 8px rgba(30,50,90,.15);
|
box-shadow:0 2px 8px rgba(30,50,90,.15);
|
||||||
}
|
}
|
||||||
.pupil-empty{margin:auto; color:var(--muted); font-size:16px; font-weight:600;}
|
.pupil-empty{margin:auto; color:var(--muted); font-size:16px; font-weight:600;}
|
||||||
|
.pupil-path{flex:1 0 100%;display:grid;grid-template-columns:1fr auto;gap:5px 16px;max-width:860px;margin:0 auto;padding:14px 18px;box-sizing:border-box;border:1px solid var(--line);border-radius:16px;background:var(--surface);box-shadow:0 5px 18px rgba(30,50,90,.08)}
|
||||||
|
.pupil-path-title{font-size:18px;font-weight:900;color:var(--ink)}
|
||||||
|
.pupil-path-progress{font-size:13px;font-weight:800;color:var(--muted)}
|
||||||
|
.pupil-path-dots{grid-column:1/-1;display:flex;align-items:center;gap:7px;overflow:auto}
|
||||||
|
.pupil-path-dots span{width:28px;height:28px;flex:none;display:grid;place-items:center;border-radius:50%;background:var(--surface-2);border:2px solid var(--line);color:var(--muted);font-weight:900;font-size:12px}
|
||||||
|
.pupil-path-dots span.complete{background:var(--green);border-color:var(--green);color:#fff}
|
||||||
|
.pupil-path-dots span.current{border-color:var(--accent);color:var(--accent-ink);background:var(--accent-soft)}
|
||||||
|
.pupil-path-actions{flex:1 0 100%;display:flex;justify-content:center;padding:4px 0 24px}
|
||||||
|
.pupil-path-actions button{min-height:44px;padding:0 22px}
|
||||||
|
.pupil-path-finish{position:relative;overflow:hidden;min-height:280px;flex:1 0 100%;display:flex;flex-direction:column;justify-content:center;align-items:center;gap:10px;text-align:center;border-radius:20px;background:linear-gradient(145deg,var(--accent-soft),var(--surface))}
|
||||||
|
.pupil-path-finish strong{font-size:clamp(24px,5vw,38px);color:var(--accent-ink)}
|
||||||
|
.pupil-path-unlocked{font-weight:900;color:var(--green)}
|
||||||
/* ---------- Ouderportaal ---------- */
|
/* ---------- Ouderportaal ---------- */
|
||||||
/* ouders zien geen whiteboard: alleen het portaal met hun kinderen */
|
/* ouders zien geen whiteboard: alleen het portaal met hun kinderen */
|
||||||
#parentView{display:none;}
|
#parentView{display:none;}
|
||||||
|
|
@ -1802,12 +1814,14 @@ body.hc #penbar .pen-current,body.hc #penbar .pen-popover{border:2px solid #000;
|
||||||
.avatar-dance-stage.av-dance-spin .avatar-face{animation:avDanceSpin 2.8s cubic-bezier(.4,0,.2,1) both;}
|
.avatar-dance-stage.av-dance-spin .avatar-face{animation:avDanceSpin 2.8s cubic-bezier(.4,0,.2,1) both;}
|
||||||
.avatar-dance-stage.av-dance-shuffle .avatar-face{animation:avDanceShuffle 2.8s ease-in-out both;}
|
.avatar-dance-stage.av-dance-shuffle .avatar-face{animation:avDanceShuffle 2.8s ease-in-out both;}
|
||||||
.avatar-dance-stage.av-dance-wiggle .avatar-face{animation:avDanceWiggle 2.8s ease-in-out both;}
|
.avatar-dance-stage.av-dance-wiggle .avatar-face{animation:avDanceWiggle 2.8s ease-in-out both;}
|
||||||
|
.avatar-dance-stage.av-dance-robot .avatar-face{animation:avDanceRobot 2.8s steps(2,end) both;}
|
||||||
@keyframes avDanceStage{0%{opacity:0}8%,82%{opacity:1}100%{opacity:0}}
|
@keyframes avDanceStage{0%{opacity:0}8%,82%{opacity:1}100%{opacity:0}}
|
||||||
@keyframes avDanceBounce{0%,100%{transform:translateY(0) scale(1)}10%,36%,62%{transform:translateY(5px) scale(1.08,.92)}22%,48%,74%{transform:translateY(-34px) scale(.96,1.04)}86%{transform:translateY(-12px) rotate(5deg)}}
|
@keyframes avDanceBounce{0%,100%{transform:translateY(0) scale(1)}10%,36%,62%{transform:translateY(5px) scale(1.08,.92)}22%,48%,74%{transform:translateY(-34px) scale(.96,1.04)}86%{transform:translateY(-12px) rotate(5deg)}}
|
||||||
@keyframes avDanceTwist{0%,100%{transform:rotate(0) translateX(0)}12%,36%,60%,84%{transform:rotate(-13deg) translateX(-10px)}24%,48%,72%{transform:rotate(13deg) translateX(10px)}90%{transform:scale(1.08)}}
|
@keyframes avDanceTwist{0%,100%{transform:rotate(0) translateX(0)}12%,36%,60%,84%{transform:rotate(-13deg) translateX(-10px)}24%,48%,72%{transform:rotate(13deg) translateX(10px)}90%{transform:scale(1.08)}}
|
||||||
@keyframes avDanceSpin{0%,10%,100%{transform:rotate(0) scale(1)}20%{transform:translateY(-12px) rotate(-12deg)}62%{transform:translateY(-20px) rotate(372deg) scale(1.08)}78%{transform:translateY(0) rotate(360deg) scale(1)}88%{transform:scale(1.1,.9)}}
|
@keyframes avDanceSpin{0%,10%,100%{transform:rotate(0) scale(1)}20%{transform:translateY(-12px) rotate(-12deg)}62%{transform:translateY(-20px) rotate(372deg) scale(1.08)}78%{transform:translateY(0) rotate(360deg) scale(1)}88%{transform:scale(1.1,.9)}}
|
||||||
@keyframes avDanceShuffle{0%,100%{transform:translateX(0) rotate(0)}12%,36%,60%{transform:translateX(-30px) translateY(-7px) rotate(-7deg)}24%,48%,72%{transform:translateX(30px) translateY(0) rotate(7deg)}82%{transform:translateX(-10px) scale(1.08,.92)}90%{transform:translateX(10px) scale(.96,1.04)}}
|
@keyframes avDanceShuffle{0%,100%{transform:translateX(0) rotate(0)}12%,36%,60%{transform:translateX(-30px) translateY(-7px) rotate(-7deg)}24%,48%,72%{transform:translateX(30px) translateY(0) rotate(7deg)}82%{transform:translateX(-10px) scale(1.08,.92)}90%{transform:translateX(10px) scale(.96,1.04)}}
|
||||||
@keyframes avDanceWiggle{0%,100%{transform:rotate(0) scale(1)}10%,30%,50%,70%{transform:rotate(-10deg) scale(1.04)}20%,40%,60%,80%{transform:rotate(10deg) scale(1.04)}88%{transform:translateY(-24px) scale(.95,1.08)}94%{transform:translateY(0) scale(1.1,.9)}}
|
@keyframes avDanceWiggle{0%,100%{transform:rotate(0) scale(1)}10%,30%,50%,70%{transform:rotate(-10deg) scale(1.04)}20%,40%,60%,80%{transform:rotate(10deg) scale(1.04)}88%{transform:translateY(-24px) scale(.95,1.08)}94%{transform:translateY(0) scale(1.1,.9)}}
|
||||||
|
@keyframes avDanceRobot{0%,100%{transform:translate(0) rotate(0)}15%{transform:translateX(-24px) rotate(-8deg)}30%{transform:translateX(-24px) rotate(8deg)}45%{transform:translateY(-26px) scale(1.06)}60%{transform:translateX(24px) rotate(8deg)}75%{transform:translateX(24px) rotate(-8deg)}90%{transform:translateY(-10px) scale(.96,1.08)}}
|
||||||
.avatar-dance-stage.av-dance-reduced{animation:none;}
|
.avatar-dance-stage.av-dance-reduced{animation:none;}
|
||||||
.avatar-dance-stage.av-dance-reduced .avatar-face{animation:none;transform:none;}
|
.avatar-dance-stage.av-dance-reduced .avatar-face{animation:none;transform:none;}
|
||||||
.avatar-editor{display:flex; flex-direction:column; gap:12px; padding:14px; height:100%; min-height:0; overflow:auto;}
|
.avatar-editor{display:flex; flex-direction:column; gap:12px; padding:14px; height:100%; min-height:0; overflow:auto;}
|
||||||
|
|
@ -2408,6 +2422,35 @@ 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 */
|
||||||
|
.cast-path-list{display:flex;flex-direction:column;gap:6px;margin-top:8px}
|
||||||
|
.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-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-path-step .am-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||||
|
.cast-path-step .am-btn{min-width:30px;padding:4px 7px}
|
||||||
|
|
||||||
|
/* Leerkrachtdashboard: scanbare status per leerling */
|
||||||
|
#settingsModal .teacher-dashboard{margin:10px 0 20px}
|
||||||
|
#settingsModal .dashboard-summary{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:8px;margin-bottom:10px}
|
||||||
|
#settingsModal .dashboard-summary-chip{display:flex;align-items:center;gap:7px;padding:9px 10px;border-radius:10px;background:var(--surface-2);color:var(--muted)}
|
||||||
|
#settingsModal .dashboard-summary-chip strong{font-size:20px;color:var(--ink)}
|
||||||
|
#settingsModal .dashboard-summary-chip span{font-size:11px;font-weight:800}
|
||||||
|
#settingsModal .dashboard-summary-chip.complete{box-shadow:inset 3px 0 var(--green)}
|
||||||
|
#settingsModal .dashboard-summary-chip.active{box-shadow:inset 3px 0 var(--blue)}
|
||||||
|
#settingsModal .dashboard-summary-chip.support{box-shadow:inset 3px 0 var(--orange)}
|
||||||
|
#settingsModal .dashboard-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(180px,1fr));gap:8px}
|
||||||
|
#settingsModal .dashboard-card{display:flex;flex-direction:column;align-items:stretch;gap:5px;text-align:left;padding:11px;border:1px solid var(--line);border-radius:12px;background:var(--surface);color:var(--ink);font:inherit;cursor:pointer}
|
||||||
|
#settingsModal .dashboard-card:hover{border-color:var(--accent);box-shadow:0 4px 14px rgba(30,50,90,.1)}
|
||||||
|
#settingsModal .dashboard-status{align-self:flex-start;padding:2px 7px;border-radius:99px;background:var(--surface-2);color:var(--muted);font-size:10px;font-weight:900}
|
||||||
|
#settingsModal .dashboard-card.complete .dashboard-status{background:color-mix(in srgb,var(--green) 16%,var(--surface));color:var(--green)}
|
||||||
|
#settingsModal .dashboard-card.support .dashboard-status{background:color-mix(in srgb,var(--orange) 16%,var(--surface));color:var(--orange)}
|
||||||
|
#settingsModal .dashboard-name{font-size:14px}
|
||||||
|
#settingsModal .dashboard-path{font-size:11px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
#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-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}}
|
||||||
|
|
||||||
/* Voortgang-tab: klassenmanagement-koppeling + grafiek (v0.4.35-beta e.v.) */
|
/* Voortgang-tab: klassenmanagement-koppeling + grafiek (v0.4.35-beta e.v.) */
|
||||||
#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;
|
||||||
|
|
|
||||||
|
|
@ -725,6 +725,52 @@
|
||||||
|
|
||||||
panel.appendChild(h("div","am-group", T("amAssignPupils")));
|
panel.appendChild(h("div","am-group", T("amAssignPupils")));
|
||||||
const pupils = USERS.filter(u=>u.role==="pupil");
|
const pupils = USERS.filter(u=>u.role==="pupil");
|
||||||
|
|
||||||
|
panel.appendChild(h("div","am-group", T("amDashboardTitle")));
|
||||||
|
const dashboardClass = classSel();
|
||||||
|
panel.appendChild(dashboardClass);
|
||||||
|
const dashboardBody = h("div","teacher-dashboard");
|
||||||
|
panel.appendChild(dashboardBody);
|
||||||
|
async function loadDashboard(){
|
||||||
|
dashboardBody.innerHTML = "";
|
||||||
|
if(!dashboardClass.value){ dashboardBody.appendChild(h("div","guestnote",T("amPickClassHint"))); return; }
|
||||||
|
dashboardBody.appendChild(h("div","guestnote","…"));
|
||||||
|
try{
|
||||||
|
const data = await api("/progress/dashboard/class/"+dashboardClass.value);
|
||||||
|
dashboardBody.innerHTML = "";
|
||||||
|
const summary = h("div","dashboard-summary");
|
||||||
|
["complete","active","support","notStarted"].forEach(status=>{
|
||||||
|
const chip = h("div","dashboard-summary-chip "+status);
|
||||||
|
chip.appendChild(h("strong",null,String(data.summary?.[status]||0)));
|
||||||
|
chip.appendChild(h("span",null,T("amDashboardStatus_"+status)));
|
||||||
|
summary.appendChild(chip);
|
||||||
|
});
|
||||||
|
dashboardBody.appendChild(summary);
|
||||||
|
if(!(data.pupils||[]).length){ dashboardBody.appendChild(h("div","guestnote",T("castNoPupils"))); return; }
|
||||||
|
const grid = h("div","dashboard-grid");
|
||||||
|
(data.pupils||[]).forEach(pupil=>{
|
||||||
|
const card = h("button","dashboard-card "+pupil.status);
|
||||||
|
card.type = "button";
|
||||||
|
card.appendChild(h("span","dashboard-status",T("amDashboardStatus_"+pupil.status)));
|
||||||
|
card.appendChild(h("strong","dashboard-name",pupil.pupilName));
|
||||||
|
if(pupil.sequence){
|
||||||
|
card.appendChild(h("span","dashboard-path",pupil.sequence.title));
|
||||||
|
const bar = h("span","dashboard-path-bar");
|
||||||
|
const fill = h("i"); fill.style.width = Math.round((pupil.sequence.completed/pupil.sequence.total)*100)+"%";
|
||||||
|
bar.appendChild(fill); card.appendChild(bar);
|
||||||
|
card.appendChild(h("span","am-role",T("amDashboardSteps").replace("{done}",pupil.sequence.completed).replace("{total}",pupil.sequence.total)));
|
||||||
|
}
|
||||||
|
card.appendChild(h("span","dashboard-score",pupil.attempts ? pupil.accuracy+"% · "+pupil.correct+"/"+pupil.attempts : T("amDashboardNoActivity")));
|
||||||
|
card.addEventListener("click",()=>{ pupilSel.value=String(pupil.pupilId); pupilSel.dispatchEvent(new Event("change")); });
|
||||||
|
grid.appendChild(card);
|
||||||
|
});
|
||||||
|
dashboardBody.appendChild(grid);
|
||||||
|
}catch(e){ dashboardBody.innerHTML=""; dashboardBody.appendChild(h("div","am-msg",e.message)); }
|
||||||
|
}
|
||||||
|
dashboardClass.addEventListener("change",loadDashboard);
|
||||||
|
loadDashboard();
|
||||||
|
|
||||||
|
panel.appendChild(h("div","am-group", T("amProgressDetail")));
|
||||||
const pupilSel = h("select","am-sel");
|
const pupilSel = h("select","am-sel");
|
||||||
fillPupilSelect(pupilSel, pupils);
|
fillPupilSelect(pupilSel, pupils);
|
||||||
panel.appendChild(pupilSel);
|
panel.appendChild(pupilSel);
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,19 @@ const AVATAR_VARIANTS = Object.freeze({
|
||||||
animal: ["cat", "dog", "rabbit", "bear"],
|
animal: ["cat", "dog", "rabbit", "bear"],
|
||||||
});
|
});
|
||||||
const AVATAR_HAIR = ["none", "tuft", "spiky", "curly", "wavy", "bob", "ponytail", "braids", "mohawk"];
|
const AVATAR_HAIR = ["none", "tuft", "spiky", "curly", "wavy", "bob", "ponytail", "braids", "mohawk"];
|
||||||
const AVATAR_HEAD_ACC = ["none", "cap", "beanie", "crown", "wizardhat", "glasses", "sunglasses", "eyepatch", "bow", "flower", "headphones", "partyhat"];
|
const AVATAR_HEAD_ACC = ["none", "cap", "beanie", "crown", "wizardhat", "glasses", "sunglasses", "eyepatch", "bow", "flower", "headphones", "partyhat", "star"];
|
||||||
const AVATAR_BODY_ACC = ["none", "scarf", "cape", "badge", "necklace", "bowtie", "belt", "backpack", "satchel", "wings"];
|
const AVATAR_BODY_ACC = ["none", "scarf", "cape", "badge", "necklace", "bowtie", "belt", "backpack", "satchel", "wings", "medal"];
|
||||||
const AVATAR_DANCES = Object.freeze(["bounce", "twist", "spin", "shuffle", "wiggle"]);
|
const AVATAR_DANCES = Object.freeze(["bounce", "twist", "spin", "shuffle", "wiggle", "robot"]);
|
||||||
|
const AVATAR_REWARD_ITEMS = Object.freeze({star:"head:star", medal:"body:medal", robot:"dance:robot"});
|
||||||
|
let avatarRewardKeys = new Set();
|
||||||
|
let avatarRewardPoints = 0;
|
||||||
|
async function loadAvatarRewards(){
|
||||||
|
const data = await api("/me/avatar-rewards");
|
||||||
|
avatarRewardKeys = new Set(data.rewards || []);
|
||||||
|
avatarRewardPoints = Number(data.points || 0);
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
function avatarAcceptRewards(keys){ (keys || []).forEach(key=>avatarRewardKeys.add(key)); }
|
||||||
const AVATAR_DEFAULT = Object.freeze({
|
const AVATAR_DEFAULT = Object.freeze({
|
||||||
species: "dino", speciesVariant: "rex", bodyColor: "#3fae6a", hairStyle: "none", hairColor: "#5b3a29",
|
species: "dino", speciesVariant: "rex", bodyColor: "#3fae6a", hairStyle: "none", hairColor: "#5b3a29",
|
||||||
accessoryHead: "none", accessoryBody: "none",
|
accessoryHead: "none", accessoryBody: "none",
|
||||||
|
|
@ -382,6 +392,9 @@ const AVATAR_HEAD_ACC_MARKUP = {
|
||||||
<path d="M 36.5 17 C 45 21 55 21 63.5 17" stroke="#f4c34e" stroke-width="2.2" fill="none"/>
|
<path d="M 36.5 17 C 45 21 55 21 63.5 17" stroke="#f4c34e" stroke-width="2.2" fill="none"/>
|
||||||
<circle cx="45" cy="3" r="2" fill="#f0b73a"/><circle cx="56" cy="8" r="2" fill="#3a8ee0"/><circle cx="47" cy="14" r="2" fill="#f0b73a"/>
|
<circle cx="45" cy="3" r="2" fill="#f0b73a"/><circle cx="56" cy="8" r="2" fill="#3a8ee0"/><circle cx="47" cy="14" r="2" fill="#f0b73a"/>
|
||||||
<circle cx="50" cy="-15" r="4.2" fill="#f0b73a"/>`,
|
<circle cx="50" cy="-15" r="4.2" fill="#f0b73a"/>`,
|
||||||
|
star: () => `
|
||||||
|
<path class="av-part av-headwear av-reward-star" d="M 50 -18 L 55 -7 L 67 -6 L 58 2 L 61 14 L 50 8 L 39 14 L 42 2 L 33 -6 L 45 -7 Z" fill="#ffd84d" stroke="#b8750b" stroke-width="1.5" stroke-linejoin="round"/>
|
||||||
|
<path d="M 45 -5 L 49 -11 M 43 1 L 50 5" stroke="#fff4a8" stroke-width="1.6" stroke-linecap="round"/>`,
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Oogaccessoires volgen afwijkende oogindelingen van cycloop en alien. */
|
/* Oogaccessoires volgen afwijkende oogindelingen van cycloop en alien. */
|
||||||
|
|
@ -473,6 +486,13 @@ const AVATAR_BODY_ACC_MARKUP = {
|
||||||
<path d="M 11 58 C 23 61 30 67 37 74 M 89 58 C 77 61 70 67 63 74" stroke="#fff" stroke-width="2" fill="none" opacity=".7"/>`,
|
<path d="M 11 58 C 23 61 30 67 37 74 M 89 58 C 77 61 70 67 63 74" stroke="#fff" stroke-width="2" fill="none" opacity=".7"/>`,
|
||||||
front: () => `<circle class="av-part av-wing-clasp" cx="37" cy="65" r="2.4" fill="#5799ad"/><circle cx="63" cy="65" r="2.4" fill="#5799ad"/>`,
|
front: () => `<circle class="av-part av-wing-clasp" cx="37" cy="65" r="2.4" fill="#5799ad"/><circle cx="63" cy="65" r="2.4" fill="#5799ad"/>`,
|
||||||
},
|
},
|
||||||
|
medal: {
|
||||||
|
back: () => "",
|
||||||
|
front: () => `
|
||||||
|
<path class="av-part av-reward-medal" d="M 43 61 L 48 76 L 52 76 L 57 61" fill="none" stroke="#2879c9" stroke-width="4" stroke-linejoin="round"/>
|
||||||
|
<circle cx="50" cy="79" r="9" fill="#ffd84d" stroke="#b8750b" stroke-width="1.6"/>
|
||||||
|
<path d="M 50 73 L 52 77 L 57 78 L 53 81 L 54 86 L 50 83 L 46 86 L 47 81 L 43 78 L 48 77 Z" fill="#fff2a3"/>`,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Pet en muts bedekken de voorste haarlaag. Lange stijlen houden hun
|
/* Pet en muts bedekken de voorste haarlaag. Lange stijlen houden hun
|
||||||
|
|
@ -511,9 +531,10 @@ function renderAvatarFace(config, size){
|
||||||
anatomische ankers blijven. Zonder expliciete keuze rouleren de dansen. */
|
anatomische ankers blijven. Zonder expliciete keuze rouleren de dansen. */
|
||||||
let avatarDanceCursor = -1;
|
let avatarDanceCursor = -1;
|
||||||
function avatarDanceChoice(preferred){
|
function avatarDanceChoice(preferred){
|
||||||
if(AVATAR_DANCES.includes(preferred)) return preferred;
|
const available = AVATAR_DANCES.filter(dance=>dance !== "robot" || avatarRewardKeys.has("dance:robot"));
|
||||||
avatarDanceCursor = (avatarDanceCursor + 1) % AVATAR_DANCES.length;
|
if(available.includes(preferred)) return preferred;
|
||||||
return AVATAR_DANCES[avatarDanceCursor];
|
avatarDanceCursor = (avatarDanceCursor + 1) % available.length;
|
||||||
|
return available[avatarDanceCursor];
|
||||||
}
|
}
|
||||||
function celebrateAvatar(host, options){
|
function celebrateAvatar(host, options){
|
||||||
if(!host || typeof currentUser === "undefined" || !currentUser || !currentUser.avatar) return null;
|
if(!host || typeof currentUser === "undefined" || !currentUser || !currentUser.avatar) return null;
|
||||||
|
|
@ -628,9 +649,17 @@ function buildAvatarEditor(container, initialConfig, onChange){
|
||||||
hairSpan.textContent = T("avatarHairStyle");
|
hairSpan.textContent = T("avatarHairStyle");
|
||||||
[...hairSel.options].forEach(o=>o.textContent = T("avatarHair_" + o.value));
|
[...hairSel.options].forEach(o=>o.textContent = T("avatarHair_" + o.value));
|
||||||
headAccSpan.textContent = T("avatarAccHead");
|
headAccSpan.textContent = T("avatarAccHead");
|
||||||
[...headAccSel.options].forEach(o=>o.textContent = T("avatarAcc_" + o.value));
|
[...headAccSel.options].forEach(o=>{
|
||||||
|
const key = AVATAR_REWARD_ITEMS[o.value];
|
||||||
|
o.disabled = !!key && !avatarRewardKeys.has(key);
|
||||||
|
o.textContent = T("avatarAcc_" + o.value) + (o.disabled ? " 🔒" : "");
|
||||||
|
});
|
||||||
bodyAccSpan.textContent = T("avatarAccBody");
|
bodyAccSpan.textContent = T("avatarAccBody");
|
||||||
[...bodyAccSel.options].forEach(o=>o.textContent = T("avatarAcc_" + o.value));
|
[...bodyAccSel.options].forEach(o=>{
|
||||||
|
const key = AVATAR_REWARD_ITEMS[o.value];
|
||||||
|
o.disabled = !!key && !avatarRewardKeys.has(key);
|
||||||
|
o.textContent = T("avatarAcc_" + o.value) + (o.disabled ? " 🔒" : "");
|
||||||
|
});
|
||||||
}
|
}
|
||||||
function update(){
|
function update(){
|
||||||
const variants = AVATAR_VARIANTS[config.species];
|
const variants = AVATAR_VARIANTS[config.species];
|
||||||
|
|
@ -688,7 +717,7 @@ async function saveAvatar(config){
|
||||||
id: "avatar", icon: "🦖", group: "personal", order: 3,
|
id: "avatar", icon: "🦖", group: "personal", order: 3,
|
||||||
label: ()=>T("stAvatar"), sub: ()=>T("stAvatarSub"),
|
label: ()=>T("stAvatar"), sub: ()=>T("stAvatarSub"),
|
||||||
visible: ()=>!!currentUser,
|
visible: ()=>!!currentUser,
|
||||||
activate(){ showSettingsDom("avatar"); build(); },
|
activate(){ showSettingsDom("avatar"); loadAvatarRewards().then(build).catch(build); },
|
||||||
keywords: ()=>[T("avatarSpecies_dino"), T("avatarSpecies_monster"), T("avatarSpecies_animal"),
|
keywords: ()=>[T("avatarSpecies_dino"), T("avatarSpecies_monster"), T("avatarSpecies_animal"),
|
||||||
...Object.values(AVATAR_VARIANTS).flat().map(id=>T("avatarVariant_" + id)),
|
...Object.values(AVATAR_VARIANTS).flat().map(id=>T("avatarVariant_" + id)),
|
||||||
"personage","avatar","dinosaurus","monster","dier","haar","hair","kleur","color"],
|
"personage","avatar","dinosaurus","monster","dier","haar","hair","kleur","color"],
|
||||||
|
|
@ -719,7 +748,7 @@ function mountAvatarWidget(root){
|
||||||
host.appendChild(saveBtn);
|
host.appendChild(saveBtn);
|
||||||
host.appendChild(msg);
|
host.appendChild(msg);
|
||||||
}
|
}
|
||||||
build();
|
loadAvatarRewards().then(build).catch(build);
|
||||||
document.addEventListener("langchange", ()=>{ root.querySelector(".play-mission").textContent = T("wg_avatar_d"); });
|
document.addEventListener("langchange", ()=>{ root.querySelector(".play-mission").textContent = T("wg_avatar_d"); });
|
||||||
/* currentUser.avatar is de bron van waarheid (server), niet het widget-eigen
|
/* currentUser.avatar is de bron van waarheid (server), niet het widget-eigen
|
||||||
initState - geen dubbele opslag van dezelfde data op twee plekken. */
|
initState - geen dubbele opslag van dezelfde data op twee plekken. */
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ 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 = [];
|
||||||
/* 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 */
|
||||||
|
|
@ -34,13 +35,18 @@ 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 })).filter(w=>w.wid);
|
return [...instances].map(inst=>({ wid: inst.wid, id: inst.def.id, cat: inst.def.cat })).filter(w=>w.wid);
|
||||||
|
}
|
||||||
|
function castPathWidgetAllowed(widgetId){
|
||||||
|
const widget = currentBoardWidgets().find(item=>item.wid===widgetId);
|
||||||
|
return !!widget && ["taal","rekenen"].includes(widget.cat);
|
||||||
}
|
}
|
||||||
|
|
||||||
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 = [];
|
||||||
castActiveKeys = new Set(); castError = "";
|
castActiveKeys = new Set(); castError = "";
|
||||||
castLoading = true;
|
castLoading = true;
|
||||||
renderCast();
|
renderCast();
|
||||||
|
|
@ -68,7 +74,10 @@ function castTargetRow(target){
|
||||||
row.addEventListener("click", async ()=>{
|
row.addEventListener("click", async ()=>{
|
||||||
try{
|
try{
|
||||||
const body = { boardId: curSlot().id };
|
const body = { boardId: curSlot().id };
|
||||||
if(castSelWidget){ body.widgetId = castSelWidget; body.mode = castSelMode; }
|
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 });
|
await api(`/assignments/${target.type}/${target.id}`, { method:"PUT", body });
|
||||||
castActiveKeys.add(key);
|
castActiveKeys.add(key);
|
||||||
castError = "";
|
castError = "";
|
||||||
|
|
@ -112,6 +121,7 @@ function renderCast(){
|
||||||
widgetSel.addEventListener("change", ()=>{
|
widgetSel.addEventListener("change", ()=>{
|
||||||
castSelWidget = widgetSel.value;
|
castSelWidget = widgetSel.value;
|
||||||
modeSel.style.display = castSelWidget ? "" : "none";
|
modeSel.style.display = castSelWidget ? "" : "none";
|
||||||
|
addStep.disabled = !castPathWidgetAllowed(castSelWidget) || castPathSteps.length >= 12;
|
||||||
});
|
});
|
||||||
modeSel.addEventListener("change", ()=>{ castSelMode = modeSel.value; });
|
modeSel.addEventListener("change", ()=>{ castSelMode = modeSel.value; });
|
||||||
const whatRow = h("div","am-inline");
|
const whatRow = h("div","am-inline");
|
||||||
|
|
@ -119,6 +129,49 @@ function renderCast(){
|
||||||
whatRow.appendChild(modeSel);
|
whatRow.appendChild(modeSel);
|
||||||
sheet.appendChild(whatRow);
|
sheet.appendChild(whatRow);
|
||||||
|
|
||||||
|
sheet.appendChild(h("div","am-group", T("castPathTitle")));
|
||||||
|
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(); });
|
||||||
|
sheet.appendChild(pathTitle);
|
||||||
|
const addStep = h("button","am-btn", T("castPathAdd"));
|
||||||
|
addStep.type = "button";
|
||||||
|
addStep.disabled = !castPathWidgetAllowed(castSelWidget) || castPathSteps.length >= 12;
|
||||||
|
addStep.title = T("castPathOnlyLearning");
|
||||||
|
addStep.addEventListener("click", ()=>{
|
||||||
|
if(!castPathWidgetAllowed(castSelWidget) || castPathSteps.length >= 12) return;
|
||||||
|
const widget = currentBoardWidgets().find(item=>item.wid===castSelWidget);
|
||||||
|
castPathSteps.push({boardId:curSlot().id, widgetId:castSelWidget, widgetType:widget?.id || "", mode:castSelMode});
|
||||||
|
if(!castPathTitle) castPathTitle = T("castPathDefault");
|
||||||
|
castActiveKeys.clear();
|
||||||
|
renderCast();
|
||||||
|
});
|
||||||
|
sheet.appendChild(addStep);
|
||||||
|
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")));
|
sheet.appendChild(h("div","am-group", T("castClasses")));
|
||||||
const classList = h("div","am-list");
|
const classList = h("div","am-list");
|
||||||
if(castLoading) classList.appendChild(h("div","guestnote", "…"));
|
if(castLoading) classList.appendChild(h("div","guestnote", "…"));
|
||||||
|
|
|
||||||
|
|
@ -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.54-beta";
|
const VERSION = "0.4.55-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
|
||||||
|
|
@ -188,7 +188,7 @@ const I18N = {
|
||||||
avatarAcc_glasses:"Bril", avatarAcc_sunglasses:"Zonnebril", avatarAcc_eyepatch:"Ooglapje", avatarAcc_bow:"Strik", avatarAcc_flower:"Bloem",
|
avatarAcc_glasses:"Bril", avatarAcc_sunglasses:"Zonnebril", avatarAcc_eyepatch:"Ooglapje", avatarAcc_bow:"Strik", avatarAcc_flower:"Bloem",
|
||||||
avatarAcc_headphones:"Koptelefoon", avatarAcc_partyhat:"Feesthoedje", avatarAcc_scarf:"Sjaal", avatarAcc_cape:"Cape",
|
avatarAcc_headphones:"Koptelefoon", avatarAcc_partyhat:"Feesthoedje", avatarAcc_scarf:"Sjaal", avatarAcc_cape:"Cape",
|
||||||
avatarAcc_badge:"Insigne", avatarAcc_necklace:"Ketting", avatarAcc_bowtie:"Vlinderdas", avatarAcc_belt:"Riem",
|
avatarAcc_badge:"Insigne", avatarAcc_necklace:"Ketting", avatarAcc_bowtie:"Vlinderdas", avatarAcc_belt:"Riem",
|
||||||
avatarAcc_backpack:"Rugtas", avatarAcc_satchel:"Schoudertas", avatarAcc_wings:"Vleugels",
|
avatarAcc_backpack:"Rugtas", avatarAcc_satchel:"Schoudertas", avatarAcc_wings:"Vleugels", avatarAcc_star:"Gouden ster", avatarAcc_medal:"Medaille",
|
||||||
avatarSave:"Opslaan", avatarSaved:"Personage opgeslagen ✓",
|
avatarSave:"Opslaan", avatarSaved:"Personage opgeslagen ✓",
|
||||||
clock24Hour:"24-uursklok",
|
clock24Hour:"24-uursklok",
|
||||||
widgetSettings:"Instellingen", clockWelcome:"Oefenen met de klok", clockWelcomeHint:"Kies bovenaan niveau 1, 2 of 3. Kies bij Instellingen hoe de opdracht wordt getoond en start daarna.", clockSetTitle:"Zet de wijzers op de goede tijd", clockReadTitle:"Bekijk de opdracht en maak de tijd", clockChooseTitle:"Kies het juiste tijdblok", clockYourTask:"Jouw opdracht", clockLinkHands:"Grote wijzer draait mee", clockTypeHint:"Voer de tijd in als UU:MM.", clockLevel1Hint:"Niveau 1: hele en halve uren", clockLevel2Hint:"Niveau 2: stappen van vijf minuten", clockLevel3Hint:"Niveau 3: alle minuten, met optioneel 24 uur en dagdelen", clockBuildHint:"Sleep vier cijfers naar de vakken en maak UU:MM.", clockDigitGhost:"?", clockDigit:"Cijfer {digit}", clockIncomplete:"Maak eerst de volledige tijd af.", clockCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", clockTryAgain:"Bijna! Probeer het nog een keer.", clockStartTask:"Start opdracht", clockNextTask:"Volgende opdracht", clockLevel:"Niveau", clockLevelHour:"Hele uren", clockLevelQuarter:"Kwartieren", clockLevelFive:"Vijf minuten", clockLevelMinute:"Iedere minuut", clockLearningStyle:"Opdracht tonen", clockStyleAnalogue:"Analoge klok", clockStyleDigital:"Grote digitale tijd", clockStyleWords:"In woorden", clockStyleBoth:"Klok, cijfers en woorden", clockDragHint:"Sleep de wijzers om de tijd te veranderen.", clockGame:"Spel", clockGameSet:"Klok zetten", clockGameRead:"Tijd aflezen", clockGameChoose:"Kies de tijd", clockStart:"Kies ‘Nieuwe opdracht’ om te beginnen", clockRead:"Welke tijd zie je? Stel je antwoord hieronder in.", clockChoose:"Welke digitale tijd hoort bij deze klok?", clockTry:"Nog niet. Het goede antwoord is {time}.", clockScore:"Score: {score} · reeks: {streak}", clockWordsHour:"{hour} uur", clockWordsQuarterPast:"kwart over {hour}", clockWordsHalf:"half {hour}", clockWordsQuarterTo:"kwart voor {hour}", clockWordsPast:"{minutes} over {hour}", clockWordsTo:"{minutes} voor {hour}", clockMorning:"in de ochtend", clockAfternoon:"in de middag", clockEvening:"in de avond", clockNight:"in de nacht", fractionRows:"Rijen", fractionLabels:"Breuken tonen", fractionHint:"Tik in een rij tot waar je de breuk wilt kleuren.", fractionSelected:"Gekozen breuk: {fraction}", fractionClear:"Leegmaken", clockTask:"Oefenopdracht", clockSet:"Zet de klok op {time}", clockHour:"Uur", clockMinute:"Minuten", placeNumber:"Getal", placeGame:"Spel", placeGameExplore:"Verkennen", placeGameBuild:"Bouwen", placeLevel:"Niveau", placeLevel1Hint:"Niveau 1: getallen tot 100 (T en E)", placeLevel2Hint:"Niveau 2: getallen tot 1000 (H, T en E)", placeLevel3Hint:"Niveau 3: getallen tot 1.000.000 (HD, TD, D, H, T en E)", placeBuildTitle:"Bouw het getal", placeYourTask:"Jouw opdracht", placeBuildHint:"Sleep de cijfers naar de vakken en bouw het getal.", placeDigitGhost:"?", placeIncomplete:"Maak eerst het hele getal af.", placeCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", placeTryAgain:"Bijna! Probeer het nog een keer.", placeNextTask:"Volgende opdracht", placeScore:"Score: {score} · reeks: {streak}", moneyMake:"Maak € {amount}", wordtypeSentence:"Zin", wordtypeHint:"Tik: blauw zelfstandig naamwoord · groen werkwoord · geel bijvoeglijk naamwoord", scheduleActivity:"activiteit…", scheduleEmpty:"Voeg de eerste activiteit toe.", scheduleCount:"{count} momenten op de planning", pollReset:"Opnieuw", pollQuestion:"Vraag", pollOptions:"Antwoorden (één per regel)", pollTotal:"{count} stemmen", moodTotal:"{count} kinderen hebben gekozen", moodVote:"Kies {face}", galleryFavorites:"Favorieten", galleryFavorite:"Favoriet maken", galleryEmpty:"Geen widgets gevonden.", gallerySearch:"Zoek widgets…", templateMath:"Rekenles", templateLanguage:"Taalles", templateDay:"Dagstart", exportBoard:"Exporteren", importBoard:"Importeren", importError:"Dit is geen geldig Teach-bordbestand.", widgetDuplicate:"Dupliceren", widgetLock:"Vastzetten", widgetUnlock:"Losmaken", widgetPreview:"Voorbeeld zonder bediening", undo:"Ongedaan maken", redo:"Opnieuw",
|
widgetSettings:"Instellingen", clockWelcome:"Oefenen met de klok", clockWelcomeHint:"Kies bovenaan niveau 1, 2 of 3. Kies bij Instellingen hoe de opdracht wordt getoond en start daarna.", clockSetTitle:"Zet de wijzers op de goede tijd", clockReadTitle:"Bekijk de opdracht en maak de tijd", clockChooseTitle:"Kies het juiste tijdblok", clockYourTask:"Jouw opdracht", clockLinkHands:"Grote wijzer draait mee", clockTypeHint:"Voer de tijd in als UU:MM.", clockLevel1Hint:"Niveau 1: hele en halve uren", clockLevel2Hint:"Niveau 2: stappen van vijf minuten", clockLevel3Hint:"Niveau 3: alle minuten, met optioneel 24 uur en dagdelen", clockBuildHint:"Sleep vier cijfers naar de vakken en maak UU:MM.", clockDigitGhost:"?", clockDigit:"Cijfer {digit}", clockIncomplete:"Maak eerst de volledige tijd af.", clockCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", clockTryAgain:"Bijna! Probeer het nog een keer.", clockStartTask:"Start opdracht", clockNextTask:"Volgende opdracht", clockLevel:"Niveau", clockLevelHour:"Hele uren", clockLevelQuarter:"Kwartieren", clockLevelFive:"Vijf minuten", clockLevelMinute:"Iedere minuut", clockLearningStyle:"Opdracht tonen", clockStyleAnalogue:"Analoge klok", clockStyleDigital:"Grote digitale tijd", clockStyleWords:"In woorden", clockStyleBoth:"Klok, cijfers en woorden", clockDragHint:"Sleep de wijzers om de tijd te veranderen.", clockGame:"Spel", clockGameSet:"Klok zetten", clockGameRead:"Tijd aflezen", clockGameChoose:"Kies de tijd", clockStart:"Kies ‘Nieuwe opdracht’ om te beginnen", clockRead:"Welke tijd zie je? Stel je antwoord hieronder in.", clockChoose:"Welke digitale tijd hoort bij deze klok?", clockTry:"Nog niet. Het goede antwoord is {time}.", clockScore:"Score: {score} · reeks: {streak}", clockWordsHour:"{hour} uur", clockWordsQuarterPast:"kwart over {hour}", clockWordsHalf:"half {hour}", clockWordsQuarterTo:"kwart voor {hour}", clockWordsPast:"{minutes} over {hour}", clockWordsTo:"{minutes} voor {hour}", clockMorning:"in de ochtend", clockAfternoon:"in de middag", clockEvening:"in de avond", clockNight:"in de nacht", fractionRows:"Rijen", fractionLabels:"Breuken tonen", fractionHint:"Tik in een rij tot waar je de breuk wilt kleuren.", fractionSelected:"Gekozen breuk: {fraction}", fractionClear:"Leegmaken", clockTask:"Oefenopdracht", clockSet:"Zet de klok op {time}", clockHour:"Uur", clockMinute:"Minuten", placeNumber:"Getal", placeGame:"Spel", placeGameExplore:"Verkennen", placeGameBuild:"Bouwen", placeLevel:"Niveau", placeLevel1Hint:"Niveau 1: getallen tot 100 (T en E)", placeLevel2Hint:"Niveau 2: getallen tot 1000 (H, T en E)", placeLevel3Hint:"Niveau 3: getallen tot 1.000.000 (HD, TD, D, H, T en E)", placeBuildTitle:"Bouw het getal", placeYourTask:"Jouw opdracht", placeBuildHint:"Sleep de cijfers naar de vakken en bouw het getal.", placeDigitGhost:"?", placeIncomplete:"Maak eerst het hele getal af.", placeCorrect:"Goed gedaan! Kies de volgende opdracht. 🌟", placeTryAgain:"Bijna! Probeer het nog een keer.", placeNextTask:"Volgende opdracht", placeScore:"Score: {score} · reeks: {streak}", moneyMake:"Maak € {amount}", wordtypeSentence:"Zin", wordtypeHint:"Tik: blauw zelfstandig naamwoord · groen werkwoord · geel bijvoeglijk naamwoord", scheduleActivity:"activiteit…", scheduleEmpty:"Voeg de eerste activiteit toe.", scheduleCount:"{count} momenten op de planning", pollReset:"Opnieuw", pollQuestion:"Vraag", pollOptions:"Antwoorden (één per regel)", pollTotal:"{count} stemmen", moodTotal:"{count} kinderen hebben gekozen", moodVote:"Kies {face}", galleryFavorites:"Favorieten", galleryFavorite:"Favoriet maken", galleryEmpty:"Geen widgets gevonden.", gallerySearch:"Zoek widgets…", templateMath:"Rekenles", templateLanguage:"Taalles", templateDay:"Dagstart", exportBoard:"Exporteren", importBoard:"Importeren", importError:"Dit is geen geldig Teach-bordbestand.", widgetDuplicate:"Dupliceren", widgetLock:"Vastzetten", widgetUnlock:"Losmaken", widgetPreview:"Voorbeeld zonder bediening", undo:"Ongedaan maken", redo:"Opnieuw",
|
||||||
|
|
@ -347,8 +347,11 @@ const I18N = {
|
||||||
dtLock:"Vastzetten: kan niet meer verschuiven of draaien, alleen nog langs tekenen",
|
dtLock:"Vastzetten: kan niet meer verschuiven of draaien, alleen nog langs tekenen",
|
||||||
dtMode:"Basis: grote hoofdhoeken en hele centimeters. Volledig: graden per 2° met dubbele schaal, millimeters en evenwijdige hulplijnen.",
|
dtMode:"Basis: grote hoofdhoeken en hele centimeters. Volledig: graden per 2° met dubbele schaal, millimeters en evenwijdige hulplijnen.",
|
||||||
dtModeBasic:"basis", dtModeFull:"volledig",
|
dtModeBasic:"basis", dtModeFull:"volledig",
|
||||||
pupilEmpty:"Je leerkracht heeft nog niets voor je klaargezet.",
|
pupilEmpty:"Je leerkracht heeft nog niets voor je klaargezet.", pupilWatching:"meekijken",
|
||||||
pupilWatching:"meekijken",
|
pupilPath:"Mijn opdrachtenreeks", pupilPathStep:"Opdracht {current} van {total}", pupilPathComplete:"Reeks voltooid",
|
||||||
|
pupilPathWellDone:"Heel goed gedaan!", pupilPathReward:"Je personage viert het met een dansje.",
|
||||||
|
pupilPathNext:"Stap afronden en verder", pupilPathFirst:"Rond eerst de opdracht hierboven af",
|
||||||
|
pupilRewardUnlocked:"{count} nieuwe avatarbeloning(en) vrijgespeeld!",
|
||||||
amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen", amOverarching:"Systeem",
|
amUsers:"Gebruikers", amClasses:"Klassen", amSchools:"Scholen", amOverarching:"Systeem",
|
||||||
amPickSchoolHint:"Kies hierboven eerst een school.", amPickSchool:"Kies een school",
|
amPickSchoolHint:"Kies hierboven eerst een school.", amPickSchool:"Kies een school",
|
||||||
amPupils:"Leerlingen", amPupilOne:"Leerling", amEdit:"Bewerken",
|
amPupils:"Leerlingen", amPupilOne:"Leerling", amEdit:"Bewerken",
|
||||||
|
|
@ -402,7 +405,11 @@ const I18N = {
|
||||||
cast:"Cast", castTitle:"Cast bord", castWhat:"Wat cast je?",
|
cast:"Cast", castTitle:"Cast bord", castWhat:"Wat cast je?",
|
||||||
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",
|
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.",
|
||||||
|
amDashboardTitle:"Klasdashboard", amProgressDetail:"Detail per leerling",
|
||||||
|
amDashboardStatus_complete:"Klaar", amDashboardStatus_active:"Bezig", amDashboardStatus_support:"Hulp nodig", amDashboardStatus_notStarted:"Nog niet gestart",
|
||||||
|
amDashboardSteps:"{done} van {total} stappen", amDashboardNoActivity:"Nog geen activiteit",
|
||||||
amProgress:"Voortgang", amProgressEmpty:"Nog geen voortgang voor deze leerling.",
|
amProgress:"Voortgang", amProgressEmpty:"Nog geen voortgang voor deze leerling.",
|
||||||
amProgressLast14Days:"Laatste 14 dagen", amProgressMistakes:"Recente fouten",
|
amProgressLast14Days:"Laatste 14 dagen", amProgressMistakes:"Recente fouten",
|
||||||
amProgressTimeline:"Volledige tijdlijn",
|
amProgressTimeline:"Volledige tijdlijn",
|
||||||
|
|
@ -578,7 +585,7 @@ const I18N = {
|
||||||
avatarAcc_glasses:"Glasses", avatarAcc_sunglasses:"Sunglasses", avatarAcc_eyepatch:"Eye patch", avatarAcc_bow:"Bow", avatarAcc_flower:"Flower",
|
avatarAcc_glasses:"Glasses", avatarAcc_sunglasses:"Sunglasses", avatarAcc_eyepatch:"Eye patch", avatarAcc_bow:"Bow", avatarAcc_flower:"Flower",
|
||||||
avatarAcc_headphones:"Headphones", avatarAcc_partyhat:"Party hat", avatarAcc_scarf:"Scarf", avatarAcc_cape:"Cape",
|
avatarAcc_headphones:"Headphones", avatarAcc_partyhat:"Party hat", avatarAcc_scarf:"Scarf", avatarAcc_cape:"Cape",
|
||||||
avatarAcc_badge:"Badge", avatarAcc_necklace:"Necklace", avatarAcc_bowtie:"Bow tie", avatarAcc_belt:"Belt",
|
avatarAcc_badge:"Badge", avatarAcc_necklace:"Necklace", avatarAcc_bowtie:"Bow tie", avatarAcc_belt:"Belt",
|
||||||
avatarAcc_backpack:"Backpack", avatarAcc_satchel:"Satchel", avatarAcc_wings:"Wings",
|
avatarAcc_backpack:"Backpack", avatarAcc_satchel:"Satchel", avatarAcc_wings:"Wings", avatarAcc_star:"Golden star", avatarAcc_medal:"Medal",
|
||||||
avatarSave:"Save", avatarSaved:"Character saved ✓",
|
avatarSave:"Save", avatarSaved:"Character saved ✓",
|
||||||
clock24Hour:"24-hour clock",
|
clock24Hour:"24-hour clock",
|
||||||
widgetSettings:"Settings", clockWelcome:"Practise telling time", clockWelcomeHint:"Choose level 1, 2 or 3 at the top. In Settings, choose how the task is shown, then start.", clockSetTitle:"Move the hands to the correct time", clockReadTitle:"View the task and make the time", clockChooseTitle:"Choose the correct time block", clockYourTask:"Your task", clockLinkHands:"Move the minute hand along", clockTypeHint:"Enter the time as HH:MM.", clockLevel1Hint:"Level 1: whole and half hours", clockLevel2Hint:"Level 2: five-minute steps", clockLevel3Hint:"Level 3: every minute, with optional 24-hour time and day parts", clockBuildHint:"Drag four digits into the slots to make HH:MM.", clockDigitGhost:"?", clockDigit:"Digit {digit}", clockIncomplete:"Complete the full time first.", clockCorrect:"Well done! Choose the next task. 🌟", clockTryAgain:"Almost! Try again.", clockStartTask:"Start task", clockNextTask:"Next task", clockLevel:"Level", clockLevelHour:"Whole hours", clockLevelQuarter:"Quarter hours", clockLevelFive:"Five minutes", clockLevelMinute:"Every minute", clockLearningStyle:"Show task as", clockStyleAnalogue:"Analogue clock", clockStyleDigital:"Large digital time", clockStyleWords:"In words", clockStyleBoth:"Clock, digits and words", clockDragHint:"Drag the hands to change the time.", clockGame:"Game", clockGameSet:"Set the clock", clockGameRead:"Read the time", clockGameChoose:"Choose the time", clockStart:"Choose ‘New task’ to begin", clockRead:"What time is it? Set your answer below.", clockChoose:"Which digital time matches this clock?", clockTry:"Not yet. The correct answer is {time}.", clockScore:"Score: {score} · streak: {streak}", clockWordsHour:"{hour} o'clock", clockWordsQuarterPast:"quarter past {hour}", clockWordsHalf:"half past {hour}", clockWordsQuarterTo:"quarter to {hour}", clockWordsPast:"{minutes} past {hour}", clockWordsTo:"{minutes} to {hour}", clockMorning:"in the morning", clockAfternoon:"in the afternoon", clockEvening:"in the evening", clockNight:"at night", fractionRows:"Rows", fractionLabels:"Show fractions", fractionHint:"Tap a row up to the part you want to colour.", fractionSelected:"Selected fraction: {fraction}", fractionClear:"Clear", clockTask:"Practice task", clockSet:"Set the clock to {time}", clockHour:"Hour", clockMinute:"Minutes", placeNumber:"Number", placeGame:"Game", placeGameExplore:"Explore", placeGameBuild:"Build", placeLevel:"Level", placeLevel1Hint:"Level 1: numbers up to 100 (T and E)", placeLevel2Hint:"Level 2: numbers up to 1000 (H, T and E)", placeLevel3Hint:"Level 3: numbers up to 1,000,000 (HD, TD, D, H, T and E)", placeBuildTitle:"Build the number", placeYourTask:"Your task", placeBuildHint:"Drag the digits into the slots to build the number.", placeDigitGhost:"?", placeIncomplete:"Complete the whole number first.", placeCorrect:"Well done! Choose the next task. 🌟", placeTryAgain:"Almost! Try again.", placeNextTask:"Next task", placeScore:"Score: {score} · streak: {streak}", moneyMake:"Make € {amount}", wordtypeSentence:"Sentence", wordtypeHint:"Tap: blue noun · green verb · yellow adjective", scheduleActivity:"activity…", scheduleEmpty:"Add the first activity.", scheduleCount:"{count} moments in the schedule", pollReset:"Reset", pollQuestion:"Question", pollOptions:"Answers (one per line)", pollTotal:"{count} votes", moodTotal:"{count} children have chosen", moodVote:"Choose {face}", galleryFavorites:"Favourites", galleryFavorite:"Add favourite", galleryEmpty:"No widgets found.", gallerySearch:"Search widgets…", templateMath:"Maths lesson", templateLanguage:"Language lesson", templateDay:"Day start", exportBoard:"Export", importBoard:"Import", importError:"This is not a valid Teach board file.", widgetDuplicate:"Duplicate", widgetLock:"Lock", widgetUnlock:"Unlock", widgetPreview:"Preview without controls", undo:"Undo", redo:"Redo",
|
widgetSettings:"Settings", clockWelcome:"Practise telling time", clockWelcomeHint:"Choose level 1, 2 or 3 at the top. In Settings, choose how the task is shown, then start.", clockSetTitle:"Move the hands to the correct time", clockReadTitle:"View the task and make the time", clockChooseTitle:"Choose the correct time block", clockYourTask:"Your task", clockLinkHands:"Move the minute hand along", clockTypeHint:"Enter the time as HH:MM.", clockLevel1Hint:"Level 1: whole and half hours", clockLevel2Hint:"Level 2: five-minute steps", clockLevel3Hint:"Level 3: every minute, with optional 24-hour time and day parts", clockBuildHint:"Drag four digits into the slots to make HH:MM.", clockDigitGhost:"?", clockDigit:"Digit {digit}", clockIncomplete:"Complete the full time first.", clockCorrect:"Well done! Choose the next task. 🌟", clockTryAgain:"Almost! Try again.", clockStartTask:"Start task", clockNextTask:"Next task", clockLevel:"Level", clockLevelHour:"Whole hours", clockLevelQuarter:"Quarter hours", clockLevelFive:"Five minutes", clockLevelMinute:"Every minute", clockLearningStyle:"Show task as", clockStyleAnalogue:"Analogue clock", clockStyleDigital:"Large digital time", clockStyleWords:"In words", clockStyleBoth:"Clock, digits and words", clockDragHint:"Drag the hands to change the time.", clockGame:"Game", clockGameSet:"Set the clock", clockGameRead:"Read the time", clockGameChoose:"Choose the time", clockStart:"Choose ‘New task’ to begin", clockRead:"What time is it? Set your answer below.", clockChoose:"Which digital time matches this clock?", clockTry:"Not yet. The correct answer is {time}.", clockScore:"Score: {score} · streak: {streak}", clockWordsHour:"{hour} o'clock", clockWordsQuarterPast:"quarter past {hour}", clockWordsHalf:"half past {hour}", clockWordsQuarterTo:"quarter to {hour}", clockWordsPast:"{minutes} past {hour}", clockWordsTo:"{minutes} to {hour}", clockMorning:"in the morning", clockAfternoon:"in the afternoon", clockEvening:"in the evening", clockNight:"at night", fractionRows:"Rows", fractionLabels:"Show fractions", fractionHint:"Tap a row up to the part you want to colour.", fractionSelected:"Selected fraction: {fraction}", fractionClear:"Clear", clockTask:"Practice task", clockSet:"Set the clock to {time}", clockHour:"Hour", clockMinute:"Minutes", placeNumber:"Number", placeGame:"Game", placeGameExplore:"Explore", placeGameBuild:"Build", placeLevel:"Level", placeLevel1Hint:"Level 1: numbers up to 100 (T and E)", placeLevel2Hint:"Level 2: numbers up to 1000 (H, T and E)", placeLevel3Hint:"Level 3: numbers up to 1,000,000 (HD, TD, D, H, T and E)", placeBuildTitle:"Build the number", placeYourTask:"Your task", placeBuildHint:"Drag the digits into the slots to build the number.", placeDigitGhost:"?", placeIncomplete:"Complete the whole number first.", placeCorrect:"Well done! Choose the next task. 🌟", placeTryAgain:"Almost! Try again.", placeNextTask:"Next task", placeScore:"Score: {score} · streak: {streak}", moneyMake:"Make € {amount}", wordtypeSentence:"Sentence", wordtypeHint:"Tap: blue noun · green verb · yellow adjective", scheduleActivity:"activity…", scheduleEmpty:"Add the first activity.", scheduleCount:"{count} moments in the schedule", pollReset:"Reset", pollQuestion:"Question", pollOptions:"Answers (one per line)", pollTotal:"{count} votes", moodTotal:"{count} children have chosen", moodVote:"Choose {face}", galleryFavorites:"Favourites", galleryFavorite:"Add favourite", galleryEmpty:"No widgets found.", gallerySearch:"Search widgets…", templateMath:"Maths lesson", templateLanguage:"Language lesson", templateDay:"Day start", exportBoard:"Export", importBoard:"Import", importError:"This is not a valid Teach board file.", widgetDuplicate:"Duplicate", widgetLock:"Lock", widgetUnlock:"Unlock", widgetPreview:"Preview without controls", undo:"Undo", redo:"Redo",
|
||||||
|
|
@ -737,8 +744,11 @@ const I18N = {
|
||||||
dtLock:"Lock in place: can no longer move or rotate, only draw along it",
|
dtLock:"Lock in place: can no longer move or rotate, only draw along it",
|
||||||
dtMode:"Basic: big main angles and whole centimetres. Full: degrees every 2° with a double scale, millimetres and parallel guide lines.",
|
dtMode:"Basic: big main angles and whole centimetres. Full: degrees every 2° with a double scale, millimetres and parallel guide lines.",
|
||||||
dtModeBasic:"basic", dtModeFull:"full",
|
dtModeBasic:"basic", dtModeFull:"full",
|
||||||
pupilEmpty:"Your teacher hasn't set up anything for you yet.",
|
pupilEmpty:"Your teacher hasn't set up anything for you yet.", pupilWatching:"watching",
|
||||||
pupilWatching:"watching",
|
pupilPath:"My learning path", pupilPathStep:"Task {current} of {total}", pupilPathComplete:"Path completed",
|
||||||
|
pupilPathWellDone:"Very well done!", pupilPathReward:"Your character celebrates with a dance.",
|
||||||
|
pupilPathNext:"Complete step and continue", pupilPathFirst:"Complete the task above first",
|
||||||
|
pupilRewardUnlocked:"{count} new avatar reward(s) unlocked!",
|
||||||
amUsers:"Users", amClasses:"Classes", amSchools:"Schools", amOverarching:"System",
|
amUsers:"Users", amClasses:"Classes", amSchools:"Schools", amOverarching:"System",
|
||||||
amPickSchoolHint:"First pick a school above.", amPickSchool:"Pick a school",
|
amPickSchoolHint:"First pick a school above.", amPickSchool:"Pick a school",
|
||||||
amPupils:"Pupils", amPupilOne:"Pupil", amEdit:"Edit",
|
amPupils:"Pupils", amPupilOne:"Pupil", amEdit:"Edit",
|
||||||
|
|
@ -792,7 +802,11 @@ const I18N = {
|
||||||
cast:"Cast", castTitle:"Cast board", castWhat:"What are you casting?",
|
cast:"Cast", castTitle:"Cast board", castWhat:"What are you casting?",
|
||||||
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",
|
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.",
|
||||||
|
amDashboardTitle:"Class dashboard", amProgressDetail:"Pupil details",
|
||||||
|
amDashboardStatus_complete:"Complete", amDashboardStatus_active:"Working", amDashboardStatus_support:"Needs help", amDashboardStatus_notStarted:"Not started",
|
||||||
|
amDashboardSteps:"{done} of {total} steps", amDashboardNoActivity:"No activity yet",
|
||||||
amProgress:"Progress", amProgressEmpty:"No progress for this pupil yet.",
|
amProgress:"Progress", amProgressEmpty:"No progress for this pupil yet.",
|
||||||
amProgressLast14Days:"Last 14 days", amProgressMistakes:"Recent mistakes",
|
amProgressLast14Days:"Last 14 days", amProgressMistakes:"Recent mistakes",
|
||||||
amProgressTimeline:"Full timeline",
|
amProgressTimeline:"Full timeline",
|
||||||
|
|
|
||||||
|
|
@ -13,15 +13,54 @@ const pupilView = document.getElementById("pupilView");
|
||||||
let pupilPollTimer = null;
|
let pupilPollTimer = null;
|
||||||
let pupilLastSig = null;
|
let pupilLastSig = null;
|
||||||
let pupilMode = "werken";
|
let pupilMode = "werken";
|
||||||
|
let pupilPendingRewards = [];
|
||||||
|
|
||||||
function pupilAllowedDefs(){
|
function pupilAllowedDefs(){
|
||||||
return REGISTRY.filter(d => PUPIL_WIDGET_CATS.includes(d.cat));
|
return REGISTRY.filter(d => PUPIL_WIDGET_CATS.includes(d.cat));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function renderPupilWidgets(widgets, mode, preview=false){
|
async function renderPupilWidgets(widgets, mode, preview=false, sequence=null){
|
||||||
pupilView.innerHTML = "";
|
pupilView.innerHTML = "";
|
||||||
const defs = pupilAllowedDefs();
|
const defs = pupilAllowedDefs();
|
||||||
const watch = mode === "kijken";
|
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);
|
||||||
|
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
|
const usable = widgets
|
||||||
.map(w => ({ w, def: defs.find(d => d.id === w.id) }))
|
.map(w => ({ w, def: defs.find(d => d.id === w.id) }))
|
||||||
.filter(x => x.def);
|
.filter(x => x.def);
|
||||||
|
|
@ -42,6 +81,8 @@ async function renderPupilWidgets(widgets, mode, preview=false){
|
||||||
try{ levels[subject] = await api("/my/level?subject="+subject); }
|
try{ levels[subject] = await api("/my/level?subject="+subject); }
|
||||||
catch(e){ levels[subject] = null; }
|
catch(e){ levels[subject] = null; }
|
||||||
}));
|
}));
|
||||||
|
let stepButton = null;
|
||||||
|
let stepReady = watch || !!sequence?.ready;
|
||||||
usable.forEach(({ w, def }, idx)=>{
|
usable.forEach(({ w, def }, idx)=>{
|
||||||
const card = document.createElement("div");
|
const card = document.createElement("div");
|
||||||
card.className = "widget pupil-card" + (watch ? " watch" : "");
|
card.className = "widget pupil-card" + (watch ? " watch" : "");
|
||||||
|
|
@ -79,6 +120,10 @@ async function renderPupilWidgets(widgets, mode, preview=false){
|
||||||
zichtbaar in de console (geen storende UI voor de leerling, wél een
|
zichtbaar in de console (geen storende UI voor de leerling, wél een
|
||||||
spoor voor wie het probleem moet natrekken). */
|
spoor voor wie het probleem moet natrekken). */
|
||||||
api("/my/progress", { method:"POST", body: { widgetId, widgetType: def.id, ...p } })
|
api("/my/progress", { method:"POST", body: { widgetId, widgetType: def.id, ...p } })
|
||||||
|
.then(()=>{
|
||||||
|
stepReady = true;
|
||||||
|
if(stepButton){ stepButton.disabled = false; stepButton.textContent = T("pupilPathNext"); }
|
||||||
|
})
|
||||||
.catch(e=>console.warn("voortgang loggen mislukt:", e.message));
|
.catch(e=>console.warn("voortgang loggen mislukt:", e.message));
|
||||||
}};
|
}};
|
||||||
/* De vakinstelling van de leerkracht is per leerling en dus leidend boven
|
/* De vakinstelling van de leerkracht is per leerling en dus leidend boven
|
||||||
|
|
@ -92,6 +137,29 @@ async function renderPupilWidgets(widgets, mode, preview=false){
|
||||||
if(typeof appendLearningProfileBadge === "function")
|
if(typeof appendLearningProfileBadge === "function")
|
||||||
appendLearningProfileBadge(body, def.cat, state, def.id);
|
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(){
|
async function pupilPoll(){
|
||||||
|
|
@ -100,10 +168,10 @@ async function pupilPoll(){
|
||||||
const mode = res.mode === "kijken" ? "kijken" : "werken";
|
const mode = res.mode === "kijken" ? "kijken" : "werken";
|
||||||
/* modus in de signature zodat een moduswissel ook zonder widget-wijziging
|
/* modus in de signature zodat een moduswissel ook zonder widget-wijziging
|
||||||
een her-render (en dus de juiste overlay) oplevert */
|
een her-render (en dus de juiste overlay) oplevert */
|
||||||
const sig = mode + "|" + JSON.stringify(res.widgets || []);
|
const sig = mode + "|" + JSON.stringify(res.widgets || []) + "|" + JSON.stringify(res.sequence || null);
|
||||||
if(sig !== pupilLastSig){
|
if(sig !== pupilLastSig){
|
||||||
pupilLastSig = sig;
|
pupilLastSig = sig;
|
||||||
await renderPupilWidgets(res.widgets || [], mode);
|
await renderPupilWidgets(res.widgets || [], mode, false, res.sequence || null);
|
||||||
}
|
}
|
||||||
if(mode !== pupilMode){
|
if(mode !== pupilMode){
|
||||||
pupilMode = mode;
|
pupilMode = mode;
|
||||||
|
|
|
||||||
268
src/api.js
268
src/api.js
|
|
@ -201,9 +201,25 @@ export default async function api(app) {
|
||||||
animal: ['cat', 'dog', 'rabbit', 'bear'],
|
animal: ['cat', 'dog', 'rabbit', 'bear'],
|
||||||
};
|
};
|
||||||
const AVATAR_HAIR = ['none', 'tuft', 'spiky', 'curly', 'wavy', 'bob', 'ponytail', 'braids', 'mohawk'];
|
const AVATAR_HAIR = ['none', 'tuft', 'spiky', 'curly', 'wavy', 'bob', 'ponytail', 'braids', 'mohawk'];
|
||||||
const AVATAR_HEAD_ACC = ['none', 'cap', 'beanie', 'crown', 'wizardhat', 'glasses', 'sunglasses', 'eyepatch', 'bow', 'flower', 'headphones', 'partyhat'];
|
const AVATAR_HEAD_ACC = ['none', 'cap', 'beanie', 'crown', 'wizardhat', 'glasses', 'sunglasses', 'eyepatch', 'bow', 'flower', 'headphones', 'partyhat', 'star'];
|
||||||
const AVATAR_BODY_ACC = ['none', 'scarf', 'cape', 'badge', 'necklace', 'bowtie', 'belt', 'backpack', 'satchel', 'wings'];
|
const AVATAR_BODY_ACC = ['none', 'scarf', 'cape', 'badge', 'necklace', 'bowtie', 'belt', 'backpack', 'satchel', 'wings', 'medal'];
|
||||||
|
const AVATAR_REWARD_CATALOG = [
|
||||||
|
{ key: 'head:star', type: 'head', item: 'star', points: 1 },
|
||||||
|
{ key: 'body:medal', type: 'body', item: 'medal', points: 3 },
|
||||||
|
{ key: 'dance:robot', type: 'dance', item: 'robot', points: 6 },
|
||||||
|
];
|
||||||
|
const avatarRewardKey = (type, item) =>
|
||||||
|
AVATAR_REWARD_CATALOG.find((reward) => reward.type === type && reward.item === item)?.key || null;
|
||||||
const isHexColor = (v) => typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v);
|
const isHexColor = (v) => typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v);
|
||||||
|
app.get('/me/avatar-rewards', async (req, reply) => {
|
||||||
|
need(req, reply);
|
||||||
|
const points = Number((await pool.query(
|
||||||
|
'SELECT reward_points FROM users WHERE id = $1', [req.user.id])).rows[0]?.reward_points || 0);
|
||||||
|
const rewards = (await pool.query(
|
||||||
|
'SELECT reward_key FROM user_avatar_rewards WHERE user_id = $1 ORDER BY unlocked_at',
|
||||||
|
[req.user.id])).rows.map((row) => row.reward_key);
|
||||||
|
return { points, rewards, catalog: AVATAR_REWARD_CATALOG };
|
||||||
|
});
|
||||||
app.put('/me/avatar', async (req, reply) => {
|
app.put('/me/avatar', async (req, reply) => {
|
||||||
need(req, reply);
|
need(req, reply);
|
||||||
const b = req.body ?? {};
|
const b = req.body ?? {};
|
||||||
|
|
@ -216,6 +232,16 @@ export default async function api(app) {
|
||||||
if (!isHexColor(b.hairColor)) return fail(reply, 400, 'ongeldige haarkleur');
|
if (!isHexColor(b.hairColor)) return fail(reply, 400, 'ongeldige haarkleur');
|
||||||
if (!AVATAR_HEAD_ACC.includes(b.accessoryHead)) return fail(reply, 400, 'ongeldig hoofdaccessoire');
|
if (!AVATAR_HEAD_ACC.includes(b.accessoryHead)) return fail(reply, 400, 'ongeldig hoofdaccessoire');
|
||||||
if (!AVATAR_BODY_ACC.includes(b.accessoryBody)) return fail(reply, 400, 'ongeldig lichaamsaccessoire');
|
if (!AVATAR_BODY_ACC.includes(b.accessoryBody)) return fail(reply, 400, 'ongeldig lichaamsaccessoire');
|
||||||
|
const requiredRewards = [
|
||||||
|
avatarRewardKey('head', b.accessoryHead),
|
||||||
|
avatarRewardKey('body', b.accessoryBody),
|
||||||
|
].filter(Boolean);
|
||||||
|
if (requiredRewards.length) {
|
||||||
|
const owned = (await pool.query(
|
||||||
|
'SELECT reward_key FROM user_avatar_rewards WHERE user_id = $1 AND reward_key = ANY($2::text[])',
|
||||||
|
[req.user.id, requiredRewards])).rows.map((row) => row.reward_key);
|
||||||
|
if (requiredRewards.some((key) => !owned.includes(key))) return fail(reply, 403, 'avatarbeloning nog niet vrijgespeeld');
|
||||||
|
}
|
||||||
const avatar = {
|
const avatar = {
|
||||||
species: b.species, speciesVariant, bodyColor: b.bodyColor, hairStyle: b.hairStyle, hairColor: b.hairColor,
|
species: b.species, speciesVariant, bodyColor: b.bodyColor, hairStyle: b.hairStyle, hairColor: b.hairColor,
|
||||||
accessoryHead: b.accessoryHead, accessoryBody: b.accessoryBody,
|
accessoryHead: b.accessoryHead, accessoryBody: b.accessoryBody,
|
||||||
|
|
@ -807,6 +833,11 @@ export default async function api(app) {
|
||||||
const t = (await pool.query('SELECT display_name, username, data FROM users WHERE id = $1', [a.teacher_id])).rows[0];
|
const t = (await pool.query('SELECT display_name, username, data FROM users WHERE id = $1', [a.teacher_id])).rows[0];
|
||||||
const board = t ? findBoardById(t.data, a.board_id) : null;
|
const board = t ? findBoardById(t.data, a.board_id) : null;
|
||||||
const widget = a.widget_id ? (board?.data?.widgets || []).find((w) => w.wid === a.widget_id) : null;
|
const widget = a.widget_id ? (board?.data?.widgets || []).find((w) => w.wid === a.widget_id) : null;
|
||||||
|
const sequence = Array.isArray(a.sequence) ? a.sequence.map((step) => {
|
||||||
|
const stepBoard = t ? findBoardById(t.data, step.boardId) : null;
|
||||||
|
const stepWidget = (stepBoard?.data?.widgets || []).find((w) => w.wid === step.widgetId);
|
||||||
|
return { ...step, boardName: stepBoard?.name || null, widgetType: stepWidget?.id || null };
|
||||||
|
}) : [];
|
||||||
return {
|
return {
|
||||||
teacherId: Number(a.teacher_id),
|
teacherId: Number(a.teacher_id),
|
||||||
teacherName: t ? (t.display_name || t.username) : null,
|
teacherName: t ? (t.display_name || t.username) : null,
|
||||||
|
|
@ -815,6 +846,8 @@ export default async function api(app) {
|
||||||
widgetId: a.widget_id || null,
|
widgetId: a.widget_id || null,
|
||||||
widgetType: widget ? widget.id : null,
|
widgetType: widget ? widget.id : null,
|
||||||
mode: a.mode || 'werken',
|
mode: a.mode || 'werken',
|
||||||
|
title: a.title || '',
|
||||||
|
sequence,
|
||||||
updatedAt: a.updated_at,
|
updatedAt: a.updated_at,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
@ -834,6 +867,50 @@ export default async function api(app) {
|
||||||
}
|
}
|
||||||
return { boardId, widgetId: widgetId ?? null, mode: m };
|
return { boardId, widgetId: widgetId ?? null, mode: m };
|
||||||
};
|
};
|
||||||
|
const PUPIL_ASSIGNMENT_WIDGETS = new Set([
|
||||||
|
'anchor', 'letters', 'flits', 'hak', 'ball', 'sentence', 'wordtypes',
|
||||||
|
'madd', 'msub', 'mmul', 'mdiv', 'mpct', 'numberline', 'fractions', 'clock', 'placevalue', 'money',
|
||||||
|
]);
|
||||||
|
const parseAssignmentBody = (req, reply) => {
|
||||||
|
const raw = req.body ?? {};
|
||||||
|
if (!Array.isArray(raw.sequence) || raw.sequence.length === 0) {
|
||||||
|
const share = parseShareBody(req, reply);
|
||||||
|
if (share.error) return share;
|
||||||
|
return { ...share, title: '', sequence: [] };
|
||||||
|
}
|
||||||
|
if (raw.sequence.length > 12) return fail(reply, 400, 'een opdrachtenreeks heeft maximaal 12 stappen');
|
||||||
|
const title = typeof raw.title === 'string' ? raw.title.trim() : '';
|
||||||
|
if (!title || title.length > 80) return fail(reply, 400, 'geef de opdrachtenreeks een titel van maximaal 80 tekens');
|
||||||
|
const sequence = [];
|
||||||
|
for (let index = 0; index < raw.sequence.length; index += 1) {
|
||||||
|
const item = raw.sequence[index] ?? {};
|
||||||
|
const boardId = item.boardId || raw.boardId;
|
||||||
|
const board = typeof boardId === 'string' ? findBoardById(req.user.data, boardId) : null;
|
||||||
|
if (!board) return fail(reply, 400, `bord van stap ${index + 1} onbekend`);
|
||||||
|
if (!item.widgetId || typeof item.widgetId !== 'string' || item.widgetId.length > 100)
|
||||||
|
return fail(reply, 400, `ongeldige widget in stap ${index + 1}`);
|
||||||
|
const widget = (board.data?.widgets || []).find((candidate) => candidate.wid === item.widgetId);
|
||||||
|
if (!widget) return fail(reply, 400, `widget van stap ${index + 1} staat niet op dit bord`);
|
||||||
|
if (!PUPIL_ASSIGNMENT_WIDGETS.has(widget.id))
|
||||||
|
return fail(reply, 400, `widget van stap ${index + 1} is niet geschikt voor de leerlingomgeving`);
|
||||||
|
const mode = item.mode ?? 'werken';
|
||||||
|
if (mode !== 'kijken' && mode !== 'werken') return fail(reply, 400, `ongeldige modus in stap ${index + 1}`);
|
||||||
|
sequence.push({ id: `step-${index + 1}`, boardId, widgetId: item.widgetId, mode });
|
||||||
|
}
|
||||||
|
const first = sequence[0];
|
||||||
|
return { boardId: first.boardId, widgetId: first.widgetId, mode: first.mode, title, sequence };
|
||||||
|
};
|
||||||
|
const saveAssignment = async (targetColumn, targetId, teacherId, share) => {
|
||||||
|
const row = (await pool.query(
|
||||||
|
`INSERT INTO assignments (${targetColumn}, teacher_id, board_id, widget_id, mode, title, sequence)
|
||||||
|
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||||
|
ON CONFLICT (${targetColumn}) WHERE ${targetColumn} IS NOT NULL
|
||||||
|
DO UPDATE SET teacher_id = $2, board_id = $3, widget_id = $4, mode = $5,
|
||||||
|
title = $6, sequence = $7, updated_at = now()
|
||||||
|
RETURNING id`,
|
||||||
|
[targetId, teacherId, share.boardId, share.widgetId, share.mode, share.title, JSON.stringify(share.sequence)])).rows[0];
|
||||||
|
if (row?.id) await pool.query('DELETE FROM assignment_step_progress WHERE assignment_id = $1', [row.id]);
|
||||||
|
};
|
||||||
|
|
||||||
app.get('/assignments/class/:id', async (req, reply) => {
|
app.get('/assignments/class/:id', async (req, reply) => {
|
||||||
need(req, reply, PERMISSIONS['assignments.manage']);
|
need(req, reply, PERMISSIONS['assignments.manage']);
|
||||||
|
|
@ -846,17 +923,13 @@ export default async function api(app) {
|
||||||
});
|
});
|
||||||
app.put('/assignments/class/:id', async (req, reply) => {
|
app.put('/assignments/class/:id', async (req, reply) => {
|
||||||
need(req, reply, PERMISSIONS['assignments.manage']);
|
need(req, reply, PERMISSIONS['assignments.manage']);
|
||||||
const share = parseShareBody(req, reply);
|
const share = parseAssignmentBody(req, reply);
|
||||||
if (share.error) return share;
|
if (share.error) return share;
|
||||||
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
|
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
|
||||||
if (!c) return fail(reply, 404, 'klas onbekend');
|
if (!c) return fail(reply, 404, 'klas onbekend');
|
||||||
if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten');
|
if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten');
|
||||||
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten');
|
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten');
|
||||||
await pool.query(
|
await saveAssignment('class_id', c.id, req.user.id, share);
|
||||||
`INSERT INTO assignments (class_id, teacher_id, board_id, widget_id, mode) VALUES ($1,$2,$3,$4,$5)
|
|
||||||
ON CONFLICT (class_id) WHERE class_id IS NOT NULL
|
|
||||||
DO UPDATE SET teacher_id = $2, board_id = $3, widget_id = $4, mode = $5, updated_at = now()`,
|
|
||||||
[c.id, req.user.id, share.boardId, share.widgetId, share.mode]);
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
app.delete('/assignments/class/:id', async (req, reply) => {
|
app.delete('/assignments/class/:id', async (req, reply) => {
|
||||||
|
|
@ -884,16 +957,12 @@ export default async function api(app) {
|
||||||
});
|
});
|
||||||
app.put('/assignments/pupil/:id', async (req, reply) => {
|
app.put('/assignments/pupil/:id', async (req, reply) => {
|
||||||
need(req, reply, PERMISSIONS['assignments.manage']);
|
need(req, reply, PERMISSIONS['assignments.manage']);
|
||||||
const share = parseShareBody(req, reply);
|
const share = parseAssignmentBody(req, reply);
|
||||||
if (share.error) return share;
|
if (share.error) return share;
|
||||||
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
|
const u = (await pool.query('SELECT * FROM users WHERE id = $1', [req.params.id])).rows[0];
|
||||||
if (!u) return fail(reply, 404, 'gebruiker onbekend');
|
if (!u) return fail(reply, 404, 'gebruiker onbekend');
|
||||||
if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten');
|
if (!(await pupilAccessible(req, u))) return fail(reply, 403, 'geen rechten');
|
||||||
await pool.query(
|
await saveAssignment('pupil_id', u.id, req.user.id, share);
|
||||||
`INSERT INTO assignments (pupil_id, teacher_id, board_id, widget_id, mode) VALUES ($1,$2,$3,$4,$5)
|
|
||||||
ON CONFLICT (pupil_id) WHERE pupil_id IS NOT NULL
|
|
||||||
DO UPDATE SET teacher_id = $2, board_id = $3, widget_id = $4, mode = $5, updated_at = now()`,
|
|
||||||
[u.id, req.user.id, share.boardId, share.widgetId, share.mode]);
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
app.delete('/assignments/pupil/:id', async (req, reply) => {
|
app.delete('/assignments/pupil/:id', async (req, reply) => {
|
||||||
|
|
@ -906,23 +975,109 @@ export default async function api(app) {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Leerling-kant: de effectieve toewijzing (eigen override, anders de klas).
|
// Leerling-kant: de effectieve toewijzing (eigen override, anders de klas).
|
||||||
// Live opgezocht (geen kopie) zodat nieuwe woorden bij de leerkracht meteen
|
// Bij een reeks wordt uitsluitend de eerste nog niet afgeronde stap live uit
|
||||||
// doorkomen zodra de leerling-pagina opnieuw ophaalt (polling).
|
// het bord van de leerkracht geladen; losse toewijzingen blijven ongewijzigd.
|
||||||
|
const effectiveAssignment = async (user) => {
|
||||||
|
let assignment = (await pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [user.id])).rows[0];
|
||||||
|
if (!assignment && user.class_id) {
|
||||||
|
assignment = (await pool.query('SELECT * FROM assignments WHERE class_id = $1', [user.class_id])).rows[0];
|
||||||
|
}
|
||||||
|
return assignment || null;
|
||||||
|
};
|
||||||
|
const assignmentWidgets = async (assignment, boardId, widgetId) => {
|
||||||
|
const teacher = (await pool.query('SELECT data FROM users WHERE id = $1', [assignment.teacher_id])).rows[0];
|
||||||
|
const board = teacher ? findBoardById(teacher.data, boardId) : null;
|
||||||
|
let widgets = (board?.data?.widgets || []).map((widget) => ({ id: widget.id, wid: widget.wid, state: widget.state }));
|
||||||
|
if (widgetId) widgets = widgets.filter((widget) => widget.wid === widgetId);
|
||||||
|
return widgets;
|
||||||
|
};
|
||||||
|
|
||||||
app.get('/my/assignment', async (req, reply) => {
|
app.get('/my/assignment', async (req, reply) => {
|
||||||
need(req, reply);
|
need(req, reply);
|
||||||
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
|
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
|
||||||
let a = (await pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [req.user.id])).rows[0];
|
const assignment = await effectiveAssignment(req.user);
|
||||||
if (!a && req.user.class_id) {
|
if (!assignment) return { widgets: [], mode: 'werken', updatedAt: null, sequence: null };
|
||||||
a = (await pool.query('SELECT * FROM assignments WHERE class_id = $1', [req.user.class_id])).rows[0];
|
const steps = Array.isArray(assignment.sequence) ? assignment.sequence : [];
|
||||||
|
if (!steps.length) {
|
||||||
|
return {
|
||||||
|
widgets: await assignmentWidgets(assignment, assignment.board_id, assignment.widget_id),
|
||||||
|
mode: assignment.mode || 'werken', updatedAt: assignment.updated_at, sequence: null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
if (!a) return { widgets: [], mode: 'werken', updatedAt: null };
|
const completedRows = (await pool.query(
|
||||||
const t = (await pool.query('SELECT data FROM users WHERE id = $1', [a.teacher_id])).rows[0];
|
'SELECT step_id, completed_at FROM assignment_step_progress WHERE assignment_id = $1 AND pupil_id = $2',
|
||||||
const board = t ? findBoardById(t.data, a.board_id) : null;
|
[assignment.id, req.user.id])).rows;
|
||||||
let widgets = (board?.data?.widgets || []).map((w) => ({ id: w.id, wid: w.wid, state: w.state }));
|
const completedIds = new Set(completedRows.map((row) => row.step_id));
|
||||||
// één-widget-toewijzing: alleen die widget; is hij inmiddels van het bord
|
const currentIndex = steps.findIndex((step) => !completedIds.has(step.id));
|
||||||
// verwijderd, dan een lege lijst (zelfde weergave als geen toewijzing)
|
const current = currentIndex >= 0 ? steps[currentIndex] : null;
|
||||||
if (a.widget_id) widgets = widgets.filter((w) => w.wid === a.widget_id);
|
const previous = currentIndex > 0 ? steps[currentIndex - 1] : null;
|
||||||
return { widgets, mode: a.mode || 'werken', updatedAt: a.updated_at };
|
const readySince = previous
|
||||||
|
? (completedRows.find((row) => row.step_id === previous.id)?.completed_at || assignment.updated_at)
|
||||||
|
: assignment.updated_at;
|
||||||
|
const ready = !current || current.mode === 'kijken' || !!(await pool.query(
|
||||||
|
`SELECT 1 FROM progress_events
|
||||||
|
WHERE pupil_id = $1 AND widget_id = $2 AND created_at >= $3 LIMIT 1`,
|
||||||
|
[req.user.id, current.widgetId, readySince])).rows[0];
|
||||||
|
return {
|
||||||
|
widgets: current ? await assignmentWidgets(assignment, current.boardId, current.widgetId) : [],
|
||||||
|
mode: current?.mode || 'werken',
|
||||||
|
updatedAt: assignment.updated_at,
|
||||||
|
sequence: {
|
||||||
|
title: assignment.title || '',
|
||||||
|
steps: steps.map((step) => ({ id: step.id, mode: step.mode, completed: completedIds.has(step.id) })),
|
||||||
|
currentIndex: currentIndex < 0 ? steps.length : currentIndex,
|
||||||
|
completed: completedIds.size,
|
||||||
|
total: steps.length,
|
||||||
|
done: currentIndex < 0,
|
||||||
|
ready,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/my/assignment/steps/:stepId/complete', async (req, reply) => {
|
||||||
|
need(req, reply);
|
||||||
|
if (req.user.role !== 'pupil') return fail(reply, 403, 'alleen voor leerlingen');
|
||||||
|
const assignment = await effectiveAssignment(req.user);
|
||||||
|
const steps = Array.isArray(assignment?.sequence) ? assignment.sequence : [];
|
||||||
|
if (!assignment || !steps.length) return fail(reply, 400, 'geen actieve opdrachtenreeks');
|
||||||
|
const completedRows = (await pool.query(
|
||||||
|
'SELECT step_id, completed_at FROM assignment_step_progress WHERE assignment_id = $1 AND pupil_id = $2',
|
||||||
|
[assignment.id, req.user.id])).rows;
|
||||||
|
const completedIds = new Set(completedRows.map((row) => row.step_id));
|
||||||
|
const currentIndex = steps.findIndex((step) => !completedIds.has(step.id));
|
||||||
|
const current = currentIndex >= 0 ? steps[currentIndex] : null;
|
||||||
|
if (!current || current.id !== req.params.stepId) return fail(reply, 409, 'deze stap is niet de huidige opdracht');
|
||||||
|
if (current.mode !== 'kijken') {
|
||||||
|
const previous = currentIndex > 0 ? steps[currentIndex - 1] : null;
|
||||||
|
const readySince = previous
|
||||||
|
? (completedRows.find((row) => row.step_id === previous.id)?.completed_at || assignment.updated_at)
|
||||||
|
: assignment.updated_at;
|
||||||
|
const evidence = (await pool.query(
|
||||||
|
`SELECT 1 FROM progress_events
|
||||||
|
WHERE pupil_id = $1 AND widget_id = $2 AND created_at >= $3 LIMIT 1`,
|
||||||
|
[req.user.id, current.widgetId, readySince])).rows[0];
|
||||||
|
if (!evidence) return fail(reply, 409, 'rond eerst een opdracht in de widget af');
|
||||||
|
}
|
||||||
|
const inserted = (await pool.query(
|
||||||
|
`INSERT INTO assignment_step_progress (assignment_id, pupil_id, step_id)
|
||||||
|
VALUES ($1,$2,$3) ON CONFLICT DO NOTHING RETURNING step_id`,
|
||||||
|
[assignment.id, req.user.id, current.id])).rows[0];
|
||||||
|
let points = Number(req.user.reward_points || 0);
|
||||||
|
let unlocked = [];
|
||||||
|
if (inserted) {
|
||||||
|
points = Number((await pool.query(
|
||||||
|
'UPDATE users SET reward_points = reward_points + 1 WHERE id = $1 RETURNING reward_points',
|
||||||
|
[req.user.id])).rows[0]?.reward_points || points + 1);
|
||||||
|
unlocked = (await pool.query(
|
||||||
|
`INSERT INTO user_avatar_rewards (user_id, reward_key)
|
||||||
|
SELECT $1, reward_key FROM (VALUES
|
||||||
|
('head:star', 1), ('body:medal', 3), ('dance:robot', 6)
|
||||||
|
) AS rewards(reward_key, required_points)
|
||||||
|
WHERE required_points <= $2
|
||||||
|
ON CONFLICT DO NOTHING RETURNING reward_key`,
|
||||||
|
[req.user.id, points])).rows.map((row) => row.reward_key);
|
||||||
|
}
|
||||||
|
return { ok: true, points, unlocked, done: completedIds.size + (inserted ? 1 : 0) >= steps.length };
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- klassenmanagement: niveaugroep + niveau per leerling per vakgebied -------
|
// ---- klassenmanagement: niveaugroep + niveau per leerling per vakgebied -------
|
||||||
|
|
@ -1085,9 +1240,13 @@ export default async function api(app) {
|
||||||
const { widgetId, widgetType, attempts, correct, stars, detail } = req.body ?? {};
|
const { widgetId, widgetType, attempts, correct, stars, detail } = req.body ?? {};
|
||||||
if (!widgetId || typeof widgetId !== 'string' || widgetId.length > 100) return fail(reply, 400, 'ongeldige widgetId');
|
if (!widgetId || typeof widgetId !== 'string' || widgetId.length > 100) return fail(reply, 400, 'ongeldige widgetId');
|
||||||
if (!widgetType || typeof widgetType !== 'string' || widgetType.length > 40) return fail(reply, 400, 'ongeldige widgetType');
|
if (!widgetType || typeof widgetType !== 'string' || widgetType.length > 40) return fail(reply, 400, 'ongeldige widgetType');
|
||||||
let a = (await pool.query('SELECT * FROM assignments WHERE pupil_id = $1', [req.user.id])).rows[0];
|
let a = await effectiveAssignment(req.user);
|
||||||
if (!a && req.user.class_id) {
|
if (a && Array.isArray(a.sequence) && a.sequence.length) {
|
||||||
a = (await pool.query('SELECT * FROM assignments WHERE class_id = $1', [req.user.class_id])).rows[0];
|
const completed = new Set((await pool.query(
|
||||||
|
'SELECT step_id FROM assignment_step_progress WHERE assignment_id = $1 AND pupil_id = $2',
|
||||||
|
[a.id, req.user.id])).rows.map((row) => row.step_id));
|
||||||
|
const current = a.sequence.find((step) => !completed.has(step.id));
|
||||||
|
if (!current || current.widgetId !== widgetId) return fail(reply, 409, 'deze widget is niet de huidige stap');
|
||||||
}
|
}
|
||||||
// thuiskind (leerling zonder school, ouderportaal): geen toewijzing nodig,
|
// thuiskind (leerling zonder school, ouderportaal): geen toewijzing nodig,
|
||||||
// de voortgang wordt gelogd met de (eerste) gekoppelde ouder als
|
// de voortgang wordt gelogd met de (eerste) gekoppelde ouder als
|
||||||
|
|
@ -1152,6 +1311,55 @@ export default async function api(app) {
|
||||||
return { events: rows.map((e) => ({ ...describeProgressEvent(e), pupilId: Number(e.pupil_id), pupilName: e.display_name || e.username })) };
|
return { events: rows.map((e) => ({ ...describeProgressEvent(e), pupilId: Number(e.pupil_id), pupilName: e.display_name || e.username })) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/progress/dashboard/class/:id', async (req, reply) => {
|
||||||
|
need(req, reply, PERMISSIONS['progress.view']);
|
||||||
|
const c = (await pool.query('SELECT * FROM classes WHERE id = $1', [req.params.id])).rows[0];
|
||||||
|
if (!c) return fail(reply, 404, 'klas onbekend');
|
||||||
|
if (!sameSchool(req, c)) return fail(reply, 403, 'geen rechten');
|
||||||
|
if (teacherOnly(req) && !(await classOwnedByTeacher(c.id, req.user.id))) return fail(reply, 403, 'geen rechten');
|
||||||
|
const pupils = (await pool.query(
|
||||||
|
`SELECT u.id, u.display_name, u.username,
|
||||||
|
COALESCE(SUM(pe.attempts), 0) AS attempts,
|
||||||
|
COALESCE(SUM(pe.correct), 0) AS correct,
|
||||||
|
MAX(pe.created_at) AS last_activity
|
||||||
|
FROM users u LEFT JOIN progress_events pe ON pe.pupil_id = u.id
|
||||||
|
WHERE u.class_id = $1 AND u.role = 'pupil'
|
||||||
|
GROUP BY u.id, u.display_name, u.username
|
||||||
|
ORDER BY COALESCE(u.display_name, u.username)`, [c.id])).rows;
|
||||||
|
const assignments = (await pool.query(
|
||||||
|
`SELECT * FROM assignments
|
||||||
|
WHERE class_id = $1 OR pupil_id IN (SELECT id FROM users WHERE class_id = $1 AND role = 'pupil')`, [c.id])).rows;
|
||||||
|
const completed = (await pool.query(
|
||||||
|
`SELECT asp.assignment_id, asp.pupil_id, COUNT(*) AS completed
|
||||||
|
FROM assignment_step_progress asp JOIN users u ON u.id = asp.pupil_id
|
||||||
|
WHERE u.class_id = $1 GROUP BY asp.assignment_id, asp.pupil_id`, [c.id])).rows;
|
||||||
|
const classAssignment = assignments.find((assignment) => Number(assignment.class_id) === Number(c.id));
|
||||||
|
const completedMap = new Map(completed.map((row) => [`${row.assignment_id}:${row.pupil_id}`, Number(row.completed)]));
|
||||||
|
const overview = pupils.map((pupil) => {
|
||||||
|
const assignment = assignments.find((item) => Number(item.pupil_id) === Number(pupil.id)) || classAssignment;
|
||||||
|
const total = Array.isArray(assignment?.sequence) ? assignment.sequence.length : 0;
|
||||||
|
const done = assignment ? (completedMap.get(`${assignment.id}:${pupil.id}`) || 0) : 0;
|
||||||
|
const attempts = Number(pupil.attempts || 0);
|
||||||
|
const correct = Number(pupil.correct || 0);
|
||||||
|
const accuracy = attempts ? Math.round((correct / attempts) * 100) : 0;
|
||||||
|
let status = 'notStarted';
|
||||||
|
if (total && done >= total) status = 'complete';
|
||||||
|
else if (attempts >= 5 && accuracy < 60) status = 'support';
|
||||||
|
else if (attempts || done) status = 'active';
|
||||||
|
return {
|
||||||
|
pupilId: Number(pupil.id), pupilName: pupil.display_name || pupil.username,
|
||||||
|
attempts, correct, accuracy, lastActivity: pupil.last_activity,
|
||||||
|
sequence: assignment && total ? { title: assignment.title || '', completed: done, total } : null,
|
||||||
|
status,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const summary = overview.reduce((counts, pupil) => {
|
||||||
|
counts[pupil.status] += 1;
|
||||||
|
return counts;
|
||||||
|
}, { complete: 0, active: 0, support: 0, notStarted: 0 });
|
||||||
|
return { pupils: overview, summary };
|
||||||
|
});
|
||||||
|
|
||||||
// ---- ouderportaal -----------------------------------------------------------
|
// ---- ouderportaal -----------------------------------------------------------
|
||||||
// Ouders (role 'parent', school_id NULL) registreren zichzelf zonder school,
|
// Ouders (role 'parent', school_id NULL) registreren zichzelf zonder school,
|
||||||
// beheren gekoppelde kind-accounts en melden thuiskinderen aan bij een school.
|
// beheren gekoppelde kind-accounts en melden thuiskinderen aan bij een school.
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ async function loadAvatarRenderer(){
|
||||||
document:{createElement:fakeAvatarElement,createElementNS:fakeAvatarElement},
|
document:{createElement:fakeAvatarElement,createElementNS:fakeAvatarElement},
|
||||||
};
|
};
|
||||||
runInNewContext(source.slice(0,cutoff)+
|
runInNewContext(source.slice(0,cutoff)+
|
||||||
';globalThis.__avatar={renderAvatarFace,avatarBodyMarkup,sanitize:avatarSanitize,variants:AVATAR_VARIANTS,hair:AVATAR_HAIR_MARKUP,bodyAccessories:AVATAR_BODY_ACC_MARKUP,headAccessories:AVATAR_HEAD_ACC_MARKUP,hairIds:AVATAR_HAIR,headIds:AVATAR_HEAD_ACC,bodyIds:AVATAR_BODY_ACC,dances:AVATAR_DANCES,nextDance:avatarDanceChoice,celebrate:celebrateAvatar};',
|
';globalThis.__avatar={renderAvatarFace,avatarBodyMarkup,sanitize:avatarSanitize,variants:AVATAR_VARIANTS,hair:AVATAR_HAIR_MARKUP,bodyAccessories:AVATAR_BODY_ACC_MARKUP,headAccessories:AVATAR_HEAD_ACC_MARKUP,hairIds:AVATAR_HAIR,headIds:AVATAR_HEAD_ACC,bodyIds:AVATAR_BODY_ACC,dances:AVATAR_DANCES,nextDance:avatarDanceChoice,celebrate:celebrateAvatar,unlock:avatarAcceptRewards};',
|
||||||
context);
|
context);
|
||||||
context.__avatar.timers=timers;
|
context.__avatar.timers=timers;
|
||||||
return context.__avatar;
|
return context.__avatar;
|
||||||
|
|
@ -101,13 +101,16 @@ test("iedere categorie heeft vier anatomisch herkenbare avatarvarianten",async()
|
||||||
assert.equal(api.sanitize({species:"monster",speciesVariant:"rabbit"}).speciesVariant,"horned");
|
assert.equal(api.sanitize({species:"monster",speciesVariant:"rabbit"}).speciesVariant,"horned");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("voltooide opdrachten rouleren door vijf herkenbare avatardansen",async()=>{
|
test("voltooide opdrachten rouleren door vijf basisdansen en een vrijspeelbare robotdans",async()=>{
|
||||||
const api=await loadAvatarRenderer();
|
const api=await loadAvatarRenderer();
|
||||||
const dances=["bounce","twist","spin","shuffle","wiggle"];
|
const basic=["bounce","twist","spin","shuffle","wiggle"];
|
||||||
|
const dances=[...basic,"robot"];
|
||||||
assert.deepEqual(Array.from(api.dances),dances);
|
assert.deepEqual(Array.from(api.dances),dances);
|
||||||
assert.deepEqual(dances.map(()=>api.nextDance()),dances);
|
assert.deepEqual(basic.map(()=>api.nextDance()),basic);
|
||||||
|
assert.equal(api.nextDance("robot"),"bounce","robotdans blijft eerst vergrendeld");
|
||||||
|
api.unlock(["dance:robot"]);
|
||||||
|
assert.equal(api.nextDance("robot"),"robot");
|
||||||
assert.equal(api.nextDance("spin"),"spin");
|
assert.equal(api.nextDance("spin"),"spin");
|
||||||
assert.equal(api.nextDance("onbekend"),"bounce");
|
|
||||||
|
|
||||||
const hostClasses=new Set(),stages=[];
|
const hostClasses=new Set(),stages=[];
|
||||||
const host={
|
const host={
|
||||||
|
|
@ -204,8 +207,8 @@ test("haar volgt de kop met natuurlijke details en correcte lagen",async()=>{
|
||||||
|
|
||||||
test('accessoires gebruiken vaste ankers en correcte voor- en achterlagen',async()=>{
|
test('accessoires gebruiken vaste ankers en correcte voor- en achterlagen',async()=>{
|
||||||
const api=await loadAvatarRenderer();
|
const api=await loadAvatarRenderer();
|
||||||
assert.deepEqual(Array.from(api.headIds),['none','cap','beanie','crown','wizardhat','glasses','sunglasses','eyepatch','bow','flower','headphones','partyhat']);
|
assert.deepEqual(Array.from(api.headIds),['none','cap','beanie','crown','wizardhat','glasses','sunglasses','eyepatch','bow','flower','headphones','partyhat','star']);
|
||||||
assert.deepEqual(Array.from(api.bodyIds),['none','scarf','cape','badge','necklace','bowtie','belt','backpack','satchel','wings']);
|
assert.deepEqual(Array.from(api.bodyIds),['none','scarf','cape','badge','necklace','bowtie','belt','backpack','satchel','wings','medal']);
|
||||||
|
|
||||||
for(const id of api.bodyIds){
|
for(const id of api.bodyIds){
|
||||||
assert.equal(typeof api.bodyAccessories[id].back,'function',id+' mist achterlaag');
|
assert.equal(typeof api.bodyAccessories[id].back,'function',id+' mist achterlaag');
|
||||||
|
|
@ -240,13 +243,13 @@ test('accessoires gebruiken vaste ankers en correcte voor- en achterlagen',async
|
||||||
assert.ok(wings.indexOf('av-wings')<wings.indexOf('av-torso'));
|
assert.ok(wings.indexOf('av-wings')<wings.indexOf('av-torso'));
|
||||||
assert.ok(wings.indexOf('av-wing-clasp')>wings.indexOf('av-head-large'));
|
assert.ok(wings.indexOf('av-wing-clasp')>wings.indexOf('av-head-large'));
|
||||||
|
|
||||||
for(const [id,part] of [['scarf','av-scarf'],['badge','av-badge'],['necklace','av-necklace'],['bowtie','av-bowtie'],['belt','av-belt']]){
|
for(const [id,part] of [['scarf','av-scarf'],['badge','av-badge'],['necklace','av-necklace'],['bowtie','av-bowtie'],['belt','av-belt'],['medal','av-reward-medal']]){
|
||||||
const markup=api.renderAvatarFace({...base,accessoryBody:id},'lg').innerHTML;
|
const markup=api.renderAvatarFace({...base,accessoryBody:id},'lg').innerHTML;
|
||||||
assert.ok(markup.indexOf(part)>markup.indexOf('av-head-large'),id+' hoort voor op het lijf');
|
assert.ok(markup.indexOf(part)>markup.indexOf('av-head-large'),id+' hoort voor op het lijf');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test("alle 12960 variant-, haar- en accessoirecombinaties renderen volledig",async()=>{
|
test("alle 15444 variant-, haar- en accessoirecombinaties renderen volledig",async()=>{
|
||||||
const api=await loadAvatarRenderer();
|
const api=await loadAvatarRenderer();
|
||||||
let combinations=0;
|
let combinations=0;
|
||||||
for(const speciesId of ["dino","monster","animal"]){
|
for(const speciesId of ["dino","monster","animal"]){
|
||||||
|
|
@ -273,7 +276,7 @@ test("alle 12960 variant-, haar- en accessoirecombinaties renderen volledig",asy
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
assert.equal(combinations,12960);
|
assert.equal(combinations,15444);
|
||||||
});
|
});
|
||||||
test("client, API en beide talen bevatten dezelfde avataropties",async()=>{
|
test("client, API en beide talen bevatten dezelfde avataropties",async()=>{
|
||||||
const [apiSource,core]=await Promise.all([
|
const [apiSource,core]=await Promise.all([
|
||||||
|
|
@ -281,7 +284,7 @@ test("client, API en beide talen bevatten dezelfde avataropties",async()=>{
|
||||||
readFile("public/js/core.js","utf8"),
|
readFile("public/js/core.js","utf8"),
|
||||||
]);
|
]);
|
||||||
const hair=["tuft","wavy","bob","braids"];
|
const hair=["tuft","wavy","bob","braids"];
|
||||||
const accessories=["beanie","wizardhat","eyepatch","flower","bowtie","belt","satchel","wings"];
|
const accessories=["beanie","wizardhat","eyepatch","flower","bowtie","belt","satchel","wings","star","medal"];
|
||||||
const variants=["rex","triceratops","stegosaurus","raptor","horned","cyclops","slime","alien","cat","dog","rabbit","bear"];
|
const variants=["rex","triceratops","stegosaurus","raptor","horned","cyclops","slime","alien","cat","dog","rabbit","bear"];
|
||||||
for(const id of hair){
|
for(const id of hair){
|
||||||
assert.ok(apiSource.includes("'"+id+"'"),id+" ontbreekt in API-validatie");
|
assert.ok(apiSource.includes("'"+id+"'"),id+" ontbreekt in API-validatie");
|
||||||
|
|
|
||||||
223
test/learning-paths.test.js
Normal file
223
test/learning-paths.test.js
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
import test from 'node:test';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { readFile } from 'node:fs/promises';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
import cookie from '@fastify/cookie';
|
||||||
|
import rateLimit from '@fastify/rate-limit';
|
||||||
|
import api from '../src/api.js';
|
||||||
|
|
||||||
|
const cookies = { teach_session: 'x'.repeat(64) };
|
||||||
|
const teacherData = {
|
||||||
|
boards: {
|
||||||
|
folders: [{
|
||||||
|
boards: [{
|
||||||
|
id: 'board-1',
|
||||||
|
name: 'Taalbord',
|
||||||
|
data: {
|
||||||
|
widgets: [
|
||||||
|
{ id: 'letters', wid: 'letters-1', state: { words: ['boom'] } },
|
||||||
|
{ id: 'sentence', wid: 'sentence-1', state: { level: 1 } },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
}],
|
||||||
|
}],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fastifyWith(user, query) {
|
||||||
|
const app = Fastify({ trustProxy: 2 });
|
||||||
|
await app.register(cookie);
|
||||||
|
await app.register(rateLimit, { global: false });
|
||||||
|
app.decorate('pg', { query });
|
||||||
|
await app.register(api, { prefix: '/api' });
|
||||||
|
await app.ready();
|
||||||
|
return app;
|
||||||
|
}
|
||||||
|
|
||||||
|
function authRows(user, sql) {
|
||||||
|
if (sql.includes('FROM sessions s JOIN users u')) return { rows: [user] };
|
||||||
|
if (sql.includes('FROM user_roles')) return { rows: [] };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
test('opdrachtenreeks toont alleen de eerste nog niet afgeronde live widget', async () => {
|
||||||
|
const pupil = {
|
||||||
|
id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50,
|
||||||
|
data: {}, data_rev: 0, reward_points: 2,
|
||||||
|
};
|
||||||
|
const assignment = {
|
||||||
|
id: 80, pupil_id: 9, teacher_id: 3, board_id: 'board-1',
|
||||||
|
widget_id: 'letters-1', mode: 'werken', title: 'Taalroute',
|
||||||
|
updated_at: new Date('2026-07-23T08:00:00Z'),
|
||||||
|
sequence: [
|
||||||
|
{ id: 'step-1', boardId: 'board-1', widgetId: 'letters-1', mode: 'werken' },
|
||||||
|
{ id: 'step-2', boardId: 'board-1', widgetId: 'sentence-1', mode: 'kijken' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const query = async (sql) => {
|
||||||
|
const auth = authRows(pupil, sql);
|
||||||
|
if (auth) return auth;
|
||||||
|
if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') return { rows: [assignment] };
|
||||||
|
if (sql.startsWith('SELECT step_id') && sql.includes('FROM assignment_step_progress')) return { rows: [{ step_id: 'step-1' }] };
|
||||||
|
if (sql === 'SELECT data FROM users WHERE id = $1') return { rows: [{ data: teacherData }] };
|
||||||
|
return { rows: [] };
|
||||||
|
};
|
||||||
|
const app = await fastifyWith(pupil, query);
|
||||||
|
const response = await app.inject({ method: 'GET', url: '/api/my/assignment', cookies });
|
||||||
|
assert.equal(response.statusCode, 200, response.body);
|
||||||
|
const body = response.json();
|
||||||
|
assert.equal(body.widgets.length, 1);
|
||||||
|
assert.equal(body.widgets[0].wid, 'sentence-1');
|
||||||
|
assert.equal(body.mode, 'kijken');
|
||||||
|
assert.deepEqual(body.sequence, {
|
||||||
|
title: 'Taalroute',
|
||||||
|
steps: [
|
||||||
|
{ id: 'step-1', mode: 'werken', completed: true },
|
||||||
|
{ id: 'step-2', mode: 'kijken', completed: false },
|
||||||
|
],
|
||||||
|
currentIndex: 1, completed: 1, total: 2, done: false, ready: true,
|
||||||
|
});
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('stapafronding is opeenvolgend en kent punt en nieuwe beloning één keer toe', async () => {
|
||||||
|
const pupil = {
|
||||||
|
id: 9, username: 'lena', role: 'pupil', school_id: 2, class_id: 50,
|
||||||
|
data: {}, data_rev: 0, reward_points: 2,
|
||||||
|
};
|
||||||
|
const assignment = {
|
||||||
|
id: 80, pupil_id: 9, teacher_id: 3, board_id: 'board-1', updated_at: new Date(),
|
||||||
|
sequence: [
|
||||||
|
{ id: 'step-1', boardId: 'board-1', widgetId: 'letters-1', mode: 'werken' },
|
||||||
|
{ id: 'step-2', boardId: 'board-1', widgetId: 'sentence-1', mode: 'kijken' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const calls = [];
|
||||||
|
const query = async (sql, params = []) => {
|
||||||
|
calls.push({ sql, params });
|
||||||
|
const auth = authRows(pupil, sql);
|
||||||
|
if (auth) return auth;
|
||||||
|
if (sql === 'SELECT * FROM assignments WHERE pupil_id = $1') return { rows: [assignment] };
|
||||||
|
if (sql.startsWith('SELECT step_id') && sql.includes('FROM assignment_step_progress')) return { rows: [{ step_id: 'step-1' }] };
|
||||||
|
if (sql.startsWith('INSERT INTO assignment_step_progress')) return { rows: [{ step_id: 'step-2' }] };
|
||||||
|
if (sql.startsWith('UPDATE users SET reward_points')) return { rows: [{ reward_points: 3 }] };
|
||||||
|
if (sql.startsWith('INSERT INTO user_avatar_rewards')) return { rows: [{ reward_key: 'body:medal' }] };
|
||||||
|
return { rows: [] };
|
||||||
|
};
|
||||||
|
const app = await fastifyWith(pupil, query);
|
||||||
|
const skipped = await app.inject({
|
||||||
|
method: 'POST', url: '/api/my/assignment/steps/step-1/complete', cookies,
|
||||||
|
});
|
||||||
|
assert.equal(skipped.statusCode, 409);
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'POST', url: '/api/my/assignment/steps/step-2/complete', cookies,
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 200, response.body);
|
||||||
|
assert.deepEqual(response.json(), {
|
||||||
|
ok: true, points: 3, unlocked: ['body:medal'], done: true,
|
||||||
|
});
|
||||||
|
assert.equal(calls.filter((call) => call.sql.startsWith('UPDATE users SET reward_points')).length, 1);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('leerkracht kan een gevalideerde reeks van maximaal twaalf stappen opslaan', async () => {
|
||||||
|
const teacher = {
|
||||||
|
id: 3, username: 'juf', display_name: 'Juf', role: 'teacher',
|
||||||
|
school_id: 2, class_id: null, data: teacherData, data_rev: 0,
|
||||||
|
};
|
||||||
|
const calls = [];
|
||||||
|
const query = async (sql, params = []) => {
|
||||||
|
calls.push({ sql, params });
|
||||||
|
const auth = authRows(teacher, sql);
|
||||||
|
if (auth) return auth;
|
||||||
|
if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 50, school_id: 2 }] };
|
||||||
|
if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [{ '?column?': 1 }] };
|
||||||
|
if (sql.includes('INSERT INTO assignments')) return { rows: [{ id: 80 }] };
|
||||||
|
return { rows: [] };
|
||||||
|
};
|
||||||
|
const app = await fastifyWith(teacher, query);
|
||||||
|
const payload = {
|
||||||
|
boardId: 'board-1', title: 'Taalroute',
|
||||||
|
sequence: [
|
||||||
|
{ widgetId: 'letters-1', mode: 'werken' },
|
||||||
|
{ widgetId: 'sentence-1', mode: 'kijken' },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'PUT', url: '/api/assignments/class/50', cookies, payload,
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 200, response.body);
|
||||||
|
const insert = calls.find((call) => call.sql.includes('INSERT INTO assignments'));
|
||||||
|
assert.ok(insert);
|
||||||
|
assert.equal(insert.params[5], 'Taalroute');
|
||||||
|
assert.deepEqual(JSON.parse(insert.params[6]), [
|
||||||
|
{ id: 'step-1', boardId: 'board-1', widgetId: 'letters-1', mode: 'werken' },
|
||||||
|
{ id: 'step-2', boardId: 'board-1', widgetId: 'sentence-1', mode: 'kijken' },
|
||||||
|
]);
|
||||||
|
const tooLong = await app.inject({
|
||||||
|
method: 'PUT', url: '/api/assignments/class/50', cookies,
|
||||||
|
payload: { ...payload, sequence: Array.from({ length: 13 }, () => payload.sequence[0]) },
|
||||||
|
});
|
||||||
|
assert.equal(tooLong.statusCode, 400);
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('dashboard markeert klaar, bezig, hulp nodig en nog niet gestart', async () => {
|
||||||
|
const teacher = {
|
||||||
|
id: 3, username: 'juf', role: 'teacher', school_id: 2, class_id: null,
|
||||||
|
data: teacherData, data_rev: 0,
|
||||||
|
};
|
||||||
|
const query = async (sql) => {
|
||||||
|
const auth = authRows(teacher, sql);
|
||||||
|
if (auth) return auth;
|
||||||
|
if (sql.startsWith('SELECT * FROM classes')) return { rows: [{ id: 50, school_id: 2 }] };
|
||||||
|
if (sql.startsWith('SELECT 1 FROM class_teachers')) return { rows: [{ '?column?': 1 }] };
|
||||||
|
if (sql.includes('COALESCE(SUM(pe.attempts)')) return { rows: [
|
||||||
|
{ id: 1, display_name: 'A', attempts: 10, correct: 10 },
|
||||||
|
{ id: 2, display_name: 'B', attempts: 8, correct: 3 },
|
||||||
|
{ id: 3, display_name: 'C', attempts: 2, correct: 2 },
|
||||||
|
{ id: 4, display_name: 'D', attempts: 0, correct: 0 },
|
||||||
|
] };
|
||||||
|
if (sql.startsWith('SELECT * FROM assignments')) return { rows: [{
|
||||||
|
id: 80, class_id: 50, title: 'Route',
|
||||||
|
sequence: [{ id: 'step-1' }, { id: 'step-2' }],
|
||||||
|
}] };
|
||||||
|
if (sql.includes('FROM assignment_step_progress asp')) return { rows: [
|
||||||
|
{ assignment_id: 80, pupil_id: 1, completed: 2 },
|
||||||
|
] };
|
||||||
|
return { rows: [] };
|
||||||
|
};
|
||||||
|
const app = await fastifyWith(teacher, query);
|
||||||
|
const response = await app.inject({
|
||||||
|
method: 'GET', url: '/api/progress/dashboard/class/50', cookies,
|
||||||
|
});
|
||||||
|
assert.equal(response.statusCode, 200, response.body);
|
||||||
|
assert.deepEqual(response.json().pupils.map((pupil) => pupil.status),
|
||||||
|
['complete', 'support', 'active', 'notStarted']);
|
||||||
|
assert.deepEqual(response.json().summary,
|
||||||
|
{ complete: 1, active: 1, support: 1, notStarted: 1 });
|
||||||
|
await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test('database, leerlingweergave en beheer bevatten de drie samenhangende uitbreidingen', async () => {
|
||||||
|
const [migration, pupil, cast, admin, avatar, css, core] = await Promise.all([
|
||||||
|
readFile('db/021_learning_paths.sql', 'utf8'),
|
||||||
|
readFile('public/js/pupil.js', 'utf8'),
|
||||||
|
readFile('public/js/cast.js', 'utf8'),
|
||||||
|
readFile('public/js/admin.js', 'utf8'),
|
||||||
|
readFile('public/js/avatar.js', 'utf8'),
|
||||||
|
readFile('public/css/teach.css', 'utf8'),
|
||||||
|
readFile('public/js/core.js', 'utf8'),
|
||||||
|
]);
|
||||||
|
for (const table of ['assignment_step_progress', 'user_avatar_rewards']) assert.ok(migration.includes(table));
|
||||||
|
assert.match(pupil, /pupil-path[\s\S]*assignment\/steps/);
|
||||||
|
assert.match(cast, /castPathSteps[\s\S]*body\.sequence/);
|
||||||
|
assert.match(admin, /progress\/dashboard\/class/);
|
||||||
|
for (const reward of ['head:star', 'body:medal', 'dance:robot']) {
|
||||||
|
assert.ok(avatar.includes(reward));
|
||||||
|
}
|
||||||
|
assert.match(css, /av-dance-robot/);
|
||||||
|
for (const key of ['pupilPathStep', 'castPathAdd', 'amDashboardTitle']) {
|
||||||
|
assert.equal(core.split(`${key}:`).length - 1, 2, `${key} moet tweetalig zijn`);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -66,6 +66,6 @@ test('voorbeeldrol begrenst clientmenu’s maar verandert serverpermissies niet'
|
||||||
assert.match(admin,/activeRoles\(\)\.flatMap/);
|
assert.match(admin,/activeRoles\(\)\.flatMap/);
|
||||||
assert.match(admin,/show:\(\)=>activeRole\(\)==="super"/);
|
assert.match(admin,/show:\(\)=>activeRole\(\)==="super"/);
|
||||||
assert.match(explorer,/activeRole\(\) === "pupil"/);
|
assert.match(explorer,/activeRole\(\) === "pupil"/);
|
||||||
assert.match(pupil,/async function renderPupilWidgets\(widgets, mode, preview=false\)/);
|
assert.match(pupil,/async function renderPupilWidgets\(widgets, mode, preview=false, sequence=null\)/);
|
||||||
assert.match(pupil,/if\(!preview\)await Promise\.all/);
|
assert.match(pupil,/if\(!preview\)await Promise\.all/);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import api from '../src/api.js';
|
||||||
// Ingelogde leerkracht via een gemockte sessie: elke query die de sessie-join
|
// Ingelogde leerkracht via een gemockte sessie: elke query die de sessie-join
|
||||||
// doet levert de gebruiker, de data-update legt zijn SQL+params vast zodat de
|
// doet levert de gebruiker, de data-update legt zijn SQL+params vast zodat de
|
||||||
// test de rev-voorwaarde en het 409-pad kan controleren.
|
// test de rev-voorwaarde en het 409-pad kan controleren.
|
||||||
function makeApp({ dataRev = 3, updateSucceeds = true } = {}) {
|
function makeApp({ dataRev = 3, updateSucceeds = true, ownedRewards = [] } = {}) {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const pool = {
|
const pool = {
|
||||||
async query(sql, params = []) {
|
async query(sql, params = []) {
|
||||||
|
|
@ -18,6 +18,9 @@ function makeApp({ dataRev = 3, updateSucceeds = true } = {}) {
|
||||||
school_id: 2, class_id: null, data: {}, data_rev: dataRev,
|
school_id: 2, class_id: null, data: {}, data_rev: dataRev,
|
||||||
}] };
|
}] };
|
||||||
if (sql.includes('FROM user_roles')) return { rows: [] };
|
if (sql.includes('FROM user_roles')) return { rows: [] };
|
||||||
|
if (sql.startsWith('SELECT reward_key FROM user_avatar_rewards')) {
|
||||||
|
return { rows: ownedRewards.map((reward_key) => ({ reward_key })) };
|
||||||
|
}
|
||||||
if (sql.startsWith('SELECT data, data_rev')) return { rows: [{ data: { boards: {} }, data_rev: dataRev }] };
|
if (sql.startsWith('SELECT data, data_rev')) return { rows: [{ data: { boards: {} }, data_rev: dataRev }] };
|
||||||
if (sql.startsWith('UPDATE users SET data =')) {
|
if (sql.startsWith('UPDATE users SET data =')) {
|
||||||
return updateSucceeds ? { rows: [{ data_rev: dataRev + 1 }] } : { rows: [] };
|
return updateSucceeds ? { rows: [{ data_rev: dataRev + 1 }] } : { rows: [] };
|
||||||
|
|
@ -71,6 +74,24 @@ test("avatar-API weigert een variant uit een andere categorie", async () => {
|
||||||
assert.equal(calls.some((c) => c.sql.startsWith("UPDATE users SET avatar_data")),false);
|
assert.equal(calls.some((c) => c.sql.startsWith("UPDATE users SET avatar_data")),false);
|
||||||
await app.close();
|
await app.close();
|
||||||
});
|
});
|
||||||
|
test("avatar-API weigert een beloningsaccessoire totdat het is vrijgespeeld", async () => {
|
||||||
|
const locked = await makeApp();
|
||||||
|
const denied = await locked.app.inject({
|
||||||
|
method: "PUT", url: "/api/me/avatar", cookies: sessionCookie,
|
||||||
|
payload: { ...avatarPayload, accessoryHead: "star" },
|
||||||
|
});
|
||||||
|
assert.equal(denied.statusCode, 403, denied.body);
|
||||||
|
assert.equal(locked.calls.some((call) => call.sql.startsWith("UPDATE users SET avatar_data")), false);
|
||||||
|
await locked.app.close();
|
||||||
|
|
||||||
|
const unlocked = await makeApp({ ownedRewards: ["head:star"] });
|
||||||
|
const allowed = await unlocked.app.inject({
|
||||||
|
method: "PUT", url: "/api/me/avatar", cookies: sessionCookie,
|
||||||
|
payload: { ...avatarPayload, accessoryHead: "star" },
|
||||||
|
});
|
||||||
|
assert.equal(allowed.statusCode, 200, allowed.body);
|
||||||
|
await unlocked.app.close();
|
||||||
|
});
|
||||||
test('GET /me/data geeft data én rev terug', async () => {
|
test('GET /me/data geeft data én rev terug', async () => {
|
||||||
const { app } = await makeApp({ dataRev: 3 });
|
const { app } = await makeApp({ dataRev: 3 });
|
||||||
const res = await app.inject({ method: 'GET', url: '/api/me/data', cookies: sessionCookie });
|
const res = await app.inject({ method: 'GET', url: '/api/me/data', cookies: sessionCookie });
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue