From b08862b139159d230313826ddee12fc9a27ae424 Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Tue, 10 Nov 2026 13:01:23 +0100 Subject: [PATCH] Back up unsaved editor work locally, structure the settings, fix archive accessibility - The editor keeps unsaved changes in the browser and offers them after a crash, a closed tab or an expired session. No timed save on the server: every save is a full revision. - The save status stays visible in narrow windows. - Settings get a sticky section bar, two folded groups and a save bar that stays in view. - The admin post list fetches terms and images once, not per post. - Archive post titles are second-level headings, the archive head keeps full text contrast, the skip link sits above a sticky header, a linked image without alternative text names its link. - The editor check starts through the dev router like the others. Co-Authored-By: Claude Fable 5.2 --- docs/ARCHITECTURE.md | 8 +++++ public/assets/admin.css | 16 +++++++++ public/assets/confirm.js | 4 +++ public/assets/editor.css | 3 +- public/assets/editor.js | 54 ++++++++++++++++++++++++++++ public/assets/site.css | 12 +++---- scripts/editor-check.mjs | 2 +- src/Content/MediaLibrary.php | 3 +- src/Content/PostRepository.php | 34 ++++++++++++++++++ src/Controller/EditorController.php | 4 ++- src/Controller/PageController.php | 2 +- templates/admin/editor.html.twig | 1 + templates/admin/settings.html.twig | 44 ++++++++++++++++------- templates/blocks/block.html.twig | 5 +-- templates/blocks/post-list.html.twig | 4 ++- tests/BacklogTest.php | 4 +++ tests/CmsTest.php | 8 +++++ 17 files changed, 182 insertions(+), 26 deletions(-) diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 3187246..eb9bd63 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,6 +2,14 @@ Stand: 10.11.2026. Arbeitstitel KantSites; Produktname offen. +## ADR 130 – Editor: lokale Sicherung statt Autosave auf dem Server (10.11.2026) + +**Kontext:** Der Entwicklungsplan nennt „Autosave mit sichtbarem Speicherstatus“. Gespeichert wurde nur per Knopf oder Strg+S; nach Absturz, geschlossenem Tab oder abgelaufener Sitzung war die Arbeit weg, und „Als JSON sichern“ hatte kein Gegenstück zum Einlesen. + +**Entscheidung:** Ungespeicherte Änderungen liegen zusätzlich im `localStorage` des Browsers (Schlüssel `kant-editor:`, je Seite und Bereich), geschrieben 0,6 s nach der letzten Änderung und beim Verlassen. Beim Öffnen bietet der Editor eine abweichende Sicherung zum Wiederherstellen an – als ein Schritt im Verlauf – oder zum Verwerfen. Nach dem Speichern verschwindet sie. Kein Speichern auf dem Server im Takt. + +**Folgen:** Jedes Speichern legt eine vollständige Fassung in `revision` an, und die wird nie gekürzt (ADR 009 ff.); ein Autosave alle paar Sekunden hätte die Fassungsliste entwertet und die Datenbank wachsen lassen. Das wäre erst mit zusammengefassten Entwurfsfassungen vertretbar – eine Schemaänderung, die hier nicht ansteht. Die Sicherung gilt nur für dieses Gerät und diesen Browser und ersetzt kein Speichern. Ist der Serverstand inzwischen neuer, sagt das Angebot das dazu; das Speichern danach überschreibt ihn nicht still, sondern legt eine neue Fassung über die fremde. Ist der Speicher des Browsers voll oder gesperrt, arbeitet der Editor ohne Sicherung weiter. + ## ADR 129 – Bilder: Stufen 720 und 1280, Abbild für den Webserver (10.11.2026) **Kontext:** Die Komplettanalyse maß auf kant-it.de rund 250 ms bis zum ersten Byte eines Bildes gegen rund 100 ms für eine statische Datei, weil jedes Bild durch die Anwendung lief (ADR 009). Zwischen den Stufen 480 und 960 lag außerdem genau der Bedarf eines Telefons (412 px × 1,75 = 721 px); Becker kam damit mobil auf einen LCP von 2,6 s. diff --git a/public/assets/admin.css b/public/assets/admin.css index 06b7c91..8628ba3 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -1165,3 +1165,19 @@ details.form-card > summary.form-card-summary::before, details.form-card[open] > .confirm-dialog .dialog-actions .primary, .confirm-dialog .dialog-actions .secondary { margin: 0 } .primary.danger { background: var(--danger); border-color: var(--danger) } .primary.danger:hover:not(:disabled) { background: #6f251b; border-color: #6f251b } +/* ---- Lange Formulare: Sprungleiste, Klappbereiche, stehende Speicherleiste (Einstellungen) ---- */ +.jump-nav { position: sticky; top: 0; z-index: 6; display: flex; flex-wrap: wrap; gap: 6px; margin: 0 -4px 6px; padding: 10px 4px; background: color-mix(in srgb, var(--bg, #f3f5f1) 92%, transparent); backdrop-filter: blur(6px) } +.jump-nav a { display: inline-flex; align-items: center; min-height: 32px; padding: 4px 12px; border: 1px solid var(--line); border-radius: 999px; background: var(--paper); color: var(--ink); font-size: 12.5px; font-weight: 600; text-decoration: none } +.jump-nav a:hover { border-color: var(--accent) } +.stack-section[id], .section-head h2[id] { scroll-margin-top: 64px } +.field-fold > summary { display: flex; align-items: baseline; justify-content: space-between; gap: 12px; padding: 12px 0 8px; font-weight: 700; cursor: pointer; list-style: none } +.field-fold > summary::-webkit-details-marker { display: none } +.field-fold > summary::before { content: ''; flex: none; width: 7px; height: 7px; margin-right: 2px; border-right: 2px solid currentColor; border-bottom: 2px solid currentColor; transform: translateY(-2px) rotate(-45deg) } +.field-fold[open] > summary::before { transform: translateY(-3px) rotate(45deg) } +.field-fold > summary .muted { margin-left: auto; font-size: 12px; font-weight: 500 } +.field-fold:not([open]) { padding-bottom: 4px } +.form-save-bar { position: sticky; bottom: 0; z-index: 5; display: flex; flex-wrap: wrap; align-items: center; gap: 8px 16px; margin: 22px -22px -24px; padding: 14px 22px; border-top: 1px solid var(--line); border-radius: 0 0 12px 12px; background: color-mix(in srgb, var(--paper) 94%, transparent); backdrop-filter: blur(6px) } +.card-body .form-save-bar .primary { margin: 0 } +.form-save-bar .muted { font-size: 12px } +@media (prefers-reduced-motion: no-preference) { html { scroll-behavior: smooth } } +@media (max-width: 700px) { .jump-nav { flex-wrap: nowrap; overflow-x: auto; scrollbar-width: none } .jump-nav a { flex: none } .form-save-bar .muted { display: none } } diff --git a/public/assets/confirm.js b/public/assets/confirm.js index 6636446..c97dafc 100644 --- a/public/assets/confirm.js +++ b/public/assets/confirm.js @@ -48,6 +48,10 @@ }; window.kantConfirm = ask; + // Ein ungültiges Feld in einem zugeklappten Bereich kann der Browser nicht anspringen - das + // Absenden bliebe ohne jede Meldung stehen. Der Bereich klappt deshalb auf. + document.addEventListener('invalid', event => { event.target.closest?.('details:not([open])')?.setAttribute('open', ''); }, true); + document.addEventListener('submit', event => { const form = event.target; if (!(form instanceof HTMLFormElement) || !form.dataset.confirmTitle || form.dataset.confirmed) return; diff --git a/public/assets/editor.css b/public/assets/editor.css index 8f6133a..0fe8c1e 100644 --- a/public/assets/editor.css +++ b/public/assets/editor.css @@ -38,6 +38,7 @@ html:has(>.editor-body){overflow:hidden;height:100%} .editor-header .secondary{background:transparent;color:#fff;border:1px solid #ffffff4d;font-size:12px;padding:0 13px;min-height:34px;white-space:nowrap} .editor-header .secondary:hover:not(:disabled){background:#ffffff1a;border-color:#ffffff99} .editor-header .primary{font-size:12px;padding:0 15px;min-height:34px;white-space:nowrap} +.editor-message.editor-backup{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:8px 16px;background:#eef2ea;color:#1b2a24;border-bottom-color:#cfd8cc}.editor-backup-actions{display:flex;gap:8px;align-items:center}.editor-backup .secondary,.editor-backup .text-button{margin:0;min-height:30px;padding:4px 12px;font-size:12px} .editor-message{border-radius:0;background:#fff0e9;color:#873e24;border-bottom:1px solid #e6c3b2;flex-shrink:0;padding:9px 16px;font-size:12px;line-height:1.6} .recovery-bar{display:flex;gap:12px;align-items:center;padding:10px 16px;border-bottom:1px solid var(--line);background:#fff;font-size:12px;flex-wrap:wrap} .dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:25px} @@ -277,7 +278,7 @@ html:has(>.editor-body){overflow:hidden;height:100%} @media(prefers-reduced-motion:reduce){.ov-insert{transition:none;opacity:1}} /* ---- narrow screens ---------------------------------------------------------------------- */ -@media(max-width:1100px){.editor-layout{grid-template-columns:290px minmax(0,1fr)}.editor-page-name{width:150px}.save-actions>span{display:none}.viewport-controls button span{display:none}.viewport-controls button{padding:0 9px}} +@media(max-width:1100px){.editor-layout{grid-template-columns:290px minmax(0,1fr)}.editor-page-name{width:150px}.save-actions>span{font-size:0;max-width:none;white-space:nowrap}.save-actions>span::before{font-size:11px;content:attr(data-short)}.save-actions>span[data-state=dirty]::before{content:'● Ungespeichert';color:#ffcf8a}.save-actions>span[data-state=saving]::before{content:'Speichert …'}.save-actions>span[data-state=conflict]::before{content:'● Konflikt';color:#ffb0a0}.save-actions>span[data-state=saved]::before{content:'Gespeichert'}.save-actions>span[data-state=loading]::before{content:'Lädt …'}.viewport-controls button span{display:none}.viewport-controls button{padding:0 9px}} @media(max-width:850px){ .editor-body{overflow:auto}#editor{height:auto;min-height:100dvh} .editor-header{flex-wrap:wrap;height:auto;padding:8px 10px;gap:8px}.editor-page-name{flex:1;width:auto}.viewport-controls{order:6;margin:0}.save-actions{margin-left:auto} diff --git a/public/assets/editor.js b/public/assets/editor.js index 0464422..1fcfe8f 100644 --- a/public/assets/editor.js +++ b/public/assets/editor.js @@ -88,6 +88,51 @@ function playPreview() { $('#preview-motion').addEventListener('click', () => { previewPlayback = !previewPlayback; playPreview(); }); +// ---- Lokale Sicherung -------------------------------------------------------------------------- +// Ungespeicherte Änderungen liegen zusätzlich im Browser dieses Geräts. Stürzt der Browser ab, +// wird der Tab geschlossen oder läuft die Sitzung ab, bietet der Editor sie beim nächsten Öffnen +// zum Wiederherstellen an. Bewusst kein Speichern auf dem Server im Takt: Jedes Speichern legt +// dort eine vollständige Fassung an, und die Liste der Fassungen soll zeigen, was jemand +// gespeichert hat - nicht jeden Tastendruck. +const backupKey = `kant-editor:${root.dataset.api}`; +let backupTimer = null, backupOffer = null; +function readBackup() { + try { + const entry = JSON.parse(localStorage.getItem(backupKey) || 'null'); + return entry && Array.isArray(entry.document?.blocks) ? entry : null; + } catch { return null; } +} +function writeBackup() { + // Solange ein Angebot offen ist, bleibt die Sicherung unangetastet: Der eben geladene Stand + // ist unverändert und würde sie sonst löschen, bevor jemand entschieden hat. + if (!history || backupOffer) return; + try { + if (history.dirty) localStorage.setItem(backupKey, JSON.stringify({ at: Date.now(), revisionId, document: history.document })); + else localStorage.removeItem(backupKey); + } catch { /* Speicher voll oder gesperrt: dann ohne Sicherung, der Editor arbeitet weiter. */ } +} +function scheduleBackup() { clearTimeout(backupTimer); backupTimer = setTimeout(writeBackup, 600); } +function closeBackupOffer(remove) { + backupOffer = null; $('#local-backup').hidden = true; + if (remove) { try { localStorage.removeItem(backupKey); } catch { /* nichts zu tun */ } } +} +function offerBackup() { + const entry = readBackup(); + if (!entry || JSON.stringify(entry.document) === JSON.stringify(history.document)) { closeBackupOffer(Boolean(entry)); return; } + backupOffer = entry; + const when = new Date(entry.at).toLocaleString('de-DE', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' }); + $('#local-backup-text').textContent = `Auf diesem Gerät liegen ungespeicherte Änderungen vom ${when} Uhr.` + + (entry.revisionId && entry.revisionId !== revisionId ? ' Seitdem wurde die Seite auf dem Server gespeichert – beim Wiederherstellen ersetzt dein Stand den neueren, sobald du speicherst.' : ''); + $('#local-backup').hidden = false; +} +function restoreBackup() { + const entry = backupOffer; if (!entry || !history || busy) return; + const wanted = repairSchema(clone(entry.document)); + // Als ein Schritt im Verlauf: „Rückgängig“ führt zum geladenen Stand zurück. + history.change(doc => { for (const key of Object.keys(doc)) delete doc[key]; Object.assign(doc, wanted); }); + closeBackupOffer(false); selected = null; changed(); +} + function notify(message = '', recover = false) { $('#message').textContent = message; $('#message').hidden = !message; @@ -118,6 +163,10 @@ async function request(path, body, signal) { function controls() { const ready = Boolean(history); $('#save-status').textContent = busy ? 'Wird gespeichert …' : conflict ? 'Speicherkonflikt' : !ready ? 'Wird geladen …' : history.dirty ? 'Ungespeicherte Änderungen' : editingArea ? 'Bereich gespeichert' : revisionId === publishedRevisionId ? 'Veröffentlicht' : 'Entwurf gespeichert'; + // Der Zustand als Kennung: Auf schmalen Fenstern zeigt die Kopfzeile statt des ganzen Satzes + // nur ein kurzes Wort dazu (editor.css) - vorher verschwand die Anzeige dort ganz. + $('#save-status').dataset.state = busy ? 'saving' : conflict ? 'conflict' : !ready ? 'loading' : history.dirty ? 'dirty' : 'saved'; + if (ready && !busy) scheduleBackup(); $('#save').disabled = !ready || busy || conflict || !history.dirty; if ($('#publish')) $('#publish').disabled = !ready || busy || conflict || history.dirty || revisionId === publishedRevisionId; $('#undo').disabled = !ready || busy || !history.past.length; @@ -1451,6 +1500,7 @@ async function load() { registerPlugins(data.plugins || [], data.presets || []); buildLibrary(); buildPluginPresets(); selected = null; propertyPanel = 'content'; collapsed.clear(); conflict = false; notify(); if (language) setView('translate'); + offerBackup(); changed(); resizePreview(); } catch (error) { notify(error.message, true); } finally { busy = false; controls(); } @@ -1952,6 +2002,10 @@ function onKey(event) { } } window.addEventListener('keydown', onKey); +$('#local-backup-restore').addEventListener('click', restoreBackup); +$('#local-backup-discard').addEventListener('click', () => closeBackupOffer(true)); +// Beim Verlassen sofort schreiben, nicht erst nach der Wartezeit. +window.addEventListener('pagehide', () => { clearTimeout(backupTimer); writeBackup(); }); window.addEventListener('beforeunload', event => { if (history?.dirty || busy) { event.preventDefault(); event.returnValue = ''; } }); buildLibrary(); // The workspace is a fixed frame; a scroll position restored from an earlier visit would push diff --git a/public/assets/site.css b/public/assets/site.css index 4dec849..a793c4a 100644 --- a/public/assets/site.css +++ b/public/assets/site.css @@ -12,7 +12,7 @@ p{hyphens:auto} /* Über dem Inhalt: der Kopf nimmt keinen Platz ein, die erste Sektion beginnt unter ihm. Farbe und Schatten kommen aus der Gestaltung des Kopfbereichs, auch die für den gescrollten Zustand. */ .site-header.is-overlay{position:absolute;top:0;left:0;right:0;z-index:40;border-bottom:0;background:transparent;box-shadow:none} -.site-header.is-overlay.is-sticky{position:fixed}.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:none;font-weight:800}.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}h2{font-size:clamp(32px,4vw,49px);font-weight:500}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;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:var(--paper);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} +.site-header.is-overlay.is-sticky{position:fixed}.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:none;font-weight:800}.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}h2{font-size:clamp(32px,4vw,49px);font-weight:500}h3{font-size:26px;letter-spacing:-.035em;line-height:1.2}.block-text{font-size:18px;line-height:1.7;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)}#archiv-kopf .block-text{color:inherit}.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:60;background:var(--ink);color:var(--paper);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:767px){.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. */ @@ -27,15 +27,15 @@ 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}.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);text-decoration:none;width:fit-content} +.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 :is(h2,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);text-decoration:none;width:fit-content} .post-more::after{content:'';display:inline-block;width:1em;height:1em;background:currentColor;-webkit-mask:var(--kant-svg-pfeil) center/contain no-repeat;mask:var(--kant-svg-pfeil) center/contain no-repeat;vertical-align:-.12em;transition:transform .2s} -.post-more:hover::after{transform:translateX(4px)}.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:767px){.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-more:hover::after{transform:translateX(4px)}.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:767px){.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 :is(h2,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;text-decoration:none}.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} -.kant .post-card h3{margin:0;font-size:19px;font-weight:800;line-height:1.12;color:var(--ink);letter-spacing:-.2px} -.post-card h3 a{text-decoration:none} -.post-card h3 a:hover{text-decoration:underline} +.kant .post-card :is(h2,h3){margin:0;font-size:19px;font-weight:800;line-height:1.12;color:var(--ink);letter-spacing:-.2px} +.post-card :is(h2,h3) a{text-decoration:none} +.post-card :is(h2,h3) a:hover{text-decoration:underline} .post-meta,.post-header{display:flex;flex-wrap:wrap;gap:6px 16px;align-items:baseline;font-size:13px;margin:0} .post-header{padding-block:22px 0} .post-excerpt{margin:0;line-height:1.65} diff --git a/scripts/editor-check.mjs b/scripts/editor-check.mjs index bcb4850..7bd6fb4 100644 --- a/scripts/editor-check.mjs +++ b/scripts/editor-check.mjs @@ -14,7 +14,7 @@ await new Promise(resolve => probe.listen(0, '127.0.0.1', resolve)); const port = probe.address().port; await new Promise(resolve => probe.close(resolve)); const origin = `http://127.0.0.1:${port}`; -const server = spawn(php, ['-d', 'upload_max_filesize=8M', '-d', 'post_max_size=10M', '-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public'], { +const server = spawn(php, ['-d', 'upload_max_filesize=8M', '-d', 'post_max_size=10M', '-d', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, env: { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory, MEDIA_MIRROR_DIRECTORY: fixture.mediaDirectory + '-abbild', SITE_ORIGIN: origin }, stdio: 'ignore', diff --git a/src/Content/MediaLibrary.php b/src/Content/MediaLibrary.php index 375518d..61f0fd1 100644 --- a/src/Content/MediaLibrary.php +++ b/src/Content/MediaLibrary.php @@ -130,7 +130,8 @@ final class MediaLibrary 'SELECT * FROM media WHERE id IN ('.implode(',', array_fill(0, count($ids), '?')).')', $ids, ) as $row) { - $found[$row['id']] = $this->present($row); + // Auch für find(): Wer die Bilder einer Liste vorab holt, erspart jede Einzelabfrage danach. + $found[$row['id']] = $this->gemerkt[$row['id']] = $this->present($row); } return $found; diff --git a/src/Content/PostRepository.php b/src/Content/PostRepository.php index 17139f4..6e364e7 100644 --- a/src/Content/PostRepository.php +++ b/src/Content/PostRepository.php @@ -313,6 +313,40 @@ final class PostRepository } /** Term names of one page, as the comma separated list the metadata form edits. */ + /** + * Kategorien und Schlagwörter mehrerer Beiträge in einer Abfrage, als Text je Beitrag. + * + * Die Liste der Verwaltung fragte vorher für jeden Beitrag einzeln nach: zwanzig Beiträge, + * vierzig Abfragen. + * + * @param list $pageIds + * + * @return array + */ + public function namesForMany(array $pageIds): array + { + $names = []; + foreach ($this->termsForMany($pageIds) as $id => $terms) { + $names[$id] = [ + 'category' => implode(', ', array_column($terms['category'], 'name')), + 'tag' => implode(', ', array_column($terms['tag'], 'name')), + ]; + } + + return $names; + } + + /** + * Holt die Bilder einer Liste in einer Abfrage, damit die Vorlage sie danach einzeln findet, + * ohne je Zeile die Datenbank zu fragen. + * + * @param list $mediaIds + */ + public function preloadImages(array $mediaIds): void + { + $this->media->findMany(array_map('strval', array_filter($mediaIds))); + } + public function namesFor(string $pageId): array { $terms = $this->termsFor($pageId); diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index 41cda08..9870b90 100644 --- a/src/Controller/EditorController.php +++ b/src/Controller/EditorController.php @@ -49,11 +49,13 @@ final class EditorController extends AbstractController $result = $this->pages->editorPage($type, $request->query->getInt('seite', 1), $request->query->getInt('limit', 20), $query, $status); $pages = $result['items']; $parentChoices = []; + $terms = 'post' === $type ? $this->posts->namesForMany(array_column($pages, 'id')) : []; + $this->posts->preloadImages([...array_column($pages, 'featured_media_id'), ...array_column($pages, 'og_media_id')]); foreach ($pages as &$page) { $page['document'] = json_decode($page['document'] ?? '{}', true, 512, JSON_THROW_ON_ERROR); $page['title'] = $page['document']['title'] ?? $page['slug']; $page['depth'] = substr_count((string) $page['path'], '/'); - $page['terms'] = 'post' === $page['type'] ? $this->posts->namesFor($page['id']) : ['category' => '', 'tag' => '']; + $page['terms'] = $terms[$page['id']] ?? ['category' => '', 'tag' => '']; if ($page['parent_id'] && !isset($parentChoices[$page['parent_id']])) { $parentChoices[$page['parent_id']] = $this->pages->choices(id: $page['parent_id'])[0] ?? null; } diff --git a/src/Controller/PageController.php b/src/Controller/PageController.php index 91a32e3..77372de 100644 --- a/src/Controller/PageController.php +++ b/src/Controller/PageController.php @@ -960,7 +960,7 @@ final class PageController extends AbstractController 'blocks' => [ ['id' => 'archiv-kopf', 'type' => 'section', 'layout' => ['tone' => 'mint', 'width' => 'normal'], 'children' => $kopf], ['id' => 'archiv', 'type' => 'section', 'layout' => ['tone' => 'paper', 'width' => 'normal'], 'children' => [ - ['id' => 'archiv-liste', 'type' => 'postlist', 'props' => ['count' => PostRepository::PER_PAGE, 'category' => 'category' === $kind && null !== $term ? $term['slug'] : '', 'tag' => 'tag' === $kind && null !== $term ? $term['slug'] : '', 'layout' => 'karten', 'paginate' => true]], + ['id' => 'archiv-liste', 'type' => 'postlist', 'props' => ['count' => PostRepository::PER_PAGE, 'category' => 'category' === $kind && null !== $term ? $term['slug'] : '', 'tag' => 'tag' === $kind && null !== $term ? $term['slug'] : '', 'layout' => 'karten', 'paginate' => true, 'headingLevel' => 2]], ]], ], ]; diff --git a/templates/admin/editor.html.twig b/templates/admin/editor.html.twig index 9fbebaf..674dfb1 100644 --- a/templates/admin/editor.html.twig +++ b/templates/admin/editor.html.twig @@ -37,6 +37,7 @@ +