From 24d89b8bb51d14dfde19d2bdf86f89871a8d94d3 Mon Sep 17 00:00:00 2001 From: Ramon Date: Sun, 19 Jul 2026 07:34:25 +0200 Subject: [PATCH] feat: eigen personage (avatar) voor leerlingen, ouders en staf (v0.4.23-beta) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 Claude-Session: https://claude.ai/code/session_014EPxzBVRXZZnBPvSAaPAbJ --- VERSION | 2 +- db/018_user_avatar.sql | 6 ++ public/css/teach.css | 56 ++++++++++ public/index.html | 1 + public/js/admin.js | 5 +- public/js/avatar.js | 216 ++++++++++++++++++++++++++++++++++++++ public/js/board.js | 1 + public/js/core.js | 20 +++- public/js/widgets/math.js | 17 ++- src/api.js | 25 +++++ src/auth.js | 1 + test/widgets.test.js | 1 + 12 files changed, 346 insertions(+), 5 deletions(-) create mode 100644 db/018_user_avatar.sql create mode 100644 public/js/avatar.js diff --git a/VERSION b/VERSION index 1f03f53..b81f59b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.4.22-beta +0.4.23-beta \ No newline at end of file diff --git a/db/018_user_avatar.sql b/db/018_user_avatar.sql new file mode 100644 index 0000000..fbdf5ad --- /dev/null +++ b/db/018_user_avatar.sql @@ -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; diff --git a/public/css/teach.css b/public/css/teach.css index 199b49d..fb11767 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -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-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;} +/* =========== 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 */ .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;} diff --git a/public/index.html b/public/index.html index fa80deb..52a4f29 100644 --- a/public/index.html +++ b/public/index.html @@ -18,6 +18,7 @@ + diff --git a/public/js/admin.js b/public/js/admin.js index 56e61df..877e828 100644 --- a/public/js/admin.js +++ b/public/js/admin.js @@ -778,7 +778,10 @@ /* rij voor één persoon: avatar, naam, rol/status - tikken opent de sheet */ function personRow(u){ 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"); info.appendChild(h("span","am-name", u.displayName)); info.appendChild(h("span","am-role", T(roleKey(u.role)) + (u.pending ? ` · ${T("amPending")}` : ""))); diff --git a/public/js/avatar.js b/public/js/avatar.js new file mode 100644 index 0000000..58cebde --- /dev/null +++ b/public/js/avatar.js @@ -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 = `

`; + 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: ()=>({}) }; +} diff --git a/public/js/board.js b/public/js/board.js index 9a96957..b3e01c4 100644 --- a/public/js/board.js +++ b/public/js/board.js @@ -65,6 +65,7 @@ const REGISTRY = [ { 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:"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 } ]; const WIDGET_CATS = [["favorites","galleryFavorites"],["taal","catTaal"],["rekenen","catRekenen"],["world","catWorld"],["class","catClass"],["tools","catTools"],["media","catMedia"]]; diff --git a/public/js/core.js b/public/js/core.js index a3b7c15..8642b2a 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* 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. */ -const VERSION = "0.4.22-beta"; +const VERSION = "0.4.23-beta"; (function(){ const tag = document.getElementById("verTag"); 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", wg_compliments:"Complimentenmachine", wg_compliments_d:"Trek een willekeurig compliment om iemand blij te maken.", 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", 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.", @@ -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", wg_compliments:"Compliment machine", wg_compliments_d:"Draw a random compliment to make someone's day.", 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", 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.", diff --git a/public/js/widgets/math.js b/public/js/widgets/math.js index a344a1a..d461bbe 100644 --- a/public/js/widgets/math.js +++ b/public/js/widgets/math.js @@ -39,6 +39,19 @@ function mountMath(root, initState, mode, opts){ `; 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"), scoreEl = el(".mt-score"), doneEl = el(".mt-done"), sndBtn = el(".lg-snd"), modeBtn = el(".lg-mode"), @@ -140,7 +153,7 @@ function mountMath(root, initState, mode, opts){ function blocksSolved(){ S.busy = true; S.good++; - fbEl.textContent = T("mGood"); + showGoodFeedback(); play(sndWin); confetti(el(".mt-card")); setTimeout(next, 1000); @@ -213,7 +226,7 @@ function mountMath(root, initState, mode, opts){ S.busy = true; S.good++; aEl.classList.add("ok"); - fbEl.textContent = T("mGood"); + showGoodFeedback(); play(sndGood); confetti(el(".mt-card")); setTimeout(next, 900); diff --git a/src/api.js b/src/api.js index 603125a..1b95bf6 100644 --- a/src/api.js +++ b/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]); 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 // 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 diff --git a/src/auth.js b/src/auth.js index 6eac973..1e20c35 100644 --- a/src/auth.js +++ b/src/auth.js @@ -128,5 +128,6 @@ export function publicUser(u) { schoolId: u.school_id == null ? null : Number(u.school_id), classId: u.class_id == null ? null : Number(u.class_id), pending: !u.password_hash && !!u.link_code, + avatar: u.avatar_data || null, }; } diff --git a/test/widgets.test.js b/test/widgets.test.js index e7dcc50..86fea94 100644 --- a/test/widgets.test.js +++ b/test/widgets.test.js @@ -348,6 +348,7 @@ test('alle geregistreerde widgets hebben mobiele dekking en passen op een 320px- feelings:'.fz-zones', breathe:'.bre-circle', compliments:'.cpl-card', + avatar:'.avatar-editor', media:'.media-bar', }; assert.deepEqual(registered,Object.keys(coverage).sort(),'ieder geregistreerd widgettype staat in de mobiele audit');