teach/public/js/avatar.js
Ramon 24d89b8bb5
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 36s
feat: eigen personage (avatar) voor leerlingen, ouders en staf (v0.4.23-beta)
- 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
2026-07-19 07:34:25 +02:00

216 lines
10 KiB
JavaScript

/* 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: ()=>({}) };
}