Verbeter anatomie en plaatsing van avataraccessoires (v0.4.46-beta) #1

Open
bes-r wants to merge 192 commits from bes-r/avatar-realism into main AGit
9 changed files with 432 additions and 42 deletions
Showing only changes of commit cb97dc2bb1 - Show all commits

View file

@ -1 +1 @@
0.3.79-beta
0.3.80-beta

View file

@ -3,6 +3,11 @@
.image-catalog-modal .gallery-head{position:sticky;top:-24px;z-index:4;background:var(--surface);padding:4px 0 8px;margin-top:-4px}
.ic-tools{display:grid;grid-template-columns:minmax(180px,1.5fr) repeat(3,minmax(130px,.8fr)) auto;gap:8px;position:sticky;top:12px;z-index:3;background:var(--surface);padding:8px 0 10px}
.ic-tools input,.ic-tools select,.ic-add select,.ic-add input,.ic-editor input,.ic-editor select{min-width:0;width:100%;border:1px solid var(--line);border-radius:10px;padding:9px 10px;background:var(--surface);color:var(--ink);font:inherit}
.ic-word-themes{display:flex;align-items:center;gap:7px;overflow-x:auto;padding:9px 10px;margin:0 0 10px;border:1px solid var(--line);border-radius:13px;background:linear-gradient(135deg,var(--surface-2),var(--accent-soft));scrollbar-width:thin}
.ic-word-themes strong{flex:none;margin-right:3px;font-size:12px;color:var(--muted)}
.ic-word-themes button{flex:none;border:1px solid var(--line);border-radius:999px;padding:7px 11px;background:var(--surface);color:var(--ink);font:inherit;font-size:12px;font-weight:850;cursor:pointer;transition:transform .12s,border-color .12s,background .12s}
.ic-word-themes button:hover{transform:translateY(-1px);border-color:var(--accent)}
.ic-word-themes button.active{border-color:var(--accent);background:var(--accent);color:#fff;box-shadow:var(--shadow-1)}
.ic-quota{padding:8px 12px;margin-bottom:10px;border-radius:10px;background:var(--accent-soft);color:var(--accent-ink);font-size:13px;font-weight:800}
.ic-add{border:1px solid var(--line);border-radius:12px;padding:10px 12px;margin-bottom:10px;background:var(--surface-2)}
.ic-add summary{cursor:pointer;font-weight:900}
@ -31,6 +36,7 @@ body.hc .ic-card,body.hc .ic-add,body.hc .ic-tools input,body.hc .ic-tools selec
.image-catalog-modal{width:100vw;max-height:100dvh;height:100dvh;border-radius:0;padding:16px}
.image-catalog-modal .gallery-head{top:-16px}
.ic-tools{top:20px;grid-template-columns:1fr 1fr;padding-top:4px}.ic-search,.ic-folders{grid-column:1/-1}
.ic-word-themes{margin-inline:-2px;padding:8px}.ic-word-themes strong{position:sticky;left:0;z-index:1;padding:7px 6px;background:var(--surface-2)}
.ic-add-grid,.ic-editor-grid{grid-template-columns:1fr}.ic-grid{grid-template-columns:repeat(2,minmax(0,1fr))}
.ic-choose{grid-template-rows:96px auto auto}.ic-choose img{height:96px}
.img-quota-row{grid-template-columns:1fr 1fr}.img-quota-row .am-name{grid-column:1/-1}

View file

@ -813,11 +813,12 @@
en de accentlijn van de actieve tab valt daaroverheen. */
.ed-tabs{
display:flex; gap:2px; border-bottom:1px solid var(--line); margin-bottom:14px;
overflow-x:auto; scrollbar-width:thin;
}
.ed-tabs button{
border:none; border-radius:0; padding:10px 15px 11px; font:inherit; font-weight:700;
font-size:14px; background:transparent; color:var(--muted); cursor:pointer;
position:relative; margin-bottom:-1px;
position:relative; margin-bottom:-1px; flex:none;
}
.ed-tabs button:hover{color:var(--ink);}
.ed-tabs button.active{color:var(--ink);}
@ -858,6 +859,9 @@
font-size:14px; cursor:pointer; display:inline-block;}
.ed-upload input{display:none;}
.ed-uphint{font-size:12.5px; color:var(--muted); margin-top:10px; line-height:1.45;}
.ed-p-library{min-height:120px;padding:18px;border:1px dashed var(--accent);border-radius:14px;background:linear-gradient(135deg,var(--surface-2),var(--accent-soft));text-align:center;}
.ed-open-library{min-height:46px;font-weight:850;}
.ed-library-hint{max-width:520px;margin:10px auto 0;color:var(--muted);font-size:13px;line-height:1.5;}
.lg-ed-list{display:flex; flex-direction:column; gap:6px;}
.lg-ed-list .empty{color:var(--muted); font-weight:700; text-align:center; padding:18px 0;}
.ed-row{display:flex; align-items:center; gap:12px; background:var(--surface-2); border-radius:12px; padding:7px 12px;}

View file

@ -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.3.79-beta";
const VERSION = "0.3.80-beta";
(function(){
const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION;
@ -48,7 +48,7 @@ const I18N = {
instr2_mid:"Zoek de <b>middelste letter</b> van het woord.",
instr2_end:"Zoek de <b>eindletter</b> van het woord.",
instr3:"Spel het woord bij het plaatje. De nopjes helpen je!",
card:"Kaart", cardGeneral:"Algemeen", word:"Woord", welldone:"Goed zo!", next:"Volgende woord ▸",
card:"Kaart", cardGeneral:"Algemeen", cardStart:"Startwoorden", wordThemes:"Woordthemas", word:"Woord", welldone:"Goed zo!", next:"Volgende woord ▸",
cardDone:"Kaart klaar! 🎉", pickCard:"Kies een nieuwe kaart of een ander niveau.",
myWords:"Mijn woorden", manage:"Thema's en woorden beheren", addWord:" Toevoegen",
pickPic:"Kies een plaatje",
@ -167,7 +167,7 @@ const I18N = {
wordPh:"woord (25 letters)", close:"Sluiten",
badWord:"Gebruik 2 t/m 5 letters (a-z).", needPic:"Kies eerst een plaatje.",
noWords:"Nog geen eigen woorden. Voeg er hierboven een toe!",
tabEmoji:"😀 Emoji", tabPicto:"🖼️ Picto's", tabUpload:"📷 Upload",
tabEmoji:"😀 Emoji", tabPicto:"🖼️ Picto's", tabUpload:"📷 Upload", tabLibrary:"🗂 Bibliotheek",
pictoSearch:"Zoeken", pictoPh:"zoekwoord…", pictoNone:"Niets gevonden. Probeer een ander woord.",
pictoLoad:"Zoeken…", pictoErr:"Kon picto's niet laden (internet nodig).",
pictoCredit:"Picto's: <a href='https://arasaac.org' target='_blank'>ARASAAC</a> (CC BY-NC-SA, Gobierno de Aragón). Ook leuk: <a href='https://www.sclera.be' target='_blank'>sclera.be</a> (gratis download, dan via Upload toevoegen).",
@ -175,6 +175,8 @@ const I18N = {
upHint:"Tip: vierkant plaatje, witte of doorzichtige achtergrond, één duidelijk voorwerp, géén tekst in het plaatje.",
save:"Opslaan", cancel:"Annuleren",
imgCatalog:"Afbeeldingscatalogus", imgSearch:"Zoek op naam, map of thema…",
imgOpenLibrary:"Open afbeeldingenbibliotheek", imgLanguageHint:"Hier vind je alle vaste woordplaatjes, eigen woordkaarten en uploads bij elkaar.",
imgWordThemes:"Taalthemas",
imgFolders:"Afbeeldingen en mappen", imgUpload:"Afbeeldingen uploaden",
imgChooseFiles:"Kies één of meer afbeeldingen…", imgFolder:"Map", imgThemes:"Themas",
imgNewTheme:"Nieuw thema…", imgAddTheme:"Thema toevoegen", imgEdit:"Afbeelding bewerken", imgName:"Naam",
@ -324,7 +326,7 @@ const I18N = {
instr2_mid:"Find the <b>middle letter</b> of the word.",
instr2_end:"Find the <b>ending letter</b> of the word.",
instr3:"Spell the word for the picture. The little studs will help you!",
card:"Card", cardGeneral:"General", word:"Word", welldone:"Well done!", next:"Next word ▸",
card:"Card", cardGeneral:"General", cardStart:"Starter words", wordThemes:"Word themes", word:"Word", welldone:"Well done!", next:"Next word ▸",
cardDone:"Card complete! 🎉", pickCard:"Pick a new card or another level.",
myWords:"My words", manage:"Manage themes and words", addWord:" Add",
pickPic:"Choose a picture",
@ -443,7 +445,7 @@ const I18N = {
wordPh:"word (25 letters)", close:"Close",
badWord:"Use 2 to 5 letters (a-z).", needPic:"Pick a picture first.",
noWords:"No custom words yet. Add one above!",
tabEmoji:"😀 Emoji", tabPicto:"🖼️ Pictos", tabUpload:"📷 Upload",
tabEmoji:"😀 Emoji", tabPicto:"🖼️ Pictos", tabUpload:"📷 Upload", tabLibrary:"🗂 Library",
pictoSearch:"Search", pictoPh:"search word…", pictoNone:"Nothing found. Try another word.",
pictoLoad:"Searching…", pictoErr:"Could not load pictos (internet required).",
pictoCredit:"Pictos: <a href='https://arasaac.org' target='_blank'>ARASAAC</a> (CC BY-NC-SA, Gobierno de Aragón). Also nice: <a href='https://www.sclera.be' target='_blank'>sclera.be</a> (free download, then add via Upload).",
@ -451,6 +453,8 @@ const I18N = {
upHint:"Tip: square picture, white or transparent background, one clear object, no text in the picture.",
save:"Save", cancel:"Cancel",
imgCatalog:"Image catalogue", imgSearch:"Search by name, folder or theme…",
imgOpenLibrary:"Open image library", imgLanguageHint:"All starter pictures, custom word cards and uploads are collected here.",
imgWordThemes:"Language themes",
imgFolders:"Images and folders", imgUpload:"Upload images",
imgChooseFiles:"Choose one or more images…", imgFolder:"Folder", imgThemes:"Themes",
imgNewTheme:"New theme…", imgAddTheme:"Add theme", imgEdit:"Edit image", imgName:"Name",

View file

@ -24,23 +24,228 @@ function dotGrid(letter){
return grid;
}
/* built-in general words: ONE card per language (extendable via the editor) */
const WORDS = {
nl:[
["aap","🐵"],["vis","🐟"],["zon","☀️"],["kat","🐈"],["bal","⚽"],["ei","🥚"],
["boom","🌳"],["huis","🏠"],["maan","🌙"],["boot","⛵"],["kip","🐔"],["muis","🐭"],
["boek","📕"],["ster","⭐"],["roos","🌹"],["vuur","🔥"],["fiets","🚲"],["taart","🎂"],
["jas","🧥"],["bril","👓"],["klok","⏰"],["peer","🍐"],["kaas","🧀"],["eend","🦆"],
["hand","✋"],["voet","🦶"],["neus","👃"],["oog","👁️"],["oor","👂"],["mond","👄"]
],
en:[
["cat","🐈"],["dog","🐕"],["sun","☀️"],["egg","🥚"],["ball","⚽"],["fish","🐟"],
["tree","🌳"],["moon","🌙"],["boat","⛵"],["star","⭐"],["book","📕"],["cake","🎂"],
["house","🏠"],["mouse","🐭"],["apple","🍎"],["train","🚂"],["fire","🔥"],["rose","🌹"],
["bear","🐻"],["duck","🦆"],["frog","🐸"],["bee","🐝"],["cow","🐄"],["pig","🐷"],
["hand","✋"],["foot","🦶"],["nose","👃"],["eye","👁️"],["ear","👂"],["milk","🥛"]
]
/* De taalplaatjes hebben één centrale bron. Daardoor gebruiken de spelletjes,
de themakiezer en de afbeeldingenbibliotheek altijd dezelfde woorden en
verdwijnen oude plaatjes niet wanneer de woordenschat wordt uitgebreid. */
const LANGUAGE_THEME_DEFS = [
{id:"animals", icon:"🐾", nl:"Dieren", en:"Animals", colors:["#eaf7df","#cdeeb5"]},
{id:"food", icon:"🍎", nl:"Eten & drinken", en:"Food & drink", colors:["#fff0dc","#ffd9aa"]},
{id:"nature", icon:"🌿", nl:"Natuur & weer", en:"Nature & weather", colors:["#e4f7ee","#bfead4"]},
{id:"travel", icon:"🚲", nl:"Onderweg", en:"Getting around", colors:["#e6f3ff","#beddf8"]},
{id:"home", icon:"🎒", nl:"Thuis & school", en:"Home & school", colors:["#f1ecff","#d9ccff"]},
{id:"body", icon:"👕", nl:"Lichaam & kleding", en:"Body & clothes", colors:["#ffeaf1","#ffcbdc"]},
{id:"actions", icon:"🏃", nl:"Doen", en:"Actions", colors:["#fff8d9","#f6e8a4"]},
{id:"feelings", icon:"🙂", nl:"Gevoelens", en:"Feelings", colors:["#e9f5ff","#c8e4fa"]}
];
const LANGUAGE_CONCEPTS = [
["monkey","animals","aap","monkey","🐵"],
["cat","animals","kat","cat","🐈"],
["dog","animals","hond","dog","🐕"],
["fish","animals","vis","fish","🐟"],
["hen","animals","kip","hen","🐔"],
["mouse","animals","muis","mouse","🐭"],
["bear","animals","beer","bear","🐻"],
["duck","animals","eend","duck","🦆"],
["frog","animals","kikker","frog","🐸"],
["bee","animals","bij","bee","🐝"],
["cow","animals","koe","cow","🐄"],
["pig","animals","varken","pig","🐷"],
["owl","animals","uil","owl","🦉"],
["goat","animals","geit","goat","🐐"],
["hare","animals","haas","hare","🐇"],
["horse","animals","paard","horse","🐴"],
["lion","animals","leeuw","lion","🦁"],
["ant","animals","mier","ant","🐜"],
["egg","food","ei","egg","🥚"],
["apple","food","appel","apple","🍎"],
["pear","food","peer","pear","🍐"],
["cheese","food","kaas","cheese","🧀"],
["milk","food","melk","milk","🥛"],
["cake","food","taart","cake","🎂"],
["bread","food","brood","bread","🍞"],
["soup","food","soep","soup","🥣"],
["rice","food","rijst","rice","🍚"],
["corn","food","mais","corn","🌽"],
["grape","food","druif","grape","🍇"],
["strawberry","food","aardbei","strawberry","🍓"],
["candy","food","snoep","candy","🍬"],
["lemon","food","citroen","lemon","🍋"],
["pizza","food","pizza","pizza","🍕"],
["juice","food","sap","juice","🧃"],
["cookie","food","koek","cookie","🍪"],
["carrot","food","wortel","carrot","🥕"],
["sun","nature","zon","sun","☀️"],
["moon","nature","maan","moon","🌙"],
["star","nature","ster","star","⭐"],
["tree","nature","boom","tree","🌳"],
["rose","nature","roos","rose","🌹"],
["fire","nature","vuur","fire","🔥"],
["flower","nature","bloem","flower","🌷"],
["leaf","nature","blad","leaf","🍃"],
["cloud","nature","wolk","cloud","☁️"],
["rain","nature","regen","rain","🌧️"],
["snow","nature","sneeuw","snow","❄️"],
["water","nature","water","water","💧"],
["stone","nature","steen","stone","🪨"],
["sand","nature","zand","sand","🏖️"],
["sea","nature","zee","sea","🌊"],
["forest","nature","bos","forest","🌲"],
["grass","nature","gras","grass","🌱"],
["wind","nature","wind","wind","💨"],
["rainbow","nature","regenboog","rainbow","🌈"],
["car","travel","auto","car","🚗"],
["bike","travel","fiets","bike","🚲"],
["boat","travel","boot","boat","⛵"],
["train","travel","trein","train","🚂"],
["bus","travel","bus","bus","🚌"],
["taxi","travel","taxi","taxi","🚕"],
["truck","travel","vrachtauto","truck","🚚"],
["jet","travel","vliegtuig","plane","✈️"],
["tram","travel","tram","tram","🚋"],
["ship","travel","schip","ship","🚢"],
["rocket","travel","raket","rocket","🚀"],
["road","travel","weg","road","🛣️"],
["wheel","travel","wiel","wheel","🛞"],
["sail","travel","zeil","sail","⛵"],
["scooter","travel","step","scooter","🛴"],
["tractor","travel","tractor","tractor","🚜"],
["helicopter","travel","helikopter","helicopter","🚁"],
["house","home","huis","house","🏠"],
["book","home","boek","book","📕"],
["pen","home","pen","pen","🖊️"],
["pencil","home","potlood","pencil","✏️"],
["bag","home","tas","bag","🎒"],
["coat","home","jas","coat","🧥"],
["chair","home","stoel","chair","🪑"],
["bed","home","bed","bed","🛏️"],
["door","home","deur","door","🚪"],
["clock","home","klok","clock","⏰"],
["glasses","home","bril","glasses","👓"],
["ball","home","bal","ball","⚽"],
["doll","home","pop","doll","🪆"],
["block","home","blok","block","🧱"],
["key","home","sleutel","key","🔑"],
["lamp","home","lamp","lamp","💡"],
["cup","home","beker","cup","🥤"],
["brush","home","borstel","brush","🪥"],
["drum","home","trommel","drum","🥁"],
["gift","home","cadeau","gift","🎁"],
["paint","home","verf","paint","🎨"],
["scissors","home","schaar","scissors","✂️"],
["desk","home","bureau","desk","🗄️"],
["school","home","school","school","🏫"],
["hand","body","hand","hand","✋"],
["foot","body","voet","foot","🦶"],
["nose","body","neus","nose","👃"],
["eye","body","oog","eye","👁️"],
["ear","body","oor","ear","👂"],
["mouth","body","mond","mouth","👄"],
["hair","body","haar","hair","🧑‍🦱"],
["head","body","hoofd","head","🙂"],
["arm","body","arm","arm","💪"],
["leg","body","been","leg","🦵"],
["cap","body","pet","cap","🧢"],
["sock","body","sok","sock","🧦"],
["shoe","body","schoen","shoe","👟"],
["dress","body","jurk","dress","👗"],
["shirt","body","shirt","shirt","👕"],
["glove","body","want","glove","🧤"],
["pants","body","broek","trousers","👖"],
["skirt","body","rok","skirt","👗"],
["tooth","body","tand","tooth","🦷"],
["read","actions","lezen","read","📖"],
["eat","actions","eten","eat","🍽️"],
["drink","actions","drinken","drink","🥤"],
["sleep","actions","slapen","sleep","😴"],
["walk","actions","lopen","walk","🚶"],
["swim","actions","zwemmen","swim","🏊"],
["jump","actions","springen","jump","🤸"],
["laugh","actions","lachen","laugh","😄"],
["cry","actions","huilen","cry","😢"],
["sing","actions","zingen","sing","🎤"],
["dance","actions","dansen","dance","💃"],
["draw","actions","tekenen","draw","🖍️"],
["write","actions","schrijven","write","✍️"],
["play","actions","spelen","play","🧸"],
["sit","actions","zitten","sit","🧎"],
["run","actions","rennen","run","🏃"],
["climb","actions","klimmen","climb","🧗"],
["wave","actions","zwaaien","wave","👋"],
["look","actions","kijken","look","👀"],
["think","actions","denken","think","🤔"],
["happy","feelings","blij","happy","😀"],
["sad","feelings","verdrietig","sad","😢"],
["angry","feelings","boos","angry","😠"],
["scared","feelings","bang","scared","😨"],
["tired","feelings","moe","tired","🥱"],
["sick","feelings","ziek","sick","🤒"],
["proud","feelings","trots","proud","😌"],
["calm","feelings","rustig","calm","🙂"],
["surprised","feelings","verrast","surprised","😮"],
["excited","feelings","enthousiast","excited","🤩"]
].map(([id,theme,nl,en,pic])=>({id,theme,nl,en,pic}));
/* De vertrouwde startkaart blijft exact dezelfde 30 woorden houden. */
const START_WORD_KEYS = {
nl:["monkey","fish","sun","cat","ball","egg","tree","house","moon","boat","hen","mouse","book","star","rose","fire","bike","cake","coat","glasses","clock","pear","cheese","duck","hand","foot","nose","eye","ear","mouth"],
en:["cat","dog","sun","egg","ball","fish","tree","moon","boat","star","book","cake","house","mouse","apple","train","fire","rose","bear","duck","frog","bee","cow","pig","hand","foot","nose","eye","ear","milk"]
};
const WORDS = Object.fromEntries(["nl","en"].map(lang=>[
lang, START_WORD_KEYS[lang].map(id=>{
const concept=LANGUAGE_CONCEPTS.find(item=>item.id===id);
return [concept[lang],concept.pic];
})
]));
function languageThemeList(lang=LANG){
return LANGUAGE_THEME_DEFS.map(theme=>({
...theme,
name:theme[lang],
words:LANGUAGE_CONCEPTS.filter(item=>item.theme===theme.id).map(item=>[item[lang],item.pic])
}));
}
function languageEmojiDataUrl(emoji,colors=["#f4f8ff","#dce9fa"]){
const svg=`<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320" viewBox="0 0 320 320"><defs><linearGradient id="g" x2="1" y2="1"><stop stop-color="${colors[0]}"/><stop offset="1" stop-color="${colors[1]}"/></linearGradient></defs><rect width="320" height="320" rx="48" fill="url(#g)"/><circle cx="160" cy="160" r="112" fill="#fff" opacity=".92"/><text x="160" y="177" text-anchor="middle" dominant-baseline="middle" font-family="Apple Color Emoji,Segoe UI Emoji,Noto Color Emoji,sans-serif" font-size="148">${emoji}</text></svg>`;
return "data:image/svg+xml;charset=utf-8,"+encodeURIComponent(svg);
}
function languagePictureCatalog(lang=LANG){
const builtinThemes=languageThemeList(lang).map((theme,index)=>({
id:-200-index,nameNl:theme.nl,nameEn:theme.en,scope:"global",filterOnly:true,
languageBuiltin:true,wordPicture:true,icon:theme.icon
}));
const themeIds=new Map(LANGUAGE_THEME_DEFS.map((theme,index)=>[theme.id,-200-index]));
const images=LANGUAGE_CONCEPTS.map((item,index)=>{
const theme=LANGUAGE_THEME_DEFS.find(def=>def.id===item.theme);
return {
id:-1000-index,name:item[lang],folder:`Taal/Woordenschat/${theme[lang]}`,
scope:"global",themes:[themeIds.get(item.theme)],tags:[item.nl,item.en,theme.nl,theme.en],
src:languageEmojiDataUrl(item.pic,theme.colors),value:item.pic,canManage:false,canDelete:false,
wordPicture:true,builtin:true
};
});
const ownThemes=[];
const customImages=[];
let customId=-5000;
const addOwn=(words,name,folder,themeId)=>{
(words||[]).forEach(([word,pic])=>{
if(!word||!pic)return;
customImages.push({
id:customId--,name:word,folder,scope:"personal",themes:[themeId],tags:[word,name],
src:isImg(pic)?pic:languageEmojiDataUrl(pic),value:pic,canManage:false,canDelete:false,
wordPicture:true,builtin:false
});
});
};
const generalThemeId=-400;
ownThemes.push({id:generalThemeId,nameNl:"Mijn woordkaarten",nameEn:"My word cards",scope:"personal",filterOnly:true,wordPicture:true,icon:"⭐"});
addOwn(GENERAL_EXTRA[lang],lang==="nl"?"Mijn woordkaarten":"My word cards","Taal/Mijn woordkaarten",generalThemeId);
THEMES[lang].forEach((theme,index)=>{
const id=-401-index;
ownThemes.push({id,nameNl:theme.name,nameEn:theme.name,scope:"personal",filterOnly:true,wordPicture:true,icon:"📁"});
addOwn(theme.words,theme.name,["Taal/Mijn themas",theme.folder,theme.name].filter(Boolean).join("/"),id);
});
return {themes:builtinThemes.concat(ownThemes),images:images.concat(customImages)};
}
/* teacher's own additions to the general card (persisted per user) */
let GENERAL_EXTRA = {nl:[], en:[]};
function generalList(){ return WORDS[LANG].concat(GENERAL_EXTRA[LANG]); }
@ -66,7 +271,7 @@ function themesFromData(data){
});
return t;
}
const isImg = p => typeof p==="string" && (p.startsWith("data:") || p.startsWith("http"));
const isImg = p => typeof p==="string" && /^(?:data:|https?:|blob:|\/)/.test(p);
function setPic(el, pic){
if(isImg(pic)){ el.innerHTML = ""; const im = document.createElement("img"); im.src = pic; im.alt=""; el.appendChild(im); }
else el.textContent = pic;
@ -90,7 +295,9 @@ function anchorSources(){
}
function normWordSource(card){
if(typeof card==="string"){
if(card.startsWith("t")){
if(card.startsWith("b:")){
if(languageThemeList().some(theme=>theme.id===card.slice(2))) return card;
}else if(card.startsWith("t")){
const th = THEMES[LANG][+card.slice(1)];
if(th && th.words.length) return card;
}else if(card.startsWith("a")){
@ -101,6 +308,7 @@ function normWordSource(card){
}
function wordSourceList(card){
card = normWordSource(card);
if(card.startsWith("b:")) return languageThemeList().find(theme=>theme.id===card.slice(2)).words;
if(card.startsWith("t")) return THEMES[LANG][+card.slice(1)].words;
if(card.startsWith("a")) return anchorSources()[+card.slice(1)].words;
return generalList();
@ -108,8 +316,17 @@ function wordSourceList(card){
function fillWordSourceSel(sel, card){
sel.innerHTML = "";
const og = document.createElement("option");
og.value = "g"; og.textContent = T("cardGeneral");
og.value = "g"; og.textContent = "⭐ "+T("cardStart");
sel.appendChild(og);
const builtinGroup = document.createElement("optgroup");
builtinGroup.label = "📚 "+T("wordThemes");
languageThemeList().forEach(theme=>{
const option=document.createElement("option");
option.value="b:"+theme.id;
option.textContent=`${theme.icon} ${theme.name} (${theme.words.length})`;
builtinGroup.appendChild(option);
});
sel.appendChild(builtinGroup);
/* thema's zonder map bovenaan, daarna per map gegroepeerd (optgroup) -
zelfde indeling als de theme-dropdown in de woorden-editor */
const groups = new Map();
@ -158,4 +375,3 @@ const EMOJI_CATS = [
splitEmoji("⚽🏀🎈🎁🧸📕✏️🖍️✂️🎨🥁🎸👓⌚⏰💡🔑🧤🧢👟👗🧥🛏️🪑🚪🏠"),
splitEmoji("✋👂👃👁️👄🦶😀😢😴🤧👶👦👧👵👴")
];

View file

@ -34,9 +34,18 @@ async function uploadImageToCatalog(file, options={}){
async function loadImageCatalog(){
IMAGE_CATALOG=await api("/images");
refreshWordPictureCatalog();
return IMAGE_CATALOG;
}
function refreshWordPictureCatalog(){
const wordCatalog=languagePictureCatalog(LANG);
IMAGE_CATALOG.images=(IMAGE_CATALOG.images||[]).filter(image=>!image.wordPicture);
IMAGE_CATALOG.themes=(IMAGE_CATALOG.themes||[]).filter(theme=>!theme.wordPicture);
IMAGE_CATALOG.images=wordCatalog.images.concat(IMAGE_CATALOG.images);
IMAGE_CATALOG.themes=wordCatalog.themes.concat(IMAGE_CATALOG.themes);
}
function ensureImageCatalog(){
if(imageCatalogWrap)return imageCatalogWrap;
const wrap=document.createElement("div");
@ -51,6 +60,7 @@ function ensureImageCatalog(){
<select class="ic-folder-filter"></select>
<button class="tbtn ghost ic-folders" type="button"></button>
</div>
<div class="ic-word-themes"></div>
<div class="ic-quota"></div>
<details class="ic-add">
<summary></summary>
@ -78,7 +88,7 @@ function ensureImageCatalog(){
q(".ic-close").onclick=closeImageCatalog;
wrap.onclick=e=>{if(e.target===wrap)closeImageCatalog()};
q(".ic-search").oninput=renderImageCatalogGrid;
q(".ic-theme-filter").onchange=renderImageCatalogGrid;
q(".ic-theme-filter").onchange=()=>{renderImageCatalogGrid();renderLanguageThemeChips()};
q(".ic-scope-filter").onchange=renderImageCatalogGrid;
q(".ic-folder-filter").onchange=renderImageCatalogGrid;
q(".ic-folders").onclick=openImageFolderExplorer;
@ -125,14 +135,33 @@ function fillImageSelect(select,items,value,label){
function fillThemeSelect(select,selected=[],includeAll=false,asset=null){
const current=new Set((selected||[]).map(Number));select.innerHTML="";
if(includeAll)select.append(new Option(T("imgAllThemes"),""));
IMAGE_CATALOG.themes.filter(theme=>!asset||theme.scope==="global"
IMAGE_CATALOG.themes.filter(theme=>(includeAll||!theme.filterOnly)&&(!asset||theme.scope==="global"
||(asset.scope==="school"&&theme.scope==="school")
||(asset.scope==="personal"&&theme.scope==="personal"))
||(asset.scope==="personal"&&theme.scope==="personal")))
.forEach(theme=>{
const option=new Option(imageThemeName(theme),theme.id);
option.selected=current.has(theme.id);select.append(option);
});
}
function renderLanguageThemeChips(){
if(!imageCatalogWrap)return;
const host=imageCatalogWrap.querySelector(".ic-word-themes");
const select=imageCatalogWrap.querySelector(".ic-theme-filter");
const themes=IMAGE_CATALOG.themes.filter(theme=>theme.languageBuiltin);
host.innerHTML="";host.hidden=!themes.length;
if(!themes.length)return;
const label=document.createElement("strong");label.textContent=T("imgWordThemes");host.append(label);
themes.forEach(theme=>{
const button=document.createElement("button");button.type="button";
button.textContent=(theme.icon||"🖼️")+" "+imageThemeName(theme);
button.classList.toggle("active",select.value===String(theme.id));
button.onclick=()=>{
select.value=select.value===String(theme.id)?"":String(theme.id);
renderImageCatalogGrid();renderLanguageThemeChips();
};
host.append(button);
});
}
function applyImageCatalogLabels(){
const q=s=>imageCatalogWrap.querySelector(s);
q(".ic-title").textContent=T("imgCatalog");
@ -155,6 +184,7 @@ function applyImageCatalogLabels(){
}
function renderImageCatalog(){
const q=s=>imageCatalogWrap.querySelector(s);
refreshWordPictureCatalog();
applyImageCatalogLabels();
const themeValue=q(".ic-theme-filter").value,scopeValue=q(".ic-scope-filter").value,folderValue=q(".ic-folder-filter").value;
fillThemeSelect(q(".ic-theme-filter"),[],true);
@ -170,6 +200,7 @@ function renderImageCatalog(){
const ownFolders=catalogOwnFolders().map(path=>({value:path,label:path||T("imgFolderRoot")}));
fillImageSelect(q(".ic-upload-folder"),ownFolders,q(".ic-upload-folder").value);
fillThemeSelect(q(".ic-upload-themes"),[...q(".ic-upload-themes").selectedOptions].map(o=>+o.value),false,{scope:IMAGE_CATALOG.uploadScope});
renderLanguageThemeChips();
const quota=IMAGE_CATALOG.quota||{},limit=quota.limitBytes;
let text=T("imgQuotaUsed").replace("{used}",imageBytes(quota.usedBytes));
text+=" · "+(limit==null?T("imgUnlimited"):T("imgQuotaOf").replace("{limit}",imageBytes(limit)));

View file

@ -1,4 +1,4 @@
/* teach - herbruikbare plaatjeskiezer (emoji / ARASAAC-zoeken / eigen upload).
/* teach - herbruikbare plaatjeskiezer (bibliotheek / emoji / ARASAAC / upload).
Bouwt voort op dezelfde bouwstenen als de woordkaart-editor in letters.js
(EMOJI_CATS, arasaacSearch/arasaacUrl, setPic, dezelfde CSS-klassen) i.p.v.
een tweede plaatjessysteem te maken. Gebruikt door widgets die snel een los
@ -30,6 +30,7 @@ function ensurePicPicker(){
<button type="button" data-t="emoji"></button>
<button type="button" data-t="picto"></button>
<button type="button" data-t="upload"></button>
<button type="button" data-t="library"></button>
</div>
<div class="ed-pane">
<div class="ed-p-emoji">
@ -49,6 +50,10 @@ function ensurePicPicker(){
<label class="ed-upload"><span class="ed-uplbl"></span><input type="file" accept="image/*"></label>
<div class="ed-uphint"></div>
</div>
<div class="ed-p-library" style="display:none;">
<button class="tbtn ed-open-library" type="button"></button>
<p class="ed-library-hint"></p>
</div>
</div>
</div>`;
document.body.appendChild(wrap);
@ -66,6 +71,8 @@ function ensurePicPicker(){
q(".ed-credit").innerHTML = T("pictoCredit");
q(".ed-pgo").textContent = T("pictoSearch");
q(".ed-psearch").placeholder = T("pictoPh");
q(".ed-open-library").textContent = "🗂 "+T("imgOpenLibrary");
q(".ed-library-hint").textContent = T("imgLanguageHint");
q(".pp-close").title = T("close");
refreshTabs();
}
@ -77,6 +84,7 @@ function ensurePicPicker(){
q(".ed-p-emoji").style.display = tab==="emoji" ? "block" : "none";
q(".ed-p-picto").style.display = tab==="picto" ? "block" : "none";
q(".ed-p-upload").style.display = tab==="upload" ? "block" : "none";
q(".ed-p-library").style.display = tab==="library" ? "block" : "none";
}
wrap.querySelectorAll(".ed-tabs button").forEach(b=>{
b.addEventListener("click", ()=>{ tab = b.dataset.t; refreshTabs(); });
@ -125,9 +133,22 @@ function ensurePicPicker(){
}
q(".ed-pgo").addEventListener("click", searchPictos);
q(".ed-psearch").addEventListener("keydown", e=>{ if(e.key==="Enter") searchPictos(); });
q(".ed-open-library").addEventListener("click", ()=>{
openImageCatalog((src,image)=>choosePic(image?.value||src));
});
q(".ed-p-upload input").addEventListener("change", e=>{
q(".ed-p-upload input").addEventListener("change", async e=>{
const f = e.target.files[0]; if(!f) return;
e.target.value = "";
if(currentUser){
const hint=q(".ed-uphint"),old=hint.textContent;
hint.textContent=T("imgUploading");
try{
const image=await uploadImageToCatalog(f,{folder:"Taal/Mijn woordkaarten"});
choosePic(image.src);
}catch(err){hint.textContent=err.message;setTimeout(()=>{hint.textContent=old},3500)}
return;
}
const img = new Image();
img.onload = ()=>{
const c = document.createElement("canvas");
@ -138,7 +159,6 @@ function ensurePicPicker(){
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(f);
e.target.value = "";
});
wrap.__applyLabels = applyLabels;
@ -154,9 +174,9 @@ function openPicPicker(word, onSelect){
wrap.__applyLabels();
wrap.__renderCats();
wrap.querySelector(".ed-psearch").value = word || "";
/* met een woord starten we meteen op het picto-zoekresultaat i.p.v. het emoji-tabblad,
anders lijkt de vooraf ingevulde zoekopdracht nergens naartoe te leiden */
wrap.__setTab(word ? "picto" : "emoji");
/* Bij een woord staat de herbruikbare, thematisch ingedeelde bibliotheek
voorop. Zonder account blijft de bestaande online pictozoeker beschikbaar. */
wrap.__setTab(word ? (currentUser ? "library" : "picto") : "emoji");
wrap.classList.add("open");
if(word) wrap.__searchPictos();
if(word && !currentUser) wrap.__searchPictos();
}

View file

@ -49,7 +49,7 @@ function mountLetterGame(root, initState, opts){
<div class="ed-msg"></div>
</div>
<div class="ed-tabs">
<button data-t="emoji"></button><button data-t="picto"></button><button data-t="upload"></button>
<button data-t="emoji"></button><button data-t="picto"></button><button data-t="upload"></button><button data-t="library"></button>
</div>
<div class="ed-pane">
<div class="ed-p-emoji">
@ -69,6 +69,10 @@ function mountLetterGame(root, initState, opts){
<label class="ed-upload"><span class="ed-uplbl"></span><input type="file" accept="image/*"></label>
<div class="ed-uphint"></div>
</div>
<div class="ed-p-library" style="display:none;">
<button class="tbtn ed-open-library" type="button"></button>
<p class="ed-library-hint"></p>
</div>
</div>
<div class="lg-ed-list"></div>
</div>
@ -312,7 +316,7 @@ function mountLetterGame(root, initState, opts){
edThemeSel.innerHTML = "";
const og = document.createElement("option");
og.value = "g";
og.textContent = `${T("cardGeneral")} (${generalList().length})`;
og.textContent = `${T("cardStart")} (${generalList().length})`;
edThemeSel.appendChild(og);
/* thema's zonder map bovenaan, daarna per map gegroepeerd - zelfde
indeling als fillWordSourceSel (data.js) in de widget-dropdowns */
@ -519,6 +523,7 @@ function mountLetterGame(root, initState, opts){
el(".ed-p-emoji").style.display = edTab==="emoji" ? "block" : "none";
el(".ed-p-picto").style.display = edTab==="picto" ? "block" : "none";
el(".ed-p-upload").style.display = edTab==="upload" ? "block" : "none";
el(".ed-p-library").style.display = edTab==="library" ? "block" : "none";
if(edTab==="picto" && !edPSearch.value) edPSearch.value = edWord.value.trim();
}
@ -568,12 +573,25 @@ function mountLetterGame(root, initState, opts){
}
el(".ed-pgo").addEventListener("click", searchPictos);
edPSearch.addEventListener("keydown", e=>{ if(e.key==="Enter") searchPictos(); });
el(".ed-open-library").addEventListener("click", ()=>{
openImageCatalog((src,image)=>selectPic(image?.value||src));
});
/* typing a word auto-fills picto search */
edWord.addEventListener("input", ()=>{ edPSearch.value = edWord.value.trim(); });
/* upload tab */
edFile.addEventListener("change", ()=>{
edFile.addEventListener("change", async ()=>{
const f = edFile.files[0]; if(!f) return;
edFile.value = "";
if(currentUser){
const hint=el(".ed-uphint"),old=hint.textContent;
hint.textContent=T("imgUploading");
try{
const image=await uploadImageToCatalog(f,{name:edWord.value.trim(),folder:"Taal/Mijn woordkaarten"});
selectPic(image.src);
}catch(err){hint.textContent=err.message;setTimeout(()=>{hint.textContent=old},3500)}
return;
}
const img = new Image();
img.onload = ()=>{
const c = document.createElement("canvas");
@ -584,7 +602,6 @@ function mountLetterGame(root, initState, opts){
URL.revokeObjectURL(img.src);
};
img.src = URL.createObjectURL(f);
edFile.value = "";
});
el(".ed-add").addEventListener("click", ()=>{
@ -615,6 +632,8 @@ function mountLetterGame(root, initState, opts){
el(".ed-title").textContent = T("manage");
el(".ed-uplbl").textContent = T("upload");
el(".ed-uphint").textContent = T("upHint");
el(".ed-open-library").textContent = "🗂 "+T("imgOpenLibrary");
el(".ed-library-hint").textContent = T("imgLanguageHint");
el(".ed-credit").innerHTML = T("pictoCredit");
el(".ed-pgo").textContent = T("pictoSearch");
edPSearch.placeholder = T("pictoPh");
@ -757,4 +776,3 @@ function confetti(container){
}
}

View file

@ -0,0 +1,91 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {readFile} from 'node:fs/promises';
import {runInNewContext} from 'node:vm';
async function languageData(extra=''){
const source=await readFile('public/js/data.js','utf8');
const context={LANG:'nl',T:key=>key};
runInNewContext(source+`; ${extra}
globalThis.__language={
concepts:LANGUAGE_CONCEPTS,
themesNl:languageThemeList("nl"),
themesEn:languageThemeList("en"),
words:WORDS,
catalogNl:languagePictureCatalog("nl")
};`,context);
return JSON.parse(JSON.stringify(context.__language));
}
test('taalwidgets delen een ruime tweetalige woordenschat in acht duidelijke themas',async()=>{
const data=await languageData();
assert.ok(data.concepts.length>=140,'verwacht minimaal 140 vaste woordplaatjes');
assert.equal(data.themesNl.length,8);
assert.equal(data.themesEn.length,8);
assert.equal(new Set(data.concepts.map(item=>item.id)).size,data.concepts.length);
for(const concept of data.concepts){
assert.ok(concept.nl&&concept.en&&concept.pic,concept.id);
assert.ok(data.themesNl.some(theme=>theme.id===concept.theme),concept.theme);
}
for(const themes of [data.themesNl,data.themesEn]){
for(const theme of themes)assert.ok(theme.words.length>=10,theme.id);
assert.equal(themes.reduce((sum,theme)=>sum+theme.words.length,0),data.concepts.length);
}
});
test('alle oude startwoorden blijven staan en krijgen ook een bibliotheekplaatje',async()=>{
const data=await languageData();
const oldNl=['aap','vis','zon','kat','bal','ei','boom','huis','maan','boot','kip','muis','boek','ster','roos','vuur','fiets','taart','jas','bril','klok','peer','kaas','eend','hand','voet','neus','oog','oor','mond'];
const oldEn=['cat','dog','sun','egg','ball','fish','tree','moon','boat','star','book','cake','house','mouse','apple','train','fire','rose','bear','duck','frog','bee','cow','pig','hand','foot','nose','eye','ear','milk'];
assert.deepEqual(data.words.nl.map(([word])=>word),oldNl);
assert.deepEqual(data.words.en.map(([word])=>word),oldEn);
const names=new Set(data.catalogNl.images.map(image=>image.name));
for(const word of oldNl)assert.ok(names.has(word),word);
});
test('vaste en eigen woordplaatjes worden als herbruikbare catalogusitems aangeboden',async()=>{
const data=await languageData('GENERAL_EXTRA.nl=[["test","🧪"]]; THEMES.nl=[{name:"Boerderij",folder:"School",words:[["stal","🏚️"]]}];');
const builtins=data.catalogNl.images.filter(image=>image.builtin);
assert.equal(builtins.length,data.concepts.length);
assert.equal(data.catalogNl.themes.filter(theme=>theme.languageBuiltin).length,8);
for(const image of builtins){
assert.ok(image.id<0);
assert.ok(image.src.startsWith('data:image/svg+xml;charset=utf-8,'));
assert.ok(image.value&&image.themes.length===1);
assert.equal(image.canManage,false);
}
const testPic=data.catalogNl.images.find(image=>image.name==='test');
const stablePic=data.catalogNl.images.find(image=>image.name==='stal');
assert.equal(testPic.value,'🧪');
assert.equal(stablePic.value,'🏚️');
assert.equal(stablePic.folder,'Taal/Mijn themas/School/Boerderij');
});
test('alle vier taalspellen gebruiken themabronnen en kunnen de bibliotheek openen',async()=>{
const [data,catalog,picker,letters,css,catalogCss]=await Promise.all([
readFile('public/js/data.js','utf8'),
readFile('public/js/image-catalog.js','utf8'),
readFile('public/js/picker.js','utf8'),
readFile('public/js/widgets/letters.js','utf8'),
readFile('public/css/teach.css','utf8'),
readFile('public/css/image-catalog.css','utf8'),
]);
assert.match(data,/card\.startsWith\("b:"\)/);
assert.match(data,/builtinGroup\.label = "📚 "/);
for(const widget of ['letters','flits','hak','ball']){
const source=await readFile(`public/js/widgets/${widget}.js`,'utf8');
assert.match(source,/wordSourceList\(/,widget);
assert.match(source,/fillWordSourceSel\(/,widget);
}
assert.match(catalog,/refreshWordPictureCatalog/);
assert.match(catalog,/languagePictureCatalog\(LANG\)/);
assert.match(catalog,/ic-word-themes/);
assert.match(picker,/data-t="library"/);
assert.match(picker,/uploadImageToCatalog\(f,/);
assert.match(letters,/data-t="library"/);
assert.match(letters,/openImageCatalog\(\(src,image\)/);
assert.match(letters,/uploadImageToCatalog\(f,/);
assert.match(css,/\.ed-tabs\{[^}]*overflow-x:auto/);
assert.match(catalogCss,/\.ic-word-themes\{[^}]*overflow-x:auto/);
assert.match(catalogCss,/@media\(max-width:760px\)/);
});