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 <noreply@anthropic.com>
This commit is contained in:
1 parent
5e46c98825
commit
b08862b139
17 files changed
+182
-26
No files matched your search
@@ -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:<API-Adresse>`, 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.
|
||||
|
||||
@@ -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 } }
|
||||
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string> $pageIds
|
||||
*
|
||||
* @return array<string, array{category: string, tag: string}>
|
||||
*/
|
||||
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<string|null> $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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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]],
|
||||
]],
|
||||
],
|
||||
];
|
||||
|
||||
@@ -37,6 +37,7 @@
|
||||
</div>
|
||||
</header>
|
||||
<div id="message" class="editor-message" role="alert" hidden></div>
|
||||
<div id="local-backup" class="editor-message editor-backup" role="status" hidden><span id="local-backup-text"></span><span class="editor-backup-actions"><button type="button" class="secondary" id="local-backup-restore">Wiederherstellen</button><button type="button" class="text-button" id="local-backup-discard">Verwerfen</button></span></div>
|
||||
<div id="recovery" class="recovery-bar" hidden><button id="download" class="secondary">Änderungen als JSON sichern</button><button id="reload" class="secondary">Serverstand laden</button><a href="{{ path('admin_login') }}" target="_blank" rel="noopener">Erneut anmelden ↗</a></div>
|
||||
<main id="admin-main" class="editor-layout" tabindex="-1">
|
||||
<aside class="sidebar" aria-label="Bearbeitung" data-view="elements">
|
||||
|
||||
@@ -6,6 +6,19 @@
|
||||
{% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
|
||||
{% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script><script type="module" src="/assets/favicon-field.js?v={{ kant_version() }}"></script><script type="module" src="/assets/logo-field.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||
{% block content %}
|
||||
{# Die Seite ist lang. Die Leiste bleibt beim Scrollen stehen und führt zu jedem Abschnitt. #}
|
||||
<nav class="jump-nav" aria-label="Abschnitte dieser Seite">
|
||||
<a href="#not-found">404-Seite</a>
|
||||
<a href="#sprachen">Sprachen</a>
|
||||
<a href="#unternehmen">Unternehmen</a>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<a href="#favicon-title">Symbol</a>
|
||||
<a href="#nach-oben">Knopf „nach oben“</a>
|
||||
<a href="#spamschutz">Spamschutz</a>
|
||||
<a href="#einwilligung">Einwilligung</a>
|
||||
{% endif %}
|
||||
<a href="#weiterleitungen">Weiterleitungen</a>
|
||||
</nav>
|
||||
|
||||
{% if missingLegal is not empty %}
|
||||
{# Not a hint, a warning: in Germany both pages are required, and they have to be reachable from
|
||||
@@ -99,7 +112,7 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
<section class="stack-section">
|
||||
<section class="stack-section" id="unternehmen">
|
||||
<div class="section-head"><h2>Unternehmensdaten</h2><span class="muted">Grundlage für Organization und LocalBusiness</span></div>
|
||||
{% if is_granted('ROLE_ADMIN') %}
|
||||
<form action="{{ path('admin_settings_save') }}" method="post" class="card card-body">
|
||||
@@ -146,8 +159,9 @@
|
||||
<label for="sameAs">Profile</label>
|
||||
<textarea id="sameAs" name="sameAs" rows="3" placeholder="https://www.linkedin.com/company/…">{{ settings.sameAs|join('\n') }}</textarea>
|
||||
<p class="field-help">Eine HTTPS-Adresse je Zeile, höchstens zwanzig. Die sozialen Profile unten stehen automatisch mit in den strukturierten Daten.</p>
|
||||
<fieldset class="field-set">
|
||||
<legend>Soziale Profile</legend>
|
||||
{% set profile = settings.social|default({})|filter(v => v)|length %}
|
||||
<details class="field-set field-fold"{{ profile ? ' open' : '' }}>
|
||||
<summary>Soziale Profile <span class="muted">{{ profile ? profile ~ ' eingetragen' : 'keine eingetragen' }}</span></summary>
|
||||
<p class="field-help">Einmal hier eintragen, dann mit dem Element „Soziale Profile“ an jeder Stelle einsetzen – im Kopf, im Fuß, in einer Kontaktsektion. Größe, Form und Farbe bestimmt das Element. Leere Felder erscheinen nicht.</p>
|
||||
<div class="fields">
|
||||
{% for key, name in socialNetworks %}
|
||||
@@ -157,9 +171,10 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
<fieldset class="field-set">
|
||||
<legend>Öffnungszeiten</legend>
|
||||
</details>
|
||||
{% set zeiten = settings.openingHours|default([])|filter(h => h.opens|default('') or h.closes|default(''))|length %}
|
||||
<details class="field-set field-fold"{{ zeiten ? ' open' : '' }}>
|
||||
<summary>Öffnungszeiten <span class="muted">{{ zeiten ? zeiten ~ ' Tag(e) eingetragen' : 'keine eingetragen' }}</span></summary>
|
||||
<p class="field-help">Als Uhrzeit wie 07:30. Ein Tag ohne Angabe gilt als geschlossen. Erscheint als <code>openingHoursSpecification</code> in den strukturierten Daten – der Text auf der Website ist davon unabhängig.</p>
|
||||
<div class="fields">
|
||||
{% for day, label in weekdays %}
|
||||
@@ -170,7 +185,7 @@
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</fieldset>
|
||||
</details>
|
||||
<label for="areaServed">Einsatzgebiet</label>
|
||||
<textarea id="areaServed" name="areaServed" rows="3" placeholder="Baden-Baden Rastatt Karlsruhe">{{ settings.areaServed|join('\n') }}</textarea>
|
||||
<p class="field-help">Ein Ort je Zeile, höchstens vierzig. Erscheint als <code>areaServed</code> in den strukturierten Daten.</p>
|
||||
@@ -217,7 +232,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Das Bild kommt aus der Mediathek, dort kann es auch gleich hochgeladen werden. Danach das Quadrat auf den gewünschten Ausschnitt ziehen und die Größe einstellen. Beim Speichern entstehen daraus PNG-Dateien in 32 und 180 Pixeln.</p>
|
||||
<div class="section-head"><h2>Knopf „nach oben"</h2><span class="muted">Erscheint beim Scrollen, in der Ecke</span></div>
|
||||
<div class="section-head"><h2 id="nach-oben">Knopf „nach oben"</h2><span class="muted">Erscheint beim Scrollen, in der Ecke</span></div>
|
||||
<label class="check-label" for="scrollTop"><input id="scrollTop" name="scrollTop" type="checkbox" value="1"{{ settings.scrollTop ? ' checked' : '' }}> Knopf „nach oben" anzeigen</label>
|
||||
<p class="field-help">Springt an den Seitenanfang. Er braucht kein JavaScript: In Browsern mit Scroll-Zeitachse blendet er sich beim Scrollen ein, in den übrigen steht er von Anfang an da.</p>
|
||||
<div class="fields">
|
||||
@@ -260,7 +275,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<p class="field-help">Farben entweder als <code>@schlüssel</code> aus der Palette oder als Wert wie <code>#1a2b3c</code>. Leer bedeutet: Akzentfarbe des Designs auf Weiß.</p>
|
||||
<div class="section-head"><h2>Spamschutz für Formulare</h2><span class="muted">Optional, von Cloudflare</span></div>
|
||||
<div class="section-head"><h2 id="spamschutz">Spamschutz für Formulare</h2><span class="muted">Optional, von Cloudflare</span></div>
|
||||
<p class="field-help">Eingebaut sind bereits drei Prüfungen ohne Dritte: ein verstecktes Feld, ein signierter Zeitstempel und höchstens fünf Absendungen je Absender und Stunde. Das hält automatisch ausgefüllte Formulare ab. Gegen jemanden, der einen echten Browser fernsteuert, hilft <strong>Cloudflare Turnstile</strong> – die Prüfung ohne Bilderrätsel. Der Preis: das Widget braucht JavaScript (ohne Skript ist das Formular dann nicht absendbar), und die IP-Adresse des Absenders geht an Cloudflare, was in die Datenschutzerklärung gehört. Schlüssel gibt es kostenlos im Cloudflare-Konto unter „Turnstile“.</p>
|
||||
<label class="check-label" for="turnstile"><input id="turnstile" name="turnstile" type="checkbox" value="1"{{ settings.turnstile ? ' checked' : '' }}> Turnstile-Prüfung in Formularen anzeigen</label>
|
||||
<label for="turnstileSiteKey">Öffentlicher Schlüssel (Site Key)</label>
|
||||
@@ -271,7 +286,7 @@
|
||||
<label for="robotsExtra">Zusätzliche Zeilen für robots.txt</label>
|
||||
<textarea id="robotsExtra" name="robotsExtra" rows="8">{{ settings.robotsExtra }}</textarea>
|
||||
<p class="field-help">Nur Kommentarzeilen und einfache Anweisungen wie <code>Disallow: /pfad</code>, <code>Allow:</code>, <code>User-agent:</code> und <code>Crawl-delay:</code>. Leerzeilen trennen Blöcke, höchstens 200 Zeilen. Hier stehen zum Beispiel eigene Regeln je Bot – etwa welche KI-Crawler die Website lesen dürfen und welche nicht.</p>
|
||||
<div class="section-head"><h2>Einwilligung</h2><span class="muted">Nur nötig, wenn die Website etwas von Dritten lädt</span></div>
|
||||
<div class="section-head"><h2 id="einwilligung">Einwilligung</h2><span class="muted">Nur nötig, wenn die Website etwas von Dritten lädt</span></div>
|
||||
<label class="check-label" for="stickyHeader"><input id="stickyHeader" name="stickyHeader" type="checkbox" value="1"{{ settings.stickyHeader ? ' checked' : '' }}> Kopfbereich beim Scrollen oben halten</label>
|
||||
<label class="check-label" for="headerOverlay"><input id="headerOverlay" name="headerOverlay" type="checkbox" value="1"{{ settings.headerOverlay ? ' checked' : '' }}> Kopfbereich über dem Inhalt schweben lassen</label>
|
||||
<p class="field-help">Der Kopfbereich liegt dann über der ersten Sektion, wie der transparente Header in Flatsome. Seine Farben für den gescrollten Zustand stellst du im Editor des Kopfbereichs unter Design → Hintergrund „beim Scrollen“ ein; zusammen mit „oben halten“ bleibt er beim Scrollen sichtbar.</p>
|
||||
@@ -349,14 +364,19 @@
|
||||
</div>
|
||||
<p class="field-help">Die Entscheidung liegt in einem Cookie dieser Website, ein halbes Jahr lang, ohne Kennung und ohne Übermittlung. Unter <code>/einwilligung</code> kann sie jederzeit geändert werden; auf jeder Seite steht dafür ein Link.</p>
|
||||
|
||||
<button class="primary" type="submit">Einstellungen speichern</button>
|
||||
{# Bleibt am unteren Rand stehen, solange das Formular zu sehen ist: Der Knopf lag vorher
|
||||
mehrere Bildschirmhöhen unter dem Feld, das man gerade geändert hatte. #}
|
||||
<div class="form-save-bar">
|
||||
<button class="primary" type="submit">Einstellungen speichern</button>
|
||||
<span class="muted">Gilt für alles von „Unternehmensdaten“ bis „Einwilligung“.</span>
|
||||
</div>
|
||||
</form>
|
||||
{% else %}
|
||||
<p class="card card-body muted">Unternehmensdaten, Knopf „nach oben“, Spamschutz und Einwilligung ändert nur ein Administrator.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="stack-section">
|
||||
<section class="stack-section" id="weiterleitungen">
|
||||
<div class="section-head"><h2>Weiterleitungen <span class="count">{{ redirects.total }}</span></h2><span class="muted">Werden vor dem 404 geprüft.</span></div>
|
||||
<form action="{{ path('admin_redirect_save') }}" method="post" class="card card-body">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('settings') }}">
|
||||
|
||||
@@ -65,7 +65,8 @@
|
||||
Betrachter den Klick ab - die Seite bleibt also in jedem Fall benutzbar. #}
|
||||
<a class="image-link image-zoom" href="{{ grossesBild }}" data-zoom data-caption="{{ props.caption|default('') }}" aria-label="{{ props.alt|default('Bild'|kant_t) }} {{ 'vergrößern'|kant_t }}">
|
||||
{%- elseif imageLinked -%}
|
||||
<a class="image-link" href="{{ props.href }}"{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>
|
||||
{# Ein Bild ohne Alternativtext - ein dekoratives Logo etwa - gäbe dem Link keinen Namen. #}
|
||||
<a class="image-link" href="{{ props.href }}"{% if props.alt|default('') == '' %} aria-label="{{ props.caption|default('') ?: (props.href == '/' ? 'Startseite'|kant_t : 'Link'|kant_t ~ ': ' ~ props.href) }}"{% endif %}{% include 'blocks/link-attributes.html.twig' with {newTab: props.newTab|default(false)} only %}>
|
||||
{%- endif -%}<img src="{{ props.src }}" alt="{{ props.alt }}" width="{{ props.width }}" height="{{ props.height }}" loading="{{ eager or inHeader|default(false) ? 'eager' : 'lazy' }}"{{ eager ? ' fetchpriority="high"' : '' }} decoding="async"{% if props.sources|default([]) %} srcset="{% for source in props.sources %}{{ source.src }} {{ source.width }}w{% if not loop.last %}, {% endif %}{% endfor %}" sizes="{{ imageSizes }}"{% endif %}>{% if klick == 'vergroessern' or imageLinked %}</a>{% endif %}{% if props.caption|default('') %}<figcaption>{{ props.caption }}</figcaption>{% endif %}</figure>
|
||||
{% elseif type == 'iconcard' %}
|
||||
<div id="{{ block.id }}" class="block-iconcard{{ designClasses }}">
|
||||
@@ -298,7 +299,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 {editing: editing|default(false), 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 %}
|
||||
{% include 'blocks/post-list.html.twig' with {editing: editing|default(false), 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, headingLevel: block.props.headingLevel|default(3) == 2 ? 2 : 3} only %}
|
||||
</div>
|
||||
{% elseif type == 'postnav' %}
|
||||
<div id="{{ block.id }}" class="block-postnav{{ designClasses }}">
|
||||
|
||||
@@ -18,7 +18,9 @@
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="post-body">
|
||||
<h3><a href="{{ post.href }}">{{ post.title }}</a></h3>
|
||||
{# Im Archiv folgt die Liste direkt auf die Hauptüberschrift, dort ist ein Beitrag eine
|
||||
Überschrift zweiter Ebene. In einer Seite steht sie unter einer Abschnittsüberschrift. #}
|
||||
<h{{ headingLevel|default(3) }}><a href="{{ post.href }}">{{ post.title }}</a></h{{ headingLevel|default(3) }}>
|
||||
{% if (showMeta ?? true) and (post.publishedAt or post.author) %}
|
||||
<p class="post-meta">
|
||||
{% if post.publishedAt %}<time datetime="{{ post.publishedAt|date('Y-m-d') }}">{{ post.publishedAt|date('d.m.Y') }}</time>{% endif %}
|
||||
|
||||
@@ -103,6 +103,10 @@ final class BacklogTest extends TestCase
|
||||
self::assertStringContainsString('<h1', $html);
|
||||
self::assertStringContainsString('Beiträge', $html);
|
||||
self::assertStringContainsString('Aus der Fußsektion', $html, 'Der Fuß samt verknüpfter Sektion steht unter dem Archiv.');
|
||||
// Auf die Hauptüberschrift folgt im Archiv die zweite Ebene, nicht die dritte - sonst
|
||||
// springt die Gliederung, und Lighthouse zieht dafür Punkte bei der Barrierefreiheit ab.
|
||||
self::assertMatchesRegularExpression('#<div class="post-body">\s*<h2><a href="/[a-z0-9-]+">#', $html);
|
||||
self::assertStringNotContainsString('<h3><a', $html);
|
||||
self::assertStringContainsString('#fuss-hinweis p{letter-spacing:0.3em}', $html, 'Eigenes CSS der Sektion kommt mit.');
|
||||
self::assertStringContainsString('scroll-top', $html, 'Der Nach-oben-Knopf ist da wie auf jeder Seite.');
|
||||
self::assertStringContainsString('<link rel="canonical" href="https://cms.test/beitraege">', $html);
|
||||
|
||||
@@ -1035,6 +1035,14 @@ final class CmsTest extends TestCase
|
||||
self::assertStringContainsString('<a class="image-link" href="/">', $html);
|
||||
self::assertStringContainsString('alt="Firmenlogo"', $html);
|
||||
|
||||
// Ein dekoratives Logo hat keinen Alternativtext - der Link bekäme sonst keinen Namen.
|
||||
$document['blocks'][array_key_last($document['blocks'])]['props'] = ['src' => '/media/'.str_repeat('a', 32).'/480', 'alt' => '', 'decorative' => true, 'width' => 480, 'height' => 120, 'href' => '/'];
|
||||
$document['blocks'][] = ['id' => 'siegel', 'type' => 'image', 'props' => ['src' => '/media/'.str_repeat('b', 32).'/480', 'alt' => '', 'decorative' => true, 'width' => 480, 'height' => 120, 'href' => '/kontakt']];
|
||||
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', $document, 2), 3);
|
||||
$html = $this->request('/')->getContent();
|
||||
self::assertStringContainsString('<a class="image-link" href="/" aria-label="Startseite">', $html);
|
||||
self::assertStringContainsString('<a class="image-link" href="/kontakt" aria-label="Link: /kontakt">', $html);
|
||||
|
||||
// Datum, Autor und Kategorie über einem Beitrag lassen sich abschalten - ein Beitrag mit
|
||||
// eigenem Kopfbild trägt den Titel schon, und die Zeile darüber steht dann im Weg.
|
||||
$this->publishedPost('projekt', 'Ein Projekt', '2026-02-01', ['Projekte'], []);
|
||||
|
||||
Reference in new issue
Block a user