All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 1m28s
802 lines
32 KiB
JavaScript
802 lines
32 KiB
JavaScript
/* widget: Letterblokken (Ik leer lezen) */
|
||
function mountLetterGame(root, initState, opts){
|
||
const readonly = !!(opts && opts.readonly);
|
||
const onProgress = opts && opts.onProgress;
|
||
const S = { level:1, yearGroup:null, managedLearningLevel:false, card:0, wordIdx:0, stars:0, lang:LANG, count:6, order:[], snd:true, amode:"blocks", assignmentWords:null, assignmentThemeName:"" };
|
||
if(initState){
|
||
S.level = initState.level||1;
|
||
S.yearGroup = initState.yearGroup||null;
|
||
S.managedLearningLevel = !!initState.managedLearningLevel;
|
||
S.card = initState.card ?? "g";
|
||
S.count = initState.count||6;
|
||
S.snd = initState.snd !== false;
|
||
S.amode = initState.amode||"blocks";
|
||
S.assignmentWords = Array.isArray(initState.assignmentWords) ? initState.assignmentWords : null;
|
||
S.assignmentThemeName = typeof initState.assignmentThemeName==="string" ? initState.assignmentThemeName : "";
|
||
}
|
||
const play = f=>{ if(S.snd) f(); };
|
||
|
||
root.innerHTML = `
|
||
<div class="lg">
|
||
<div class="lg-top">
|
||
<button class="lg-snd"></button>
|
||
<button class="lg-mode"></button>
|
||
<div class="lg-levels">
|
||
<button data-l="1"></button><button data-l="2"></button><button data-l="3"></button>
|
||
</div>
|
||
<div class="lg-cards"></div>
|
||
<select class="lg-num"></select>
|
||
<div class="lg-progress"><span class="lg-count"></span><span class="lg-stars"></span></div>
|
||
</div>
|
||
<div class="lg-instr"></div>
|
||
<div class="lg-card">
|
||
<div class="lg-pic"></div>
|
||
<div class="lg-slots"></div>
|
||
<div class="lg-done"><div class="dg">🌟</div><div class="dt"></div><div class="dp" style="color:var(--muted);font-weight:700;"></div></div>
|
||
</div>
|
||
<div class="lg-tray blocks-tray"></div>
|
||
<div class="lg-tray lg-kbd"></div>
|
||
<div class="lg-bottom"><button class="lg-next"></button></div>
|
||
<div class="lg-editor">
|
||
<div class="lg-ed-head"><b class="ed-title"></b><button class="wclose lg-ed-close">✕</button></div>
|
||
<div class="lg-ed-themerow">
|
||
<select class="ed-theme-sel"></select>
|
||
<button class="tbtn ed-theme-add">+</button>
|
||
<input type="text" class="ed-theme-name" maxlength="16" style="display:none;">
|
||
<button class="ed-theme-mv">🗂</button>
|
||
<button class="ed-theme-del">🗑</button>
|
||
</div>
|
||
<div class="lg-ed-form">
|
||
<input type="text" class="ed-word" maxlength="5">
|
||
<div class="ed-preview">?</div>
|
||
<button class="ed-add"></button>
|
||
<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="library"></button>
|
||
</div>
|
||
<div class="ed-pane">
|
||
<div class="ed-p-emoji">
|
||
<div class="ed-cats"></div>
|
||
<div class="ed-grid"></div>
|
||
</div>
|
||
<div class="ed-p-picto" style="display:none;">
|
||
<div class="ed-pictorow">
|
||
<input type="text" class="ed-psearch" maxlength="30">
|
||
<button class="tbtn ed-pgo"></button>
|
||
</div>
|
||
<div class="ed-status"></div>
|
||
<div class="ed-pgrid"></div>
|
||
<div class="ed-credit"></div>
|
||
</div>
|
||
<div class="ed-p-upload" style="display:none;">
|
||
<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>
|
||
</div>`;
|
||
|
||
const el = s => root.querySelector(s);
|
||
const slotsEl = el(".lg-slots"), trayEl = el(".blocks-tray"), picEl = el(".lg-pic"),
|
||
instrEl = el(".lg-instr"), nextBtn = el(".lg-next"), doneEl = el(".lg-done"),
|
||
countEl = el(".lg-count"), starsEl = el(".lg-stars"), cardsEl = el(".lg-cards");
|
||
|
||
/* card buttons (built dynamically: N cards + star + edit) */
|
||
const numSel = root.querySelector(".lg-num");
|
||
function buildNumSel(){
|
||
numSel.innerHTML = "";
|
||
[4,6,8,10,"all"].forEach(v=>{
|
||
const o = document.createElement("option");
|
||
o.value = v;
|
||
o.textContent = v==="all" ? T("all") : v;
|
||
numSel.appendChild(o);
|
||
});
|
||
numSel.value = S.count;
|
||
numSel.title = T("numWords");
|
||
}
|
||
numSel.addEventListener("change", ()=>{
|
||
S.count = numSel.value==="all" ? "all" : +numSel.value;
|
||
newRound(); render();
|
||
});
|
||
const sndBtn = root.querySelector(".lg-snd");
|
||
sndBtn.addEventListener("click", ()=>{
|
||
S.snd = !S.snd;
|
||
updateSndBtn();
|
||
});
|
||
function updateSndBtn(){
|
||
sndBtn.textContent = S.snd ? "🔔" : "🔕";
|
||
sndBtn.classList.toggle("off", !S.snd);
|
||
sndBtn.title = T("sndWidget");
|
||
modeBtn.textContent = S.amode==="blocks" ? "🧱" : "⌨️";
|
||
modeBtn.title = T("amode");
|
||
root.querySelector(".lg").classList.toggle("typemode", S.amode==="type");
|
||
}
|
||
const modeBtn = root.querySelector(".lg-mode");
|
||
modeBtn.addEventListener("click", ()=>{
|
||
S.amode = S.amode==="blocks" ? "type" : "blocks";
|
||
updateSndBtn();
|
||
});
|
||
|
||
/* on-screen letter keyboard for type mode */
|
||
const kbdEl = root.querySelector(".lg-kbd");
|
||
"abcdefghijklmnopqrstuvwxyz".split("").forEach(l=>{
|
||
const b = document.createElement("button");
|
||
b.textContent = l;
|
||
b.className = VOWELS.includes(l) ? "v" : "c";
|
||
b.addEventListener("click", ()=>typeLetter(l));
|
||
kbdEl.appendChild(b);
|
||
});
|
||
function typeLetter(l){
|
||
if(doneEl.classList.contains("show") || edEl.classList.contains("open")) return;
|
||
const slot = slotsEl.querySelector(".slot:not(.filled)");
|
||
if(!slot) return;
|
||
if(slot.dataset.letter === l){
|
||
const b = makeBlock(l);
|
||
b.classList.add("in-slot");
|
||
b.dataset.placedAt = Date.now();
|
||
slot.appendChild(b);
|
||
slot.classList.add("filled");
|
||
play(sndGood);
|
||
checkComplete();
|
||
}else{
|
||
slot.classList.add("shake");
|
||
setTimeout(()=>slot.classList.remove("shake"), 400);
|
||
play(sndBad);
|
||
}
|
||
}
|
||
|
||
/* word source: one compact dropdown (general card + themes + anchor grids) */
|
||
let srcSel = null;
|
||
function buildCardButtons(){
|
||
cardsEl.innerHTML = "";
|
||
srcSel = document.createElement("select");
|
||
srcSel.className = "fl-sel";
|
||
if(S.assignmentWords?.length){
|
||
srcSel.appendChild(new Option("🎯 "+(S.assignmentThemeName||T("castTheme")),"assignment"));
|
||
srcSel.disabled=true;
|
||
}else{
|
||
fillWordSourceSel(srcSel, S.card,S.yearGroup,S.level);
|
||
S.card=srcSel.value||"g";
|
||
srcSel.addEventListener("change", ()=>{ S.card = srcSel.value; newRound(); render(); });
|
||
}
|
||
cardsEl.appendChild(srcSel);
|
||
if(!readonly){
|
||
const edit = document.createElement("button");
|
||
edit.className = "lg-edit"; edit.textContent = "✎";
|
||
edit.addEventListener("click", openEditor);
|
||
cardsEl.appendChild(edit);
|
||
}
|
||
}
|
||
buildCardButtons();
|
||
|
||
root.querySelectorAll(".lg-levels button").forEach(b=>{
|
||
b.addEventListener("click", ()=>{ S.level = +b.dataset.l; newRound(); render(); });
|
||
b.disabled = S.managedLearningLevel;
|
||
});
|
||
nextBtn.addEventListener("click", ()=>{
|
||
S.wordIdx++;
|
||
if(S.wordIdx >= S.order.length){ showCardDone(); } else render();
|
||
});
|
||
document.addEventListener("langchange", ()=>{
|
||
S.lang=LANG; if(!S.assignmentWords)S.card="g"; buildCardButtons(); buildNumSel(); newRound(); render();
|
||
});
|
||
document.addEventListener("wordschange", ()=>{
|
||
buildCardButtons(); newRound(); render();
|
||
if(edEl.classList.contains("open")){ renderThemeSel(); renderEdList(); }
|
||
});
|
||
|
||
function fullList(){
|
||
if(S.assignmentWords?.length)return languageWordsForYear(S.assignmentWords,S.yearGroup,S.level);
|
||
S.card = normWordSource(S.card);
|
||
const selected=languageWordsForYear(wordSourceList(S.card),S.yearGroup,S.level);
|
||
if(selected.length)return selected;
|
||
S.card="g";return languageWordsForYear(wordSourceList("g"),S.yearGroup,S.level);
|
||
}
|
||
function newRound(){
|
||
const list = fullList();
|
||
const n = S.count==="all" ? list.length : Math.min(+S.count, list.length);
|
||
S.order = shuffle(list.map((_,i)=>i)).slice(0,n);
|
||
S.wordIdx = 0; S.stars = 0;
|
||
}
|
||
function currentWord(){ return fullList()[S.order[S.wordIdx]]; }
|
||
|
||
function makeBlock(letter){
|
||
const b = document.createElement("div");
|
||
b.className = "blok " + (VOWELS.includes(letter) ? "vowel" : "cons");
|
||
b.dataset.letter = letter;
|
||
b.innerHTML = `<div class="bl">${letter}</div>`;
|
||
const dg = dotGrid(letter); dg.classList.add("blok-dots");
|
||
b.appendChild(dg);
|
||
attachDrag(b);
|
||
return b;
|
||
}
|
||
function makeSlot(letter, showGhost){
|
||
const s = document.createElement("div");
|
||
s.className = "slot";
|
||
s.dataset.letter = letter;
|
||
s.innerHTML = `<div class="ghost">${showGhost ? letter : ""}</div>`;
|
||
const dg = dotGrid(letter); dg.classList.add("slot-dots");
|
||
s.appendChild(dg);
|
||
s.addEventListener("click", ()=>{
|
||
const placed = s.querySelector(".blok");
|
||
/* ignore the synthetic click that follows a successful drop */
|
||
if(placed && !placed.classList.contains("fixed") &&
|
||
Date.now() - (+placed.dataset.placedAt || 0) > 400){
|
||
placed.classList.remove("in-slot");
|
||
trayEl.appendChild(placed);
|
||
s.classList.remove("filled");
|
||
}
|
||
});
|
||
return s;
|
||
}
|
||
function distractors(word, n){
|
||
const pool = "abcdefghijklmnoprstuvwz".split("").filter(c=>!word.includes(c));
|
||
return shuffle(pool).slice(0,n);
|
||
}
|
||
|
||
function render(){
|
||
if(!S.order.length || S.wordIdx >= S.order.length) newRound();
|
||
doneEl.classList.remove("show");
|
||
nextBtn.classList.remove("show");
|
||
nextBtn.textContent = T("next");
|
||
root.querySelectorAll(".lg-levels button").forEach(b=>{
|
||
b.textContent = T("lvl"+b.dataset.l);
|
||
b.classList.toggle("active", +b.dataset.l===S.level);
|
||
});
|
||
const editBtn = cardsEl.querySelector(".lg-edit");
|
||
if(editBtn) editBtn.title = T("manage");
|
||
numSel.title = T("numWords");
|
||
const allOpt = numSel.querySelector('option[value="all"]');
|
||
if(allOpt) allOpt.textContent = T("all");
|
||
|
||
const [word, pic] = currentWord();
|
||
const literacyMode=languageTaskRules(S.yearGroup,S.level)?.literacyMode||"words";
|
||
setPic(picEl, pic);
|
||
countEl.textContent = `${T("word")} ${S.wordIdx+1}/${S.order.length}`;
|
||
starsEl.textContent = S.stars ? S.stars+" ★" : "";
|
||
slotsEl.innerHTML = ""; trayEl.innerHTML = "";
|
||
|
||
const letters = word.split("");
|
||
if(literacyMode==="letters"){
|
||
const first=letters[0];
|
||
instrEl.textContent=LANG==="nl"?"Welke letter hoor je vooraan?":"Which letter do you hear first?";
|
||
slotsEl.appendChild(makeSlot(first,false));
|
||
shuffle([first].concat(distractors(word,S.level===1?2:3))).forEach(l=>trayEl.appendChild(makeBlock(l)));
|
||
}
|
||
else if(S.level===1){
|
||
instrEl.innerHTML = T("instr1");
|
||
letters.forEach(l=>slotsEl.appendChild(makeSlot(l,true)));
|
||
shuffle(letters.concat(distractors(word,3))).forEach(l=>trayEl.appendChild(makeBlock(l)));
|
||
}
|
||
else if(S.level===2){
|
||
const types = ["begin","end","mid"];
|
||
const posType = types[S.wordIdx % 3];
|
||
let idx = posType==="begin" ? 0 : posType==="end" ? letters.length-1 : Math.floor(letters.length/2);
|
||
instrEl.innerHTML = T("instr2_"+posType);
|
||
letters.forEach((l,i)=>{
|
||
if(i===idx){ slotsEl.appendChild(makeSlot(l,false)); }
|
||
else{
|
||
const s = makeSlot(l,true);
|
||
s.classList.add("filled");
|
||
const b = makeBlock(l); b.classList.add("in-slot","fixed");
|
||
b.style.pointerEvents = "none";
|
||
s.appendChild(b);
|
||
slotsEl.appendChild(s);
|
||
}
|
||
});
|
||
shuffle([letters[idx]].concat(distractors(word,3))).forEach(l=>trayEl.appendChild(makeBlock(l)));
|
||
}
|
||
else{
|
||
instrEl.innerHTML = T("instr3");
|
||
letters.forEach(l=>slotsEl.appendChild(makeSlot(l,false)));
|
||
shuffle(letters.concat(distractors(word,3))).forEach(l=>trayEl.appendChild(makeBlock(l)));
|
||
}
|
||
}
|
||
|
||
function checkComplete(){
|
||
const open = slotsEl.querySelectorAll(".slot:not(.filled)").length;
|
||
if(open===0){
|
||
S.stars++;
|
||
starsEl.textContent = S.stars ? S.stars+" ★" : "";
|
||
play(sndWin); confetti(el(".lg-card"));
|
||
doneEl.querySelector(".dt").textContent = T("welldone");
|
||
doneEl.querySelector(".dp").textContent = currentWord()[0];
|
||
doneEl.classList.add("show");
|
||
nextBtn.classList.add("show");
|
||
if(onProgress) onProgress({ attempts: 1, correct: 1, stars: 1 });
|
||
}
|
||
}
|
||
function showCardDone(){
|
||
doneEl.querySelector(".dt").textContent = T("cardDone");
|
||
doneEl.querySelector(".dp").textContent = T("pickCard");
|
||
doneEl.classList.add("show");
|
||
nextBtn.classList.remove("show");
|
||
celebrateAvatar(root);
|
||
newRound();
|
||
}
|
||
|
||
/* ---------- custom words editor ---------- */
|
||
const edEl = el(".lg-editor"), edWord = el(".ed-word"),
|
||
edPrev = el(".ed-preview"), edMsg = el(".ed-msg"), edList = el(".lg-ed-list"),
|
||
edFile = el(".ed-p-upload input"), edPSearch = el(".ed-psearch"),
|
||
edPGrid = el(".ed-pgrid"), edStatus = el(".ed-status"),
|
||
edThemeSel = el(".ed-theme-sel"), edThemeAdd = el(".ed-theme-add"),
|
||
edThemeName = el(".ed-theme-name"), edThemeDel = el(".ed-theme-del"),
|
||
edThemeMv = el(".ed-theme-mv");
|
||
let edPic = "", edTab = "emoji", edCat = 0, edTheme = "g";
|
||
|
||
/* ---- word target: the general card or one of the themes ---- */
|
||
function renderThemeSel(){
|
||
const list = THEMES[S.lang];
|
||
if(edTheme !== "g" && edTheme >= list.length) edTheme = "g";
|
||
edThemeSel.innerHTML = "";
|
||
const og = document.createElement("option");
|
||
og.value = "g";
|
||
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 */
|
||
const groups = new Map();
|
||
list.forEach((th,i)=>{
|
||
const f = th.folder || "";
|
||
if(!groups.has(f)) groups.set(f, []);
|
||
groups.get(f).push([th, i]);
|
||
});
|
||
const addOpt = (parent, th, i)=>{
|
||
const o = document.createElement("option");
|
||
o.value = i; o.textContent = `★ ${th.name} (${th.words.length})`;
|
||
parent.appendChild(o);
|
||
};
|
||
(groups.get("") || []).forEach(([th,i])=>addOpt(edThemeSel, th, i));
|
||
[...groups.keys()].filter(f=>f).sort().forEach(f=>{
|
||
const grp = document.createElement("optgroup");
|
||
grp.label = "📁 " + f;
|
||
groups.get(f).forEach(([th,i])=>addOpt(grp, th, i));
|
||
edThemeSel.appendChild(grp);
|
||
});
|
||
edThemeSel.value = edTheme;
|
||
edThemeAdd.title = T("newTheme");
|
||
edThemeDel.title = T("delTheme");
|
||
edThemeMv.title = T("themeFolder");
|
||
edThemeName.placeholder = T("themeNamePh");
|
||
}
|
||
edThemeSel.addEventListener("change", ()=>{
|
||
edTheme = edThemeSel.value==="g" ? "g" : +edThemeSel.value;
|
||
renderEdList();
|
||
});
|
||
edThemeAdd.addEventListener("click", ()=>{
|
||
edThemeName.style.display = "block";
|
||
edThemeName.value = "";
|
||
edThemeName.focus();
|
||
});
|
||
let themeDone = false;
|
||
function commitTheme(){
|
||
if(themeDone) return; themeDone = true;
|
||
const nm = edThemeName.value.trim();
|
||
if(nm){
|
||
THEMES[S.lang].push({name:nm, words:[]});
|
||
edTheme = THEMES[S.lang].length-1;
|
||
persistWords();
|
||
renderThemeSel(); renderEdList();
|
||
document.dispatchEvent(new CustomEvent("wordschange"));
|
||
}
|
||
edThemeName.style.display = "none";
|
||
setTimeout(()=>{ themeDone = false; }, 100);
|
||
}
|
||
edThemeName.addEventListener("keydown", ev=>{
|
||
ev.stopPropagation();
|
||
if(ev.key==="Enter") commitTheme();
|
||
if(ev.key==="Escape"){ themeDone=true; edThemeName.style.display="none"; setTimeout(()=>{themeDone=false;},100); }
|
||
});
|
||
edThemeName.addEventListener("blur", commitTheme);
|
||
edThemeDel.addEventListener("click", ()=>{
|
||
if(edTheme==="g" || !THEMES[S.lang].length) return;
|
||
THEMES[S.lang].splice(edTheme,1);
|
||
edTheme = "g";
|
||
persistWords();
|
||
renderThemeSel(); renderEdList();
|
||
document.dispatchEvent(new CustomEvent("wordschange"));
|
||
});
|
||
/* 🗂 opent de mappenverkenner voor thema's: zelfde Windows-stijl verkenner
|
||
als voor de borden (explorer.js), met de thema's van de actieve taal.
|
||
Nesting zit in het folder-veld als pad met "/"-scheiding. Ankerthema's
|
||
(id "an-...") staan vast in de map "⚓" - anchor.js zet die toch terug. */
|
||
function themesExplorerAdapter(){
|
||
const split = n => (n||"").split("/").map(s=>s.trim()).filter(Boolean);
|
||
const join = p => p.join("/");
|
||
const themes = ()=>THEMES[S.lang];
|
||
const isAnchor = th => typeof th.id === "string" && th.id.startsWith("an-");
|
||
/* mappen zijn afgeleid van themavelden; nieuwe lege mappen bestaan
|
||
sessie-lokaal tot er een thema in wordt gezet */
|
||
const pending = new Set();
|
||
const changed = ()=>{
|
||
persistWords();
|
||
document.dispatchEvent(new CustomEvent("wordschange"));
|
||
};
|
||
return {
|
||
title: ()=>T("themeFolder"),
|
||
allowRootItems: true,
|
||
newItemLabel: ()=>T("newTheme"),
|
||
list(path){
|
||
const p = join(path);
|
||
const folderMap = new Map();
|
||
const items = [];
|
||
const seen = new Set(pending);
|
||
themes().forEach((th, i)=>{
|
||
const j = join(split(th.folder));
|
||
if(j === p){
|
||
items.push({
|
||
key: i, name: `${th.name} (${th.words.length})`,
|
||
icon: isAnchor(th) ? "⚓" : "★",
|
||
movable: !isAnchor(th), deletable: !isAnchor(th),
|
||
active: edTheme === i,
|
||
});
|
||
}else if(!p || j.startsWith(p + "/")){
|
||
seen.add(j);
|
||
}
|
||
});
|
||
seen.forEach(j=>{
|
||
if(!(j === p || (!p && j) || j.startsWith(p + "/"))) return;
|
||
if(j === p) return;
|
||
const seg = (p ? j.slice(p.length + 1) : j).split("/")[0];
|
||
if(!seg) return;
|
||
const under = themes().filter(t=>{ const tj = join(split(t.folder)); const full = p ? p+"/"+seg : seg; return tj === full || tj.startsWith(full + "/"); }).length;
|
||
folderMap.set(seg, under);
|
||
});
|
||
const folders = [...folderMap].map(([name, count])=>({name, count, fixed: name === "⚓" && !p}))
|
||
.sort((a,b)=>a.name.localeCompare(b.name));
|
||
return { folders, items };
|
||
},
|
||
createFolder(path, name){
|
||
const full = join([...path, name]);
|
||
if(this.list(path).folders.some(f=>f.name===name)) return T("exExists");
|
||
pending.add(full);
|
||
return null;
|
||
},
|
||
renameFolder(path, oldName, newName){
|
||
return this.moveFolder([...path, oldName], path, newName);
|
||
},
|
||
moveFolder(srcPath, targetPath, newName){
|
||
const from = join(srcPath);
|
||
if(from === "⚓") return T("exFixedFolder");
|
||
const to = join([...targetPath, newName || srcPath[srcPath.length-1]]);
|
||
if(from === to) return null;
|
||
if(themes().some(t=>{ const j = join(split(t.folder)); return j === to || j.startsWith(to + "/"); }) || pending.has(to)) return T("exExists");
|
||
themes().forEach(t=>{
|
||
const j = join(split(t.folder));
|
||
if(j === from || j.startsWith(from + "/")) t.folder = to + j.slice(from.length);
|
||
});
|
||
[...pending].forEach(j=>{
|
||
if(j === from || j.startsWith(from + "/")){ pending.delete(j); pending.add(to + j.slice(from.length)); }
|
||
});
|
||
changed();
|
||
return null;
|
||
},
|
||
deleteFolder(path, name){
|
||
const full = join([...path, name]);
|
||
if(full === "⚓") return T("exFixedFolder");
|
||
if(themes().some(t=>{ const j = join(split(t.folder)); return j === full || j.startsWith(full + "/"); })) return T("exFolderNotEmpty");
|
||
[...pending].forEach(j=>{ if(j === full || j.startsWith(full + "/")) pending.delete(j); });
|
||
return null;
|
||
},
|
||
createItem(path){
|
||
const nm = (S.lang==="nl" ? "Thema " : "Theme ") + (themes().length + 1);
|
||
const th = { name: nm, words: [] };
|
||
if(path.length) th.folder = join(path);
|
||
themes().push(th);
|
||
pending.delete(join(path));
|
||
changed();
|
||
return null;
|
||
},
|
||
renameItem(i, name){
|
||
const th = themes()[i];
|
||
if(!th || isAnchor(th)) return isAnchor(th) ? T("exFixedFolder") : null;
|
||
th.name = name;
|
||
changed();
|
||
return null;
|
||
},
|
||
moveItem(i, targetPath){
|
||
const th = themes()[i];
|
||
if(!th || isAnchor(th)) return th ? T("exFixedFolder") : null;
|
||
if(targetPath.length) th.folder = join(targetPath); else delete th.folder;
|
||
pending.delete(join(targetPath));
|
||
changed();
|
||
return null;
|
||
},
|
||
deleteItem(i){
|
||
const th = themes()[i];
|
||
if(!th || isAnchor(th)) return th ? T("exFixedFolder") : null;
|
||
themes().splice(i, 1);
|
||
if(edTheme === i) edTheme = "g";
|
||
else if(edTheme !== "g" && edTheme > i) edTheme--;
|
||
changed();
|
||
return null;
|
||
},
|
||
openItem(i){
|
||
if(themes()[i]) edTheme = i;
|
||
},
|
||
};
|
||
}
|
||
edThemeMv.addEventListener("click", ()=>{
|
||
openExplorer(themesExplorerAdapter(), ()=>{ renderThemeSel(); renderEdList(); });
|
||
});
|
||
|
||
function selectPic(p, btn){
|
||
edPic = p; setPic(edPrev, p);
|
||
root.querySelectorAll(".ed-grid button.sel,.ed-pgrid button.sel").forEach(b=>b.classList.remove("sel"));
|
||
if(btn) btn.classList.add("sel");
|
||
}
|
||
|
||
/* tabs */
|
||
root.querySelectorAll(".ed-tabs button").forEach(b=>{
|
||
b.addEventListener("click", ()=>{ edTab = b.dataset.t; refreshTabs(); });
|
||
});
|
||
function refreshTabs(){
|
||
root.querySelectorAll(".ed-tabs button").forEach(b=>{
|
||
b.textContent = T("tab"+b.dataset.t[0].toUpperCase()+b.dataset.t.slice(1));
|
||
b.classList.toggle("active", b.dataset.t===edTab);
|
||
});
|
||
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();
|
||
}
|
||
|
||
/* emoji tab */
|
||
function renderEmojiCats(){
|
||
const cats = el(".ed-cats");
|
||
cats.innerHTML = "";
|
||
T("cats").forEach((name,i)=>{
|
||
const b = document.createElement("button");
|
||
b.textContent = name;
|
||
b.classList.toggle("active", i===edCat);
|
||
b.addEventListener("click", ()=>{ edCat=i; renderEmojiCats(); });
|
||
cats.appendChild(b);
|
||
});
|
||
const grid = el(".ed-grid");
|
||
grid.innerHTML = "";
|
||
EMOJI_CATS[edCat].forEach(em=>{
|
||
const b = document.createElement("button");
|
||
b.type="button"; b.textContent = em;
|
||
b.classList.toggle("sel", em===edPic);
|
||
b.addEventListener("click", ()=>selectPic(em, b));
|
||
grid.appendChild(b);
|
||
});
|
||
}
|
||
|
||
/* picto tab (ARASAAC) */
|
||
async function searchPictos(){
|
||
const term = edPSearch.value.trim();
|
||
if(!term) return;
|
||
edStatus.textContent = T("pictoLoad");
|
||
edPGrid.innerHTML = "";
|
||
try{
|
||
const arr = await arasaacSearch(term, S.lang);
|
||
if(!arr.length){ edStatus.textContent = T("pictoNone"); return; }
|
||
edStatus.textContent = "";
|
||
arr.slice(0,12).forEach(p=>{
|
||
const url = arasaacUrl(p._id);
|
||
const b = document.createElement("button");
|
||
b.type="button";
|
||
const im = document.createElement("img");
|
||
im.src = url; im.loading = "lazy"; im.alt = term;
|
||
b.appendChild(im);
|
||
b.addEventListener("click", ()=>selectPic(url, b));
|
||
edPGrid.appendChild(b);
|
||
});
|
||
}catch(e){ edStatus.textContent = T("pictoErr"); }
|
||
}
|
||
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", 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");
|
||
const s = Math.min(1, 128/Math.max(img.width, img.height));
|
||
c.width = Math.round(img.width*s); c.height = Math.round(img.height*s);
|
||
c.getContext("2d").drawImage(img, 0, 0, c.width, c.height);
|
||
selectPic(c.toDataURL("image/png"));
|
||
URL.revokeObjectURL(img.src);
|
||
};
|
||
img.src = URL.createObjectURL(f);
|
||
});
|
||
|
||
el(".ed-add").addEventListener("click", ()=>{
|
||
const w = edWord.value.trim().toLowerCase();
|
||
if(!/^[a-z]{2,5}$/.test(w)){ edMsg.textContent = T("badWord"); return; }
|
||
if(!edPic){ edMsg.textContent = T("needPic"); return; }
|
||
edMsg.textContent = "";
|
||
if(edTheme==="g"){
|
||
GENERAL_EXTRA[S.lang].push([w, edPic]); /* extend the general card */
|
||
}else{
|
||
THEMES[S.lang][edTheme].words.push([w, edPic]);
|
||
}
|
||
persistWords();
|
||
renderThemeSel();
|
||
edWord.value = ""; edPSearch.value = ""; edPic = ""; edPrev.textContent = "?";
|
||
root.querySelectorAll(".ed-grid button.sel,.ed-pgrid button.sel").forEach(b=>b.classList.remove("sel"));
|
||
renderEdList();
|
||
play(sndGood);
|
||
document.dispatchEvent(new CustomEvent("wordschange"));
|
||
});
|
||
el(".lg-ed-close").addEventListener("click", ()=>{
|
||
edEl.classList.remove("open");
|
||
buildCardButtons();
|
||
newRound();
|
||
render();
|
||
});
|
||
function openEditor(){
|
||
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");
|
||
edWord.placeholder = T("wordPh");
|
||
el(".ed-add").textContent = T("addWord");
|
||
el(".lg-ed-close").title = T("close");
|
||
edMsg.textContent = ""; edStatus.textContent = "";
|
||
refreshTabs();
|
||
renderEmojiCats();
|
||
renderThemeSel();
|
||
renderEdList();
|
||
edEl.classList.add("open");
|
||
}
|
||
function renderEdList(){
|
||
edList.innerHTML = "";
|
||
const isGen = edTheme==="g";
|
||
const base = isGen ? WORDS[S.lang] : [];
|
||
const list = isGen ? GENERAL_EXTRA[S.lang] : (THEMES[S.lang][edTheme] ? THEMES[S.lang][edTheme].words : []);
|
||
if(!base.length && !list.length){
|
||
edList.innerHTML = `<div class="empty">${T("noWords")}</div>`;
|
||
return;
|
||
}
|
||
const addRow = (entry, delFn)=>{
|
||
const row = document.createElement("div");
|
||
row.className = "ed-row" + (delFn ? "" : " base");
|
||
const p = document.createElement("div"); p.className = "ep"; setPic(p, entry[1]);
|
||
const wv = document.createElement("div"); wv.className = "ew"; wv.textContent = entry[0];
|
||
row.appendChild(p); row.appendChild(wv);
|
||
if(delFn){
|
||
const del = document.createElement("button"); del.className = "edel"; del.textContent = "✕";
|
||
del.addEventListener("click", delFn);
|
||
row.appendChild(del);
|
||
}
|
||
edList.appendChild(row);
|
||
};
|
||
/* own words first (deletable), built-in general words below (fixed) */
|
||
list.forEach((entry, i)=>addRow(entry, ()=>{
|
||
list.splice(i,1);
|
||
persistWords();
|
||
renderThemeSel(); renderEdList();
|
||
document.dispatchEvent(new CustomEvent("wordschange"));
|
||
}));
|
||
base.forEach(entry=>addRow(entry, null));
|
||
}
|
||
|
||
/* ---------- drag & drop ----------
|
||
Hit-testing is geometric (cursor vs slot rectangles) instead of
|
||
elementFromPoint, so overlays/transforms can never break drops. */
|
||
function attachDrag(block){
|
||
block.addEventListener("pointerdown", e=>{
|
||
if(block.classList.contains("in-slot")) return;
|
||
if(e.pointerType==="mouse" && e.button!==0) return;
|
||
e.preventDefault();
|
||
/* avoid implicit touch capture retargeting the follow-up click */
|
||
try{ block.releasePointerCapture(e.pointerId); }catch(_){}
|
||
const clone = block.cloneNode(true);
|
||
clone.id = "dragClone";
|
||
document.body.appendChild(clone);
|
||
block.classList.add("dragging");
|
||
const moveTo = ev=>{ const zf = VZ(); clone.style.left = (ev.clientX/zf)+"px"; clone.style.top = (ev.clientY/zf)+"px"; };
|
||
moveTo(e);
|
||
let hovered = null;
|
||
const hitSlot = ev=>{
|
||
const pad = 8;
|
||
let found = null;
|
||
slotsEl.querySelectorAll(".slot:not(.filled)").forEach(s=>{
|
||
const r = s.getBoundingClientRect();
|
||
if(ev.clientX >= r.left-pad && ev.clientX <= r.right+pad &&
|
||
ev.clientY >= r.top-pad && ev.clientY <= r.bottom+pad) found = s;
|
||
});
|
||
return found;
|
||
};
|
||
const move = ev=>{
|
||
moveTo(ev);
|
||
const slot = hitSlot(ev);
|
||
if(hovered && hovered!==slot) hovered.classList.remove("droptarget");
|
||
hovered = slot;
|
||
if(hovered) hovered.classList.add("droptarget");
|
||
};
|
||
const up = ev=>{
|
||
document.removeEventListener("pointermove",move);
|
||
document.removeEventListener("pointerup",up);
|
||
document.removeEventListener("pointercancel",cancel);
|
||
clone.remove();
|
||
block.classList.remove("dragging");
|
||
if(hovered) hovered.classList.remove("droptarget");
|
||
const target = hitSlot(ev);
|
||
if(target){
|
||
if(target.dataset.letter === block.dataset.letter){
|
||
target.classList.add("filled");
|
||
block.classList.add("in-slot");
|
||
block.dataset.placedAt = Date.now();
|
||
target.appendChild(block);
|
||
play(sndGood);
|
||
checkComplete();
|
||
}else{
|
||
target.classList.add("shake");
|
||
setTimeout(()=>target.classList.remove("shake"), 400);
|
||
play(sndBad);
|
||
}
|
||
}
|
||
};
|
||
const cancel = ()=>{
|
||
document.removeEventListener("pointermove",move);
|
||
document.removeEventListener("pointerup",up);
|
||
document.removeEventListener("pointercancel",cancel);
|
||
clone.remove();
|
||
block.classList.remove("dragging");
|
||
if(hovered) hovered.classList.remove("droptarget");
|
||
};
|
||
document.addEventListener("pointermove",move);
|
||
document.addEventListener("pointerup",up);
|
||
document.addEventListener("pointercancel",cancel);
|
||
});
|
||
}
|
||
|
||
buildNumSel();
|
||
const assignedRules=languageTaskRules(S.yearGroup,S.level);
|
||
if(S.managedLearningLevel&&assignedRules)S.level=assignedRules.letterLevel;
|
||
root.classList.toggle("managed-learning-level",S.managedLearningLevel);
|
||
updateSndBtn();
|
||
newRound();
|
||
render();
|
||
document.addEventListener("langchange", updateSndBtn);
|
||
return {
|
||
getState: ()=>({ level:S.level, card:S.card, count:S.count, snd:S.snd, amode:S.amode }),
|
||
setState: st=>{ if(st){ S.level=st.level||1; S.yearGroup=st.yearGroup||null; S.managedLearningLevel=!!st.managedLearningLevel; const rules=languageTaskRules(S.yearGroup,S.level);if(S.managedLearningLevel&&rules)S.level=rules.letterLevel; S.card=st.card??0; S.count=st.count||6; S.snd=st.snd!==false; S.amode=st.amode||"blocks"; S.assignmentWords=Array.isArray(st.assignmentWords)?st.assignmentWords:null; S.assignmentThemeName=typeof st.assignmentThemeName==="string"?st.assignmentThemeName:""; buildCardButtons(); buildNumSel(); updateSndBtn(); newRound(); render(); } }
|
||
};
|
||
}
|
||
|
||
function confetti(container){
|
||
const colors = ["#e0554d","#3b7dd8","#3fae6a","#f7c948","#9b59b6"];
|
||
const rect = container.getBoundingClientRect();
|
||
for(let i=0;i<28;i++){
|
||
const c = document.createElement("div");
|
||
c.className = "confetti";
|
||
c.style.left = Math.random()*rect.width + "px";
|
||
c.style.background = colors[i%colors.length];
|
||
c.style.animationDuration = (1.2+Math.random()*1.4)+"s";
|
||
c.style.animationDelay = (Math.random()*0.3)+"s";
|
||
container.appendChild(c);
|
||
setTimeout(()=>c.remove(), 3200);
|
||
}
|
||
}
|