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
216 lines
10 KiB
JavaScript
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: ()=>({}) };
|
|
}
|