Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
86da83f754 | ||
|
|
8b6b53fe0f | ||
|
|
732b86897c | ||
|
|
cecb0771db | ||
|
|
b08862b139 | ||
|
|
5e46c98825 | ||
|
|
4c663d8087 |
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.
|
||||
|
||||
+30
-1
@@ -1,5 +1,30 @@
|
||||
# Projektstatus
|
||||
|
||||
## Fassung 1.43.0: Oberfläche – lokale Sicherung im Editor, Einstellungen gegliedert, Archive barrierefrei (10.11.2026)
|
||||
|
||||
**Anlass:** Umut: „Ja, weiter“ – das vierte Paket aus der Komplettanalyse. ADR 130.
|
||||
|
||||
**Erledigt (keine Migration, kein Schemawechsel):**
|
||||
- E3: Der Editor sichert ungespeicherte Änderungen im Browser und bietet sie nach Absturz, geschlossenem Tab oder abgelaufener Sitzung zum Wiederherstellen an. Bewusst kein Autosave auf dem Server (ADR 130).
|
||||
- E6: Der Speicherstatus bleibt unter 1100 px Fensterbreite sichtbar (Kurzform „● Ungespeichert“, „Speichert …“, „● Konflikt“).
|
||||
- Einstellungen: Sprungleiste zu den acht Abschnitten, die beim Scrollen stehen bleibt; „Soziale Profile“ und „Öffnungszeiten“ zugeklappt mit Zusammenfassung, solange nichts eingetragen ist; die Speicherleiste steht am unteren Rand. Ein ungültiges Feld in einem zugeklappten Bereich klappt ihn beim Absenden auf.
|
||||
- Beitragsliste der Verwaltung: Kategorien, Schlagwörter und Bilder in je einer Abfrage statt je Beitrag.
|
||||
- B1: Im Archiv ist ein Beitragstitel eine Überschrift zweiter Ebene (in Seiten weiter dritter).
|
||||
- B2: Der Text im Archivkopf erbt die volle Textfarbe (vorher 4,48:1 auf dem getönten Grund).
|
||||
- B3: Der Sprunglink liegt über einem klebenden Kopf.
|
||||
- B4: Ein verlinktes Bild ohne Alternativtext gibt dem Link einen Namen.
|
||||
- `test:editor` startet wie alle anderen Prüfungen über `bin/dev-router.php`.
|
||||
|
||||
**Geprüft:** PHPUnit 701 grün (PHP 8.4, zwei Tests erweitert), `npm test` 41, php-cs-fixer, Twig-Lint, `test:editor` grün. Im Browser (Playwright, Pilotkopie): Titel ändern, 1 s warten – Sicherung liegt im Browser; neu laden – Angebot mit Uhrzeit; Wiederherstellen – Änderung im Baum, „Rückgängig“ aktiv; Speichern – auf dem Server und Sicherung weg; Verwerfen – Sicherung weg. Status bei 1000 px Breite sichtbar. Einstellungen: Sprung zu „Spamschutz“ landet unter der Leiste, Speicherleiste am unteren Rand sichtbar, 390 px ohne Querscrollen, ungültige Adresse klappt den Bereich auf. Aufnahmen `reports/lokale-sicherung-1.43.png`, `reports/einstellungen-1.43.png`. Nicht geprüft: PHP 8.2, `test:browser`, Safari, Lighthouse für `/beitraege` nach der Änderung (live erst nach dem Update messbar), die Abfragezahl der Beitragsliste als Zahl (im Produktionsmodus fehlt die Kopfzeile; Antwort lokal 119 ms).
|
||||
|
||||
**Bekannte Fehler / offen:** Aus der Analyse offen: E7 Ziehen im Strukturbaum, E9 Design-Panel verliert Suche und Fokus, E10 Trägheit bei großen Seiten, Symbole als SVG (rund 120 Stellen), Farb- und Maß-Tokens, Tabellen auf dem Telefon, B5–B7, P3/P4/P8/P9, F8.
|
||||
|
||||
**Release:** Tag `v1.43.0` auf `main`, Releaseeintrag mit Zip (3708 Dateien, 6,1 MB), `.sha256` und `install.php`, gebaut aus sauberem Checkout des Tags (ohne ImmoScout-Plugin); anonymer Download stimmt mit der Prüfsumme überein (5a7a83a1…), `releases/latest` meldet v1.43.0.
|
||||
|
||||
**Live nachgemessen (kant-it.de auf 1.43.0, 10.11.2026):** Lighthouse 13.4.1 (`audit.mjs`, je drei Läufe): `/`, `/projekte` und `/beitraege` mobil und Desktop überall 100 in allen fünf Bereichen – `/beitraege` vorher 95 bei der Barrierefreiheit (Überschriftenfolge und Kontrast im Archivkopf bestehen jetzt). Die Beitragsseite `/worga-baustellen-app-handwerk` bleibt bei 96: der Knopf „Zu worga.net“ hat weiße Schrift auf der Akzentfarbe #e63946 bei 14 px, Kontrast 4,16:1 – Inhalt und Design der Seite, kein Kernfehler. Im Archiv zehn Beitragstitel als `h2`, keiner als `h3`. „Bildgrößen ergänzen“ ist bei 4 von 18 Bildern der Startseite.
|
||||
|
||||
**Nächster Schritt:** Die übrigen Installationen in der Zentrale auf 1.43.0 bringen. Für kant-it.de entscheiden, ob der rote Knopf auf den Beitragsseiten eine dunklere Fläche (#c1121f, die Linkfarbe der Website, 5,9:1) oder größere fette Schrift bekommt. Früher notiert: live `/beitraege` und eine Beitragsseite mit Lighthouse nachmessen (Barrierefreiheit vorher 95 und 96).
|
||||
|
||||
## Fassung 1.42.0: Geschwindigkeit – kleinere Bilder fürs Telefon, Bilder ohne PHP (10.11.2026)
|
||||
|
||||
**Anlass:** Umut: „Ja, 1.42 angehen“ – das dritte Paket aus der Komplettanalyse. ADR 129.
|
||||
@@ -22,7 +47,11 @@ Das ist ein lokaler Vergleich mit dem PHP-Entwicklungsserver; der Gewinn kommt h
|
||||
|
||||
**Bekannte Fehler / offen:** PHPs eingebauter Server ohne Router antwortet mit 404 auf ein Bild, zu dessen Kennung ein Ordner im Abbild liegt (lokal immer mit `bin/dev-router.php` starten). Aus der Analyse offen: P3 OPcache-Anzeige, P4 `no-store`, P5-Rest (Brotkrumen), P8 Layoutsprünge im Schrittformular und beim Video, P9 Stylesheet teilen; dazu Oberfläche und Barrierefreiheit der Archive.
|
||||
|
||||
**Nächster Schritt:** Zweig `tempo-1.42` nach `main`, Tag `v1.42.0`, Releaseeintrag – nach Umuts Freigabe. Nach dem Update auf einer Live-Seite prüfen: ein Bild zweimal abrufen, der zweite Abruf muss ohne `Set-Cookie`/PHP-Kopfzeilen und spürbar schneller kommen; unter Aufgaben läuft „Bildgrößen ergänzen“.
|
||||
**Release:** Tag `v1.42.0` auf `main`, Releaseeintrag mit Zip (3708 Dateien, 6,1 MB), `.sha256` und `install.php`, gebaut aus sauberem Checkout des Tags (ohne ImmoScout-Plugin); anonymer Download stimmt mit der Prüfsumme überein (8fa187f9…), `releases/latest` meldet v1.42.0. Die Git-Anmeldung war wieder abgelaufen, Umut hat selbst gepusht.
|
||||
|
||||
**Live nachgemessen (kant-it.de, 10.11.2026, nach Umuts Update von 1.37.0 auf 1.42.0):** Die Regel greift beim Hoster. Erster Abruf eines Bildes über PHP (268 ms, mit CSP-Kopf der Anwendung), ab dem zweiten vom Webserver (ETag von Apache, 111–132 ms). Alle 59 Bildadressen der Startseite kommen danach statisch, im Mittel nach 112 ms statt vorher rund 250 ms – so schnell wie eine Skriptdatei. Eine unbekannte Breite antwortet weiter mit 404. „Bildgrößen ergänzen“ läuft: zum Messzeitpunkt trugen 2 von 18 Bildern der Startseite schon die Stufe 720. Lighthouse 13.4.1 (`audit.mjs`, simuliert, je drei Läufe) gegen die Messung vom Vormittag auf 1.37.0: LCP Desktop 642 → 425 ms (`/`), 534 → 398 ms (`/projekte`), 612 → 420 ms (Beitrag), 585 → 436 ms (`/beitraege`); mobil 1668 → 1690 ms (`/`), 1488 → 1499, 1711 → 1579, 1779 → 1697 ms. Übertragene Daten der Startseite mobil 1020 → 444 KB. Leistung überall 100 (Startseite Desktop vorher 99). Barrierefreiheit unverändert 95 auf `/beitraege` und 96 auf dem Beitrag (B1, B2 offen). Der Vergleich liegt Stunden und fünf Fassungen auseinander; mobil ist der LCP im Rahmen der Schwankung gleich.
|
||||
|
||||
**Nächster Schritt:** Die übrigen Installationen in der Zentrale auf 1.42.0 bringen. Dort jeweils prüfen: ein Bild zweimal abrufen, der zweite Abruf muss ohne `Set-Cookie`/PHP-Kopfzeilen und spürbar schneller kommen; unter Aufgaben läuft „Bildgrößen ergänzen“.
|
||||
|
||||
## Fassung 1.41.0: Stabilität – Altbestand lesbar, Konflikte, Sicherungen, Rückfragen (10.11.2026)
|
||||
|
||||
|
||||
@@ -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