Drei Aufbauten fuer neue Beitraege
CMS quality / php (8.2) (push) Canceled after 0s
CMS quality / php (8.4) (push) Canceled after 0s
CMS quality / browser (push) Canceled after 0s
CMS quality / mysql (push) Canceled after 0s

Umut wollte mehrere Designs zur Auswahl. Bei Seiten waere das falsch -
dort weiss vorher niemand, was daraus wird. Bei Beitraegen ist die Form
fast immer dieselbe, und wer jedes Mal von vorn anfaengt, baut jedes Mal
etwas leicht anderes.

Bildkopf (Bild ueber die volle Breite, Titel darauf), Klassisch (Titel und
Vorspann schmal, Bild darunter) und Magazin (Titel neben den Eckdaten).
Gewaehlt wird beim Anlegen; danach ist jeder Block aenderbar wie jeder
andere. Kein Aufbau verlaesst sich auf ein Bild, das es noch nicht gibt.

Dabei ist eine Regel gefallen: der erste Textblock eines Abschnitts wurde
automatisch zur Kleinbeschriftung in Akzentfarbe. Das gestaltet nach
Position statt nach Absicht - im Fussbereich wurde daraus roter
Versalientext, in der Magazin-Vorlage aus dem Einstiegsabsatz eine
Beschriftung. Wer eine Beschriftung will, stellt sie ein.

Geprueft: 398 Tests unter PHP 8.2 und 8.4, Editortest, Browserpruefung,
axe ohne Verstoss auf allen drei Aufbauten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
kantarciandClaude Opus 5 committed 2026-09-13 11:12:16 +02:00
1 parent 1b22265a54
commit 84e88c6636
7 files changed
+331 -5

No files matched your search

+255
View File
@@ -0,0 +1,255 @@
<?php
namespace App\Content;
/**
* Fertige Aufbauten für einen neuen Beitrag.
*
* Ein leeres Dokument ist für eine Seite richtig - dort weiß niemand vorher, was daraus wird. Ein
* Beitrag dagegen hat fast immer dieselbe Form: ein Kopf, ein Text, Bilder, und am Ende ein Weg
* weiter. Wer jedes Mal von vorn anfängt, baut jedes Mal etwas leicht anderes, und nach zwanzig
* Beiträgen sieht ein Archiv aus wie zwanzig Websites.
*
* Die drei Aufbauten hier sind Startpunkte, keine Schablonen: jeder Block lässt sich danach
* verschieben, ändern und löschen wie jeder andere Block auch. Was sie festlegen, ist die erste
* Entscheidung - und die ist die, die am schwersten nachzuholen ist.
*/
final class PostLayouts
{
public const DEFAULT = 'bildkopf';
/** @return array<string, array{name: string, description: string}> */
public static function all(): array
{
return [
'bildkopf' => [
'name' => 'Bildkopf',
'description' => 'Großes Beitragsbild über die volle Breite, Titel darauf. Für Projekte und alles, was von einem Bild lebt.',
],
'klassisch' => [
'name' => 'Klassisch',
'description' => 'Titel und Vorspann in einer schmalen Spalte, Bild darunter. Ruhig zu lesen, gut für lange Texte.',
],
'magazin' => [
'name' => 'Magazin',
'description' => 'Titel neben den Eckdaten, danach Text und Galerie. Für Beiträge, bei denen Kunde, Leistung und Jahr zählen.',
],
];
}
public static function exists(string $key): bool
{
return isset(self::all()[$key]);
}
/** Das vollständige Dokument eines neuen Beitrags in der gewählten Form. */
public static function document(string $key, string $title): array
{
$document = [
'schemaVersion' => 18,
'title' => $title,
'description' => 'Ein neuer Beitrag. Ersetze diese Beschreibung durch eine kurze Zusammenfassung – sie erscheint in Suchergebnissen und auf der Beitragskarte.',
'blocks' => match ($key) {
'klassisch' => self::klassisch($title),
'magazin' => self::magazin($title),
default => self::bildkopf($title),
},
];
return $document;
}
/**
* Bildkopf: das Beitragsbild füllt den Kopf, der Titel liegt darauf.
*
* Das Bild ist beim Anlegen noch keines - es kommt ein Platzhalter, und im Designbereich wird
* daraus mit zwei Klicks das Beitragsbild. Ein dunkler Kopf ohne Bild sieht dabei nicht kaputt
* aus, sondern schlicht, und das ist der Punkt: kein Aufbau darf auf ein Bild angewiesen sein,
* das noch niemand hochgeladen hat.
*/
private static function bildkopf(string $title): array
{
return [
self::section('kopf', [
self::badge('kopf-label', 'Beitrag'),
self::heading('kopf-titel', $title, 1, ['fontSize' => '46px', 'fontWeight' => '800', 'lineHeight' => 1.12, 'letterSpacing' => '-1px', 'maxWidth' => '900px', 'margin' => self::spacing('0px', '18px', 'auto', 'auto')]),
self::line('kopf-linie'),
], [
'backgroundColor' => '@ink', 'color' => '#ffffff', 'textAlign' => 'center',
'display' => 'flex', 'alignItems' => 'flex-end', 'justifyContent' => 'center',
'minHeight' => '58vh', 'padding' => self::spacing('110px', '52px', '0px', '0px'),
]),
self::narrow('text', [
self::heading('text-h', 'Über den Beitrag', 2, ['fontSize' => '28px', 'fontWeight' => '800', 'color' => '@ink']),
self::text('text-1', 'Schreibe hier den ersten Absatz. Er beantwortet am besten die Frage, warum jemand weiterlesen sollte.'),
self::text('text-2', 'Der zweite Absatz darf ins Detail gehen: was die Aufgabe war, wie der Weg aussah, was dabei herauskam.'),
]),
self::section('galerie', [
self::badge('galerie-label', 'Bildergalerie', true),
self::heading('galerie-h', 'Eindrücke', 2, ['fontSize' => '30px', 'fontWeight' => '800', 'color' => '@ink']),
self::gallery('galerie-bilder', 2),
], ['backgroundColor' => '@paper', 'padding' => self::spacing('60px', '60px', '0px', '0px')]),
self::section('weiter', [self::nav('weiter-nav')], ['padding' => self::spacing('50px', '50px', '0px', '0px')]),
self::section('aufruf', [self::cta('aufruf-band')], ['backgroundColor' => '@ink', 'color' => '#ffffff', 'padding' => self::spacing('62px', '62px', '0px', '0px')]),
];
}
/** Klassisch: Titel und Vorspann schmal, Bild darunter, dann der Text. */
private static function klassisch(string $title): array
{
return [
self::narrow('kopf', [
self::badge('kopf-label', 'Beitrag', true),
self::heading('kopf-titel', $title, 1, ['fontSize' => '42px', 'fontWeight' => '800', 'lineHeight' => 1.15, 'letterSpacing' => '-0.8px']),
self::text('kopf-vorspann', 'Ein Vorspann in zwei, drei Sätzen. Er steht für sich und sagt, worum es geht – wer nur ihn liest, weiß Bescheid.', ['fontSize' => '20px', 'lineHeight' => 1.6]),
], '70px', '30px'),
self::section('bild', [self::image('bild-1')], ['padding' => self::spacing('0px', '10px', '0px', '0px')]),
self::narrow('text', [
self::text('text-1', 'Der erste Absatz des Haupttextes. Ab hier darf es ausführlich werden.'),
self::heading('text-h', 'Ein Zwischentitel', 2, ['fontSize' => '26px', 'fontWeight' => '800', 'color' => '@ink', 'margin' => self::spacing('34px', '12px', '0px', '0px')]),
self::text('text-2', 'Zwischentitel machen einen langen Text überhaupt erst lesbar. Setze alle drei bis fünf Absätze einen.'),
]),
self::section('weiter', [self::nav('weiter-nav')], ['backgroundColor' => '@paper', 'padding' => self::spacing('50px', '50px', '0px', '0px')]),
];
}
/** Magazin: Titel links, Eckdaten rechts, danach Text und Galerie. */
private static function magazin(string $title): array
{
return [
self::section('kopf', [
['id' => 'kopf-zeile', 'type' => 'row', 'style' => ['desktop' => ['gridColumns' => 2, 'gap' => '40px', 'alignItems' => 'flex-end'], 'mobile' => ['gridColumns' => 1, 'gap' => '24px']], 'children' => [
['id' => 'kopf-links', 'type' => 'column', 'children' => [
self::badge('kopf-label', 'Beitrag', true),
self::heading('kopf-titel', $title, 1, ['fontSize' => '44px', 'fontWeight' => '800', 'lineHeight' => 1.12, 'letterSpacing' => '-1px']),
]],
['id' => 'kopf-rechts', 'type' => 'column', 'style' => ['desktop' => [
'backgroundColor' => '#ffffff', 'borderRadius' => self::corners('16px'),
'borderWidth' => self::spacing('1px', '1px', '1px', '1px'), 'borderStyle' => 'solid', 'borderColor' => '@line',
'padding' => self::spacing('22px', '22px', '24px', '24px'),
]], 'children' => [
self::badge('eck-label', 'Eckdaten', true),
self::text('eck-daten', "Kunde: noch einzutragen\nLeistung: noch einzutragen\nJahr: noch einzutragen", ['fontSize' => '15px']),
]],
]],
], ['backgroundColor' => '@paper', 'padding' => self::spacing('72px', '48px', '0px', '0px')]),
self::narrow('text', [
self::text('text-1', 'Der Einstieg in den Beitrag. Was war die Aufgabe, und warum war sie nicht trivial?'),
self::heading('text-h', 'Der Weg', 2, ['fontSize' => '26px', 'fontWeight' => '800', 'color' => '@ink', 'margin' => self::spacing('30px', '12px', '0px', '0px')]),
self::text('text-2', 'Was getan wurde, in der Reihenfolge, in der es getan wurde.'),
self::heading('text-h2', 'Das Ergebnis', 2, ['fontSize' => '26px', 'fontWeight' => '800', 'color' => '@ink', 'margin' => self::spacing('30px', '12px', '0px', '0px')]),
self::text('text-3', 'Was sich seitdem geändert hat – am besten ohne Superlative.'),
]),
self::section('galerie', [self::gallery('galerie-bilder', 3)], ['backgroundColor' => '@paper', 'padding' => self::spacing('56px', '56px', '0px', '0px')]),
self::section('weiter', [self::nav('weiter-nav')], ['padding' => self::spacing('50px', '50px', '0px', '0px')]),
];
}
// ---------------------------------------------------------------- Bausteine
private static function section(string $id, array $children, array $desktop = [], array $mobile = []): array
{
$block = ['id' => $id, 'type' => 'section', 'children' => $children];
if ([] !== $desktop || [] !== $mobile) {
$block['style'] = array_filter(['desktop' => $desktop, 'mobile' => $mobile]);
}
return $block;
}
/** Ein schmaler Abschnitt steht in der Mitte: maximale Breite ohne automatischen Rand ist eine halbe Einstellung. */
private static function narrow(string $id, array $children, string $top = '54px', string $bottom = '54px'): array
{
return self::section($id, $children, [
'maxWidth' => '820px',
'margin' => self::spacing('0px', '0px', 'auto', 'auto'),
'padding' => self::spacing($top, $bottom, '0px', '0px'),
]);
}
private static function heading(string $id, string $text, int $level, array $style = []): array
{
return array_filter([
'id' => $id, 'type' => 'heading', 'props' => ['text' => $text, 'level' => $level],
'style' => [] === $style ? null : ['desktop' => $style],
], static fn ($value) => null !== $value);
}
private static function text(string $id, string $text, array $style = []): array
{
return array_filter([
'id' => $id, 'type' => 'text', 'props' => ['text' => $text],
'style' => [] === $style ? null : ['desktop' => $style],
], static fn ($value) => null !== $value);
}
/** Die kleine Beschriftung über einer Überschrift - hell auf dunklem Kopf, sonst in der Akzentfarbe. */
private static function badge(string $id, string $label, bool $quiet = false): array
{
return $quiet
? self::text($id, $label, ['fontSize' => '12px', 'fontWeight' => '700', 'letterSpacing' => '1.6px', 'textTransform' => 'uppercase', 'color' => '@link', 'margin' => self::spacing('0px', '10px', '0px', '0px')])
: self::text($id, $label, [
'display' => 'inline-block', 'backgroundColor' => '@link', 'color' => '#ffffff',
'borderRadius' => self::corners('999px'), 'padding' => self::spacing('7px', '7px', '16px', '16px'),
'fontSize' => '13px', 'fontWeight' => '700', 'letterSpacing' => '1px', 'textTransform' => 'uppercase',
'margin' => self::spacing('0px', '18px', 'auto', 'auto'),
]);
}
private static function line(string $id): array
{
return ['id' => $id, 'type' => 'divider', 'style' => ['desktop' => [
'width' => '60px', 'height' => '4px', 'backgroundColor' => '@link',
'borderRadius' => self::corners('2px'), 'borderWidth' => self::spacing('0px', '0px', '0px', '0px'),
'margin' => self::spacing('0px', '0px', 'auto', 'auto'),
]]];
}
private static function image(string $id): array
{
return ['id' => $id, 'type' => 'image', 'props' => [
'src' => '/assets/image-placeholder.svg', 'alt' => '', 'decorative' => true,
'width' => 1200, 'height' => 675, 'caption' => '',
], 'style' => ['desktop' => ['imageRadius' => self::corners('18px')]]];
}
private static function gallery(string $id, int $count): array
{
$children = [];
for ($index = 1; $index <= $count; ++$index) {
$children[] = ['id' => $id.'-'.$index, 'type' => 'image', 'props' => [
'src' => '/assets/image-placeholder.svg', 'alt' => '', 'decorative' => true,
'width' => 1200, 'height' => 900,
]];
}
return ['id' => $id, 'type' => 'gallery', 'children' => $children, 'style' => ['desktop' => [
'gridColumns' => $count, 'gap' => '18px', 'margin' => self::spacing('24px', '0px', '0px', '0px'),
], 'mobile' => ['gridColumns' => 1]]];
}
private static function nav(string $id): array
{
return ['id' => $id, 'type' => 'postnav', 'props' => ['previousLabel' => 'Vorheriger Beitrag', 'nextLabel' => 'Nächster Beitrag']];
}
private static function cta(string $id): array
{
return ['id' => $id, 'type' => 'cta', 'props' => [
'title' => 'Noch Fragen?',
'text' => 'Schreib uns – wir antworten in der Regel am selben Werktag.',
'buttonText' => 'Kontakt aufnehmen',
'href' => '/#kontakt',
]];
}
private static function spacing(string $top, string $bottom, string $left, string $right): array
{
return ['top' => $top, 'bottom' => $bottom, 'left' => $left, 'right' => $right];
}
private static function corners(string $value): array
{
return ['topLeft' => $value, 'topRight' => $value, 'bottomRight' => $value, 'bottomLeft' => $value];
}
}