feat: eigen personage (avatar) voor leerlingen, ouders en staf (v0.4.23-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 36s
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 36s
- Nieuwe migratie db/018_user_avatar.sql: kolom avatar_data (jsonb) op users. - publicUser() geeft avatar overal automatisch mee (login, /auth/me, /admin/users) - bestaande school/klas-rechten regelen vanzelf wie welke personages ziet, geen nieuwe permissie-sleutel nodig. - Nieuwe route PUT /me/avatar (open voor elke ingelogde rol, zelfde patroon als /me/data): valideert soort/kleuren/haar/accessoires, slaat de eigen rij op. - Nieuwe gedeelde module public/js/avatar.js: personage samenstellen uit soort (dino/monster/dier - elk met een vast eigen lichaamsdeel, ook de gevraagde dinostekels), lichaams- en haarkleur, haarstijl en hoofd-/ lichaamsaccessoires; gedeelde editor-UI voor zowel de instellingen-sectie "Mijn personage" als de nieuwe widget. - Instellingen-sectie "Mijn personage" (cms.js-patroon) - de leerling-toegang, want widgets van categorie "class" zijn niet toewijsbaar in het afgeschermde leerling-speelscherm. - Widget 🦖 toegevoegd aan het bord (categorie "class") voor staf en volledige-whiteboard-gebruikers. - School-navigator (admin.js): leerkracht/beheerder ziet nu het personage van elk klaslid in de personenlijst i.p.v. altijd de initialen-cirkel. - Voorbereiding gerelateerd roadmappunt: math.js toont het eigen personage kort naast "Goed!" als aanmoediging (pilot in één widget, geen nieuw feedbacktype); zonder ingesteld personage blijft het gedrag ongewijzigd. - Vertalingen (nl+en) voor de nieuwe widget, instellingen-sectie en editor. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ
This commit is contained in:
parent
8b57922307
commit
24d89b8bb5
12 changed files with 346 additions and 5 deletions
2
VERSION
2
VERSION
|
|
@ -1 +1 @@
|
||||||
0.4.22-beta
|
0.4.23-beta
|
||||||
6
db/018_user_avatar.sql
Normal file
6
db/018_user_avatar.sql
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
-- v0.4.23: eigen personage (avatar) per gebruiker - soort, kleuren, haar en
|
||||||
|
-- accessoires als één JSON-object. NULL = nog geen personage gekozen, de
|
||||||
|
-- client toont dan een neutrale standaardweergave.
|
||||||
|
|
||||||
|
ALTER TABLE users
|
||||||
|
ADD COLUMN IF NOT EXISTS avatar_data JSONB;
|
||||||
|
|
@ -1672,6 +1672,62 @@ body.hc #penbar .pen-current,body.hc #penbar .pen-popover{border:2px solid #000;
|
||||||
.bd-candle::before{content:""; position:absolute; width:8px; height:12px; left:-1.5px; top:-12px; border-radius:70% 30% 60% 40%; background:linear-gradient(#fff7a0,var(--orange)); box-shadow:0 0 8px var(--orange);}
|
.bd-candle::before{content:""; position:absolute; width:8px; height:12px; left:-1.5px; top:-12px; border-radius:70% 30% 60% 40%; background:linear-gradient(#fff7a0,var(--orange)); box-shadow:0 0 8px var(--orange);}
|
||||||
.bd-age-badge{position:absolute; right:8%; top:20%; z-index:4; min-width:58px; height:58px; padding:0 10px; border-radius:20px; display:grid; place-items:center; color:#fff; font-size:29px; font-weight:900; transform:rotate(7deg); box-shadow:0 5px 12px rgba(0,0,0,.2); border:3px solid #fff;}
|
.bd-age-badge{position:absolute; right:8%; top:20%; z-index:4; min-width:58px; height:58px; padding:0 10px; border-radius:20px; display:grid; place-items:center; color:#fff; font-size:29px; font-weight:900; transform:rotate(7deg); box-shadow:0 5px 12px rgba(0,0,0,.2); border:3px solid #fff;}
|
||||||
.bd-plate{width:min(94%,420px); height:30px; border-radius:50%; margin-top:-12px; box-shadow:0 8px 12px rgba(29,52,97,.22); z-index:1;}
|
.bd-plate{width:min(94%,420px); height:30px; border-radius:50%; margin-top:-12px; box-shadow:0 8px 12px rgba(29,52,97,.22); z-index:1;}
|
||||||
|
/* =========== Personage (avatar): gelaagde div's + CSS custom properties,
|
||||||
|
zelfde aanpak als de taart hierboven (--cake/--icing), geen SVG/afbeeldingen. ==== */
|
||||||
|
.avatar-face{position:relative; display:inline-block; flex:none;}
|
||||||
|
.avatar-face.sm{width:24px; height:24px;}
|
||||||
|
.avatar-face.md{width:44px; height:44px;}
|
||||||
|
.avatar-face.lg{width:180px; height:180px;}
|
||||||
|
.av-body{position:absolute; left:20%; right:20%; bottom:2%; top:52%; border-radius:45% 45% 40% 40%; background:var(--av-body); z-index:1;}
|
||||||
|
.av-head{position:absolute; left:8%; right:8%; top:4%; height:56%; border-radius:50%; background:var(--av-body); z-index:3; box-shadow:inset 0 -6px 10px rgba(0,0,0,.12);}
|
||||||
|
.av-part-head{position:absolute; z-index:6; display:none;}
|
||||||
|
.av-species-monster .av-part-head{display:block; left:28%; right:28%; top:0; height:14%;}
|
||||||
|
.av-species-monster .av-part-head::before,.av-species-monster .av-part-head::after{content:""; position:absolute; width:16%; height:100%; background:var(--av-body); filter:brightness(.85); clip-path:polygon(50% 0,0% 100%,100% 100%);}
|
||||||
|
.av-species-monster .av-part-head::before{left:6%;}
|
||||||
|
.av-species-monster .av-part-head::after{right:6%;}
|
||||||
|
.av-species-animal .av-part-head{display:block; left:0; right:0; top:-2%; height:22%;}
|
||||||
|
.av-species-animal .av-part-head::before,.av-species-animal .av-part-head::after{content:""; position:absolute; width:22%; height:100%; border-radius:50%; background:var(--av-body);}
|
||||||
|
.av-species-animal .av-part-head::before{left:2%;}
|
||||||
|
.av-species-animal .av-part-head::after{right:2%;}
|
||||||
|
.av-part-body{position:absolute; z-index:6; display:none;}
|
||||||
|
.av-species-dino .av-part-body{display:block; left:26%; right:26%; top:42%; height:22%;}
|
||||||
|
.av-species-dino .av-part-body::before,.av-species-dino .av-part-body::after{content:""; position:absolute; width:20%; height:100%; background:var(--av-body); filter:brightness(.72); clip-path:polygon(50% 0%,0% 100%,100% 100%);}
|
||||||
|
.av-species-dino .av-part-body::before{left:8%;}
|
||||||
|
.av-species-dino .av-part-body::after{right:8%;}
|
||||||
|
.av-species-animal .av-part-body{display:block; right:2%; bottom:8%; width:16%; height:30%; border-radius:50% 50% 50% 10%; background:var(--av-body); transform:rotate(20deg); filter:brightness(.9);}
|
||||||
|
.av-hair{position:absolute; z-index:4; display:none; background:var(--av-hair);}
|
||||||
|
.av-hair-spiky{display:block; left:14%; right:14%; top:-2%; height:22%; clip-path:polygon(0% 100%,10% 20%,25% 90%,35% 10%,50% 90%,65% 10%,75% 90%,90% 20%,100% 100%);}
|
||||||
|
.av-hair-curly{display:block; left:6%; right:6%; top:-4%; height:26%; border-radius:50% 50% 40% 40%;}
|
||||||
|
.av-hair-ponytail{display:block; left:10%; right:10%; top:-2%; height:20%; border-radius:50% 50% 30% 30%;}
|
||||||
|
.av-hair-ponytail::after{content:""; position:absolute; right:-6%; top:40%; width:20%; height:45%; border-radius:0 50% 50% 50%; background:var(--av-hair);}
|
||||||
|
.av-hair-mohawk{display:block; left:42%; right:42%; top:-8%; height:30%; border-radius:40% 40% 0 0;}
|
||||||
|
.av-hair-none{display:none;}
|
||||||
|
.av-acc-head{position:absolute; z-index:5; display:none;}
|
||||||
|
.av-acc-head-cap{display:block; left:6%; right:6%; top:2%; height:20%; border-radius:50% 50% 0 0; background:var(--blue);}
|
||||||
|
.av-acc-head-crown{display:block; left:14%; right:14%; top:-4%; height:18%; background:var(--yellow); clip-path:polygon(0% 100%,0% 40%,20% 70%,35% 10%,50% 60%,65% 10%,80% 70%,100% 40%,100% 100%);}
|
||||||
|
.av-acc-head-glasses{display:block; left:14%; right:14%; top:32%; height:16%;}
|
||||||
|
.av-acc-head-glasses::before,.av-acc-head-glasses::after{content:""; position:absolute; width:38%; height:100%; border-radius:50%; border:3px solid var(--ink); background:rgba(255,255,255,.35);}
|
||||||
|
.av-acc-head-glasses::before{left:0;}
|
||||||
|
.av-acc-head-glasses::after{right:0;}
|
||||||
|
.av-acc-head-bow{display:block; right:8%; top:8%; width:18%; height:14%; background:var(--pink); clip-path:polygon(0% 0%,50% 40%,100% 0%,100% 100%,50% 60%,0% 100%);}
|
||||||
|
.av-acc-body{position:absolute; z-index:4; display:none;}
|
||||||
|
.av-acc-body-scarf{display:block; left:18%; right:18%; top:48%; height:10%; border-radius:8px; background:var(--red);}
|
||||||
|
.av-acc-body-cape{display:block; left:10%; right:10%; top:54%; bottom:0; z-index:0; border-radius:0 0 40% 40%; background:var(--purple); opacity:.9;}
|
||||||
|
.av-acc-body-badge{display:block; left:32%; width:16%; top:62%; height:12%; border-radius:50%; background:var(--yellow); border:2px solid #fff;}
|
||||||
|
.avatar-editor{display:flex; flex-direction:column; gap:12px; padding:14px; height:100%; min-height:0; overflow:auto;}
|
||||||
|
.avatar-preview{display:flex; justify-content:center; padding:10px; background:var(--surface-2); border-radius:16px; flex:none;}
|
||||||
|
.avatar-controls{display:flex; flex-direction:column; gap:10px;}
|
||||||
|
.avatar-row{display:flex; align-items:center; gap:8px; flex-wrap:wrap;}
|
||||||
|
.avatar-row label{display:flex; flex-direction:column; gap:3px; font-size:11px; font-weight:800; color:var(--muted); flex:1; min-width:110px;}
|
||||||
|
.avatar-row select{height:36px; border:1px solid var(--line); border-radius:9px; padding:0 8px; background:var(--surface); color:var(--ink); font:inherit;}
|
||||||
|
.avatar-row input[type=color]{width:44px; height:34px; padding:1px; border:0; background:transparent; cursor:pointer;}
|
||||||
|
.avatar-species-row{display:flex; gap:8px;}
|
||||||
|
.avatar-species-row button{flex:1; height:40px; border:2px solid var(--line); border-radius:12px; background:var(--surface); color:var(--ink); font:inherit; font-weight:800; cursor:pointer;}
|
||||||
|
.avatar-species-row button.active{border-color:var(--accent); background:var(--accent-soft); color:var(--accent-ink);}
|
||||||
|
.avatar-save-msg{min-height:20px; font-size:12.5px; font-weight:800; color:var(--green); text-align:center;}
|
||||||
|
/* aanmoedigings-badge naast een goed-antwoord-melding (math.js-pilot) */
|
||||||
|
.av-cheer{display:inline-flex; vertical-align:middle; margin-left:8px; animation:avCheerBounce .6s ease;}
|
||||||
|
@keyframes avCheerBounce{0%{transform:scale(.4) translateY(6px); opacity:0;}60%{transform:scale(1.15) translateY(-4px); opacity:1;}100%{transform:scale(1) translateY(0);}}
|
||||||
/* Getallenlijn */
|
/* Getallenlijn */
|
||||||
.nlw{height:100%; min-height:0; display:flex; flex-direction:column; gap:11px; padding:14px; background:var(--surface);}
|
.nlw{height:100%; min-height:0; display:flex; flex-direction:column; gap:11px; padding:14px; background:var(--surface);}
|
||||||
.nlw-settings{display:flex; align-items:end; gap:8px; flex-wrap:wrap;}
|
.nlw-settings{display:flex; align-items:end; gap:8px; flex-wrap:wrap;}
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
<script src="js/settings.js?v=__V__" defer></script>
|
<script src="js/settings.js?v=__V__" defer></script>
|
||||||
<script src="js/cms.js?v=__V__" defer></script>
|
<script src="js/cms.js?v=__V__" defer></script>
|
||||||
<script src="js/data.js?v=__V__" defer></script>
|
<script src="js/data.js?v=__V__" defer></script>
|
||||||
|
<script src="js/avatar.js?v=__V__" defer></script>
|
||||||
<script src="js/explorer.js?v=__V__" defer></script>
|
<script src="js/explorer.js?v=__V__" defer></script>
|
||||||
<script src="js/picker.js?v=__V__" defer></script>
|
<script src="js/picker.js?v=__V__" defer></script>
|
||||||
<script src="js/widgets/letters.js?v=__V__" defer></script>
|
<script src="js/widgets/letters.js?v=__V__" defer></script>
|
||||||
|
|
|
||||||
|
|
@ -778,7 +778,10 @@
|
||||||
/* rij voor één persoon: avatar, naam, rol/status - tikken opent de sheet */
|
/* rij voor één persoon: avatar, naam, rol/status - tikken opent de sheet */
|
||||||
function personRow(u){
|
function personRow(u){
|
||||||
const row = h("div","am-row am-person");
|
const row = h("div","am-row am-person");
|
||||||
row.appendChild(avatar(u.displayName));
|
/* eigen personage vervangt de initialen-cirkel zodra iemand er één heeft
|
||||||
|
ingesteld (Instellingen > "Mijn personage") - zo ziet een leerkracht de
|
||||||
|
personages van de klas via het scherm dat toch al gebruikt wordt. */
|
||||||
|
row.appendChild(u.avatar ? renderAvatarFace(u.avatar, "sm") : avatar(u.displayName));
|
||||||
const info = h("div","am-person-info");
|
const info = h("div","am-person-info");
|
||||||
info.appendChild(h("span","am-name", u.displayName));
|
info.appendChild(h("span","am-name", u.displayName));
|
||||||
info.appendChild(h("span","am-role", T(roleKey(u.role)) + (u.pending ? ` · ${T("amPending")}` : "")));
|
info.appendChild(h("span","am-role", T(roleKey(u.role)) + (u.pending ? ` · ${T("amPending")}` : "")));
|
||||||
|
|
|
||||||
216
public/js/avatar.js
Normal file
216
public/js/avatar.js
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
/* teach - personage (avatar): eigen figuur samenstellen (soort, kleuren, haar,
|
||||||
|
accessoires), gedeeld tussen de Instellingen-sectie "Mijn personage" en het
|
||||||
|
avatar-widget. Gelaagde div's + CSS custom properties, zelfde aanpak als de
|
||||||
|
verjaardagstaart-widget (--cake/--icing) - geen SVG/afbeeldingen. */
|
||||||
|
"use strict";
|
||||||
|
const AVATAR_SPECIES = ["dino", "monster", "animal"];
|
||||||
|
const AVATAR_HAIR = ["none", "spiky", "curly", "ponytail", "mohawk"];
|
||||||
|
const AVATAR_HEAD_ACC = ["none", "cap", "crown", "glasses", "bow"];
|
||||||
|
const AVATAR_BODY_ACC = ["none", "scarf", "cape", "badge"];
|
||||||
|
const AVATAR_DEFAULT = Object.freeze({
|
||||||
|
species: "dino", bodyColor: "#3fae6a", hairStyle: "none", hairColor: "#5b3a29",
|
||||||
|
accessoryHead: "none", accessoryBody: "none",
|
||||||
|
});
|
||||||
|
|
||||||
|
function avatarSanitize(config){
|
||||||
|
const c = config || {};
|
||||||
|
return {
|
||||||
|
species: AVATAR_SPECIES.includes(c.species) ? c.species : AVATAR_DEFAULT.species,
|
||||||
|
bodyColor: /^#[0-9a-fA-F]{6}$/.test(c.bodyColor||"") ? c.bodyColor : AVATAR_DEFAULT.bodyColor,
|
||||||
|
hairStyle: AVATAR_HAIR.includes(c.hairStyle) ? c.hairStyle : AVATAR_DEFAULT.hairStyle,
|
||||||
|
hairColor: /^#[0-9a-fA-F]{6}$/.test(c.hairColor||"") ? c.hairColor : AVATAR_DEFAULT.hairColor,
|
||||||
|
accessoryHead: AVATAR_HEAD_ACC.includes(c.accessoryHead) ? c.accessoryHead : AVATAR_DEFAULT.accessoryHead,
|
||||||
|
accessoryBody: AVATAR_BODY_ACC.includes(c.accessoryBody) ? c.accessoryBody : AVATAR_DEFAULT.accessoryBody,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* bouwt de gelaagde weergave van een personage; config==null → neutrale
|
||||||
|
standaardweergave (nooit een lege/kapotte weergave). */
|
||||||
|
function renderAvatarFace(config, size){
|
||||||
|
const c = avatarSanitize(config);
|
||||||
|
const face = document.createElement("div");
|
||||||
|
face.className = "avatar-face " + (size || "md") + " av-species-" + c.species;
|
||||||
|
face.style.setProperty("--av-body", c.bodyColor);
|
||||||
|
face.style.setProperty("--av-hair", c.hairColor);
|
||||||
|
face.setAttribute("role", "img");
|
||||||
|
face.setAttribute("aria-label", T("avatarAlt"));
|
||||||
|
const partHead = document.createElement("div"); partHead.className = "av-part-head";
|
||||||
|
const hair = document.createElement("div"); hair.className = "av-hair av-hair-" + c.hairStyle;
|
||||||
|
const head = document.createElement("div"); head.className = "av-head";
|
||||||
|
const accHead = document.createElement("div"); accHead.className = "av-acc-head av-acc-head-" + c.accessoryHead;
|
||||||
|
const partBody = document.createElement("div"); partBody.className = "av-part-body";
|
||||||
|
const body = document.createElement("div"); body.className = "av-body";
|
||||||
|
const accBody = document.createElement("div"); accBody.className = "av-acc-body av-acc-body-" + c.accessoryBody;
|
||||||
|
face.append(partBody, body, accBody, partHead, head, accHead, hair);
|
||||||
|
return face;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* bouwt de interactieve kiezer + live voorbeeld; roept onChange(config) aan
|
||||||
|
bij elke wijziging. Hergebruikt door zowel de instellingen-sectie als het
|
||||||
|
widget - één UI, geen duplicatie. */
|
||||||
|
function buildAvatarEditor(container, initialConfig, onChange){
|
||||||
|
let config = avatarSanitize(initialConfig);
|
||||||
|
container.innerHTML = "";
|
||||||
|
const editor = document.createElement("div");
|
||||||
|
editor.className = "avatar-editor";
|
||||||
|
const preview = document.createElement("div");
|
||||||
|
preview.className = "avatar-preview";
|
||||||
|
const controls = document.createElement("div");
|
||||||
|
controls.className = "avatar-controls";
|
||||||
|
editor.append(preview, controls);
|
||||||
|
container.appendChild(editor);
|
||||||
|
|
||||||
|
const speciesRow = document.createElement("div");
|
||||||
|
speciesRow.className = "avatar-species-row";
|
||||||
|
AVATAR_SPECIES.forEach(id=>{
|
||||||
|
const b = document.createElement("button");
|
||||||
|
b.type = "button";
|
||||||
|
b.dataset.species = id;
|
||||||
|
b.addEventListener("click", ()=>{ config.species = id; update(); });
|
||||||
|
speciesRow.appendChild(b);
|
||||||
|
});
|
||||||
|
controls.appendChild(speciesRow);
|
||||||
|
|
||||||
|
const row1 = document.createElement("div"); row1.className = "avatar-row";
|
||||||
|
const bodyColorField = document.createElement("label");
|
||||||
|
const bodyColorSpan = document.createElement("span");
|
||||||
|
const bodyColorInput = document.createElement("input"); bodyColorInput.type = "color";
|
||||||
|
bodyColorInput.addEventListener("input", ()=>{ config.bodyColor = bodyColorInput.value; update(); });
|
||||||
|
bodyColorField.append(bodyColorSpan, bodyColorInput);
|
||||||
|
const hairColorField = document.createElement("label");
|
||||||
|
const hairColorSpan = document.createElement("span");
|
||||||
|
const hairColorInput = document.createElement("input"); hairColorInput.type = "color";
|
||||||
|
hairColorInput.addEventListener("input", ()=>{ config.hairColor = hairColorInput.value; update(); });
|
||||||
|
hairColorField.append(hairColorSpan, hairColorInput);
|
||||||
|
row1.append(bodyColorField, hairColorField);
|
||||||
|
controls.appendChild(row1);
|
||||||
|
|
||||||
|
const row2 = document.createElement("div"); row2.className = "avatar-row";
|
||||||
|
const hairField = document.createElement("label");
|
||||||
|
const hairSpan = document.createElement("span");
|
||||||
|
const hairSel = document.createElement("select");
|
||||||
|
AVATAR_HAIR.forEach(id=>hairSel.appendChild(new Option("", id)));
|
||||||
|
hairSel.addEventListener("change", ()=>{ config.hairStyle = hairSel.value; update(); });
|
||||||
|
hairField.append(hairSpan, hairSel);
|
||||||
|
row2.append(hairField);
|
||||||
|
controls.appendChild(row2);
|
||||||
|
|
||||||
|
const row3 = document.createElement("div"); row3.className = "avatar-row";
|
||||||
|
const headAccField = document.createElement("label");
|
||||||
|
const headAccSpan = document.createElement("span");
|
||||||
|
const headAccSel = document.createElement("select");
|
||||||
|
AVATAR_HEAD_ACC.forEach(id=>headAccSel.appendChild(new Option("", id)));
|
||||||
|
headAccSel.addEventListener("change", ()=>{ config.accessoryHead = headAccSel.value; update(); });
|
||||||
|
headAccField.append(headAccSpan, headAccSel);
|
||||||
|
const bodyAccField = document.createElement("label");
|
||||||
|
const bodyAccSpan = document.createElement("span");
|
||||||
|
const bodyAccSel = document.createElement("select");
|
||||||
|
AVATAR_BODY_ACC.forEach(id=>bodyAccSel.appendChild(new Option("", id)));
|
||||||
|
bodyAccSel.addEventListener("change", ()=>{ config.accessoryBody = bodyAccSel.value; update(); });
|
||||||
|
bodyAccField.append(bodyAccSpan, bodyAccSel);
|
||||||
|
row3.append(headAccField, bodyAccField);
|
||||||
|
controls.appendChild(row3);
|
||||||
|
|
||||||
|
function syncLabels(){
|
||||||
|
speciesRow.querySelectorAll("button").forEach(b=>{
|
||||||
|
b.textContent = T("avatarSpecies_" + b.dataset.species);
|
||||||
|
b.classList.toggle("active", b.dataset.species === config.species);
|
||||||
|
});
|
||||||
|
bodyColorSpan.textContent = T("avatarBodyColor");
|
||||||
|
hairColorSpan.textContent = T("avatarHairColor");
|
||||||
|
hairSpan.textContent = T("avatarHairStyle");
|
||||||
|
[...hairSel.options].forEach(o=>o.textContent = T("avatarHair_" + o.value));
|
||||||
|
headAccSpan.textContent = T("avatarAccHead");
|
||||||
|
[...headAccSel.options].forEach(o=>o.textContent = T("avatarAcc_" + o.value));
|
||||||
|
bodyAccSpan.textContent = T("avatarAccBody");
|
||||||
|
[...bodyAccSel.options].forEach(o=>o.textContent = T("avatarAcc_" + o.value));
|
||||||
|
}
|
||||||
|
function update(){
|
||||||
|
bodyColorInput.value = config.bodyColor;
|
||||||
|
hairColorInput.value = config.hairColor;
|
||||||
|
hairSel.value = config.hairStyle;
|
||||||
|
headAccSel.value = config.accessoryHead;
|
||||||
|
bodyAccSel.value = config.accessoryBody;
|
||||||
|
syncLabels();
|
||||||
|
preview.innerHTML = "";
|
||||||
|
preview.appendChild(renderAvatarFace(config, "lg"));
|
||||||
|
if(onChange) onChange({ ...config });
|
||||||
|
}
|
||||||
|
document.addEventListener("langchange", syncLabels);
|
||||||
|
update();
|
||||||
|
return { getConfig: ()=>({ ...config }), setConfig(c){ config = avatarSanitize(c); update(); } };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveAvatar(config){
|
||||||
|
const clean = avatarSanitize(config);
|
||||||
|
const res = await api("/me/avatar", { method: "PUT", body: clean });
|
||||||
|
currentUser.avatar = res.avatar;
|
||||||
|
document.dispatchEvent(new CustomEvent("avatarchange"));
|
||||||
|
return res.avatar;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---- Instellingen-sectie "Mijn personage" (cms.js-patroon: eigen .st-section
|
||||||
|
zelf aanmaken, niet in index.html) ---- */
|
||||||
|
(function(){
|
||||||
|
const host = document.createElement("div");
|
||||||
|
host.className = "st-section";
|
||||||
|
host.dataset.section = "avatar";
|
||||||
|
host.style.display = "none";
|
||||||
|
document.getElementById("stContent").appendChild(host);
|
||||||
|
const msg = document.createElement("p");
|
||||||
|
msg.className = "avatar-save-msg";
|
||||||
|
let editorApi = null;
|
||||||
|
function build(){
|
||||||
|
host.innerHTML = "";
|
||||||
|
editorApi = buildAvatarEditor(host, currentUser && currentUser.avatar, ()=>{ msg.textContent = ""; });
|
||||||
|
const saveBtn = document.createElement("button");
|
||||||
|
saveBtn.type = "button";
|
||||||
|
saveBtn.className = "tbtn";
|
||||||
|
saveBtn.textContent = T("avatarSave");
|
||||||
|
saveBtn.addEventListener("click", async ()=>{
|
||||||
|
try{ await saveAvatar(editorApi.getConfig()); msg.textContent = T("avatarSaved"); }
|
||||||
|
catch(e){ msg.textContent = e.message; }
|
||||||
|
});
|
||||||
|
host.appendChild(saveBtn);
|
||||||
|
host.appendChild(msg);
|
||||||
|
}
|
||||||
|
registerSettingsSection({
|
||||||
|
id: "avatar", icon: "🦖", group: "personal", order: 3,
|
||||||
|
label: ()=>T("stAvatar"), sub: ()=>T("stAvatarSub"),
|
||||||
|
visible: ()=>!!currentUser,
|
||||||
|
activate(){ showSettingsDom("avatar"); build(); },
|
||||||
|
keywords: ()=>[T("avatarSpecies_dino"), T("avatarSpecies_monster"), T("avatarSpecies_animal"),
|
||||||
|
"personage","avatar","dinosaurus","monster","dier","haar","hair","kleur","color"],
|
||||||
|
});
|
||||||
|
})();
|
||||||
|
|
||||||
|
/* ---- widget: dunne wrapper rond dezelfde editor ---- */
|
||||||
|
function mountAvatarWidget(root){
|
||||||
|
root.innerHTML = `<p class="play-mission"></p>`;
|
||||||
|
root.querySelector(".play-mission").textContent = T("wg_avatar_d");
|
||||||
|
const host = document.createElement("div");
|
||||||
|
host.style.cssText = "flex:1;min-height:0;display:flex;flex-direction:column;";
|
||||||
|
root.appendChild(host);
|
||||||
|
const msg = document.createElement("p");
|
||||||
|
msg.className = "avatar-save-msg";
|
||||||
|
let editorApi = null;
|
||||||
|
function build(){
|
||||||
|
host.innerHTML = "";
|
||||||
|
editorApi = buildAvatarEditor(host, currentUser && currentUser.avatar, ()=>{ msg.textContent = ""; });
|
||||||
|
const saveBtn = document.createElement("button");
|
||||||
|
saveBtn.type = "button";
|
||||||
|
saveBtn.className = "tbtn";
|
||||||
|
saveBtn.textContent = T("avatarSave");
|
||||||
|
saveBtn.addEventListener("click", async ()=>{
|
||||||
|
try{ await saveAvatar(editorApi.getConfig()); msg.textContent = T("avatarSaved"); }
|
||||||
|
catch(e){ msg.textContent = e.message; }
|
||||||
|
});
|
||||||
|
host.appendChild(saveBtn);
|
||||||
|
host.appendChild(msg);
|
||||||
|
}
|
||||||
|
build();
|
||||||
|
document.addEventListener("langchange", ()=>{ root.querySelector(".play-mission").textContent = T("wg_avatar_d"); });
|
||||||
|
/* currentUser.avatar is de bron van waarheid (server), niet het widget-eigen
|
||||||
|
initState - geen dubbele opslag van dezelfde data op twee plekken. */
|
||||||
|
return { getState: ()=>({}) };
|
||||||
|
}
|
||||||
|
|
@ -65,6 +65,7 @@ const REGISTRY = [
|
||||||
{ id:"feelings", cat:"class", icon:"🎭", w:560, h:520, minW:380, minH:400, mount:mountFeelings },
|
{ id:"feelings", cat:"class", icon:"🎭", w:560, h:520, minW:380, minH:400, mount:mountFeelings },
|
||||||
{ id:"breathe", cat:"class", icon:"🌬️", w:440, h:520, minW:320, minH:420, mount:mountBreathe },
|
{ id:"breathe", cat:"class", icon:"🌬️", w:440, h:520, minW:320, minH:420, mount:mountBreathe },
|
||||||
{ id:"compliments", cat:"class", icon:"💝", w:460, h:400, minW:340, minH:300, mount:mountCompliments },
|
{ id:"compliments", cat:"class", icon:"💝", w:460, h:400, minW:340, minH:300, mount:mountCompliments },
|
||||||
|
{ id:"avatar", cat:"class", icon:"🦖", w:620, h:560, minW:400, minH:400, mount:mountAvatarWidget },
|
||||||
{ id:"media", cat:"media", icon:"🎬", w:560, h:420, minW:320, minH:240, mount:mountMedia }
|
{ id:"media", cat:"media", icon:"🎬", w:560, h:420, minW:320, minH:240, mount:mountMedia }
|
||||||
];
|
];
|
||||||
const WIDGET_CATS = [["favorites","galleryFavorites"],["taal","catTaal"],["rekenen","catRekenen"],["world","catWorld"],["class","catClass"],["tools","catTools"],["media","catMedia"]];
|
const WIDGET_CATS = [["favorites","galleryFavorites"],["taal","catTaal"],["rekenen","catRekenen"],["world","catWorld"],["class","catClass"],["tools","catTools"],["media","catMedia"]];
|
||||||
|
|
|
||||||
|
|
@ -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.22-beta";
|
const VERSION = "0.4.23-beta";
|
||||||
(function(){
|
(function(){
|
||||||
const tag = document.getElementById("verTag");
|
const tag = document.getElementById("verTag");
|
||||||
tag.textContent = "v"+VERSION;
|
tag.textContent = "v"+VERSION;
|
||||||
|
|
@ -162,6 +162,15 @@ const I18N = {
|
||||||
"brePattern_4-4":"4 tellen in, 4 uit", "brePattern_4-7-8":"4 in, 7 vast, 8 uit", "brePattern_4-4-4-4":"4 in, 4 vast, 4 uit, 4 vast",
|
"brePattern_4-4":"4 tellen in, 4 uit", "brePattern_4-7-8":"4 in, 7 vast, 8 uit", "brePattern_4-4-4-4":"4 in, 4 vast, 4 uit, 4 vast",
|
||||||
wg_compliments:"Complimentenmachine", wg_compliments_d:"Trek een willekeurig compliment om iemand blij te maken.",
|
wg_compliments:"Complimentenmachine", wg_compliments_d:"Trek een willekeurig compliment om iemand blij te maken.",
|
||||||
complimentsDraw:"Trek een compliment",
|
complimentsDraw:"Trek een compliment",
|
||||||
|
wg_avatar:"Mijn personage", wg_avatar_d:"Stel je eigen personage samen: soort, kleuren, haar en accessoires.",
|
||||||
|
stAvatar:"Mijn personage", stAvatarSub:"Stel je eigen personage samen.",
|
||||||
|
avatarAlt:"Mijn personage", avatarSpecies_dino:"Dino", avatarSpecies_monster:"Monster", avatarSpecies_animal:"Dier",
|
||||||
|
avatarBodyColor:"Lichaamskleur", avatarHairColor:"Haarkleur", avatarHairStyle:"Haarstijl",
|
||||||
|
avatarHair_none:"Geen", avatarHair_spiky:"Piekerig", avatarHair_curly:"Krullen", avatarHair_ponytail:"Staartje", avatarHair_mohawk:"Hanenkam",
|
||||||
|
avatarAccHead:"Hoofdaccessoire", avatarAccBody:"Lichaamsaccessoire",
|
||||||
|
avatarAcc_none:"Geen", avatarAcc_cap:"Petje", avatarAcc_crown:"Kroon", avatarAcc_glasses:"Bril", avatarAcc_bow:"Strik",
|
||||||
|
avatarAcc_scarf:"Sjaal", avatarAcc_cape:"Cape", avatarAcc_badge:"Insigne",
|
||||||
|
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",
|
||||||
wg_sentence:"Zinsbouwer", wg_sentence_d:"Bouw zinnen met kleurgecodeerde zinsdelen en voeg eigen woorden toe.",
|
wg_sentence:"Zinsbouwer", wg_sentence_d:"Bouw zinnen met kleurgecodeerde zinsdelen en voeg eigen woorden toe.",
|
||||||
|
|
@ -485,6 +494,15 @@ const I18N = {
|
||||||
"brePattern_4-4":"4 counts in, 4 out", "brePattern_4-7-8":"4 in, 7 hold, 8 out", "brePattern_4-4-4-4":"4 in, 4 hold, 4 out, 4 hold",
|
"brePattern_4-4":"4 counts in, 4 out", "brePattern_4-7-8":"4 in, 7 hold, 8 out", "brePattern_4-4-4-4":"4 in, 4 hold, 4 out, 4 hold",
|
||||||
wg_compliments:"Compliment machine", wg_compliments_d:"Draw a random compliment to make someone's day.",
|
wg_compliments:"Compliment machine", wg_compliments_d:"Draw a random compliment to make someone's day.",
|
||||||
complimentsDraw:"Draw a compliment",
|
complimentsDraw:"Draw a compliment",
|
||||||
|
wg_avatar:"My character", wg_avatar_d:"Build your own character: species, colours, hair and accessories.",
|
||||||
|
stAvatar:"My character", stAvatarSub:"Build your own character.",
|
||||||
|
avatarAlt:"My character", avatarSpecies_dino:"Dino", avatarSpecies_monster:"Monster", avatarSpecies_animal:"Animal",
|
||||||
|
avatarBodyColor:"Body colour", avatarHairColor:"Hair colour", avatarHairStyle:"Hair style",
|
||||||
|
avatarHair_none:"None", avatarHair_spiky:"Spiky", avatarHair_curly:"Curly", avatarHair_ponytail:"Ponytail", avatarHair_mohawk:"Mohawk",
|
||||||
|
avatarAccHead:"Head accessory", avatarAccBody:"Body accessory",
|
||||||
|
avatarAcc_none:"None", avatarAcc_cap:"Cap", avatarAcc_crown:"Crown", avatarAcc_glasses:"Glasses", avatarAcc_bow:"Bow",
|
||||||
|
avatarAcc_scarf:"Scarf", avatarAcc_cape:"Cape", avatarAcc_badge:"Badge",
|
||||||
|
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",
|
||||||
wg_sentence:"Sentence builder", wg_sentence_d:"Build sentences with colour-coded parts and add your own words.",
|
wg_sentence:"Sentence builder", wg_sentence_d:"Build sentences with colour-coded parts and add your own words.",
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,19 @@ function mountMath(root, initState, mode, opts){
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
const el = s => root.querySelector(s);
|
const el = s => root.querySelector(s);
|
||||||
|
/* pilot voor "personage moedigt aan" (roadmap-gerelateerd punt): naast het
|
||||||
|
bestaande "Goed!"-bericht verschijnt kort het eigen personage, als de
|
||||||
|
leerling er één heeft ingesteld. Puur visueel, geen nieuw feedbacktype -
|
||||||
|
zonder personage blijft dit exact het oude gedrag. */
|
||||||
|
function showGoodFeedback(){
|
||||||
|
fbEl.innerHTML = "";
|
||||||
|
fbEl.appendChild(document.createTextNode(T("mGood")));
|
||||||
|
if(currentUser && currentUser.avatar){
|
||||||
|
const cheer = renderAvatarFace(currentUser.avatar, "md");
|
||||||
|
cheer.classList.add("av-cheer");
|
||||||
|
fbEl.appendChild(cheer);
|
||||||
|
}
|
||||||
|
}
|
||||||
const mtEl = el(".mt"), qEl = el(".mt-q"), aEl = el(".mt-a"), fbEl = el(".mt-fb"),
|
const mtEl = el(".mt"), qEl = el(".mt-q"), aEl = el(".mt-a"), fbEl = el(".mt-fb"),
|
||||||
scoreEl = el(".mt-score"), doneEl = el(".mt-done"),
|
scoreEl = el(".mt-score"), doneEl = el(".mt-done"),
|
||||||
sndBtn = el(".lg-snd"), modeBtn = el(".lg-mode"),
|
sndBtn = el(".lg-snd"), modeBtn = el(".lg-mode"),
|
||||||
|
|
@ -140,7 +153,7 @@ function mountMath(root, initState, mode, opts){
|
||||||
function blocksSolved(){
|
function blocksSolved(){
|
||||||
S.busy = true;
|
S.busy = true;
|
||||||
S.good++;
|
S.good++;
|
||||||
fbEl.textContent = T("mGood");
|
showGoodFeedback();
|
||||||
play(sndWin);
|
play(sndWin);
|
||||||
confetti(el(".mt-card"));
|
confetti(el(".mt-card"));
|
||||||
setTimeout(next, 1000);
|
setTimeout(next, 1000);
|
||||||
|
|
@ -213,7 +226,7 @@ function mountMath(root, initState, mode, opts){
|
||||||
S.busy = true;
|
S.busy = true;
|
||||||
S.good++;
|
S.good++;
|
||||||
aEl.classList.add("ok");
|
aEl.classList.add("ok");
|
||||||
fbEl.textContent = T("mGood");
|
showGoodFeedback();
|
||||||
play(sndGood);
|
play(sndGood);
|
||||||
confetti(el(".mt-card"));
|
confetti(el(".mt-card"));
|
||||||
setTimeout(next, 900);
|
setTimeout(next, 900);
|
||||||
|
|
|
||||||
25
src/api.js
25
src/api.js
|
|
@ -190,6 +190,31 @@ export default async function api(app) {
|
||||||
const r = await pool.query('SELECT data, data_rev FROM users WHERE id = $1', [req.user.id]);
|
const r = await pool.query('SELECT data, data_rev FROM users WHERE id = $1', [req.user.id]);
|
||||||
return { data: r.rows[0]?.data ?? {}, rev: Number(r.rows[0]?.data_rev ?? 0) };
|
return { data: r.rows[0]?.data ?? {}, rev: Number(r.rows[0]?.data_rev ?? 0) };
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ---- eigen personage (avatar) -----------------------------------------------
|
||||||
|
// Open voor iedere ingelogde rol (ook leerling/ouder) - net als /me/data hierboven,
|
||||||
|
// geen rol-beperkte PERMISSIONS-sleutel nodig, altijd de eigen rij.
|
||||||
|
const AVATAR_SPECIES = ['dino', 'monster', 'animal'];
|
||||||
|
const AVATAR_HAIR = ['none', 'spiky', 'curly', 'ponytail', 'mohawk'];
|
||||||
|
const AVATAR_HEAD_ACC = ['none', 'cap', 'crown', 'glasses', 'bow'];
|
||||||
|
const AVATAR_BODY_ACC = ['none', 'scarf', 'cape', 'badge'];
|
||||||
|
const isHexColor = (v) => typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v);
|
||||||
|
app.put('/me/avatar', async (req, reply) => {
|
||||||
|
need(req, reply);
|
||||||
|
const b = req.body ?? {};
|
||||||
|
if (!AVATAR_SPECIES.includes(b.species)) return fail(reply, 400, 'ongeldige soort');
|
||||||
|
if (!isHexColor(b.bodyColor)) return fail(reply, 400, 'ongeldige lichaamskleur');
|
||||||
|
if (!AVATAR_HAIR.includes(b.hairStyle)) return fail(reply, 400, 'ongeldige haarstijl');
|
||||||
|
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_BODY_ACC.includes(b.accessoryBody)) return fail(reply, 400, 'ongeldig lichaamsaccessoire');
|
||||||
|
const avatar = {
|
||||||
|
species: b.species, bodyColor: b.bodyColor, hairStyle: b.hairStyle, hairColor: b.hairColor,
|
||||||
|
accessoryHead: b.accessoryHead, accessoryBody: b.accessoryBody,
|
||||||
|
};
|
||||||
|
await pool.query('UPDATE users SET avatar_data = $1 WHERE id = $2', [avatar, req.user.id]);
|
||||||
|
return { avatar };
|
||||||
|
});
|
||||||
// Optimistische concurrency: de client stuurt de rev mee die hij geladen
|
// Optimistische concurrency: de client stuurt de rev mee die hij geladen
|
||||||
// heeft (x-data-rev); de update slaagt alleen als die nog actueel is, anders
|
// heeft (x-data-rev); de update slaagt alleen als die nog actueel is, anders
|
||||||
// 409 (een ander tabblad/digibord schreef eerder). Zonder header geen check
|
// 409 (een ander tabblad/digibord schreef eerder). Zonder header geen check
|
||||||
|
|
|
||||||
|
|
@ -128,5 +128,6 @@ export function publicUser(u) {
|
||||||
schoolId: u.school_id == null ? null : Number(u.school_id),
|
schoolId: u.school_id == null ? null : Number(u.school_id),
|
||||||
classId: u.class_id == null ? null : Number(u.class_id),
|
classId: u.class_id == null ? null : Number(u.class_id),
|
||||||
pending: !u.password_hash && !!u.link_code,
|
pending: !u.password_hash && !!u.link_code,
|
||||||
|
avatar: u.avatar_data || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -348,6 +348,7 @@ test('alle geregistreerde widgets hebben mobiele dekking en passen op een 320px-
|
||||||
feelings:'.fz-zones',
|
feelings:'.fz-zones',
|
||||||
breathe:'.bre-circle',
|
breathe:'.bre-circle',
|
||||||
compliments:'.cpl-card',
|
compliments:'.cpl-card',
|
||||||
|
avatar:'.avatar-editor',
|
||||||
media:'.media-bar',
|
media:'.media-bar',
|
||||||
};
|
};
|
||||||
assert.deepEqual(registered,Object.keys(coverage).sort(),'ieder geregistreerd widgettype staat in de mobiele audit');
|
assert.deepEqual(registered,Object.keys(coverage).sort(),'ieder geregistreerd widgettype staat in de mobiele audit');
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue