Drei Aufbauten fuer neue Beitraege
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:
1 parent
1b22265a54
commit
84e88c6636
7 files changed
+331
-5
No files matched your search
@@ -440,6 +440,20 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam,
|
||||
|
||||
**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides und ist für einen Cronjob gedacht.
|
||||
|
||||
## ADR 050 – Drei Aufbauten für einen Beitrag (13.09.2026)
|
||||
|
||||
Umut: „ich hätte gerne mehrere Designs zur Auswahl, 2–3 Stück für Beiträge.“
|
||||
|
||||
**Warum das bei Beiträgen sinnvoll ist und bei Seiten nicht:** bei einer Seite weiß vorher niemand, was daraus wird – ein leeres Dokument ist dort die ehrliche Voreinstellung. Ein Beitrag hat dagegen fast immer dieselbe Form: Kopf, Text, Bilder, 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:** *Bildkopf* (Bild über die volle Breite, Titel darauf – für Projekte), *Klassisch* (Titel und Vorspann schmal, Bild darunter – für lange Texte), *Magazin* (Titel neben den Eckdaten – wenn Kunde, Leistung und Jahr zählen). Gewählt wird beim Anlegen, im selben Formular wie Titel und Adresse.
|
||||
|
||||
**Es sind Startpunkte, keine Schablonen.** Jeder Block lässt sich danach verschieben, ändern und löschen wie jeder andere. Festgelegt wird nur die erste Entscheidung – und die ist die, die am schwersten nachzuholen ist.
|
||||
|
||||
**Kein Aufbau verlässt sich auf ein Bild, das es noch nicht gibt.** Wo ein Beitragsbild hingehört, steht ein Platzhalter; der dunkle Kopf ohne Bild sieht schlicht aus statt kaputt. Ein Aufbau, der erst nach dem ersten Upload funktioniert, wäre beim Anlegen wertlos.
|
||||
|
||||
**Und die Regel, die dabei gefallen ist:** der erste Textblock eines Abschnitts wurde automatisch zur Kleinbeschriftung in Akzentfarbe. Das gestaltet nach **Position statt nach Absicht** – im Fußbereich wurde daraus roter Versalientext für den Firmennamen, in der Magazin-Vorlage aus dem Einstiegsabsatz eine Kleinbeschriftung. Beides sah aus wie ein Fehler, weil es einer war. Wer eine Beschriftung will, stellt sie ein; dafür gibt es den Designbereich. Die Demoseite verliert dadurch ihre Versalienzeilen und wirkt ruhiger – dasselbe Ergebnis wie beim Rahmen der Spalten.
|
||||
|
||||
## ADR 049 – Die Beitragsseite, und warum das kein Plugin wurde (Dokumentschema v18, 13.09.2026)
|
||||
|
||||
Umut: „Beitragsseite gefällt mir nicht, sollte wie bei kant-it.de sein oder besser. Falls nötig Plugin bauen.“
|
||||
|
||||
@@ -28,6 +28,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- **Ladeverhalten gemessen statt vermutet** (ADR 039, Arbeitspaket M): ein Zähler liefert die Datenbankabfragen je Anfrage im Kopf `X-Kant-Queries` (außerhalb der Produktion). Die erste Messung zeigte 18 Abfragen auf der Startseite, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden; beide merken sich ihren Stand jetzt für die Dauer einer Anfrage – **18 → 8**. Die Browserprüfung misst je Seite HTML, CSS, Anfragen, Skripte und Abfragen gegen Obergrenzen: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 0 Skripte.
|
||||
- **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz.
|
||||
- **Slider mit Punkten ohne Skript** (ADR 044): `::scroll-marker` zeichnet je Folie einen Punkt, der aktive ist erkennbar, die Rollleiste entfällt; ältere Browser behalten das wischbare Schiebefeld. Beitragskarten sind echte Karten (Bild 16:10, 18 px Ecken). Überschriften dürfen ein Wort hervorheben (`<mark>`, in Akzentfarbe). Behoben: die Theme-Schriftskala überschrieb die Größen in Karten und Aufruf-Bändern.
|
||||
- **Drei Aufbauten für neue Beiträge** (ADR 050): „Bildkopf“, „Klassisch“ und „Magazin“, wählbar beim Anlegen. Startpunkte, keine Schablonen – jeder Block bleibt änderbar.
|
||||
- **Dokumentschema v18** (ADR 049): Baustein „Beitragsnavigation“ – Vor/Zurück zwischen Beiträgen derselben Kategorie, vom Renderer gefüllt, ohne Umbruch am Rand. Dazu Autorotation für den Slider (Sekunden, eigenes Skript nur auf Seiten mit einem solchen Slider) und Blättern in der Beitragsliste.
|
||||
- **Dokumentschema v17** (ADR 043): eine Beitragsliste kann Datum, Auszug und Kategorie ausblenden und damit als Projektraster erscheinen; ein Bild kann verlinken (Logo auf die Startseite). Dazu die Einstellung „Kopfbereich beim Scrollen oben halten“. Behoben: im Editor ließ sich in Dokumenten ab v13 kein Baustein mehr einfügen.
|
||||
- **Die Gestaltung färbt jetzt auch die Bausteine** (ADR 042): Symbole, Knöpfe, Rahmen, ruhiger Text, Fokusring und Einwilligungsdialog nehmen die Farben des Themes statt fester Werte aus `site.css`. Der Einwilligungsdialog lässt sich zusätzlich eigens färben über eine Palettenfarbe mit dem Schlüssel `consent`. Überschriften werden nicht mehr getrennt.
|
||||
|
||||
@@ -6,7 +6,7 @@ h1,h2,h3,h4,h5,h6{hyphens:manual}
|
||||
p{hyphens:auto}
|
||||
.visually-hidden{position:absolute;width:1px;height:1px;margin:-1px;padding:0;overflow:hidden;clip-path:inset(50%);white-space:nowrap;border:0}.block-image{margin:0;max-width:100%}.block-image img{max-width:100%;height:auto}
|
||||
:root{color-scheme:light;--paper:#f7f8f4;--ink:#182d2b;--muted:#50625d;--line:#d7dfd7;--mint:#e5f0b6;--accent:#315b49;font-family:Arial,Helvetica,sans-serif;color:var(--ink);background:var(--paper);font-synthesis:none}
|
||||
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid var(--accent);outline-offset:6px}main:focus{outline:none}.shell{width:min(var(--k-container,1180px),calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.site-header.is-sticky{position:sticky;top:0;z-index:40;background:var(--paper);box-shadow:0 1px 0 var(--line),0 10px 30px color-mix(in srgb,var(--ink) 8%,transparent)}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:underline}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}.section-inner>.block-text:first-child{font-size:12px;font-weight:700;letter-spacing:2px;text-transform:uppercase;color:color-mix(in srgb,var(--accent) 68%,currentColor);margin:0 0 30px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500;max-width:900px}h2{font-size:clamp(32px,4vw,49px);font-weight:500;max-width:760px}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;max-width:690px;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.block-column>.block-text:first-child{font-size:12px;font-weight:700;letter-spacing:1px;color:color-mix(in srgb,var(--accent) 68%,currentColor);margin-bottom:34px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .section-inner>.block-text:first-child{color:var(--mint)}.tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
|
||||
*{box-sizing:border-box}body{margin:0}a{color:inherit;text-underline-offset:5px}a:hover{text-decoration-thickness:2px}:focus-visible{outline:3px solid var(--accent);outline-offset:6px}main:focus{outline:none}.shell{width:min(var(--k-container,1180px),calc(100% - 64px));margin-inline:auto}.site-header{border-bottom:1px solid var(--line)}.site-header.is-sticky{position:sticky;top:0;z-index:40;background:var(--paper);box-shadow:0 1px 0 var(--line),0 10px 30px color-mix(in srgb,var(--ink) 8%,transparent)}.image-link{display:block;text-decoration:none}.brand{display:inline-flex;align-items:center;gap:5px;font-weight:800;font-size:28px;letter-spacing:-1.3px;text-decoration:none}.brand-icon{width:37px;height:37px;background:var(--ink);color:var(--mint);border-radius:10px;display:grid;place-items:center;margin-right:7px;font-size:27px;letter-spacing:-2px}.brand-light{font-weight:400}.site-header nav{display:flex;gap:32px;align-items:center;font-size:14px;font-weight:600}.site-header nav a{text-decoration:none;padding:12px 0}.site-header nav [aria-current=page]{text-decoration:underline}.site-header nav .nav-link{border:1px solid var(--ink);padding:13px 18px;border-radius:7px}.nav-link span{margin-left:18px}.block-section{padding:70px 0}.block-section:first-child{padding:74px 0 82px}h1,h2,h3{letter-spacing:-.05em;line-height:1.05;margin:0 0 24px}h1{font-size:clamp(48px,6.6vw,84px);font-weight:500;max-width:900px}h2{font-size:clamp(32px,4vw,49px);font-weight:500;max-width:760px}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;max-width:690px;margin:0 0 23px;color:color-mix(in srgb,currentColor 82%,transparent);white-space:pre-line}.button{display:inline-flex;align-items:center;gap:32px;min-height:50px;margin-top:9px;padding:15px 21px;background:var(--ink);color:var(--paper);border-radius:7px;text-decoration:none;font-size:14px;font-weight:600}.button:hover{background:color-mix(in srgb,var(--ink) 88%,var(--paper))}.block-row{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(250px,100%),1fr));gap:22px;margin-top:38px;background:transparent!important}.block-column{min-width:0}.block-column .block-text{font-size:15px}.tone-mint{background:var(--mint)}.tone-ink{background:var(--ink);color:var(--paper)}.tone-ink .block-text{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}.tone-ink .tone-ink .button{color:var(--ink);background:var(--mint)}.width-narrow>.section-inner{max-width:800px}.site-footer{border-top:1px solid var(--line)}.site-footer p,.site-footer>span{font-size:12px;color:var(--muted)}.site-footer .brand{font-size:22px}.skip-link{position:fixed;left:16px;top:-100px;z-index:10;background:var(--ink);color:white;padding:16px}.skip-link:focus{top:16px}img{display:block;max-width:100%;height:auto;border-radius:12px}.error-page{padding-block:100px}.error-page h1{max-width:800px}
|
||||
@media(max-width:650px){.shell{width:calc(100% - 36px)}.site-header nav{width:100%;justify-content:space-between;gap:12px;font-size:12px}.site-header nav .nav-link{padding:10px}.nav-link span{margin-left:6px}.block-section,.block-section:first-child{padding-block:48px}h1{font-size:49px}.block-text{font-size:16px}.site-footer{align-items:flex-start;flex-direction:column;gap:10px}.site-footer p{margin:0}.block-row{gap:16px}}
|
||||
|
||||
/* Menus. Submenus open with details/summary, so the navigation works without JavaScript. */
|
||||
@@ -108,7 +108,7 @@ p{hyphens:auto}
|
||||
unreadable - 1.87:1 in the seeded footer. These two selectors match that specificity and come
|
||||
later, so the dark tone wins where it has to. */
|
||||
.tone-ink .block-column .block-text,
|
||||
.tone-ink .block-column>.block-text:first-child{color:color-mix(in srgb,var(--paper) 86%,var(--ink))}
|
||||
.tone-ink
|
||||
|
||||
/* The consent question. Fixed to the bottom, but it never covers the whole page: the content
|
||||
stays readable while the decision is open, because a wall would make "accept" the only way
|
||||
@@ -159,8 +159,8 @@ body.consent-open{padding-bottom:260px}
|
||||
.site-footer .block-text{margin:0}
|
||||
/* Size and weight only, never colour: the tone of the area decides that, and a colour here won
|
||||
against .tone-ink and put 2.24:1 grey text on the dark footer. The browser check caught it. */
|
||||
.site-header .block-column>.block-text:first-child{font-size:24px;font-weight:800;letter-spacing:-1px;text-transform:none;margin:0}
|
||||
.site-footer .block-column>.block-text:first-child{font-size:14px;font-weight:400;letter-spacing:0;text-transform:none;margin:0}
|
||||
.site-header
|
||||
.site-footer
|
||||
/* One landmark for the three links that must be on every page. Loose text outside a landmark is
|
||||
also what axe objects to, and rightly: a screen reader then has no way to skip to it. */
|
||||
.legal-bar{padding:16px 20px;text-align:center;font-size:12px;color:var(--muted);background:color-mix(in srgb,var(--ink) 6%,var(--paper))}
|
||||
|
||||
@@ -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];
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use App\Content\DocumentValidator;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\PostLayouts;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\StyleCompiler;
|
||||
@@ -45,6 +46,7 @@ final class EditorController extends AbstractController
|
||||
'pages' => $pages,
|
||||
'trash' => $this->pages->trash(),
|
||||
'parents' => array_values(array_filter($pages, static fn (array $p): bool => 'page' === $p['type'] && $p['depth'] + 2 <= PageRepository::MAX_DEPTH)),
|
||||
'postLayouts' => PostLayouts::all(),
|
||||
// Which header and footer areas a page can choose from.
|
||||
'areaChoices' => ['header' => $this->partials->choices('header'), 'footer' => $this->partials->choices('footer')],
|
||||
]);
|
||||
@@ -65,7 +67,13 @@ final class EditorController extends AbstractController
|
||||
$parentId = $request->request->getString('parent_id') ?: null;
|
||||
$slug = trim($request->request->getString('slug')) ?: $this->slugify($title);
|
||||
try {
|
||||
$id = $this->pages->createPage($slug, $parentId, $type, DemoContent::starter($title, $type));
|
||||
// Ein Beitrag beginnt mit einem Aufbau, eine Seite leer: bei einer Seite weiß
|
||||
// niemand vorher, was daraus wird, bei einem Beitrag fast immer.
|
||||
$layout = $request->request->getString('layout', PostLayouts::DEFAULT);
|
||||
$document = 'post' === $type && PostLayouts::exists($layout)
|
||||
? PostLayouts::document($layout, $title)
|
||||
: DemoContent::starter($title, $type);
|
||||
$id = $this->pages->createPage($slug, $parentId, $type, $document);
|
||||
} catch (InvalidPage|InvalidDocument $e) {
|
||||
return $this->back($e->getMessage());
|
||||
}
|
||||
|
||||
@@ -29,6 +29,13 @@
|
||||
</select>
|
||||
<p class="field-help">Beiträge sind für Blogartikel gedacht und haben keine Unterseiten.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-layout">Aufbau (nur für Beiträge)</label>
|
||||
<select id="new-layout" name="layout">
|
||||
{% for key, layout in postLayouts %}<option value="{{ key }}"{{ key == 'bildkopf' ? ' selected' : '' }}>{{ layout.name }}</option>{% endfor %}
|
||||
</select>
|
||||
<p class="field-help">{% for key, layout in postLayouts %}<strong>{{ layout.name }}:</strong> {{ layout.description }}{% if not loop.last %} <br>{% endif %}{% endfor %} Jeder Block lässt sich danach ändern.</p>
|
||||
</div>
|
||||
<div>
|
||||
<label for="new-parent">Übergeordnete Seite</label>
|
||||
<select id="new-parent" name="parent_id">
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Content\MediaLibrary;
|
||||
use App\Content\MenuRepository;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\PostLayouts;
|
||||
use App\Content\PostRepository;
|
||||
use App\Content\RedirectRepository;
|
||||
use App\Content\RichText;
|
||||
@@ -717,6 +718,46 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString("'pointerdown', 'keydown', 'touchstart', 'wheel'", $skript);
|
||||
}
|
||||
|
||||
public function testANewPostStartsFromOneOfThreeLayouts(): void
|
||||
{
|
||||
// Umut wollte mehrere Aufbauten zur Auswahl. Ein leeres Dokument ist für eine Seite
|
||||
// richtig - dort weiß niemand vorher, was daraus wird -, für einen Beitrag fast nie.
|
||||
self::assertSame(['bildkopf', 'klassisch', 'magazin'], array_keys(PostLayouts::all()));
|
||||
|
||||
foreach (PostLayouts::all() as $key => $layout) {
|
||||
$document = PostLayouts::document($key, 'Ein Beitrag');
|
||||
// Jeder Aufbau ist ein gültiges Dokument mit genau einer Hauptüberschrift.
|
||||
(new DocumentValidator())->validate($document);
|
||||
self::assertSame(18, $document['schemaVersion'], $key);
|
||||
self::assertNotSame('', $layout['description'], $key);
|
||||
|
||||
$id = $this->pages->createPage('beitrag-'.$key, null, 'post', $document);
|
||||
$page = $this->pages->findForEditor($id);
|
||||
$this->pages->publish($id, $page['draft_revision_id'], $page['version']);
|
||||
$html = $this->request('/beitrag-'.$key)->getContent();
|
||||
self::assertStringContainsString('Ein Beitrag', $html, $key);
|
||||
// Jeder Aufbau endet mit einem Weg weiter, keiner im Nichts.
|
||||
self::assertStringContainsString('block-postnav', $html, $key);
|
||||
}
|
||||
|
||||
// Ein erfundener Aufbau fällt auf die Voreinstellung zurück statt zu scheitern.
|
||||
self::assertFalse(PostLayouts::exists('gibtsnicht'));
|
||||
self::assertSame(
|
||||
PostLayouts::document(PostLayouts::DEFAULT, 'X'),
|
||||
PostLayouts::document('gibtsnicht', 'X'),
|
||||
);
|
||||
}
|
||||
|
||||
public function testTheFirstTextOfASectionIsNoLongerSilentlyTurnedIntoALabel(): void
|
||||
{
|
||||
// Diese Regel gestaltete nach Position statt nach Absicht: im Fußbereich wurde aus dem
|
||||
// Firmennamen roter Versalientext, in einer Beitragsvorlage aus dem Einstiegsabsatz eine
|
||||
// Kleinbeschriftung. Wer eine Beschriftung will, stellt sie ein.
|
||||
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
|
||||
self::assertStringNotContainsString('.section-inner>.block-text:first-child', $css);
|
||||
self::assertStringNotContainsString('.block-column>.block-text:first-child', $css);
|
||||
}
|
||||
|
||||
public function testAPostCanPointToItsNeighboursWithoutStoringThem(): void
|
||||
{
|
||||
// Die Beitragsseite endete bisher im Nichts: gelesen, und dann? Der neue Baustein führt
|
||||
|
||||
Reference in new issue
Block a user