diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 9f09d03..8c0845e 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -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.“ diff --git a/docs/STATUS.md b/docs/STATUS.md index dc6c401..b290dad 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -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 (``, 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. diff --git a/public/assets/site.css b/public/assets/site.css index bafdcf4..c6b2b14 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -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))} diff --git a/src/Content/PostLayouts.php b/src/Content/PostLayouts.php new file mode 100644 index 0000000..d42cd37 --- /dev/null +++ b/src/Content/PostLayouts.php @@ -0,0 +1,255 @@ + */ + 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]; + } +} diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index ffd05e0..f6ddc61 100644 --- a/src/Controller/EditorController.php +++ b/src/Controller/EditorController.php @@ -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()); } diff --git a/templates/admin/pages.html.twig b/templates/admin/pages.html.twig index 0a59db9..0e90d2c 100644 --- a/templates/admin/pages.html.twig +++ b/templates/admin/pages.html.twig @@ -29,6 +29,13 @@

Beiträge sind für Blogartikel gedacht und haben keine Unterseiten.

+
+ + +

{% for key, layout in postLayouts %}{{ layout.name }}: {{ layout.description }}{% if not loop.last %}
{% endif %}{% endfor %} Jeder Block lässt sich danach ändern.

+