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(){