From 7c67ae1ff5f0c3b813a726566d55ff74d84b1fbf Mon Sep 17 00:00:00 2001 From: Pascal Scholz Date: Mon, 21 Sep 2026 23:11:19 +0200 Subject: [PATCH] Add WYSIWYG rich-text editor and reorganize admin UI into tabs Rich-text fields (hero lead, paragraphs, FAQ-style copy, datenschutz sections, etc.) now use a proper toolbar editor - bold/italic/underline buttons plus a per-selection font picker drawn from the same curated font list as the Design tab - instead of typed **markdown** syntax. Content is stored as a small allowlisted HTML subset (strong/em/u/span with a validated data-font/br) and sanitized server-side with sanitize-html on every save (admin/lib/rich-sanitize.js), independent of what the client sends - verified this rejects script tags, event handlers, and made-up font names while preserving legitimate markup, and that re-saving already-sanitized content is a no-op. The Astro site mirrors the same allowlist at render time for defense-in-depth (src/lib/rich-sanitize.ts) and only requests Google Fonts for fonts actually referenced somewhere in content, in addition to the two Design-tab fonts - verified a font used via a data-font span gets its own Google Fonts request and [data-font] CSS rule in the built page, and that unused fonts don't get requested. Migrated the three existing fields that used the old markdown-lite syntax (**bold**/*italic*) to real HTML so they keep rendering correctly under the new system. Also replaces the single long scrolling admin page with a tabbed sidebar (grouped "Inhalte"/"Design") to cut down on clutter, per feedback that the all-in-one-page layout was hard to navigate. --- DEPLOYMENT_ADMIN.md | 7 +- admin/lib/fonts.js | 44 ++++--- admin/lib/rich-sanitize.js | 36 ++++++ admin/lib/sanitize-content.js | 58 +++++++++ admin/lib/schema.js | 7 +- admin/package-lock.json | 234 +++++++++++++++++++++++++++++++++- admin/package.json | 3 +- admin/public/app.js | 213 +++++++++++++++++++++++++++++-- admin/public/index.html | 7 +- admin/public/style.css | 119 +++++++++++++++-- admin/server.js | 13 +- content/site.json | 6 +- package-lock.json | 219 ++++++++++++++++++++++++++++++- package.json | 6 +- src/lib/content.ts | 46 ++++--- src/lib/fonts.ts | 15 ++- src/lib/rich-sanitize.ts | 42 ++++++ src/pages/datenschutz.astro | 4 +- src/pages/index.astro | 22 ++-- 19 files changed, 1005 insertions(+), 96 deletions(-) create mode 100644 admin/lib/rich-sanitize.js create mode 100644 admin/lib/sanitize-content.js create mode 100644 src/lib/rich-sanitize.ts diff --git a/DEPLOYMENT_ADMIN.md b/DEPLOYMENT_ADMIN.md index e9f3392..6035411 100644 --- a/DEPLOYMENT_ADMIN.md +++ b/DEPLOYMENT_ADMIN.md @@ -16,13 +16,18 @@ Zeitpunkt Zugriff auf Benutzername/Passwort/Secrets oder den Server selbst. ## Was der Editor kann - **Texte**: alle Überschriften, Absätze, Listen, FAQ, Preis-Punkte, Mappe-Einträge usw. - der Startseite – inklusive Impressum und Datenschutzerklärung. + der Startseite – inklusive Impressum und Datenschutzerklärung. Längere Texte haben einen + Rich-Text-Editor mit Knöpfen für Fett/Kursiv/Unterstrichen sowie eine Schriftart-Auswahl, + mit der einzelne markierte Wörter/Sätze eine eigene Schriftart bekommen können (aus der + gleichen kuratierten Liste wie im Design-Tab) – unabhängig von den zwei Grundschriften. - **Bilder**: Logo, Mappe-Screenshots, Team-Foto, plus optional eigene Bilder anstelle der eingebauten Google-Vorschau und des PDF/Tabellen-Vergleichs. - **Links**: Menüpunkte, E-Mail-Adresse, Kontakt-Angaben. - **Design**: Schriftart für Überschriften/Fließtext (Auswahl aus gängigen Google Fonts oder Systemschrift), Schriftgrößen (7 Stufen, per Schieberegler), und *alle* Farben der Seite einzeln über Hex-Feld, RGB-Schieberegler und Transparenz-Regler. +- **Übersicht**: die Oberfläche ist in Reiter/Tabs gegliedert (linke Seitenleiste, gruppiert + in „Inhalte" und „Design") statt einer langen durchgehenden Seite. --- diff --git a/admin/lib/fonts.js b/admin/lib/fonts.js index 8da041a..ca8838b 100644 --- a/admin/lib/fonts.js +++ b/admin/lib/fonts.js @@ -1,25 +1,27 @@ /** - * Curated font list for the "Design" tab's font pickers. Kept in sync by - * hand with src/lib/fonts.ts (duplicated because the Astro site is ESM/TS - * and this backend is CommonJS) — only the `name` values matter here, the - * astro site owns the fallback stacks and Google Fonts URL building. + * Curated font list for the "Design" tab's font pickers and the rich-text + * editor's per-selection font picker. Kept in sync by hand with + * src/lib/fonts.ts (duplicated because the Astro site is ESM/TS and this + * backend is CommonJS). */ -const FONT_NAMES = [ - 'Playfair Display', - 'Lora', - 'Merriweather', - 'Libre Baskerville', - 'DM Serif Display', - 'Georgia (Systemschrift)', - 'Inter', - 'Roboto', - 'Open Sans', - 'Poppins', - 'Montserrat', - 'Nunito', - 'Work Sans', - 'Source Sans 3', - 'System UI (Systemschrift)', +const FONT_OPTIONS = [ + { name: 'Playfair Display', fallback: "Georgia, 'Times New Roman', serif" }, + { name: 'Lora', fallback: "Georgia, 'Times New Roman', serif" }, + { name: 'Merriweather', fallback: "Georgia, 'Times New Roman', serif" }, + { name: 'Libre Baskerville', fallback: "Georgia, 'Times New Roman', serif" }, + { name: 'DM Serif Display', fallback: "Georgia, 'Times New Roman', serif" }, + { name: 'Georgia (Systemschrift)', fallback: "Georgia, 'Times New Roman', serif" }, + { name: 'Inter', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" }, + { name: 'Roboto', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" }, + { name: 'Open Sans', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" }, + { name: 'Poppins', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" }, + { name: 'Montserrat', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" }, + { name: 'Nunito', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" }, + { name: 'Work Sans', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" }, + { name: 'Source Sans 3', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" }, + { name: 'System UI (Systemschrift)', fallback: "system-ui, -apple-system, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" }, ]; -module.exports = { FONT_NAMES }; +const FONT_NAMES = FONT_OPTIONS.map((f) => f.name); + +module.exports = { FONT_OPTIONS, FONT_NAMES }; diff --git a/admin/lib/rich-sanitize.js b/admin/lib/rich-sanitize.js new file mode 100644 index 0000000..85d9d79 --- /dev/null +++ b/admin/lib/rich-sanitize.js @@ -0,0 +1,36 @@ +const sanitizeHtml = require('sanitize-html'); +const { FONT_NAMES } = require('./fonts'); + +const OPTIONS = { + // b/i are included because execCommand('bold'/'italic') emits those in + // some browsers; transformTags normalizes them to the semantic tags. + allowedTags: ['strong', 'em', 'u', 'span', 'br', 'b', 'i'], + transformTags: { + b: 'strong', + i: 'em', + }, + allowedAttributes: { + span: ['data-font'], + }, + // Reject any span whose data-font isn't one of our curated fonts, instead + // of silently passing through an attacker-controlled string. + exclusiveFilter: (frame) => { + if (frame.tag === 'span') { + const font = frame.attribs && frame.attribs['data-font']; + return !font || !FONT_NAMES.includes(font); + } + return false; + }, + disallowedTagsMode: 'discard', +}; + +/** Sanitizes rich-text HTML from the admin editor down to a small allowlist + * (bold/italic/underline + font-picker spans) before it's stored or rendered + * on the public site. Anything else - scripts, styles, links, attributes - + * is stripped, not escaped, so plain text content is preserved. */ +function sanitizeRichText(html) { + if (typeof html !== 'string') return ''; + return sanitizeHtml(html, OPTIONS).trim(); +} + +module.exports = { sanitizeRichText }; diff --git a/admin/lib/sanitize-content.js b/admin/lib/sanitize-content.js new file mode 100644 index 0000000..8ccc5fb --- /dev/null +++ b/admin/lib/sanitize-content.js @@ -0,0 +1,58 @@ +const schema = require('./schema'); +const { sanitizeRichText } = require('./rich-sanitize'); + +function isPlainObject(v) { + return typeof v === 'object' && v !== null && !Array.isArray(v); +} + +function sanitizeItemFields(item, itemFields) { + if (!isPlainObject(item)) return item; + for (const field of itemFields) { + sanitizeField(item, field); + } + return item; +} + +function sanitizeField(obj, field) { + const value = obj[field.key]; + switch (field.type) { + case 'richtext': + if (typeof value === 'string') obj[field.key] = sanitizeRichText(value); + break; + case 'list-richtext': + if (Array.isArray(value)) obj[field.key] = value.map((v) => (typeof v === 'string' ? sanitizeRichText(v) : v)); + break; + case 'list-object': + if (Array.isArray(value)) value.forEach((item) => sanitizeItemFields(item, field.itemFields)); + break; + case 'group': + if (isPlainObject(value)) sanitizeItemFields(value, field.itemFields); + break; + default: + break; + } +} + +/** Returns a deep copy of a content.json payload with every richtext / + * list-richtext field run through the HTML allowlist sanitizer. Content + * fields never trust client-submitted HTML directly - this is the + * authoritative sanitization pass before anything is written to disk. */ +function sanitizeContentPayload(payload) { + const copy = JSON.parse(JSON.stringify(payload)); + const contentSections = schema.filter((s) => (s.file || 'content') === 'content'); + + for (const section of contentSections) { + if (section.type === 'array-root') { + const arr = copy[section.key]; + if (Array.isArray(arr)) arr.forEach((item) => sanitizeItemFields(item, section.itemFields)); + } else if (isPlainObject(copy[section.key])) { + for (const field of section.fields) { + sanitizeField(copy[section.key], field); + } + } + } + + return copy; +} + +module.exports = { sanitizeContentPayload }; diff --git a/admin/lib/schema.js b/admin/lib/schema.js index f3d9cca..9bbc061 100644 --- a/admin/lib/schema.js +++ b/admin/lib/schema.js @@ -6,7 +6,10 @@ const { FONT_NAMES } = require('./fonts'); * * Field types: text, textarea, richtext, email, boolean, image, * list-string, list-richtext, list-object, group, select, size, color. - * "richtext" fields support a tiny **bold** / *italic* subset (see src/lib/content.ts). + * "richtext"/"list-richtext" fields are edited with a WYSIWYG toolbar + * (bold/italic/underline + per-selection font) and stored as a small + * allowlisted HTML subset - see admin/lib/rich-sanitize.js for what's + * allowed and src/lib/content.ts for how it's rendered on the site. * "group" is a single nested object (not repeatable) described by itemFields. * "color" fields store { r, g, b, a }. "size" fields store a CSS length (e.g. "1.25rem"). */ @@ -285,7 +288,7 @@ module.exports = [ { key: 'text', label: 'Text (leer = Platzhalter; {email} wird durch die Kontakt-E-Mail ersetzt)', - type: 'textarea', + type: 'richtext', }, ], }, diff --git a/admin/package-lock.json b/admin/package-lock.json index 76c2a37..2b61951 100644 --- a/admin/package-lock.json +++ b/admin/package-lock.json @@ -12,7 +12,8 @@ "cookie": "^0.7.0", "dotenv": "^16.4.5", "express": "^4.19.2", - "multer": "^2.0.1" + "multer": "^2.0.1", + "sanitize-html": "^2.17.7" } }, "node_modules/accepts": { @@ -155,6 +156,12 @@ "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", "license": "MIT" }, + "node_modules/dayjs": { + "version": "1.11.23", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.23.tgz", + "integrity": "sha512-QDTCU0M0MxR3hQfnlDJfwekQiaanm1ubOD231u73WBckQ/fsamwRLiE2GBz6D3a/xF1NgfiDLJjXBa1hYOYTtQ==", + "license": "MIT" + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", @@ -164,6 +171,15 @@ "ms": "2.0.0" } }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -183,6 +199,73 @@ "npm": "1.2.8000 || >= 1.4.16" } }, + "node_modules/dom-serializer": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.1.1.tgz", + "integrity": "sha512-4MEa38/QexBob6gFNwu+EGdWvhJ1OKuNwdYY3Y3NyeWDQfnGeDYQUDfIRzWu5B5gsv03so2Uxd28YC6zrsx3Lw==", + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "16.6.1", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", @@ -224,6 +307,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.1.0.tgz", + "integrity": "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -260,6 +355,18 @@ "integrity": "sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==", "license": "MIT" }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, "node_modules/etag": { "version": "1.8.1", "resolved": "https://registry.npmjs.org/etag/-/etag-1.8.1.tgz", @@ -433,6 +540,28 @@ "node": ">= 0.4" } }, + "node_modules/htmlparser2": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz", + "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "domutils": "^4.0.2", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -480,6 +609,24 @@ "node": ">= 0.10" } }, + "node_modules/is-plain-object": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.1.0.tgz", + "integrity": "sha512-bUi/yjmtKYcRVUtWRGr0UA6xEFh2I6zWUwMrUXB3s7bmYCaZ8a+0ZsTRkrawh/mzlSD1Y0Ph8bp/U+TvBpWDNw==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/launder": { + "version": "1.7.1", + "resolved": "https://registry.npmjs.org/launder/-/launder-1.7.1.tgz", + "integrity": "sha512-mU6WRz5EusL9ZZuiZ5SO4Y6C0P9PAUR9iwdb6bzj4KDihm28DiHFw+/yk9DBH4f+Pv1wuzQ4e2jV3oQ7mkIqvw==", + "license": "MIT", + "dependencies": { + "dayjs": "^1.11.7" + } + }, "node_modules/math-intrinsics": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", @@ -573,6 +720,24 @@ "url": "https://opencollective.com/express" } }, + "node_modules/nanoid": { + "version": "3.3.19", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.19.tgz", + "integrity": "sha512-Y2tUNy4ouw6tq5oDSKeQYGOyhkUBhNOcGV/02KC+6kd9eDGqdZd++mjMiIDilrBYvjEnCYvVtsuHCuP+okSfug==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, "node_modules/negotiator": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", @@ -606,6 +771,12 @@ "node": ">= 0.8" } }, + "node_modules/parse-srcset": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/parse-srcset/-/parse-srcset-1.0.2.tgz", + "integrity": "sha512-/2qh0lav6CmI15FzA3i/2Bzk2zCgQhGMkvhOhKNcBVQ1ldgpbfiNTVslmooUmWJcADi1f1kIeynbDRVzNlfR6Q==", + "license": "MIT" + }, "node_modules/parseurl": { "version": "1.3.3", "resolved": "https://registry.npmjs.org/parseurl/-/parseurl-1.3.3.tgz", @@ -621,6 +792,40 @@ "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", "license": "MIT" }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.28", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.28.tgz", + "integrity": "sha512-RRuzqDtt5Y9h3quz5hWhK+TPnsmVs6WwSU6LkJMeY4HstUEDuYTG8UJSdawMRzmzAtV+KEoG8N3Qg2qLy5vM/A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.18", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, "node_modules/proxy-addr": { "version": "2.0.8", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.8.tgz", @@ -704,6 +909,24 @@ "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", "license": "MIT" }, + "node_modules/sanitize-html": { + "version": "2.17.7", + "resolved": "https://registry.npmjs.org/sanitize-html/-/sanitize-html-2.17.7.tgz", + "integrity": "sha512-PGtEkc9cbnedU3s9TmzDbpsZ8w086g/0Q8k8/oIO1NLNU3i5k9yn835CrjJSajp1KMmkisbO1qPXxNKO3welAg==", + "license": "MIT", + "dependencies": { + "deepmerge": "^4.2.2", + "escape-string-regexp": "^4.0.0", + "htmlparser2": "^12.0.0", + "is-plain-object": "^5.0.0", + "launder": "^1.7.1", + "parse-srcset": "^1.0.2", + "postcss": "^8.3.11" + }, + "engines": { + "node": ">=22.12.0" + } + }, "node_modules/send": { "version": "0.19.2", "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", @@ -827,6 +1050,15 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", diff --git a/admin/package.json b/admin/package.json index f91a24e..c83e9c7 100644 --- a/admin/package.json +++ b/admin/package.json @@ -12,6 +12,7 @@ "cookie": "^0.7.0", "dotenv": "^16.4.5", "express": "^4.19.2", - "multer": "^2.0.1" + "multer": "^2.0.1", + "sanitize-html": "^2.17.7" } } diff --git a/admin/public/app.js b/admin/public/app.js index fde5508..b96d974 100644 --- a/admin/public/app.js +++ b/admin/public/app.js @@ -2,7 +2,9 @@ 'use strict'; let schema = []; + let fonts = []; let state = { content: null, theme: null }; + let activeSectionKey = null; const statusEl = document.getElementById('status'); const sectionsEl = document.getElementById('sections'); const navEl = document.getElementById('section-nav'); @@ -48,10 +50,27 @@ return data; } + function injectFontPreviewStyles() { + const rules = fonts + .map((f) => `[data-font="${f.name}"] { font-family: '${f.name}', ${f.fallback}; }`) + .join('\n'); + const style = el('style', {}, [rules]); + document.head.appendChild(style); + + if (fonts.some((f) => f.name !== 'System UI (Systemschrift)' && f.name !== 'Georgia (Systemschrift)')) { + const families = fonts + .filter((f) => !f.name.includes('Systemschrift')) + .map((f) => `family=${encodeURIComponent(f.name)}:wght@400;500;600;700`) + .join('&'); + const link = el('link', { rel: 'stylesheet', href: `https://fonts.googleapis.com/css2?${families}&display=swap` }); + document.head.appendChild(link); + } + } + // --- Field renderers --- function textField(field, getValue, setValue) { - const isArea = field.type === 'textarea' || field.type === 'richtext'; + const isArea = field.type === 'textarea'; const input = isArea ? el('textarea', { rows: 4, oninput: (e) => setValue(e.target.value) }) : el('input', { @@ -59,11 +78,7 @@ oninput: (e) => setValue(e.target.value), }); input.value = getValue() || ''; - return el('div', { class: 'field' }, [ - el('label', {}, [field.label]), - input, - field.type === 'richtext' ? el('p', { class: 'field-hint' }, ['**fett**, *kursiv* möglich']) : null, - ]); + return el('div', { class: 'field' }, [el('label', {}, [field.label]), input]); } function selectField(field, getValue, setValue) { @@ -270,6 +285,140 @@ ]); } + // --- Rich text (WYSIWYG) --- + + function richTextEditor(getValue, setValue) { + const editable = el('div', { class: 'richtext-editable', contenteditable: 'true' }); + editable.innerHTML = getValue() || ''; + + function commit() { + setValue(editable.innerHTML); + } + + editable.addEventListener('input', commit); + editable.addEventListener('blur', commit); + + // Force plain-text paste so what you see while editing matches what + // gets saved (formatting from external sources isn't in our allowlist + // and would otherwise be silently stripped again on save). + editable.addEventListener('paste', (e) => { + e.preventDefault(); + const text = (e.clipboardData || window.clipboardData).getData('text/plain'); + document.execCommand('insertText', false, text); + }); + + function runCommand(cmd) { + editable.focus(); + document.execCommand(cmd, false, null); + commit(); + } + + function applyFont(fontName) { + editable.focus(); + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0 || sel.isCollapsed) { + setStatus('Bitte zuerst Text markieren.', 'error'); + return; + } + const range = sel.getRangeAt(0); + if (!editable.contains(range.commonAncestorContainer)) return; + const span = document.createElement('span'); + span.setAttribute('data-font', fontName); + try { + range.surroundContents(span); + } catch (err) { + const contents = range.extractContents(); + span.appendChild(contents); + range.insertNode(span); + } + sel.removeAllRanges(); + commit(); + } + + function removeFontFromSelection() { + editable.focus(); + const sel = window.getSelection(); + if (!sel || sel.rangeCount === 0) return; + const range = sel.getRangeAt(0); + if (!editable.contains(range.commonAncestorContainer)) return; + editable.querySelectorAll('span[data-font]').forEach((span) => { + if (range.intersectsNode(span)) { + const parent = span.parentNode; + while (span.firstChild) parent.insertBefore(span.firstChild, span); + parent.removeChild(span); + } + }); + commit(); + } + + const boldBtn = el('button', { type: 'button', class: 'rt-btn', title: 'Fett', onclick: () => runCommand('bold') }, ['B']); + const italicBtn = el('button', { type: 'button', class: 'rt-btn rt-italic', title: 'Kursiv', onclick: () => runCommand('italic') }, ['I']); + const underlineBtn = el('button', { type: 'button', class: 'rt-btn rt-underline', title: 'Unterstrichen', onclick: () => runCommand('underline') }, ['U']); + + const fontSelect = el('select', { + class: 'rt-font-select', + onchange: (e) => { + if (e.target.value === '__remove__') removeFontFromSelection(); + else if (e.target.value) applyFont(e.target.value); + e.target.value = ''; + }, + }, [ + el('option', { value: '' }, ['Schriftart …']), + el('option', { value: '__remove__' }, ['— Schriftart entfernen —']), + ...fonts.map((f) => el('option', { value: f.name }, [f.name])), + ]); + + const toolbar = el('div', { class: 'richtext-toolbar' }, [boldBtn, italicBtn, underlineBtn, fontSelect]); + + return el('div', {}, [toolbar, editable]); + } + + function richTextField(field, getValue, setValue) { + return el('div', { class: 'field' }, [el('label', {}, [field.label]), richTextEditor(getValue, setValue)]); + } + + function renderRichTextList(field, getObj) { + const wrap = el('div', { class: 'field' }, [el('label', {}, [field.label])]); + const listEl = el('div', {}, []); + + function draw() { + listEl.innerHTML = ''; + const arr = getObj()[field.key]; + arr.forEach((_, i) => { + const editorEl = richTextEditor( + () => arr[i], + (v) => { + arr[i] = v; + } + ); + const removeBtn = el('button', { + type: 'button', + class: 'remove-btn', + onclick: () => { + arr.splice(i, 1); + draw(); + }, + }, ['Entfernen']); + listEl.appendChild(el('div', { class: 'list-item-card' }, [editorEl, removeBtn])); + }); + } + + draw(); + + const addBtn = el('button', { + type: 'button', + class: 'add-btn', + onclick: () => { + getObj()[field.key].push(''); + draw(); + }, + }, ['+ hinzufügen']); + + wrap.appendChild(listEl); + wrap.appendChild(addBtn); + return wrap; + } + function renderFieldGroup(fields, getObj) { const container = el('div', {}, []); fields.forEach((field) => { @@ -287,6 +436,8 @@ container.appendChild(sizeField(field, getValue, setValue)); } else if (field.type === 'color') { container.appendChild(colorField(field, getValue, setValue)); + } else if (field.type === 'richtext') { + container.appendChild(richTextField(field, getValue, setValue)); } else if (field.type === 'group') { container.appendChild( el('fieldset', { class: 'group-field' }, [ @@ -294,8 +445,10 @@ renderFieldGroup(field.itemFields, () => getObj()[field.key]), ]) ); - } else if (field.type === 'list-string' || field.type === 'list-richtext') { + } else if (field.type === 'list-string') { container.appendChild(renderStringList(field, getObj)); + } else if (field.type === 'list-richtext') { + container.appendChild(renderRichTextList(field, getObj)); } else if (field.type === 'list-object') { container.appendChild(renderObjectList(field, getObj)); } else { @@ -313,9 +466,7 @@ listEl.innerHTML = ''; const arr = getObj()[field.key]; arr.forEach((_, i) => { - const input = field.type === 'list-richtext' - ? el('textarea', { rows: 2 }) - : el('input', { type: 'text' }); + const input = el('input', { type: 'text' }); input.value = arr[i]; input.addEventListener('input', (e) => { arr[i] = e.target.value; @@ -403,7 +554,7 @@ function renderSection(section) { const id = 'section-' + section.key; const fileKind = section.file || 'content'; - const block = el('section', { class: 'section-block', id }, [el('h2', {}, [section.label])]); + const block = el('section', { class: 'section-block', id, hidden: true }, [el('h2', {}, [section.label])]); if (section.type === 'array-root') { const listField = { key: '__root__', itemLabel: section.itemLabel, itemFields: section.itemFields, label: '' }; @@ -418,24 +569,60 @@ return block; } + function showSection(key) { + activeSectionKey = key; + sectionsEl.querySelectorAll('.section-block').forEach((elm) => { + elm.hidden = elm.id !== 'section-' + key; + }); + navEl.querySelectorAll('button').forEach((btn) => { + btn.classList.toggle('active', btn.dataset.key === key); + }); + try { + localStorage.setItem('larpaso-admin-active-section', key); + } catch (err) { + /* ignore - not essential */ + } + } + function renderAll() { sectionsEl.innerHTML = ''; navEl.innerHTML = ''; + + let lastGroup = null; schema.forEach((section) => { + const group = section.file === 'theme' ? 'Design' : 'Inhalte'; + if (group !== lastGroup) { + navEl.appendChild(el('div', { class: 'nav-group-label' }, [group])); + lastGroup = group; + } + navEl.appendChild( + el('button', { type: 'button', 'data-key': section.key, onclick: () => showSection(section.key) }, [section.label]) + ); sectionsEl.appendChild(renderSection(section)); - navEl.appendChild(el('a', { href: '#section-' + section.key }, [section.label])); }); + + let initial = schema[0] && schema[0].key; + try { + const saved = localStorage.getItem('larpaso-admin-active-section'); + if (saved && schema.some((s) => s.key === saved)) initial = saved; + } catch (err) { + /* ignore - not essential */ + } + if (initial) showSection(initial); } async function load() { setStatus('Lädt …'); - const [fetchedSchema, content, theme] = await Promise.all([ + const [fetchedSchema, content, theme, fetchedFonts] = await Promise.all([ api('/admin/api/schema'), api('/admin/api/content'), api('/admin/api/theme'), + api('/admin/api/fonts'), ]); schema = fetchedSchema; + fonts = fetchedFonts; state = { content, theme }; + injectFontPreviewStyles(); renderAll(); setStatus('Bereit.'); } diff --git a/admin/public/index.html b/admin/public/index.html index de52fb1..4a46ec2 100644 --- a/admin/public/index.html +++ b/admin/public/index.html @@ -18,9 +18,10 @@ - - -
+
+ +
+