Verbeter anatomie en plaatsing van avataraccessoires (v0.4.46-beta) #1
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-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;}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@
|
|||
<script src="js/settings.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/avatar.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/widgets/letters.js?v=__V__" defer></script>
|
||||
|
|
|
|||
|
|
@ -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")}` : "")));
|
||||
|
|
|
|||
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:"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"]];
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -39,6 +39,19 @@ function mountMath(root, initState, mode, opts){
|
|||
</div>`;
|
||||
|
||||
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);
|
||||
|
|
|
|||
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]);
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
Loading…
Reference in a new issue