Vier Darstellungen fuer die Beitragsliste
Karten (Raster mit Bild oben), Liste (Bild links, Text rechts), Kompakt (nur Titel und Datum) und Wechselseitig (Bild mal links, mal rechts). Dieselben Daten, dasselbe Markup - nur eine Klasse wechselt. Die erste Fassung von "Wechselseitig" war ein umgedrehter Flexkasten und sah auch so aus. Jetzt sitzt das Bild in einer Karte mit Ecken, Schatten und festem Verhaeltnis, der Text hat eine Nummer aus einem CSS-Zaehler, eine begrenzte Zeilenlaenge und einen sichtbaren Verweis, und zwischen den Reihen liegt eine feine Linie. Zwei Details: die Karte ist das Listenelement, also zaehlt :nth-child auf ihr - die erste Fassung wechselte deshalb gar nicht. Und die Umkehrung laeuft ueber order statt row-reverse, damit auf dem Telefon sauber wieder Bild ueber Text steht. Geprueft: 399 Tests unter PHP 8.2 und 8.4, 18 JS-Tests, Editortest, Browserpruefung, axe ohne Verstoss auf allen vier Darstellungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
84e88c6636
commit
0d6c1a17af
11 files changed
+88
-6
No files matched your search
@@ -440,6 +440,21 @@ 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 051 – Vier Darstellungen für dieselbe Beitragsliste (13.09.2026)
|
||||
|
||||
Umut wollte auch für die Auflistung mehrere Formen. Dieselben Daten, dasselbe Markup – nur eine Klasse wechselt. Eine Darstellung kostet dadurch ein paar Regeln CSS und keine Zeile PHP.
|
||||
|
||||
- **Karten** – Raster mit Bild oben. Für eine Übersicht.
|
||||
- **Liste** – Bild links, Titel und Auszug rechts. Für ein Archiv.
|
||||
- **Kompakt** – nur Titel und Datum, durch Linien getrennt, ohne Bilder. Für eine Randspalte oder ein langes Verzeichnis.
|
||||
- **Wechselseitig** – großes Bild, Text daneben, und bei jedem zweiten Beitrag die Seiten getauscht.
|
||||
|
||||
**Die erste Fassung von „Wechselseitig" war ein umgedrehter Flexkasten und sah auch so aus.** Umut hat es zu Recht hässlich genannt. Was gefehlt hat, war nicht die Umkehrung, sondern alles andere: das Bild sitzt jetzt in einer Karte mit Ecken und weichem Schatten in festem Verhältnis 16:10, damit die Reihen gleich hoch sind; der Text bekommt eine Nummer aus einem CSS-Zähler, eine begrenzte Zeilenlänge und einen sichtbaren Verweis; zwischen den Reihen liegt eine feine Linie. Beim Zeigen zieht das Bild auf und der Titel nimmt die Akzentfarbe – die Reihe selbst bleibt stehen, denn sie ist keine Karte.
|
||||
|
||||
**Zwei Details, die der Bau gelehrt hat:** die Karte *ist* das Listenelement, also zählt `:nth-child` auf ihr und nicht eine Ebene tiefer – die erste Fassung wechselte deshalb gar nicht. Und die Umkehrung läuft über `order` der beiden Spalten statt über `row-reverse`, weil sich damit auf schmalen Bildschirmen sauber wieder Bild-über-Text ergibt.
|
||||
|
||||
**Die Nummer kommt aus einem Zähler, nicht aus dem Inhalt.** Auf Seite zwei beginnt sie wieder bei 01. Das ist ehrlich: der Beitrag weiß nicht, der wievielte er ist, und eine Nummer, die das behauptet, wäre erfunden.
|
||||
|
||||
## 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.“
|
||||
|
||||
@@ -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.
|
||||
- **Vier Darstellungen der Beitragsliste** (ADR 051): Karten, Liste, Kompakt und Wechselseitig – am Baustein einstellbar, dieselben Daten, nur andere Regeln.
|
||||
- **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.
|
||||
|
||||
@@ -36,7 +36,7 @@ export function createBlock(type) {
|
||||
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 === 'postlist') block.props = { count: 3, category: '', tag: '', layout: 'karten' };
|
||||
if (type === 'postnav') block.props = { previousLabel: 'Vorheriger Beitrag', nextLabel: 'Nächster Beitrag' };
|
||||
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' };
|
||||
|
||||
@@ -289,6 +289,7 @@ function renderProperties() {
|
||||
field('Anzahl Beiträge', 'count', block.props.count, { number: true, help: 'Zwischen 1 und 24. Die Beiträge selbst kommen beim Ausliefern dazu, nicht aus dem gespeicherten Inhalt.' });
|
||||
field('Nur Kategorie', 'category', block.props.category || '', { help: 'Adresse der Kategorie, zum Beispiel projekte. Leer lassen für alle Beiträge.' });
|
||||
field('Nur Schlagwort', 'tag', block.props.tag || '', { help: 'Adresse des Schlagworts. Leer lassen für alle Beiträge.' });
|
||||
field('Darstellung', 'layout', block.props.layout || 'karten', { choices: { karten: 'Karten (Raster mit Bild oben)', liste: 'Liste (Bild links, Text rechts)', kompakt: 'Kompakt (nur Titel und Datum)', wechsel: 'Wechselseitig (Bild mal links, mal rechts)' }, help: 'Dieselben Beiträge, drei Darstellungen. Karten für eine Übersicht, Liste für ein Archiv, Kompakt für eine Randspalte.' });
|
||||
field('Blättern statt abschneiden', 'paginate', block.props.paginate === true, { checkbox: true, help: 'Für eine Übersichtsseite. Eine Auswahl auf der Startseite lässt man aus.' });
|
||||
field('Datum und Autor zeigen', 'showMeta', block.props.showMeta !== false, { checkbox: true });
|
||||
field('Auszug zeigen', 'showExcerpt', block.props.showExcerpt !== false, { checkbox: true });
|
||||
|
||||
@@ -45,6 +45,12 @@
|
||||
bekommt den Hinweis trotzdem, nur ohne Fahrt. */
|
||||
@media (prefers-reduced-motion: no-preference) {
|
||||
.post-card { transition: transform .28s ease, box-shadow .28s ease, border-color .28s ease }
|
||||
/* Eine Zeile in der kompakten Liste hebt sich nicht - sie ist keine Karte. */
|
||||
.post-list-kompakt .post-card:hover, .post-list-kompakt .post-card:focus-within { transform: none; box-shadow: none; border-color: var(--accent) }
|
||||
/* Wechselseitig: die Karte selbst bleibt stehen, nur das Bild zieht auf. */
|
||||
.post-list-wechsel .post-card:hover, .post-list-wechsel .post-card:focus-within { transform: none; box-shadow: none }
|
||||
.post-list-wechsel .post-card h3 a { transition: color .2s ease }
|
||||
.post-list-wechsel .post-card:hover h3 a, .post-list-wechsel .post-card:focus-within h3 a { color: var(--accent) }
|
||||
.post-card:hover, .post-card:focus-within {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 18px 50px -16px color-mix(in srgb, var(--ink) 28%, transparent);
|
||||
|
||||
@@ -21,7 +21,7 @@ p{hyphens:auto}
|
||||
.menu-level-2{flex-direction:column;align-items:flex-start;gap:2px}
|
||||
|
||||
/* Posts */
|
||||
.post-list{list-style:none;margin:0;padding:0;display:grid;gap:26px;grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}
|
||||
.post-list{list-style:none;margin:0;padding:0;display:grid;gap:26px}.post-list-karten{grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}.post-list-liste{gap:18px}.post-list-liste .post-card{flex-direction:row;align-items:stretch}.post-list-liste .post-image{flex:0 0 34%;max-width:300px}.post-list-liste .post-image img{height:100%;aspect-ratio:auto;min-height:170px}.post-list-liste .post-body{flex:1 1 auto;align-content:center;padding:22px 26px}.post-list-wechsel{gap:0;counter-reset:beitrag}.post-list-wechsel .post-card{position:relative;counter-increment:beitrag;display:grid;grid-template-columns:1fr 1fr;align-items:center;gap:0 56px;padding:38px 0;background:transparent;border:0;border-radius:0;overflow:visible}.post-list-wechsel .post-card + .post-card{border-top:1px solid var(--line)}.post-list-wechsel .post-image{border-radius:20px;overflow:hidden;box-shadow:0 18px 46px -22px color-mix(in srgb,var(--ink) 55%,transparent)}.post-list-wechsel .post-image img{aspect-ratio:16/10;border-radius:20px}.post-list-wechsel .post-body{padding:0;display:grid;gap:12px;align-content:center}.post-list-wechsel .post-body::before{content:counter(beitrag,decimal-leading-zero);font-size:13px;font-weight:800;letter-spacing:2px;color:var(--accent)}.post-list-wechsel .post-card h3{font-size:28px;line-height:1.18;letter-spacing:-.5px}.post-list-wechsel .post-excerpt{margin:0;max-width:46ch}.post-list-wechsel .post-meta,.post-list-wechsel .post-terms{margin:0}.post-list-wechsel .post-more{display:inline-flex;align-items:center;gap:8px;margin-top:4px;font-size:15px;font-weight:700;color:var(--accent)}.post-list-wechsel .post-more::after{content:'\2192'}.post-list-wechsel .post-card:nth-child(even) .post-image{order:2}.post-list-wechsel .post-card:nth-child(even) .post-body{order:1}@media(max-width:760px){.post-list-wechsel .post-card{grid-template-columns:1fr;gap:18px;padding:26px 0}.post-list-wechsel .post-card:nth-child(even) .post-image{order:0}.post-list-wechsel .post-card:nth-child(even) .post-body{order:0}.post-list-wechsel .post-card h3{font-size:22px}}.post-list-kompakt{gap:0}.post-list-kompakt .post-card{flex-direction:row;align-items:baseline;justify-content:space-between;gap:18px;background:transparent;border:0;border-bottom:1px solid var(--line);border-radius:0;overflow:visible}.post-list-kompakt .post-body{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 18px;padding:16px 2px}.post-list-kompakt .post-meta,.post-list-kompakt .post-terms{margin:0}.post-list-kompakt .post-excerpt{flex:1 1 100%;margin:0}
|
||||
.post-card{display:flex;flex-direction:column;background:var(--paper);border:1px solid var(--line);border-radius:18px;overflow:hidden}.post-card>.post-body{display:grid;gap:10px;padding:20px 22px 22px}
|
||||
.post-image{display:block;position:relative;overflow:hidden}.post-view{position:absolute;inset:0;display:grid;place-items:center;background:color-mix(in srgb,var(--ink) 55%,transparent);color:#fff;font-weight:700;font-size:15px;letter-spacing:.4px;opacity:0}.post-card:hover .post-view,.post-card:focus-within .post-view{opacity:1}.post-image img{width:100%;height:auto;aspect-ratio:16/10;object-fit:cover;border-radius:0;display:block;background:var(--ink)}
|
||||
.kant h1 mark,.kant h2 mark,.kant h3 mark{background:transparent;color:var(--accent);padding:0}
|
||||
|
||||
@@ -145,6 +145,14 @@ final class DocumentValidator
|
||||
// und Kategorie gehören in ein Blogarchiv und lassen eine Projektseite falsch
|
||||
// aussehen. Voreinstellung ist der alte Zustand, damit bestehende Seiten sich nicht
|
||||
// ändern, wenn niemand etwas einstellt.
|
||||
if (isset($props['layout'])) {
|
||||
// Drei Darstellungen für dieselben Daten: Raster aus Karten, Liste mit Bild
|
||||
// daneben, und eine reine Titelliste. Welche passt, hängt vom Ort ab - ein
|
||||
// Projektraster auf der Startseite, eine Liste im Archiv, eine Titelliste in
|
||||
// einer Randspalte.
|
||||
$this->require($schemaVersion >= 18, 'Die Darstellung der Beitragsliste braucht Schema v18.');
|
||||
$this->require(in_array($props['layout'], ['karten', 'liste', 'kompakt', 'wechsel'], true), 'Unbekannte Darstellung der Beitragsliste.');
|
||||
}
|
||||
if (isset($props['paginate'])) {
|
||||
// Eine Auswahl zeigt die ersten n und hört auf. Eine Übersicht blättert - sonst
|
||||
// verschweigt sie, dass es mehr gibt. Das ist die eigene Regel aus Arbeitspaket M.
|
||||
|
||||
@@ -196,7 +196,7 @@
|
||||
{% elseif type == 'postlist' %}
|
||||
<div id="{{ block.id }}" class="block-postlist{{ designClasses }}">
|
||||
{% set feed = postLists[block.id]|default([]) %}
|
||||
{% include 'blocks/post-list.html.twig' with {posts: feed.posts|default(feed), pages: feed.pages|default(1), page: feed.page|default(1), total: feed.total|default(null), showMeta: block.props.showMeta ?? true, showExcerpt: block.props.showExcerpt ?? true, showTerms: block.props.showTerms ?? true} only %}
|
||||
{% include 'blocks/post-list.html.twig' with {posts: feed.posts|default(feed), pages: feed.pages|default(1), page: feed.page|default(1), total: feed.total|default(null), layout: block.props.layout|default('karten'), showMeta: block.props.showMeta ?? true, showExcerpt: block.props.showExcerpt ?? true, showTerms: block.props.showTerms ?? true} only %}
|
||||
</div>
|
||||
{% elseif type == 'postnav' %}
|
||||
<div id="{{ block.id }}" class="block-postnav{{ designClasses }}">
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
{% if posts is empty %}
|
||||
<p class="post-empty">Hier erscheinen die Beiträge, sobald welche veröffentlicht sind.</p>
|
||||
{% else %}
|
||||
<ul class="post-list">
|
||||
{% set art = layout|default('karten') %}
|
||||
<ul class="post-list post-list-{{ art }}">
|
||||
{% for post in posts %}
|
||||
<li class="post-card">
|
||||
{% if post.image %}
|
||||
{% if post.image and art != 'kompakt' %}
|
||||
<a class="post-image" href="{{ post.href }}" tabindex="-1" aria-hidden="true">
|
||||
<img src="{{ post.image.variants|last.src }}" alt="" width="{{ post.image.variants|last.width }}" height="{{ post.image.variants|last.height }}" loading="lazy" decoding="async"
|
||||
srcset="{% for source in post.image.variants %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="(max-width: 650px) 100vw, 380px">
|
||||
@@ -23,6 +24,7 @@
|
||||
</p>
|
||||
{% endif %}
|
||||
{% if (showExcerpt ?? true) and post.excerpt %}<p class="post-excerpt">{{ post.excerpt }}</p>{% endif %}
|
||||
{% if art == 'wechsel' %}<span class="post-more" aria-hidden="true">Weiterlesen</span>{% endif %}
|
||||
{% if (showTerms ?? true) and post.terms.category is not empty %}
|
||||
<p class="post-terms">{% for term in post.terms.category %}<a href="{{ term.href }}">{{ term.name }}</a>{% endfor %}</p>
|
||||
{% endif %}
|
||||
|
||||
@@ -718,6 +718,55 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString("'pointerdown', 'keydown', 'touchstart', 'wheel'", $skript);
|
||||
}
|
||||
|
||||
public function testTheSamePostsCanBeShownInThreeWays(): void
|
||||
{
|
||||
// Umut wollte auch für die Auflistung mehrere Darstellungen. Es sind dieselben Daten und
|
||||
// dasselbe Markup - nur die Klasse wechselt, damit eine Darstellung nichts kostet außer
|
||||
// ein paar Regeln CSS.
|
||||
$this->publishedPost('projekt-eins', 'Projekt eins', '2026-02-01', ['Projekte'], []);
|
||||
|
||||
// Jeder Schritt hebt die Fassung um eins: erst das Speichern, dann das Veröffentlichen.
|
||||
$version = 0;
|
||||
foreach (['karten', 'liste', 'kompakt', 'wechsel'] as $art) {
|
||||
$document = DemoContent::starter('Übersicht');
|
||||
$document['schemaVersion'] = 18;
|
||||
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => [
|
||||
'count' => 4, 'category' => 'projekte', 'tag' => '', 'layout' => $art,
|
||||
]];
|
||||
$revision = $this->pages->saveDraft('page-1', $document, $version);
|
||||
$this->pages->publish('page-1', $revision, ++$version);
|
||||
++$version;
|
||||
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('post-list post-list-'.$art, $html, $art);
|
||||
self::assertStringContainsString('Projekt eins', $html, $art);
|
||||
}
|
||||
|
||||
// Wechselseitig kehrt jede zweite Zeile um - die Karte selbst ist das Listenelement,
|
||||
// also zählt nth-child auf ihr und nicht eine Ebene tiefer.
|
||||
$css = (string) file_get_contents(__DIR__.'/../public/assets/site.css');
|
||||
self::assertStringContainsString('.post-list-wechsel .post-card:nth-child(even) .post-image{order:2}', $css);
|
||||
// Und die Nummer kommt aus einem Zähler, nicht aus dem Inhalt: eine Liste, die auf Seite
|
||||
// zwei wieder bei 01 beginnt, wäre falsch beschriftet, aber sie wäre ehrlich - der Inhalt
|
||||
// weiß nicht, der wievielte Beitrag er ist.
|
||||
self::assertStringContainsString('counter-increment:beitrag', $css);
|
||||
|
||||
// Die kompakte Darstellung lässt die Bilder ganz weg - sonst wäre sie nicht kompakt.
|
||||
$document = DemoContent::starter('Übersicht');
|
||||
$document['schemaVersion'] = 18;
|
||||
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => ['count' => 4, 'category' => 'projekte', 'tag' => '', 'layout' => 'kompakt']];
|
||||
$revision = $this->pages->saveDraft('page-1', $document, $version);
|
||||
$this->pages->publish('page-1', $revision, ++$version);
|
||||
self::assertStringNotContainsString('post-image', $this->request('/')->getContent());
|
||||
|
||||
// Und eine erfundene Darstellung wird abgewiesen, statt still auf etwas zurückzufallen.
|
||||
$document = DemoContent::starter('Übersicht');
|
||||
$document['schemaVersion'] = 18;
|
||||
$document['blocks'][] = ['id' => 'liste', 'type' => 'postlist', 'props' => ['count' => 4, 'layout' => 'kacheln']];
|
||||
$this->expectException(InvalidDocument::class);
|
||||
(new DocumentValidator())->validate($document);
|
||||
}
|
||||
|
||||
public function testANewPostStartsFromOneOfThreeLayouts(): void
|
||||
{
|
||||
// Umut wollte mehrere Aufbauten zur Auswahl. Ein leeres Dokument ist für eine Seite
|
||||
|
||||
@@ -163,7 +163,7 @@ test('the post list block carries only its filter, lifts the schema to v5 and un
|
||||
const block = locate(history.document.blocks, id).block;
|
||||
|
||||
// Content is the filter and the count; the posts themselves are added when the page is served.
|
||||
assert.deepEqual(block.props, { count: 3, category: '', tag: '' });
|
||||
assert.deepEqual(block.props, { count: 3, category: '', tag: '', layout: 'karten' });
|
||||
assert.equal(block.children, undefined);
|
||||
assert.equal(history.document.schemaVersion, 5);
|
||||
|
||||
|
||||
Reference in new issue
Block a user