Migration 20260912000700 legt form und form_submission an. Der Formularblock im Seiteninhalt enthält nur die Adresse: Felder, Empfänger und Einwilligungstext liegen in der Datenbank, damit ein gespeichertes Dokument nicht ändern kann, wonach gefragt wird oder wohin eine Anfrage geht. Serverseitige Validierung mit einer Meldung je Feld über aria-describedby und aria-invalid; eine Ablehnung rendert die Seite mit HTTP 422 und allen Eingaben neu, eine gültige Absendung wird mit HTTP 303 zurückgeleitet. Alles ohne JavaScript. Spamschutz ohne Dienst Dritter: Honeypot, signierter Zeitstempel gegen zu schnelle und zu alte Absendungen, fünf Absendungen je Absender und Stunde. Ein erkannter Bot bekommt dieselbe Antwort wie ein Erfolg. Die Absenderadresse wird nie gespeichert, nur ein Hash. Gespeichert wird vor dem Versand, damit ein kaputter Mailversand keine Anfrage verliert. Kein CSRF-Token auf dem öffentlichen Weg, weil das eine Sitzung und damit ein Cookie auf jeder öffentlichen Seite bedeuten würde; Begründung in ADR 017. Dateiuploads fehlen bewusst. Ein echter Fehler von den Tests gefunden: das Stundenfenster des Limits entstand aus lokaler Zeit, die Zeitstempel aber aus UTC - auf einer Uhr vor UTC hätte das Limit nie gegriffen. Geprüft: 117 PHP-Tests / 450 Assertions unter PHP 8.2.12 und 8.4.22, 14 JavaScript-Modelltests, Lint/Format/Container, composer audit. Der E-Mail-Versand wurde nie wirklich ausgeführt; keine Browser- oder Lighthouse-Läufe. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
224 lines
12 KiB
JavaScript
224 lines
12 KiB
JavaScript
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular' };
|
|
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉' };
|
|
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
|
|
// document, stay selectable and movable, but the editor never edits or fills them.
|
|
export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/;
|
|
export const isPluginBlock = block => typeof block?.type === 'string' && PLUGIN_TYPE.test(block.type);
|
|
export const blockLabel = block => labels[block?.type] ?? (isPluginBlock(block) ? `Baustein ${block.type}` : 'Unbekannt');
|
|
export const blockIcon = block => icons[block?.type] ?? '❔';
|
|
export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem'].includes(block?.type);
|
|
// Nothing may be dropped into a plugin block: the core does not know its layout contract.
|
|
// Containers that accept only one kind of child, mirrored by the server side validator.
|
|
export const ONLY_CHILD = { gallery: 'image', logos: 'image', accordion: 'accordionitem', tabs: 'tabitem' };
|
|
export const canContain = (parent, block) => isContainer(parent) && !isPluginBlock(parent)
|
|
&& (ONLY_CHILD[parent.type] === undefined || ONLY_CHILD[parent.type] === block.type)
|
|
&& (block.type !== 'accordionitem' || parent.type === 'accordion')
|
|
&& (block.type !== 'tabitem' || parent.type === 'tabs');
|
|
export const clone = value => structuredClone(value);
|
|
|
|
export function locate(blocks, id, parent = null) {
|
|
for (let index = 0; index < blocks.length; index++) {
|
|
const block = blocks[index];
|
|
if (block.id === id) return { block, siblings: blocks, index, parent };
|
|
const child = locate(block.children || [], id, block);
|
|
if (child) return child;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
export function createBlock(type) {
|
|
if (!Object.hasOwn(labels, type)) throw new Error('Unbekannter Block.');
|
|
const block = { id: `b_${crypto.randomUUID().replaceAll('-', '')}`, type };
|
|
if (isContainer(block)) block.children = [];
|
|
if (type === 'accordion') block.children = [createBlock('accordionitem')];
|
|
// A single tab is not a tab group, and the script needs at least two to enhance anything.
|
|
if (type === 'tabs') block.children = [createBlock('tabitem'), createBlock('tabitem')];
|
|
if (type === 'heading') block.props = { text: 'Deine neue Überschrift', level: 2 };
|
|
if (type === 'text') block.props = { text: 'Hier beginnt dein neuer Inhalt.' };
|
|
if (type === 'button') block.props = { text: 'Mehr erfahren', href: '/' };
|
|
if (type === 'postlist') block.props = { count: 3, category: '', tag: '' };
|
|
if (type === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' };
|
|
if (type === 'stat') block.props = { value: '15+', label: 'Jahre Erfahrung' };
|
|
if (type === 'quote') block.props = { text: 'Ein Satz, den eine Kundin oder ein Kunde über dich gesagt hat.', author: 'Name', role: '', rating: 5 };
|
|
if (type === 'cta') block.props = { title: 'Bereit für den nächsten Schritt?', text: '', buttonText: 'Jetzt anfragen', href: '/' };
|
|
if (type === 'contact') block.props = { phone: '', email: '', address: '' };
|
|
if (type === 'spacer') block.props = { size: 'medium' };
|
|
if (type === 'accordionitem') block.props = { title: 'Eine Frage', open: false };
|
|
if (type === 'tabitem') block.props = { title: 'Neuer Tab' };
|
|
if (type === 'form') block.props = { form: '' };
|
|
if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 };
|
|
return block;
|
|
}
|
|
|
|
export function insertBlock(document, type, selected) {
|
|
const block = createBlock(type);
|
|
if (type === 'gallery') upgradeDocument(document, 3);
|
|
if (type === 'postlist') upgradeDocument(document, 5);
|
|
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
|
|
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
|
|
if (type === 'form') upgradeDocument(document, 8);
|
|
let target = locate(document.blocks, selected);
|
|
if (target?.block.type === 'gallery' && type !== 'image') { target.siblings.splice(target.index + 1, 0, block); return block.id; }
|
|
if (target?.parent?.type === 'gallery' && type !== 'image') target = locate(document.blocks, target.parent.id);
|
|
if (!target) document.blocks.push(block);
|
|
else if (canContain(target.block, block)) (target.block.children ??= []).push(block);
|
|
else target.siblings.splice(target.index + 1, 0, block);
|
|
return block.id;
|
|
}
|
|
|
|
export function duplicateBlock(document, selected) {
|
|
const target = locate(document.blocks, selected);
|
|
if (!target) return null;
|
|
const copy = clone(target.block);
|
|
const rekey = block => {
|
|
block.id = `b_${crypto.randomUUID().replaceAll('-', '')}`;
|
|
if (block.type === 'heading' && block.props.level === 1) block.props.level = 2;
|
|
(block.children || []).forEach(rekey);
|
|
};
|
|
rekey(copy);
|
|
target.siblings.splice(target.index + 1, 0, copy);
|
|
return copy.id;
|
|
}
|
|
|
|
export function moveBlock(document, selected, targetId, inside = false) {
|
|
const source = locate(document.blocks, selected);
|
|
const target = locate(document.blocks, targetId);
|
|
if (!source || !target || selected === targetId || locate(source.block.children || [], targetId)) return false;
|
|
if (inside && !isContainer(target.block)) return false;
|
|
if (inside && !canContain(target.block, source.block)) return false;
|
|
if (!inside && target.parent && !canContain(target.parent, source.block)) return false;
|
|
source.siblings.splice(source.index, 1);
|
|
const destination = locate(document.blocks, targetId);
|
|
if (inside) (destination.block.children ??= []).push(source.block);
|
|
else destination.siblings.splice(destination.index, 0, source.block);
|
|
return true;
|
|
}
|
|
|
|
export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' };
|
|
|
|
// Called only inside a history transaction: all other fields and IDs stay untouched.
|
|
export function upgradeDocument(document, version = 2) {
|
|
if (![1, 2, 3, 4, 5, 6, 7, 8].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
|
|
document.schemaVersion = Math.max(document.schemaVersion, version);
|
|
return document;
|
|
}
|
|
|
|
export function effectiveDesign(block, device, key) {
|
|
let value, source;
|
|
for (const name of Object.keys(devices)) {
|
|
if (block.layout?.design?.[name]?.[key] !== undefined) {
|
|
value = block.layout.design[name][key]; source = name;
|
|
}
|
|
if (name === device) break;
|
|
}
|
|
return { value, source };
|
|
}
|
|
|
|
export function setDesign(document, id, device, key, value) {
|
|
if (![1, 2, 3, 4, 5, 6, 7, 8].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
|
|
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
|
|
const block = locate(document.blocks, id)?.block;
|
|
if (!block) return;
|
|
if (value === '') {
|
|
const design = block.layout?.design;
|
|
if (!design?.[device] || !Object.hasOwn(design[device], key)) return;
|
|
delete design[device][key];
|
|
if (!Object.keys(design[device]).length) delete design[device];
|
|
if (!Object.keys(design).length) delete block.layout.design;
|
|
} else {
|
|
upgradeDocument(document);
|
|
((block.layout ??= {}).design ??= {})[device] ??= {};
|
|
block.layout.design[device][key] = value;
|
|
}
|
|
}
|
|
|
|
export function blockPath(blocks, id) {
|
|
const found = locate(blocks, id);
|
|
return found ? [...(found.parent ? blockPath(blocks, found.parent.id) : []), found.block] : [];
|
|
}
|
|
|
|
export function setEffect(document, id, key, value) {
|
|
const block = locate(document.blocks, id)?.block;
|
|
if (!block) return;
|
|
upgradeDocument(document, 3);
|
|
((block.layout ??= {}).effects ??= {})[key] = value;
|
|
}
|
|
|
|
export function mediaProps(media) {
|
|
const largest = media.variants.at(-1);
|
|
return { mediaId: media.id, src: largest.src, width: largest.width, height: largest.height, alt: media.decorative ? '' : media.alt, decorative: Boolean(media.decorative), caption: '', sources: media.variants.map(({ src, width }) => ({ src, width })) };
|
|
}
|
|
|
|
export function insertMedia(document, selected, items) {
|
|
const target = locate(document.blocks, selected)?.block;
|
|
upgradeDocument(document, 3);
|
|
if (target?.type === 'image') { target.props = mediaProps(items[0]); return target.id; }
|
|
let parent = target;
|
|
if (target?.type !== 'gallery') {
|
|
const id = insertBlock(document, 'gallery', selected); parent = locate(document.blocks, id).block;
|
|
}
|
|
for (const item of items) { const image = createBlock('image'); image.props = mediaProps(item); (parent.children ??= []).push(image); }
|
|
return parent.id;
|
|
}
|
|
|
|
export function insertPreset(document, name, selected) {
|
|
if (!['intro', 'columns', 'cta'].includes(name)) throw new Error('Unbekannte Vorlage.');
|
|
const section = createBlock('section');
|
|
const heading = text => { const block = createBlock('heading'); block.props.text = text; return block; };
|
|
const paragraph = text => { const block = createBlock('text'); block.props.text = text; return block; };
|
|
section.layout = { tone: name === 'cta' ? 'ink' : 'paper', width: name === 'columns' ? 'normal' : 'narrow' };
|
|
section.children = [heading(name === 'cta' ? 'Lass uns gemeinsam starten.' : name === 'columns' ? 'Was wir für dich tun.' : 'Raum für deine nächste Idee.'), paragraph('Erzähle hier, was dein Angebot besonders macht und welchen Nutzen deine Besucher davon haben.')];
|
|
if (name === 'columns') {
|
|
const row = createBlock('row');
|
|
row.layout = { design: { desktop: { columns: 'two', gap: 'medium' }, mobile: { columns: 'one' } } };
|
|
row.children = ['Persönlich beraten', 'Gemeinsam umsetzen'].map(text => {
|
|
const column = createBlock('column');
|
|
const title = heading(text); title.props.level = 3;
|
|
column.children = [title, paragraph('Beschreibe dein Angebot in wenigen verständlichen Sätzen.')];
|
|
return column;
|
|
});
|
|
section.children.push(row);
|
|
upgradeDocument(document);
|
|
} else section.children.push(createBlock('button'));
|
|
const top = blockPath(document.blocks, selected)[0];
|
|
const index = top ? document.blocks.findIndex(block => block.id === top.id) + 1 : document.blocks.length;
|
|
document.blocks.splice(index, 0, section);
|
|
return section.id;
|
|
}
|
|
|
|
export class EditorHistory {
|
|
constructor(document) { this.reset(document); }
|
|
reset(document) {
|
|
this.document = clone(document);
|
|
this.past = [];
|
|
this.future = [];
|
|
this.mergeKey = null;
|
|
this.saved = JSON.stringify(document);
|
|
}
|
|
change(mutator, mergeKey = null) {
|
|
const next = clone(this.document);
|
|
const result = mutator(next);
|
|
if (JSON.stringify(next) === JSON.stringify(this.document)) return result;
|
|
if (!mergeKey || this.mergeKey !== mergeKey) this.past.push(this.document);
|
|
if (this.past.length > 100) this.past.shift();
|
|
this.document = next;
|
|
this.future = [];
|
|
this.mergeKey = mergeKey;
|
|
return result;
|
|
}
|
|
undo() {
|
|
if (!this.past.length) return;
|
|
this.future.push(this.document);
|
|
this.document = this.past.pop();
|
|
this.mergeKey = null;
|
|
}
|
|
redo() {
|
|
if (!this.future.length) return;
|
|
this.past.push(this.document);
|
|
this.document = this.future.pop();
|
|
this.mergeKey = null;
|
|
}
|
|
markSaved(document) { this.saved = JSON.stringify(document); this.mergeKey = null; }
|
|
get dirty() { return JSON.stringify(this.document) !== this.saved; }
|
|
}
|