feat: CMS-sectie Inhoud voor de systeemmanager (v0.3.84-beta)
All checks were successful
dev - build & deploy naar test / build-and-deploy (push) Successful in 56s

- Nieuwe sectie "Inhoud" in Instellingen (groep Systeem, alleen systeemmanager, doorzoekbaar via het sectieregister)
- Welkomstwoord (NL/EN) verschijnt op het inlogscherm, direct aanpasbaar zonder release
- Melding voor alle gebruikers: aan/uit, niveau informatie/belangrijk, per taal; verschijnt als wegklikbare banner onder de menubalk en elke wijziging krijgt een nieuw id zodat een eerder weggeklikte melding opnieuw verschijnt
- Widget-uitleg: per widget een eigen beschrijving die de standaardtekst in de widgetbibliotheek vervangt (leeg = standaard, placeholder toont de standaardtekst)
- Snelkoppelingen naar de bestaande kanalen voor systeembrede inhoud: gedeelde bibliotheek (borden & ankerdiagrammen, map 🌐 Systeem) en de afbeeldingencatalogus
- settings.js kreeg een generiek haakje (showSettingsDom) zodat modules met een eigen paneel zich in het sectieregister kunnen voegen
- Geverifieerd in headless Chromium (15 checks): banner + dismiss + nieuw-id-gedrag, welkomstwoord, bewerken/opslaan, galerij-override, sectie onzichtbaar voor andere rollen

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0149FgUQvuwxKKEdvQGmNngF
This commit is contained in:
Ramon 2026-07-16 19:33:49 +02:00
parent aa4c5faaf9
commit 526e4e5d52
7 changed files with 274 additions and 3 deletions

View file

@ -1 +1 @@
0.3.83-beta 0.3.84-beta

View file

@ -375,6 +375,30 @@
.dtool-opts .do-wide{width:auto; min-width:48px; padding:0 9px; border-radius:12px;} .dtool-opts .do-wide{width:auto; min-width:48px; padding:0 9px; border-radius:12px;}
/* slot-knop kleurt mee zodra het gereedschap vaststaat */ /* slot-knop kleurt mee zodra het gereedschap vaststaat */
.dtool-opts .do-lock.on{background:var(--accent-soft);} .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 */ /* live hoek-uitlezing tijdens het draaien van liniaal/geodriehoek */
.dtool-deg{ .dtool-deg{
display:none; position:absolute; left:50%; top:50%; margin:-16px 0 0 -24px; display:none; position:absolute; left:50%; top:50%; margin:-16px 0 0 -24px;

View file

@ -16,6 +16,7 @@
<script src="js/core.js?v=__V__" defer></script> <script src="js/core.js?v=__V__" defer></script>
<script src="js/permissions.js?v=__V__" defer></script> <script src="js/permissions.js?v=__V__" defer></script>
<script src="js/settings.js?v=__V__" defer></script> <script src="js/settings.js?v=__V__" defer></script>
<script src="js/cms.js?v=__V__" defer></script>
<script src="js/data.js?v=__V__" defer></script> <script src="js/data.js?v=__V__" defer></script>
<script src="js/explorer.js?v=__V__" defer></script> <script src="js/explorer.js?v=__V__" defer></script>
<script src="js/picker.js?v=__V__" defer></script> <script src="js/picker.js?v=__V__" defer></script>

View file

@ -1127,7 +1127,8 @@ function renderGallery(){
defs.forEach(def=>{const c=document.createElement("div");c.className="gcard";c.tabIndex=0; 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 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 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"); 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()}; 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); 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);

221
public/js/cms.js Normal file
View file

@ -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();
})();

View file

@ -2,7 +2,7 @@
"use strict"; "use strict";
/* version shown until /api/version resolves (or if the fetch fails, e.g. offline). /* 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. */ 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(){ (function(){
const tag = document.getElementById("verTag"); const tag = document.getElementById("verTag");
tag.textContent = "v"+VERSION; tag.textContent = "v"+VERSION;
@ -211,6 +211,17 @@ const I18N = {
stVoortgangSub:"Volg per leerling de resultaten in de tijd.", stVoortgangSub:"Volg per leerling de resultaten in de tijd.",
stScholenSub:"Scholen en systeemmanagers, over alle scholen heen.", stScholenSub:"Scholen en systeemmanagers, over alle scholen heen.",
stOpslag:"Opslag", stOpslagSub:"Afbeeldingencatalogus en opslagruimte per school.", 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.", stBackups:"Reservekopieën", backupsNote:"Automatisch bewaarde eerdere versies van je borden en woorden.",
backupsEmpty:"Nog geen reservekopieën.", backupRestore:"herstel", backupRestoreConfirm:"zeker?", backupsEmpty:"Nog geen reservekopieën.", backupRestore:"herstel", backupRestoreConfirm:"zeker?",
backupRestored:"Versie hersteld ✓", backupRestored:"Versie hersteld ✓",
@ -489,6 +500,17 @@ const I18N = {
stVoortgangSub:"Follow each pupil's results over time.", stVoortgangSub:"Follow each pupil's results over time.",
stScholenSub:"Schools and system managers, across all schools.", stScholenSub:"Schools and system managers, across all schools.",
stOpslag:"Storage", stOpslagSub:"Image catalogue and storage per school.", 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.", stBackups:"Backups", backupsNote:"Automatically kept earlier versions of your boards and words.",
backupsEmpty:"No backups yet.", backupRestore:"restore", backupRestoreConfirm:"sure?", backupsEmpty:"No backups yet.", backupRestore:"restore", backupRestoreConfirm:"sure?",
backupRestored:"Version restored ✓", backupRestored:"Version restored ✓",

View file

@ -58,6 +58,8 @@ function registerSettingsSection(sec){ ST_SECTIONS.push(sec); }
}); });
} }
window.showAdminHost = ()=>showDomSection("admin"); 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"]]; const GROUPS = [["personal","stGroupPersonal"], ["school","stGroupSchool"], ["system","stGroupSystem"]];
function buildNav(){ function buildNav(){