diff --git a/VERSION b/VERSION index f91e315..7306e75 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.3.83-beta +0.3.84-beta diff --git a/public/css/teach.css b/public/css/teach.css index b26325c..07bf16a 100644 --- a/public/css/teach.css +++ b/public/css/teach.css @@ -375,6 +375,30 @@ .dtool-opts .do-wide{width:auto; min-width:48px; padding:0 9px; border-radius:12px;} /* slot-knop kleurt mee zodra het gereedschap vaststaat */ .dtool-opts .do-lock.on{background:var(--accent-soft);} + /* ---------- CMS: banner, welkomstwoord en Inhoud-sectie ---------- */ + .cms-banner{ + position:fixed; top:var(--tbar); left:0; right:0; z-index:5800; + display:flex; gap:10px; align-items:center; justify-content:center; + padding:9px 44px 9px 14px; font-weight:700; font-size:14px; text-align:center; + background:var(--accent-soft); color:var(--accent-ink); + box-shadow:0 3px 10px rgba(30,50,90,.12); + } + .cms-banner.warn{background:var(--orange); color:#fff;} + .cms-banner-x{ + position:absolute; right:8px; top:50%; transform:translateY(-50%); + width:28px; height:28px; border:none; border-radius:8px; cursor:pointer; + background:rgba(255,255,255,.35); color:inherit; font-weight:900; line-height:1; + } + #cmsWelcome{ + margin:2px 0 12px; padding:10px 12px; border-radius:12px; + background:var(--accent-soft); color:var(--accent-ink); + font-size:13.5px; font-weight:600; line-height:1.45; white-space:pre-line; + } + .cms-block{display:flex; flex-direction:column; gap:8px; align-items:flex-start;} + .cms-field{display:flex; flex-direction:column; gap:3px; width:100%;} + .cms-ta{width:100%; min-height:64px; resize:vertical; font:inherit; padding:8px 10px;} + .cms-row{display:flex; gap:12px; align-items:center; flex-wrap:wrap;} + .cms-check{display:flex; align-items:center; gap:6px; font-weight:700; cursor:pointer;} /* live hoek-uitlezing tijdens het draaien van liniaal/geodriehoek */ .dtool-deg{ display:none; position:absolute; left:50%; top:50%; margin:-16px 0 0 -24px; diff --git a/public/index.html b/public/index.html index 5b3aae4..55c77e2 100644 --- a/public/index.html +++ b/public/index.html @@ -16,6 +16,7 @@ + diff --git a/public/js/board.js b/public/js/board.js index f4086dc..2afe24b 100644 --- a/public/js/board.js +++ b/public/js/board.js @@ -1127,7 +1127,8 @@ function renderGallery(){ defs.forEach(def=>{const c=document.createElement("div");c.className="gcard";c.tabIndex=0; const icon=document.createElement("div");icon.className="gi";icon.textContent=def.icon; const name=document.createElement("div");name.className="gn";name.textContent=T("wg_"+def.id); - const desc=document.createElement("div");desc.className="gd";desc.textContent=T("wg_"+def.id+"_d"); + /* uitleg: eigen tekst van de systeemmanager (CMS) gaat vóór de standaard */ + const desc=document.createElement("div");desc.className="gd";desc.textContent=(typeof CMS!=="undefined"&&CMS.widgetInfo&&CMS.widgetInfo[def.id]&&(LANG==="en"?CMS.widgetInfo[def.id].en:CMS.widgetInfo[def.id].nl))||T("wg_"+def.id+"_d"); const star=document.createElement("button");star.className="gstar";star.type="button";star.textContent=favorites.includes(def.id)?"★":"☆";star.title=T("galleryFavorite"); star.onclick=e=>{e.stopPropagation();favorites=favorites.includes(def.id)?favorites.filter(x=>x!==def.id):[...favorites,def.id];localStorage.setItem("teachWidgetFavorites",JSON.stringify(favorites));renderGallery()}; const add=()=>{galleryWrap.classList.remove("open");makeWidget(def)};c.onclick=add;c.onkeydown=e=>{if(e.key==="Enter"||e.key===" "){e.preventDefault();add()}};c.append(star,icon,name,desc);grid.appendChild(c)});gallery.appendChild(grid); diff --git a/public/js/cms.js b/public/js/cms.js new file mode 100644 index 0000000..47bea0d --- /dev/null +++ b/public/js/cms.js @@ -0,0 +1,221 @@ +/* teach - CMS van de systeemmanager: site-inhoud die zonder release aanpasbaar + is. Drie sleutels (server: site_content, routes in api.js): + welcome - welkomstwoord op het inlogscherm, per taal; + announcement - melding/banner voor alle gebruikers (aan/uit, info of + belangrijk, per taal; elke wijziging krijgt een nieuw id + zodat weggeklikte meldingen opnieuw verschijnen); + widgetInfo - per widget een eigen uitleg die de standaardtekst in de + widgetgalerij vervangt (per taal, leeg = standaard). + Ankerdiagrammen/borden deelt de systeemmanager via de bestaande gedeelde + bibliotheek (map 🌐 Systeem in de verkenner) en afbeeldingen via de + catalogus - de Inhoud-sectie linkt daar rechtstreeks naartoe. */ +"use strict"; +let CMS = {}; +(function(){ + const DISMISS_KEY = "teach.dismissedAnnouncement"; + const cmsEl = (tag, cls, text)=>{ + const e = document.createElement(tag); + if(cls) e.className = cls; + if(text != null) e.textContent = text; + return e; + }; + const inLang = v => v ? ((LANG==="en" ? v.en : v.nl) || v.nl || v.en || "") : ""; + + async function loadContent(){ + try{ CMS = await api("/content") || {}; }catch(e){ CMS = {}; } + renderAnnouncement(); + renderWelcome(); + document.dispatchEvent(new CustomEvent("cmschange")); + } + + /* ---- banner (update-melding) voor iedereen -------------------------------- */ + let banner = null; + function renderAnnouncement(){ + if(banner){ banner.remove(); banner = null; } + const a = CMS.announcement; + const text = a && a.active ? inLang(a) : ""; + if(!text) return; + let dismissed = null; + try{ dismissed = localStorage.getItem(DISMISS_KEY); }catch(e){} + if(dismissed === String(a.id)) return; + banner = cmsEl("div", "cms-banner" + (a.level==="warn" ? " warn" : "")); + banner.appendChild(cmsEl("span", null, text)); + const x = cmsEl("button", "cms-banner-x", "✕"); + x.type = "button"; + x.title = T("close"); + x.addEventListener("click", ()=>{ + try{ localStorage.setItem(DISMISS_KEY, String(a.id)); }catch(e){} + banner.remove(); banner = null; + }); + banner.appendChild(x); + document.body.appendChild(banner); + } + + /* ---- welkomstwoord op het inlogscherm -------------------------------------- */ + function renderWelcome(){ + let box = document.getElementById("cmsWelcome"); + const text = inLang(CMS.welcome); + if(!text){ if(box) box.remove(); return; } + if(!box){ + box = cmsEl("div"); + box.id = "cmsWelcome"; + document.querySelector("#loginModal .login-logo").after(box); + } + box.textContent = text; + } + document.addEventListener("langchange", ()=>{ renderAnnouncement(); renderWelcome(); }); + + /* ---- Inhoud-sectie in Instellingen (alleen systeemmanager) ----------------- */ + const host = cmsEl("div", "st-section"); + host.dataset.section = "inhoud"; + host.style.display = "none"; + document.getElementById("stContent").appendChild(host); + + async function save(key, value, msgEl){ + try{ + await api("/admin/content/"+key, { method:"PUT", body:value }); + CMS[key] = value; + msgEl.textContent = T("cmsSaved"); + msgEl.classList.add("ok"); + renderAnnouncement(); + renderWelcome(); + document.dispatchEvent(new CustomEvent("cmschange")); + }catch(e){ + msgEl.textContent = e.message; + msgEl.classList.remove("ok"); + } + } + const langArea = (labelKey, value)=>{ + const wrap = cmsEl("label", "cms-field"); + wrap.appendChild(cmsEl("span", "am-field-lbl", T(labelKey))); + const ta = document.createElement("textarea"); + ta.className = "am-inp cms-ta"; + ta.maxLength = 2000; + ta.value = value || ""; + ta.addEventListener("keydown", ev=>ev.stopPropagation()); + wrap.appendChild(ta); + wrap._ta = ta; + return wrap; + }; + + function renderPanel(){ + host.innerHTML = ""; + + /* welkomstwoord */ + host.appendChild(cmsEl("div", "am-h3", T("cmsWelcomeH"))); + const wMsg = cmsEl("div", "formMsg"); + const wNl = langArea("cmsNl", CMS.welcome && CMS.welcome.nl); + const wEn = langArea("cmsEn", CMS.welcome && CMS.welcome.en); + const wSave = cmsEl("button", "am-btn", T("save")); + wSave.type = "button"; + wSave.addEventListener("click", ()=>save("welcome", { nl: wNl._ta.value.trim(), en: wEn._ta.value.trim() }, wMsg)); + const wBlock = cmsEl("div", "am-reset cms-block"); + [wNl, wEn, wSave, wMsg].forEach(n=>wBlock.appendChild(n)); + host.appendChild(wBlock); + + /* update-melding */ + host.appendChild(cmsEl("div", "am-h3", T("cmsAnnounceH"))); + const a = CMS.announcement || {}; + const aMsg = cmsEl("div", "formMsg"); + const activeLbl = cmsEl("label", "cms-check"); + const active = document.createElement("input"); + active.type = "checkbox"; + active.checked = !!a.active; + activeLbl.appendChild(active); + activeLbl.appendChild(cmsEl("span", null, " " + T("cmsActive"))); + const level = document.createElement("select"); + level.className = "am-sel"; + level.appendChild(new Option(T("cmsLevelInfo"), "info")); + level.appendChild(new Option(T("cmsLevelWarn"), "warn")); + level.value = a.level === "warn" ? "warn" : "info"; + const aNl = langArea("cmsNl", a.nl); + const aEn = langArea("cmsEn", a.en); + const aSave = cmsEl("button", "am-btn", T("save")); + aSave.type = "button"; + aSave.addEventListener("click", ()=>{ + /* nieuw id per wijziging: weggeklikte meldingen verschijnen dan opnieuw */ + save("announcement", { active: active.checked, level: level.value, + nl: aNl._ta.value.trim(), en: aEn._ta.value.trim(), id: Date.now() }, aMsg); + }); + const aBlock = cmsEl("div", "am-reset cms-block"); + const aRow = cmsEl("div", "cms-row"); + aRow.appendChild(activeLbl); + aRow.appendChild(level); + [aRow, aNl, aEn, aSave, aMsg].forEach(n=>aBlock.appendChild(n)); + host.appendChild(aBlock); + + /* widget-uitleg */ + host.appendChild(cmsEl("div", "am-h3", T("cmsWidgetH"))); + const gBlock = cmsEl("div", "am-reset cms-block"); + const gMsg = cmsEl("div", "formMsg"); + const pick = document.createElement("select"); + pick.className = "am-sel"; + pick.appendChild(new Option(T("cmsWidgetPick"), "")); + REGISTRY.forEach(def=>pick.appendChild(new Option(def.icon + " " + T("wg_"+def.id), def.id))); + const editor = cmsEl("div"); + editor.style.display = "none"; + pick.addEventListener("change", ()=>{ + editor.innerHTML = ""; + gMsg.textContent = ""; + if(!pick.value){ editor.style.display = "none"; return; } + const id = pick.value; + const cur = (CMS.widgetInfo || {})[id] || {}; + const gNl = langArea("cmsNl", cur.nl); + const gEn = langArea("cmsEn", cur.en); + gNl._ta.placeholder = I18N.nl["wg_"+id+"_d"] || ""; + gEn._ta.placeholder = I18N.en["wg_"+id+"_d"] || ""; + const hint = cmsEl("div", "guestnote", T("cmsWidgetHint")); + const gSave = cmsEl("button", "am-btn", T("save")); + gSave.type = "button"; + gSave.addEventListener("click", ()=>{ + const info = { ...(CMS.widgetInfo || {}) }; + const nl = gNl._ta.value.trim(), en = gEn._ta.value.trim(); + if(nl || en) info[id] = { nl, en }; + else delete info[id]; + save("widgetInfo", info, gMsg); + }); + [gNl, gEn, hint, gSave].forEach(n=>editor.appendChild(n)); + editor.style.display = ""; + }); + gBlock.appendChild(pick); + gBlock.appendChild(editor); + gBlock.appendChild(gMsg); + host.appendChild(gBlock); + + /* systeembrede inhoud: ankerdiagrammen/borden en afbeeldingen */ + host.appendChild(cmsEl("div", "am-h3", T("cmsShortcutsH"))); + const sBlock = cmsEl("div", "am-reset cms-block"); + sBlock.appendChild(cmsEl("div", "guestnote", T("cmsShortcutsHint"))); + const shared = cmsEl("button", "tbtn ghost", T("cmsOpenShared")); + shared.type = "button"; + shared.addEventListener("click", ()=>{ + document.getElementById("settingsWrap").classList.remove("open"); + document.getElementById("btnFolders").click(); + }); + const imgs = cmsEl("button", "tbtn ghost", T("cmsOpenImages")); + imgs.type = "button"; + imgs.addEventListener("click", ()=>{ + document.getElementById("settingsWrap").classList.remove("open"); + openImageCatalog(); + }); + const sRow = cmsEl("div", "cms-row"); + sRow.appendChild(shared); + sRow.appendChild(imgs); + sBlock.appendChild(sRow); + host.appendChild(sBlock); + } + + registerSettingsSection({ + id:"inhoud", icon:"📢", group:"system", order:32, + label:()=>T("stInhoud"), sub:()=>T("stInhoudSub"), + visible:()=>!!currentUser && can("content.manage"), + activate(){ + renderPanel(); + showSettingsDom("inhoud"); + }, + keywords:()=>[T("cmsWelcomeH"), T("cmsAnnounceH"), T("cmsWidgetH"), + "welkom","welcome","melding","announcement","banner","uitleg","cms","tekst","text"], + }); + + loadContent(); +})(); diff --git a/public/js/core.js b/public/js/core.js index 4e7e6a9..4a8a1da 100644 --- a/public/js/core.js +++ b/public/js/core.js @@ -2,7 +2,7 @@ "use strict"; /* version — shown until /api/version resolves (or if the fetch fails, e.g. offline). Kept in sync by hand with the VERSION file at the repo root on every release. */ -const VERSION = "0.3.83-beta"; +const VERSION = "0.3.84-beta"; (function(){ const tag = document.getElementById("verTag"); tag.textContent = "v"+VERSION; @@ -211,6 +211,17 @@ const I18N = { stVoortgangSub:"Volg per leerling de resultaten in de tijd.", stScholenSub:"Scholen en systeemmanagers, over alle scholen heen.", stOpslag:"Opslag", stOpslagSub:"Afbeeldingencatalogus en opslagruimte per school.", + stInhoud:"Inhoud", stInhoudSub:"Welkomstwoord, meldingen en widget-uitleg - direct aanpasbaar, zonder release.", + cmsWelcomeH:"Welkomstwoord (inlogscherm)", + cmsAnnounceH:"Melding voor alle gebruikers", + cmsWidgetH:"Widget-uitleg (bibliotheek)", + cmsActive:"Melding tonen", cmsLevelInfo:"informatie", cmsLevelWarn:"belangrijk", + cmsSaved:"Opgeslagen ✓", cmsNl:"Nederlands", cmsEn:"English", + cmsWidgetPick:"— kies een widget… —", + cmsWidgetHint:"Leeg laten = de standaarduitleg.", + cmsShortcutsH:"Systeembrede inhoud", + cmsShortcutsHint:"Ankerdiagrammen en borden deel je via de gedeelde bibliotheek (map 🌐 Systeem in de verkenner); afbeeldingen via de catalogus. Iedereen kan ze daar kopiëren.", + cmsOpenShared:"🌐 Borden & ankerdiagrammen", cmsOpenImages:"🖼 Afbeeldingencatalogus", stBackups:"Reservekopieën", backupsNote:"Automatisch bewaarde eerdere versies van je borden en woorden.", backupsEmpty:"Nog geen reservekopieën.", backupRestore:"herstel", backupRestoreConfirm:"zeker?", backupRestored:"Versie hersteld ✓", @@ -489,6 +500,17 @@ const I18N = { stVoortgangSub:"Follow each pupil's results over time.", stScholenSub:"Schools and system managers, across all schools.", stOpslag:"Storage", stOpslagSub:"Image catalogue and storage per school.", + stInhoud:"Content", stInhoudSub:"Welcome text, announcements and widget descriptions - editable without a release.", + cmsWelcomeH:"Welcome text (login screen)", + cmsAnnounceH:"Announcement for all users", + cmsWidgetH:"Widget descriptions (library)", + cmsActive:"Show announcement", cmsLevelInfo:"information", cmsLevelWarn:"important", + cmsSaved:"Saved ✓", cmsNl:"Nederlands", cmsEn:"English", + cmsWidgetPick:"— pick a widget… —", + cmsWidgetHint:"Leave empty for the default description.", + cmsShortcutsH:"System-wide content", + cmsShortcutsHint:"Share anchor diagrams and boards through the shared library (🌐 System folder in the explorer); images through the catalogue. Everyone can copy them from there.", + cmsOpenShared:"🌐 Boards & anchor diagrams", cmsOpenImages:"🖼 Image catalogue", stBackups:"Backups", backupsNote:"Automatically kept earlier versions of your boards and words.", backupsEmpty:"No backups yet.", backupRestore:"restore", backupRestoreConfirm:"sure?", backupRestored:"Version restored ✓", diff --git a/public/js/settings.js b/public/js/settings.js index 4116caf..e6a5e4b 100644 --- a/public/js/settings.js +++ b/public/js/settings.js @@ -58,6 +58,8 @@ function registerSettingsSection(sec){ ST_SECTIONS.push(sec); } }); } window.showAdminHost = ()=>showDomSection("admin"); + /* modules met een eigen paneel (bv. cms.js) tonen hun sectie-div hiermee */ + window.showSettingsDom = showDomSection; const GROUPS = [["personal","stGroupPersonal"], ["school","stGroupSchool"], ["system","stGroupSystem"]]; function buildNav(){