larpaso-website/admin/lib/schema.js
Pascal Scholz 7c67ae1ff5 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.
2026-09-21 23:11:19 +02:00

344 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

const { FONT_NAMES } = require('./fonts');
/**
* Describes the editable shape of content/site.json (file: 'content', the
* default) and content/theme.json (file: 'theme') for the admin UI.
*
* Field types: text, textarea, richtext, email, boolean, image,
* list-string, list-richtext, list-object, group, select, size, color.
* "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").
*/
module.exports = [
{
key: 'meta',
label: 'SEO / Meta',
fields: [{ key: 'description', label: 'Meta-Beschreibung', type: 'textarea' }],
},
{
key: 'nav',
label: 'Navigation',
fields: [
{ key: 'logo', label: 'Logo', type: 'image' },
{ key: 'logoAlt', label: 'Logo Alt-Text', type: 'text' },
{ key: 'ctaLabel', label: 'Button-Text', type: 'text' },
{
key: 'links',
label: 'Menüpunkte',
type: 'list-object',
itemLabel: 'Menüpunkt',
itemFields: [
{ key: 'label', label: 'Text', type: 'text' },
{ key: 'anchor', label: 'Sprungziel (Abschnitt-ID, ohne #)', type: 'text' },
],
},
],
},
{
key: 'hero',
label: 'Hero (ganz oben)',
fields: [
{ key: 'eyebrow', label: 'Kicker-Zeile', type: 'text' },
{ key: 'titleBefore', label: 'Titel – Teil 1', type: 'text' },
{ key: 'titleEmphasis', label: 'Titel – hervorgehobenes Wort', type: 'text' },
{ key: 'titleAfter', label: 'Titel – Teil 2', type: 'text' },
{ key: 'lead', label: 'Einleitungstext', type: 'richtext' },
{ key: 'ctaLabel', label: 'Button-Text', type: 'text' },
{
key: 'visual',
label: 'Google-Vorschau (rechts neben dem Hero-Text)',
type: 'group',
itemFields: [
{
key: 'customImage',
label: 'Eigenes Bild statt der Vorschau (leer = eingebaute Vorschau anzeigen)',
type: 'image',
},
{ key: 'label', label: 'Beschriftung oben', type: 'text' },
{ key: 'todayTag', label: 'Karte 1 – Tag-Label', type: 'text' },
{ key: 'todayUrl', label: 'Karte 1 – URL-Zeile', type: 'text' },
{ key: 'todayTitle', label: 'Karte 1 – Titel', type: 'text' },
{ key: 'todayDesc', label: 'Karte 1 – Beschreibung', type: 'textarea' },
{ key: 'dividerText', label: 'Trenntext zwischen den Karten', type: 'text' },
{ key: 'possibleTag', label: 'Karte 2 – Tag-Label', type: 'text' },
{ key: 'possibleUrl', label: 'Karte 2 – URL-Zeile', type: 'text' },
{ key: 'possibleTitle', label: 'Karte 2 – Titel', type: 'text' },
{ key: 'possibleDesc', label: 'Karte 2 – Beschreibung', type: 'richtext' },
{ key: 'possibleMeta', label: 'Karte 2 – Bewertungszeile', type: 'text' },
],
},
],
},
{
key: 'warum',
label: 'Abschnitt 02 – Warum uns',
fields: [
{ key: 'eyebrow', label: 'Kicker-Zeile', type: 'text' },
{ key: 'title', label: 'Überschrift', type: 'text' },
{ key: 'lead', label: 'Text', type: 'richtext' },
{
key: 'readability',
label: 'PDF-vs-Tabelle-Vergleich',
type: 'group',
itemFields: [
{
key: 'customImage',
label: 'Eigenes Bild statt des Vergleichs (leer = eingebauten Vergleich anzeigen)',
type: 'image',
},
{ key: 'title', label: 'Überschrift', type: 'text' },
{ key: 'lead', label: 'Text', type: 'richtext' },
{ key: 'beforeLabel', label: 'Label links ("vorher")', type: 'text' },
{ key: 'pdfName', label: 'Dateiname im PDF-Beispiel', type: 'text' },
{ key: 'pdfMeta', label: 'Zusatzzeile im PDF-Beispiel', type: 'text' },
{ key: 'afterLabel', label: 'Label rechts ("nachher")', type: 'text' },
{ key: 'hoursTitle', label: 'Titel der Tabelle', type: 'text' },
{
key: 'hoursRows',
label: 'Tabellenzeilen',
type: 'list-object',
itemLabel: 'Zeile',
itemFields: [
{ key: 'day', label: 'Tag', type: 'text' },
{ key: 'time', label: 'Uhrzeit', type: 'text' },
],
},
],
},
],
},
{
key: 'preis',
label: 'Abschnitt 01 – Preis',
fields: [
{ key: 'eyebrow', label: 'Kicker-Zeile', type: 'text' },
{ key: 'title', label: 'Überschrift', type: 'text' },
{ key: 'amount', label: 'Preis', type: 'text' },
{ key: 'amountNote', label: 'Preis-Zusatz', type: 'text' },
{ key: 'lead', label: 'Einleitungstext', type: 'richtext' },
{ key: 'slotsNote', label: 'Plätze-Hinweis', type: 'text' },
{
key: 'features',
label: 'Leistungspunkte',
type: 'list-object',
itemLabel: 'Punkt',
itemFields: [
{ key: 'text', label: 'Text', type: 'text' },
{ key: 'excluded', label: 'Als "nicht enthalten" markieren', type: 'boolean' },
],
},
{ key: 'asideTitle', label: 'Kasten-Überschrift', type: 'text' },
{ key: 'asideParagraph1', label: 'Kasten-Absatz 1', type: 'richtext' },
{ key: 'asideParagraph2', label: 'Kasten-Absatz 2', type: 'richtext' },
{ key: 'ctaLabel', label: 'Button-Text', type: 'text' },
],
},
{
key: 'highlights',
label: 'Abschnitt 02 – Die drei Punkte',
type: 'array-root',
itemLabel: 'Punkt',
itemFields: [
{ key: 'num', label: 'Kennung (z. B. I, II, III)', type: 'text' },
{ key: 'title', label: 'Titel', type: 'text' },
{ key: 'text', label: 'Text', type: 'textarea' },
],
},
{
key: 'leistungen',
label: 'Abschnitt 03 – Leistungen',
fields: [
{ key: 'eyebrow', label: 'Kicker-Zeile', type: 'text' },
{ key: 'title', label: 'Überschrift', type: 'text' },
{ key: 'lead', label: 'Text', type: 'richtext' },
{
key: 'items',
label: 'Leistungen',
type: 'list-object',
itemLabel: 'Leistung',
itemFields: [
{ key: 'num', label: 'Nummer', type: 'text' },
{ key: 'title', label: 'Titel', type: 'text' },
{ key: 'text', label: 'Text', type: 'textarea' },
],
},
{ key: 'nichtMachenTitle', label: '"Was wir nicht machen" – Überschrift', type: 'text' },
{ key: 'nichtMachen', label: '"Was wir nicht machen" – Punkte', type: 'list-string' },
],
},
{
key: 'mappe',
label: 'Abschnitt 04 – Mappe',
fields: [
{ key: 'eyebrow', label: 'Kicker-Zeile', type: 'text' },
{ key: 'title', label: 'Überschrift', type: 'text' },
{
key: 'items',
label: 'Arbeiten',
type: 'list-object',
itemLabel: 'Arbeit',
itemFields: [
{ key: 'image', label: 'Bildschirmfoto', type: 'image' },
{ key: 'kategorie', label: 'Kategorie', type: 'text' },
{ key: 'titel', label: 'Titel / Name', type: 'text' },
{ key: 'text', label: 'Text', type: 'textarea' },
{ key: 'nameHidden', label: 'Name noch verstecken (Platzhalter zeigen)', type: 'boolean' },
],
},
],
},
{
key: 'ablauf',
label: 'Abschnitt 05 – Ablauf',
fields: [
{ key: 'eyebrow', label: 'Kicker-Zeile', type: 'text' },
{ key: 'title', label: 'Überschrift', type: 'text' },
{
key: 'steps',
label: 'Schritte',
type: 'list-object',
itemLabel: 'Schritt',
itemFields: [
{ key: 'num', label: 'Nummer', type: 'text' },
{ key: 'title', label: 'Titel', type: 'text' },
{ key: 'text', label: 'Text', type: 'textarea' },
{ key: 'dauer', label: 'Dauer (leer = Platzhalter)', type: 'text' },
],
},
],
},
{
key: 'about',
label: 'Abschnitt 06 – Über uns',
fields: [
{ key: 'eyebrow', label: 'Kicker-Zeile', type: 'text' },
{ key: 'title', label: 'Überschrift', type: 'text' },
{ key: 'quote', label: 'Zitat', type: 'text' },
{ key: 'paragraphs', label: 'Absätze', type: 'list-richtext' },
{ key: 'photo', label: 'Foto', type: 'image' },
{ key: 'laraBio', label: 'Lara – Kurzbio (leer = Platzhalter)', type: 'textarea' },
{ key: 'pascalBio', label: 'Pascal – Kurzbio (leer = Platzhalter)', type: 'textarea' },
],
},
{
key: 'faq',
label: 'Abschnitt 07 – FAQ',
fields: [
{ key: 'eyebrow', label: 'Kicker-Zeile', type: 'text' },
{ key: 'title', label: 'Überschrift', type: 'text' },
{
key: 'items',
label: 'Fragen',
type: 'list-object',
itemLabel: 'Frage',
itemFields: [
{ key: 'question', label: 'Frage', type: 'text' },
{ key: 'answer', label: 'Antwort (leer = Platzhalter)', type: 'textarea' },
],
},
],
},
{
key: 'kontakt',
label: 'Abschnitt 08 – Kontakt',
fields: [
{ key: 'eyebrow', label: 'Kicker-Zeile', type: 'text' },
{ key: 'title', label: 'Überschrift', type: 'text' },
{ key: 'lead', label: 'Einleitungstext', type: 'richtext' },
{ key: 'phone', label: 'Telefonnummer (leer = Platzhalter)', type: 'text' },
{ key: 'hours', label: 'Erreichbarkeit (leer = Platzhalter)', type: 'text' },
{ key: 'email', label: 'E-Mail-Adresse', type: 'email' },
{ key: 'howWeWork', label: '"Wie wir arbeiten" (leer = Platzhalter)', type: 'textarea' },
],
},
{
key: 'footer',
label: 'Footer',
fields: [
{ key: 'laraNachname', label: 'Lara – Nachname (leer = Platzhalter)', type: 'text' },
{ key: 'pascalNachname', label: 'Pascal – Nachname (leer = Platzhalter)', type: 'text' },
],
},
{
key: 'impressum',
label: 'Impressum',
fields: [
{ key: 'anbieterAngaben', label: 'Name/Firma + Anschrift (leer = Platzhalter)', type: 'textarea' },
{ key: 'ustId', label: 'Umsatzsteuer-ID (leer = Platzhalter)', type: 'textarea' },
{ key: 'verantwortlich', label: 'Verantwortlich für den Inhalt (leer = Platzhalter)', type: 'textarea' },
{ key: 'note', label: 'Hinweistext am Seitenende', type: 'textarea' },
],
},
{
key: 'datenschutz',
label: 'Datenschutz',
fields: [
{ key: 'intro', label: 'Einleitung (leer = Platzhalter)', type: 'textarea' },
{
key: 'sections',
label: 'Abschnitte',
type: 'list-object',
itemLabel: 'Abschnitt',
itemFields: [
{ key: 'heading', label: 'Überschrift', type: 'text' },
{
key: 'text',
label: 'Text (leer = Platzhalter; {email} wird durch die Kontakt-E-Mail ersetzt)',
type: 'richtext',
},
],
},
{ key: 'note', label: 'Hinweistext am Seitenende', type: 'textarea' },
],
},
{
key: 'fonts',
file: 'theme',
label: 'Design – Schriftarten',
fields: [
{ key: 'heading', label: 'Überschriften-Schrift', type: 'select', options: FONT_NAMES },
{ key: 'body', label: 'Fließtext-Schrift', type: 'select', options: FONT_NAMES },
],
},
{
key: 'fontSizes',
file: 'theme',
label: 'Design – Schriftgrößen',
fields: [
{ key: 'sm', label: 'Klein (Kicker-Zeilen, Feinschrift)', type: 'size', min: 0.6, max: 1.5, step: 0.025 },
{ key: 'base', label: 'Standard (Fließtext)', type: 'size', min: 0.75, max: 1.5, step: 0.025 },
{ key: 'md', label: 'Mittel (Einleitungstexte)', type: 'size', min: 0.9, max: 1.75, step: 0.025 },
{ key: 'lg', label: 'Groß (H3)', type: 'size', min: 1, max: 2.5, step: 0.025 },
{ key: 'xl', label: 'Größer (H2 mobil)', type: 'size', min: 1.2, max: 3, step: 0.025 },
{ key: '2xl', label: 'Sehr groß (H2)', type: 'size', min: 1.5, max: 4, step: 0.025 },
{ key: '3xl', label: 'Riesig (H1)', type: 'size', min: 1.75, max: 5, step: 0.025 },
],
},
{
key: 'colors',
file: 'theme',
label: 'Design – Farben',
fields: [
{ key: 'bg', label: 'Hintergrund', type: 'color' },
{ key: 'bgAlt', label: 'Hintergrund (Variante)', type: 'color' },
{ key: 'surface', label: 'Flächen / Karten', type: 'color' },
{ key: 'text', label: 'Text', type: 'color' },
{ key: 'textMuted', label: 'Text (gedämpft)', type: 'color' },
{ key: 'border', label: 'Rahmen', type: 'color' },
{ key: 'primary', label: 'Primärfarbe (Buttons, Links)', type: 'color' },
{ key: 'primaryDark', label: 'Primärfarbe (Hover)', type: 'color' },
{ key: 'primaryContrast', label: 'Text auf Primärfarbe', type: 'color' },
{ key: 'accent', label: 'Akzentfarbe', type: 'color' },
{ key: 'accentDark', label: 'Akzentfarbe (dunkel)', type: 'color' },
{ key: 'success', label: 'Erfolg', type: 'color' },
{ key: 'danger', label: 'Fehler/Warnung', type: 'color' },
{ key: 'flag', label: 'Platzhalter-Markierung', type: 'color' },
{ key: 'flagBg', label: 'Platzhalter-Markierung (Hintergrund)', type: 'color' },
{ key: 'googleBlue', label: 'Linkfarbe in der Google-Vorschau', type: 'color' },
],
},
];