Ergänzt AJAX-Livesuche für Seiten, Beiträge und Mediathek
This commit is contained in:
1 parent
11b60fc5d8
commit
41d87f62a4
20 files changed
+702
-310
No files matched your search
@@ -153,6 +153,8 @@ Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfol
|
|||||||
|
|
||||||
**Seiten** und **Beiträge** sind getrennte Menüpunkte. Suche, Status einschließlich Papierkorb und 20/50 Einträge begrenzen jede Ergebnisliste bereits in der Datenbank. Vor/Zurück erhält die Filter; übergeordnete Seiten lassen sich unabhängig von der aktuellen Ergebnisseite suchen.
|
**Seiten** und **Beiträge** sind getrennte Menüpunkte. Suche, Status einschließlich Papierkorb und 20/50 Einträge begrenzen jede Ergebnisliste bereits in der Datenbank. Vor/Zurück erhält die Filter; übergeordnete Seiten lassen sich unabhängig von der aktuellen Ergebnisseite suchen.
|
||||||
|
|
||||||
|
Ab **1.7.1** aktualisieren sich Seiten, Beiträge und Mediathek beim Tippen per AJAX, ohne Enter oder Neuladen. Status, Anzahl und Blättern reagieren ebenfalls direkt. Der Cursor bleibt im Suchfeld; Zurück/Vor im Browser stellt die Filter wieder her. Die Mediathek sucht in Titel und Alternativtext über alle Ergebnisseiten. Menüs, Seitenauswahl und Editor-Suchfelder reagieren ebenfalls beim Tippen. Bei Ladefehlern bleiben die bisherigen Treffer sichtbar und „Erneut suchen“ wiederholt die Anfrage. Ohne JavaScript bleibt das Suchformular mit „Anwenden“ benutzbar.
|
||||||
|
|
||||||
Unter **Menüs** zuerst den Menüplatz wählen, links Inhalte suchen und zum Menü hinzufügen. Rechts lassen sich Beschriftung, Ziel, Reihenfolge und Unterpunkte bearbeiten. Pfeilbuttons sind per Tastatur erreichbar; Rückgängig stellt entfernte oder umgeordnete Einträge wieder her. Erst **Menü speichern** ändert die Website. Validierungs-, Verbindungs- und Versionsfehler erhalten die lokalen Eingaben. Ein paralleler Speicherstand muss bewusst neu geladen werden.
|
Unter **Menüs** zuerst den Menüplatz wählen, links Inhalte suchen und zum Menü hinzufügen. Rechts lassen sich Beschriftung, Ziel, Reihenfolge und Unterpunkte bearbeiten. Pfeilbuttons sind per Tastatur erreichbar; Rückgängig stellt entfernte oder umgeordnete Einträge wieder her. Erst **Menü speichern** ändert die Website. Validierungs-, Verbindungs- und Versionsfehler erhalten die lokalen Eingaben. Ein paralleler Speicherstand muss bewusst neu geladen werden.
|
||||||
|
|
||||||
**Bereiche** verwenden denselben Editor; Speichern wirkt sofort auf allen verknüpften Seiten. Unter **Formulare** ist die Meldung nach dem Absenden ein mehrzeiliges Feld über die verfügbare Formularbreite.
|
**Bereiche** verwenden denselben Editor; Speichern wirkt sofort auf allen verknüpften Seiten. Unter **Formulare** ist die Meldung nach dem Absenden ein mehrzeiliges Feld über die verfügbare Formularbreite.
|
||||||
@@ -164,3 +166,5 @@ Beim ersten Start von 1.7.0 wird eine vorhandene `.env.local` einmalig mit `.env
|
|||||||
Weitere Symfony-Overlays (`.env.prod`, `.env.prod.local`, `.env.local.php`) werden nicht mehr automatisch geladen. Falls ein Hosting sie bewusst verwendet hat, deren gewünschte Werte vor dem Wechsel in `.env` oder echte Servervariablen übernehmen. Der Installer, Setup und der Admin-Pfad-Befehl erzeugen keine `.env.local` mehr. Rückweg: alten Code und die beiden gesicherten Konfigurationsdateien gemeinsam wiederherstellen.
|
Weitere Symfony-Overlays (`.env.prod`, `.env.prod.local`, `.env.local.php`) werden nicht mehr automatisch geladen. Falls ein Hosting sie bewusst verwendet hat, deren gewünschte Werte vor dem Wechsel in `.env` oder echte Servervariablen übernehmen. Der Installer, Setup und der Admin-Pfad-Befehl erzeugen keine `.env.local` mehr. Rückweg: alten Code und die beiden gesicherten Konfigurationsdateien gemeinsam wiederherstellen.
|
||||||
|
|
||||||
Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`.
|
Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`.
|
||||||
|
|
||||||
|
Die Livesuche prüft `npm run test:search` mit derselben isolierten Pilotkopie: Eingabe ohne Enter, langsame Antworten, Fehler/Wiederholen, Filter, Browserhistorie, Mediathek, Tastaturfokus, mobile Ansicht und axe. Ergebnisse stehen in `reports/live-search/`. Die allgemeine Verwaltungsprüfung ohne erneuten öffentlichen Bewegungstest startet `npm run test:admin -- --admin-only`.
|
||||||
@@ -2,6 +2,16 @@
|
|||||||
|
|
||||||
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
|
Stand: 14.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||||
|
|
||||||
|
## ADR 079 – Livesuche mit gemeinsamen HTML-Ergebnissen (14.09.2026)
|
||||||
|
|
||||||
|
Seiten, Beiträge und Mediathek verwenden dieselben authentifizierten GET-Routen für vollständige Ansichten und AJAX. Der Header `X-Requested-With: XMLHttpRequest` fordert JSON mit einem serverseitig gerenderten Ergebnisfragment, Gesamtzahl und normalisierten Filtern an. Twig bleibt die einzige Darstellung für Listen, Berechtigungen, CSRF-Felder, leere Ergebnisse und Pagination. Suchformular sowie Anlegen/Hochladen bleiben außerhalb des austauschbaren Bereichs; vorhandene Formulare funktionieren weiter ohne JavaScript. Keine Schema- oder Inhaltsmigration.
|
||||||
|
|
||||||
|
Das Verwaltungsmodul wartet beim Tippen 220 ms und ersetzt nur die Ergebnisliste. Eine neue Eingabe bricht die alte Anfrage sofort ab; eine zusätzliche Sequenzprüfung verwirft verspätete Antworten. IME-Eingaben werden erst nach der Komposition gesucht. Beginnt während einer ausstehenden Suche eine Bearbeitung in der Liste, wird die Suche angehalten, damit die Antwort keine Eingabe entfernt. Nach dem Austausch werden Seitenauswahlen initialisiert und die Rückkehrfilter aller Formulare aktualisiert. Fehler erhalten die bisherigen Ergebnisse und bieten Wiederholen an. Ergebniszahl und Ladezustand sind für Screenreader zugänglich; Tastaturfokus bleibt beim Tippen erhalten.
|
||||||
|
|
||||||
|
URLs enthalten den Suchzustand. Tippen ersetzt den aktuellen Historieneintrag, Filterwahl und Blättern legen einen neuen an; `popstate` lädt passende Ergebnisse und stellt die Felder wieder her. Ohne JavaScript arbeitet das GET-Formular mit Submit und regulären Links. Das Modul gehört ausschließlich zum Admin-Paket.
|
||||||
|
|
||||||
|
Die Mediathek filtert Titel und Alternativtext vor COUNT und LIMIT in der Datenbank, inklusive Archivfilter und wörtlicher Behandlung von `%`, `_` und `!`. SQLite erhält eine Unicode-fähige LOWER-Funktion über PDO/mb_strtolower, weil die eingebaute Funktion nur ASCII verarbeitet und etwa „über“ nicht zu „Über“ passte. MySQL verwendet weiterhin seine native Funktion. Die Korrektur gilt auch für Inhalts- und Menüsuche; bestehende Daten und Indizes werden nicht verändert.
|
||||||
|
|
||||||
## ADR 078 – Kontrollierbare Slider und Einblendungen beim Erscheinen (14.09.2026)
|
## ADR 078 – Kontrollierbare Slider und Einblendungen beim Erscheinen (14.09.2026)
|
||||||
|
|
||||||
Die bisherige Scroll-Zeitachse ersetzte unterschiedliche Effekte durch dieselbe Aufwärtsbewegung. `motion.js` beobachtet stattdessen sichtbare Blöcke und spielt die gewählte Richtung/Überblendung/Skalierung/Unschärfe einmal mit der Web Animations API. Verzögerungen bleiben erhalten. Ohne JavaScript sind alle Inhalte sichtbar; reduzierte Bewegung unterbindet die Wiedergabe und bricht laufende Animationen ab. Nur Dokumente mit einem Effekt laden das Modul, einschließlich Kopf/Fuß und verknüpften Sektionen. Inhalt und Dokumentschema bleiben unverändert.
|
Die bisherige Scroll-Zeitachse ersetzte unterschiedliche Effekte durch dieselbe Aufwärtsbewegung. `motion.js` beobachtet stattdessen sichtbare Blöcke und spielt die gewählte Richtung/Überblendung/Skalierung/Unschärfe einmal mit der Web Animations API. Verzögerungen bleiben erhalten. Ohne JavaScript sind alle Inhalte sichtbar; reduzierte Bewegung unterbindet die Wiedergabe und bricht laufende Animationen ab. Nur Dokumente mit einem Effekt laden das Modul, einschließlich Kopf/Fuß und verknüpften Sektionen. Inhalt und Dokumentschema bleiben unverändert.
|
||||||
|
|||||||
@@ -1,5 +1,13 @@
|
|||||||
# Projektstatus
|
# Projektstatus
|
||||||
|
|
||||||
|
## 1.7.1 – Livesuche in der Verwaltung (14.09.2026)
|
||||||
|
|
||||||
|
**Umgesetzt:** Seiten und Beiträge suchen beim Tippen per AJAX; Status, 20/50 Einträge und Pagination aktualisieren nur die Ergebnisliste. URL, Browser-Zurück/Vor, Eingabefokus und Rückkehrfilter beim Speichern bleiben zusammenhängend. Die Mediathek durchsucht Titel und Alternativtext über alle Seiten, kombiniert mit Archivfilter. Bestehende Suchen in Menüs, übergeordneten Seiten, Editor-Werkzeugkasten/Struktur/Eigenschaften und Bildauswahl reagieren bereits beim Tippen. Kein Enter nötig; ohne JavaScript bleibt „Anwenden“ erhalten. Alte Antworten werden verworfen, Fehler erhalten Treffer und Eingaben, Wiederholen ist direkt möglich. Eine während der Suche begonnene Bearbeitung hält den Austausch an. SQLite-Suche erkennt Groß-/Kleinschreibung auch bei Umlauten. Keine Datenmigration und kein zusätzliches öffentliches JavaScript.
|
||||||
|
|
||||||
|
**Geprüft:** PHP 8.4.22: `EditorSecurityTest|CmsTest` 332 Tests / 2832 Assertions; nach der abschließenden Korrektur zur Suchanfrage „0“ die fünf Livesuche-/Medienfälle erneut mit 111 Assertions. 30 JavaScript-Tests; 24 Admin-Twig-Vorlagen; Produktionscontainer; PHP-Stilprüfung über 131 Dateien ohne Befund. `npm run test:search`: sechs Browser-Prüfgruppen mit kopierten Kant-IT-Daten, darunter schnelle/überholte Anfragen, Fehler/Wiederholen, abgelaufene Sitzung, IME, Pagination/Historie, Seitenauswahl nach AJAX, Bearbeitung während laufender Suche, Medien und Betrieb ohne JavaScript. Desktop 1440 und Mobil 390: kein horizontaler Überlauf, axe ohne Verstöße; Screenshots visuell geprüft. `test:admin -- --admin-only` bestätigt zusätzlich Menüs, Formulare, Bereiche, Speichern/Laden sowie die Vorschau mit aktivierter Bewegung; Tablet/Mobil ohne axe-Befunde. Berichte: `reports/live-search/`, `reports/admin-quality/`; Testkonten und Änderungen nur in isolierten Datenbankkopien.
|
||||||
|
|
||||||
|
**Offen:** Veröffentlichung des geprüften Commits als Release 1.7.1. MySQL und fremde Hostings wurden in diesem Schritt nicht ausgeführt; die lokale Prüfung verwendet SQLite. Öffentliche Lighthouse-Messungen wurden für diese Verwaltungsänderung nicht wiederholt; Nachweise aus 1.7.0 stehen unten.
|
||||||
|
|
||||||
## 1.7.0 – Installation, Verwaltungsabläufe und Bewegung (14.09.2026)
|
## 1.7.0 – Installation, Verwaltungsabläufe und Bewegung (14.09.2026)
|
||||||
|
|
||||||
**Veröffentlicht:** Commit `495b268` und annotierter Tag `v1.7.0` sind auf `origin` hochgeladen. [Release 1.7.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.0) enthält ZIP, SHA-256-Datei und `install.php`. Gebaut aus dem exakten Commit: 3527 Archivdateien, 204 versionierte Quelldateien bytegenau verglichen, Produktionsabhängigkeiten ohne Entwicklungspakete. Archivintegrität, Prüfsumme im Installer, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env` vorhanden, keine `.env.local`) geprüft. Alle drei Anhänge anschließend ohne Anmeldung heruntergeladen und mit den lokalen Dateien verglichen; API meldet 1.7.0 als neuestes stabiles Release. ZIP-SHA-256: `b708f7dffad74e1def31325533b646557963b0f3e702c46bd85ee109fc22a192`. Das Update ist bereitgestellt, nicht auf Kundenhostings eingespielt.
|
**Veröffentlicht:** Commit `495b268` und annotierter Tag `v1.7.0` sind auf `origin` hochgeladen. [Release 1.7.0](https://git.kant-it.de/kantarci/KantSites/releases/tag/v1.7.0) enthält ZIP, SHA-256-Datei und `install.php`. Gebaut aus dem exakten Commit: 3527 Archivdateien, 204 versionierte Quelldateien bytegenau verglichen, Produktionsabhängigkeiten ohne Entwicklungspakete. Archivintegrität, Prüfsumme im Installer, Produktionscontainer/Cache und frische Installationsseite (HTTP 200, `.env` vorhanden, keine `.env.local`) geprüft. Alle drei Anhänge anschließend ohne Anmeldung heruntergeladen und mit den lokalen Dateien verglichen; API meldet 1.7.0 als neuestes stabiles Release. ZIP-SHA-256: `b708f7dffad74e1def31325533b646557963b0f3e702c46bd85ee109fc22a192`. Das Update ist bereitgestellt, nicht auf Kundenhostings eingespielt.
|
||||||
|
|||||||
+2
-1
@@ -9,7 +9,8 @@
|
|||||||
"test:browser": "node scripts/browser-check.mjs",
|
"test:browser": "node scripts/browser-check.mjs",
|
||||||
"test:links": "node scripts/link-check.mjs",
|
"test:links": "node scripts/link-check.mjs",
|
||||||
"audit": "node scripts/audit.mjs",
|
"audit": "node scripts/audit.mjs",
|
||||||
"test:admin": "node scripts/admin-quality-check.mjs"
|
"test:admin": "node scripts/admin-quality-check.mjs",
|
||||||
|
"test:search": "node scripts/live-search-check.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@axe-core/playwright": "^4.10.0",
|
"@axe-core/playwright": "^4.10.0",
|
||||||
|
|||||||
@@ -708,6 +708,7 @@ details.card > summary:hover { background: #fafbf8 }
|
|||||||
|
|
||||||
/* Search and forms share usable widths throughout administration. */
|
/* Search and forms share usable widths throughout administration. */
|
||||||
.field-wide{grid-column:1/-1;min-width:0}.field-wide textarea{width:100%;min-height:100px;resize:vertical}
|
.field-wide{grid-column:1/-1;min-width:0}.field-wide textarea{width:100%;min-height:100px;resize:vertical}
|
||||||
|
.live-search-ready .search-submit{display:none}.search-status{min-height:20px;margin:-12px 0 0}#list-results{scroll-margin-top:16px}#list-results>.section-head{margin-top:0}#list-results:focus-visible{outline:2px solid var(--accent);outline-offset:5px}#search-error p{margin-top:0}
|
||||||
.list-toolbar{display:flex;flex-wrap:wrap;align-items:end;gap:14px;padding:18px;margin:24px 0;background:var(--paper,#fff);border:1px solid var(--line,#dce3da);border-radius:12px}
|
.list-toolbar{display:flex;flex-wrap:wrap;align-items:end;gap:14px;padding:18px;margin:24px 0;background:var(--paper,#fff);border:1px solid var(--line,#dce3da);border-radius:12px}
|
||||||
.list-toolbar>div{min-width:140px}.list-toolbar .list-search{flex:1;min-width:220px}.list-toolbar input,.list-toolbar select{width:100%}
|
.list-toolbar>div{min-width:140px}.list-toolbar .list-search{flex:1;min-width:220px}.list-toolbar input,.list-toolbar select{width:100%}
|
||||||
.content-picker{position:relative;min-width:0}.picker-input{display:flex;gap:6px}.picker-input input{width:100%;min-width:0}.picker-input button{flex:none}
|
.content-picker{position:relative;min-width:0}.picker-input{display:flex;gap:6px}.picker-input input{width:100%;min-width:0}.picker-input button{flex:none}
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { setupPicker } from './page-picker.js';
|
||||||
|
|
||||||
|
const form = document.querySelector('[data-live-search]');
|
||||||
|
if (form) {
|
||||||
|
const results = document.querySelector('#list-results');
|
||||||
|
const status = document.querySelector('#search-status');
|
||||||
|
const error = document.querySelector('#search-error');
|
||||||
|
const search = form.querySelector('input[type="search"]');
|
||||||
|
const fields = [...form.querySelectorAll('input[name],select[name]')];
|
||||||
|
let timer, controller, sequence = 0, composing = false;
|
||||||
|
|
||||||
|
const cancel = () => { clearTimeout(timer); controller?.abort(); sequence++; results.removeAttribute('aria-busy'); };
|
||||||
|
const updateReset = () => {
|
||||||
|
form.querySelector('[data-search-reset]').hidden = !fields.some(field => field.value !== (field.dataset.searchDefault || ''));
|
||||||
|
};
|
||||||
|
const urlFor = parameters => {
|
||||||
|
const url = new URL(form.action);
|
||||||
|
for (const [name, value] of Object.entries(parameters)) {
|
||||||
|
const fallback = fields.find(field => field.name === name)?.dataset.searchDefault || (name === 'seite' ? '1' : '');
|
||||||
|
if (String(value) !== fallback) url.searchParams.set(name, String(value));
|
||||||
|
}
|
||||||
|
return url;
|
||||||
|
};
|
||||||
|
const currentUrl = () => urlFor({ ...Object.fromEntries(new FormData(form)), q: search.value.trim(), seite: 1 });
|
||||||
|
const restoreFilters = url => {
|
||||||
|
for (const field of fields) field.value = url.searchParams.get(field.name) ?? field.dataset.searchDefault ?? '';
|
||||||
|
updateReset();
|
||||||
|
};
|
||||||
|
|
||||||
|
async function load(url, navigation = 'replace', focusResults = false) {
|
||||||
|
cancel(); const request = sequence;
|
||||||
|
controller = new AbortController(); const signal = controller.signal;
|
||||||
|
results.setAttribute('aria-busy', 'true'); error.hidden = true;
|
||||||
|
status.textContent = 'Ergebnisse werden gesucht …';
|
||||||
|
updateReset();
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, { signal, credentials: 'same-origin', headers: { 'X-Requested-With': 'XMLHttpRequest', Accept: 'application/json' } });
|
||||||
|
if (response.redirected || response.status === 401 || response.status === 403) throw new Error('Deine Sitzung ist abgelaufen. Bitte erneut anmelden und die Suche wiederholen.');
|
||||||
|
if (!response.ok) throw new Error('Die Suche konnte nicht geladen werden. Die bisherigen Ergebnisse bleiben sichtbar.');
|
||||||
|
const data = await response.json();
|
||||||
|
if (signal.aborted || request !== sequence) return;
|
||||||
|
if (typeof data.html !== 'string' || !data.parameters || !Number.isInteger(data.total)) throw new Error('Die Suchantwort konnte nicht verarbeitet werden.');
|
||||||
|
const parsed = new DOMParser().parseFromString(data.html, 'text/html');
|
||||||
|
results.replaceChildren(...parsed.body.childNodes);
|
||||||
|
results.querySelectorAll('.content-picker').forEach(picker => setupPicker(picker));
|
||||||
|
// Keep create/upload forms outside the replaced list on the same filtered screen.
|
||||||
|
const context = { _list_query: data.parameters.q, _list_status: data.parameters.status, _list_limit: data.parameters.limit, _list_page: data.parameters.seite, q: data.parameters.q, archiv: data.parameters.archiv, seite: data.parameters.seite };
|
||||||
|
for (const [name, value] of Object.entries(context)) if (value !== undefined) {
|
||||||
|
document.querySelectorAll(`form:not([data-live-search]) input[type="hidden"][name="${name}"]`).forEach(input => { input.value = value; });
|
||||||
|
}
|
||||||
|
const canonical = urlFor(data.parameters);
|
||||||
|
if (navigation !== 'none' && canonical.href !== location.href) history[navigation === 'push' ? 'pushState' : 'replaceState'](null, '', canonical);
|
||||||
|
status.textContent = `${data.total} Treffer${data.parameters.q ? ` für „${data.parameters.q}“` : ''}.`;
|
||||||
|
if (focusResults) { results.focus({ preventScroll: true }); results.scrollIntoView({ block: 'start' }); }
|
||||||
|
} catch (failure) {
|
||||||
|
if (signal.aborted || request !== sequence) return;
|
||||||
|
error.querySelector('[role="alert"]').textContent = failure instanceof TypeError ? 'Verbindung unterbrochen. Die bisherigen Ergebnisse bleiben sichtbar.' : failure.message;
|
||||||
|
error.hidden = false; status.textContent = 'Suche fehlgeschlagen.';
|
||||||
|
} finally {
|
||||||
|
if (request === sequence) results.removeAttribute('aria-busy');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queue = () => {
|
||||||
|
cancel(); updateReset();
|
||||||
|
if (composing) return;
|
||||||
|
error.hidden = true; status.textContent = 'Ergebnisse werden gesucht …'; results.setAttribute('aria-busy', 'true');
|
||||||
|
timer = setTimeout(() => load(currentUrl()), search.value ? 220 : 0);
|
||||||
|
};
|
||||||
|
search.addEventListener('compositionstart', () => { composing = true; cancel(); });
|
||||||
|
search.addEventListener('compositionend', () => { composing = false; queue(); });
|
||||||
|
search.addEventListener('input', queue);
|
||||||
|
form.addEventListener('change', event => { if (event.target.matches('select')) load(currentUrl(), 'push'); });
|
||||||
|
form.addEventListener('submit', event => { event.preventDefault(); if (!composing) load(currentUrl()); });
|
||||||
|
form.querySelector('[data-search-reset]').addEventListener('click', event => {
|
||||||
|
event.preventDefault(); const url = new URL(form.action); restoreFilters(url); load(url, 'push'); search.focus();
|
||||||
|
});
|
||||||
|
results.addEventListener('click', event => {
|
||||||
|
const link = event.target.closest('.pager a');
|
||||||
|
if (!link || event.button !== 0 || event.ctrlKey || event.metaKey || event.shiftKey || event.altKey) return;
|
||||||
|
event.preventDefault(); load(new URL(link.href), 'push', true);
|
||||||
|
});
|
||||||
|
// A delayed response must not overwrite a form the user has just started editing.
|
||||||
|
results.addEventListener('input', () => {
|
||||||
|
if (results.getAttribute('aria-busy') === 'true') { cancel(); status.textContent = 'Suche angehalten, während du einen Eintrag bearbeitest.'; }
|
||||||
|
});
|
||||||
|
window.addEventListener('popstate', () => { const url = new URL(location.href); restoreFilters(url); load(url, 'none'); });
|
||||||
|
window.addEventListener('pagehide', cancel);
|
||||||
|
document.querySelector('#search-retry').addEventListener('click', () => load(currentUrl()));
|
||||||
|
form.classList.add('live-search-ready');
|
||||||
|
status.textContent = 'Die Suche aktualisiert sich beim Tippen.';
|
||||||
|
search.setAttribute('aria-controls', results.id);
|
||||||
|
updateReset();
|
||||||
|
}
|
||||||
@@ -102,14 +102,15 @@ try {
|
|||||||
assert.equal(await rowLinks().count(), 20);
|
assert.equal(await rowLinks().count(), 20);
|
||||||
const firstIds = await rowLinks().evaluateAll(links => links.map(link => link.href));
|
const firstIds = await rowLinks().evaluateAll(links => links.map(link => link.href));
|
||||||
await page.getByRole('navigation', { name: 'Ergebnisseiten oben', exact: true }).getByRole('link', { name: 'Weiter' }).click();
|
await page.getByRole('navigation', { name: 'Ergebnisseiten oben', exact: true }).getByRole('link', { name: 'Weiter' }).click();
|
||||||
|
await page.waitForFunction(() => document.querySelectorAll('.rows article h3 a').length === 9);
|
||||||
assert.equal(await rowLinks().count(), 9);
|
assert.equal(await rowLinks().count(), 9);
|
||||||
assert.equal(new URL(page.url()).searchParams.get('seite'), '2');
|
assert.equal(new URL(page.url()).searchParams.get('seite'), '2');
|
||||||
assert.equal((await rowLinks().evaluateAll(links => links.map(link => link.href))).some(id => firstIds.includes(id)), false);
|
assert.equal((await rowLinks().evaluateAll(links => links.map(link => link.href))).some(id => firstIds.includes(id)), false);
|
||||||
await page.getByLabel('Pro Seite', { exact: true }).selectOption('50');
|
await page.getByLabel('Pro Seite', { exact: true }).selectOption('50');
|
||||||
await page.getByRole('button', { name: 'Anwenden', exact: true }).click();
|
await page.waitForFunction(() => document.querySelectorAll('.rows article h3 a').length === 29);
|
||||||
assert.equal(await rowLinks().count(), 29);
|
assert.equal(await rowLinks().count(), 29);
|
||||||
await page.getByLabel('Beiträge durchsuchen').fill('nichtvorhandener-qa-suchbegriff');
|
await page.getByLabel('Beiträge durchsuchen').fill('nichtvorhandener-qa-suchbegriff');
|
||||||
await page.getByRole('button', { name: 'Anwenden', exact: true }).click();
|
await page.waitForFunction(() => document.querySelectorAll('.rows article h3 a').length === 0);
|
||||||
assert.equal(await rowLinks().count(), 0);
|
assert.equal(await rowLinks().count(), 0);
|
||||||
await page.goto(origin + '/admin/posts?seite=999'); assert.equal(await rowLinks().count(), 9);
|
await page.goto(origin + '/admin/posts?seite=999'); assert.equal(await rowLinks().count(), 9);
|
||||||
await page.goto(origin + '/admin'); assert.equal(await rowLinks().count(), 4);
|
await page.goto(origin + '/admin'); assert.equal(await rowLinks().count(), 4);
|
||||||
|
|||||||
@@ -0,0 +1,162 @@
|
|||||||
|
import { chromium } from 'playwright';
|
||||||
|
import AxeBuilder from '@axe-core/playwright';
|
||||||
|
import assert from 'node:assert/strict';
|
||||||
|
import { execFileSync, spawn } from 'node:child_process';
|
||||||
|
import { mkdir, writeFile } from 'node:fs/promises';
|
||||||
|
import { randomBytes } from 'node:crypto';
|
||||||
|
import net from 'node:net';
|
||||||
|
|
||||||
|
const php = process.env.PHP_BINARY || 'php';
|
||||||
|
const fixture = JSON.parse(execFileSync(php, ['tests/pilot-fixture.php'], { encoding: 'utf8', windowsHide: true }));
|
||||||
|
const environment = { ...process.env, APP_ENV: 'prod', APP_DEBUG: '0', APP_SECRET: randomBytes(32).toString('hex'), DATABASE_URL: fixture.databaseUrl, MEDIA_DIRECTORY: fixture.mediaDirectory };
|
||||||
|
execFileSync(php, ['bin/console', 'cache:clear', '--env=prod', '--no-debug'], { stdio: 'ignore', windowsHide: true, env: environment });
|
||||||
|
const probe = net.createServer(); 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', 'memory_limit=256M', '-S', `127.0.0.1:${port}`, '-t', 'public', 'bin/dev-router.php'], { windowsHide: true, stdio: 'ignore', env: { ...environment, SITE_ORIGIN: origin } });
|
||||||
|
const report = 'reports/live-search'; await mkdir(report, { recursive: true });
|
||||||
|
let browser, page;
|
||||||
|
const checks = [], errors = [];
|
||||||
|
try {
|
||||||
|
for (let attempt = 0; attempt < 60; attempt++) {
|
||||||
|
try { if ((await fetch(origin)).ok) break; } catch {}
|
||||||
|
if (attempt === 59) throw new Error('Pilot test server did not start');
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 200));
|
||||||
|
}
|
||||||
|
browser = await chromium.launch();
|
||||||
|
const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } });
|
||||||
|
page = await context.newPage(); page.setDefaultTimeout(15000);
|
||||||
|
page.on('pageerror', error => errors.push(error.message));
|
||||||
|
await page.goto(origin + '/admin/login');
|
||||||
|
await page.getByLabel('Benutzername').fill(fixture.username); await page.getByLabel('Passwort', { exact: true }).fill(fixture.password);
|
||||||
|
await page.getByRole('button', { name: 'Anmelden', exact: true }).click(); await page.waitForURL(origin + '/admin');
|
||||||
|
const search = () => page.locator('[data-live-search] input[type="search"]');
|
||||||
|
const rows = () => page.locator('.rows article h3 a');
|
||||||
|
const settled = () => page.waitForFunction(() => /^\d+ Treffer/.test(document.querySelector('#search-status')?.textContent) && !document.querySelector('#list-results').hasAttribute('aria-busy'));
|
||||||
|
const type = async value => { await search().fill(value); await settled(); };
|
||||||
|
const count = async expected => { await settled(); assert.equal(await rows().count(), expected); };
|
||||||
|
const mark = async () => page.evaluate(() => { window.searchDocumentMarker = 'same-document'; });
|
||||||
|
const sameDocument = async () => assert.equal(await page.evaluate(() => window.searchDocumentMarker), 'same-document');
|
||||||
|
const next = () => page.locator('.pager').first().getByRole('link', { name: 'Weiter' });
|
||||||
|
|
||||||
|
await page.goto(origin + '/admin/posts'); await mark();
|
||||||
|
assert.equal(await rows().count(), 20);
|
||||||
|
await next().click(); await count(9); await sameDocument();
|
||||||
|
assert.equal(new URL(page.url()).searchParams.get('seite'), '2');
|
||||||
|
await page.goBack(); await count(20);
|
||||||
|
await page.goForward(); await count(9);
|
||||||
|
await page.getByLabel('Pro Seite', { exact: true }).selectOption('50'); await count(29);
|
||||||
|
assert.equal(new URL(page.url()).searchParams.has('seite'), false);
|
||||||
|
await type('nichtvorhandener-qa-begriff'); await count(0); await sameDocument();
|
||||||
|
assert.equal(await search().evaluate(input => input === document.activeElement), true);
|
||||||
|
assert.equal(await page.locator('.create-form input[name="_list_query"]').inputValue(), 'nichtvorhandener-qa-begriff');
|
||||||
|
await type(''); await count(29);
|
||||||
|
await page.locator('[data-search-reset]').click(); await count(20);
|
||||||
|
assert.equal(await page.getByLabel('Pro Seite', { exact: true }).inputValue(), '20');
|
||||||
|
await page.getByLabel('Status', { exact: true }).selectOption('trash'); await count(0);
|
||||||
|
await page.goBack(); await count(20);
|
||||||
|
assert.equal(await page.getByLabel('Status', { exact: true }).inputValue(), 'all');
|
||||||
|
checks.push('posts: typing, focus, filter, page size, pagination, reset, back/forward and form context without navigation');
|
||||||
|
|
||||||
|
// Let an old server response arrive after the latest query has already completed.
|
||||||
|
let delayed;
|
||||||
|
const oldRequest = new Promise(resolve => { delayed = resolve; });
|
||||||
|
await page.route(url => url.pathname === '/admin/posts' && url.searchParams.get('q') === 'old-delayed-search', async route => {
|
||||||
|
const response = await route.fetch(); delayed();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 800));
|
||||||
|
await route.fulfill({ response }).catch(() => {});
|
||||||
|
});
|
||||||
|
await search().fill('old-delayed-search'); await oldRequest;
|
||||||
|
await type(''); await count(20); await page.waitForTimeout(1000); await count(20);
|
||||||
|
await page.unrouteAll({ behavior: 'wait' });
|
||||||
|
let requests = 0;
|
||||||
|
const countRequests = request => { if (request.headers()['x-requested-with'] === 'XMLHttpRequest') requests++; };
|
||||||
|
page.on('request', countRequests);
|
||||||
|
await search().pressSequentially('fast-query', { delay: 25 }); await count(0);
|
||||||
|
assert.equal(requests, 1, 'rapid typing is debounced');
|
||||||
|
page.off('request', countRequests);
|
||||||
|
await type(''); await count(20);
|
||||||
|
await search().dispatchEvent('compositionstart'); await search().fill('ime-query');
|
||||||
|
await page.waitForTimeout(400); assert.equal(await rows().count(), 20);
|
||||||
|
await search().dispatchEvent('compositionend'); await count(0);
|
||||||
|
await type(''); await count(20);
|
||||||
|
checks.push('stale responses ignored; rapid input debounced; IME composition waits for completed text');
|
||||||
|
|
||||||
|
await page.route(url => url.pathname === '/admin/posts' && url.searchParams.get('q') === 'error-query', route => route.fulfill({ status: 500, body: 'Unavailable' }));
|
||||||
|
await search().fill('error-query'); await page.locator('#search-error').waitFor({ state: 'visible' });
|
||||||
|
assert.equal(await rows().count(), 20); assert.equal(await search().inputValue(), 'error-query');
|
||||||
|
await page.unrouteAll({ behavior: 'wait' }); await page.getByRole('button', { name: 'Erneut suchen' }).click(); await count(0);
|
||||||
|
assert.equal(await page.locator('#search-error').isVisible(), false);
|
||||||
|
await type(''); await count(20);
|
||||||
|
await page.route(url => url.pathname === '/admin/posts' && url.searchParams.get('q') === 'expired-session', route => route.fulfill({ status: 401, body: '' }));
|
||||||
|
await search().fill('expired-session'); await page.getByRole('alert').filter({ hasText: 'erneut anmelden' }).waitFor();
|
||||||
|
assert.equal(await rows().count(), 20); await page.unrouteAll({ behavior: 'wait' });
|
||||||
|
await type(''); await count(20);
|
||||||
|
checks.push('server error and expired session preserve results and input; retry succeeds');
|
||||||
|
|
||||||
|
await page.goto(origin + '/admin'); await mark();
|
||||||
|
await type('Impressum'); await count(1); await sameDocument();
|
||||||
|
assert.match(await rows().first().innerText(), /Impressum/);
|
||||||
|
await page.screenshot({ path: `${report}/pages-search.png`, fullPage: true });
|
||||||
|
await page.reload(); assert.equal(await search().inputValue(), 'Impressum'); assert.equal(await rows().count(), 1);
|
||||||
|
await type(''); await count(4);
|
||||||
|
const entry = page.locator('.rows article').filter({ hasText: 'Impressum' }).first();
|
||||||
|
await entry.locator('summary').first().click();
|
||||||
|
await entry.getByRole('combobox', { name: 'Übergeordnete Seite (optional)' }).fill('Datenschutz');
|
||||||
|
await entry.getByRole('option').filter({ hasText: 'Datenschutz' }).first().waitFor();
|
||||||
|
await entry.getByRole('option').filter({ hasText: 'Datenschutz' }).first().click();
|
||||||
|
assert.ok(await entry.locator('.picker-value').inputValue());
|
||||||
|
let pending;
|
||||||
|
const pendingRequest = new Promise(resolve => { pending = resolve; });
|
||||||
|
await page.route(url => url.pathname === '/admin' && url.searchParams.get('q') === 'late-edit-search', async route => {
|
||||||
|
const response = await route.fetch(); pending();
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 600));
|
||||||
|
await route.fulfill({ response }).catch(() => {});
|
||||||
|
});
|
||||||
|
await search().fill('late-edit-search'); await pendingRequest;
|
||||||
|
await entry.getByLabel('Titel für Suchergebnisse').fill('Ungespeicherte Bearbeitung');
|
||||||
|
await page.waitForTimeout(800);
|
||||||
|
assert.equal(await entry.getByLabel('Titel für Suchergebnisse').inputValue(), 'Ungespeicherte Bearbeitung');
|
||||||
|
assert.match(await page.locator('#search-status').innerText(), /angehalten/);
|
||||||
|
await page.unrouteAll({ behavior: 'wait' });
|
||||||
|
checks.push('pages: live title search, reloadable URLs, reinitialized parent picker after AJAX');
|
||||||
|
|
||||||
|
await page.goto(origin + '/admin/media'); await mark();
|
||||||
|
const library = await (await context.request.get(origin + '/admin/api/media')).json();
|
||||||
|
const target = library.items.filter(item => !item.archived).at(-1); assert.ok(target, 'pilot has media');
|
||||||
|
await type(target.title);
|
||||||
|
assert.ok((await page.locator('.library-body strong').allTextContents()).includes(target.title));
|
||||||
|
assert.equal(await page.locator('form[action$="/media/upload"] input[name="q"]').inputValue(), target.title);
|
||||||
|
await sameDocument();
|
||||||
|
await page.screenshot({ path: `${report}/media-search.png`, fullPage: true });
|
||||||
|
await page.getByLabel('Status', { exact: true }).selectOption('1'); await settled();
|
||||||
|
assert.equal(new URL(page.url()).searchParams.get('archiv'), '1');
|
||||||
|
await type('nichtvorhandenes-bild'); assert.equal(await page.locator('.library-tile').count(), 0);
|
||||||
|
await type(''); assert.ok(await page.locator('.library-tile').count());
|
||||||
|
const older = page.locator('.pager a[rel="next"]');
|
||||||
|
if (await older.count()) { await older.click(); await settled(); assert.equal(new URL(page.url()).searchParams.get('seite'), '2'); await sameDocument(); }
|
||||||
|
checks.push('media: global title search, archive filter, empty/reset states, pagination, upload context');
|
||||||
|
|
||||||
|
for (const width of [1440, 390]) {
|
||||||
|
await page.setViewportSize({ width, height: 900 });
|
||||||
|
for (const route of ['/admin', '/admin/posts', '/admin/media']) {
|
||||||
|
await page.goto(origin + route); await type('nichtvorhandener-begriff');
|
||||||
|
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true, `${route} fits ${width}`);
|
||||||
|
const audit = await new AxeBuilder({ page }).analyze();
|
||||||
|
assert.deepEqual(audit.violations.map(v => ({ id: v.id, targets: v.nodes.map(n => n.target) })), [], `${route} ${width}`);
|
||||||
|
if (width === 390) await page.screenshot({ path: `${report}/${route.split('/').pop()}-mobile.png`, fullPage: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const plain = await browser.newContext({ javaScriptEnabled: false, storageState: await context.storageState() });
|
||||||
|
const fallback = await plain.newPage(); await fallback.goto(origin + '/admin');
|
||||||
|
await fallback.getByLabel('Seiten durchsuchen').fill('Impressum');
|
||||||
|
await fallback.getByRole('button', { name: 'Anwenden', exact: true }).click();
|
||||||
|
assert.equal(await fallback.locator('.rows article h3 a').count(), 1);
|
||||||
|
await plain.close(); checks.push('desktop/mobile axe and overflow checks; HTML fallback without JavaScript');
|
||||||
|
assert.deepEqual(errors, []);
|
||||||
|
await writeFile(`${report}/result.json`, JSON.stringify({ checks, passed: true }, null, 2));
|
||||||
|
console.log('Live search browser checks passed:', checks.length, 'groups.');
|
||||||
|
} catch (error) {
|
||||||
|
if (page) await page.screenshot({ path: `${report}/failure.png`, fullPage: true }).catch(() => {});
|
||||||
|
throw error;
|
||||||
|
} finally { await browser?.close(); server.kill(); }
|
||||||
@@ -39,17 +39,22 @@ final class MediaLibrary
|
|||||||
*
|
*
|
||||||
* @return array{items: list<array<string, mixed>>, total: int, page: int, pages: int, perPage: int}
|
* @return array{items: list<array<string, mixed>>, total: int, page: int, pages: int, perPage: int}
|
||||||
*/
|
*/
|
||||||
public function page(int $page, bool $withArchived = false, int $perPage = self::PER_PAGE): array
|
public function page(int $page, bool $withArchived = false, int $perPage = self::PER_PAGE, string $query = ''): array
|
||||||
{
|
{
|
||||||
$where = $withArchived ? '' : ' WHERE archived = 0';
|
$qb = $this->db->createQueryBuilder()->from('media');
|
||||||
$total = (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'.$where);
|
if (!$withArchived) {
|
||||||
|
$qb->andWhere('archived = 0');
|
||||||
|
}
|
||||||
|
if ('' !== trim($query)) {
|
||||||
|
$qb->andWhere('(LOWER(title) LIKE :query ESCAPE \'!\' OR LOWER(alt) LIKE :query ESCAPE \'!\')')
|
||||||
|
->setParameter('query', '%'.str_replace(['!', '%', '_'], ['!!', '!%', '!_'], mb_strtolower(trim($query))).'%');
|
||||||
|
}
|
||||||
|
$total = (int) (clone $qb)->select('COUNT(*)')->executeQuery()->fetchOne();
|
||||||
$perPage = max(1, min(96, $perPage));
|
$perPage = max(1, min(96, $perPage));
|
||||||
$pages = max(1, (int) ceil($total / $perPage));
|
$pages = max(1, (int) ceil($total / $perPage));
|
||||||
$page = max(1, min($pages, $page));
|
$page = max(1, min($pages, $page));
|
||||||
$items = $this->db->fetchAllAssociative(
|
$items = $qb->select('*')->orderBy('created_at', 'DESC')->addOrderBy('id', 'DESC')
|
||||||
'SELECT * FROM media'.$where.' ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?',
|
->setMaxResults($perPage)->setFirstResult(($page - 1) * $perPage)->executeQuery()->fetchAllAssociative();
|
||||||
[$perPage, ($page - 1) * $perPage],
|
|
||||||
);
|
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'items' => array_map($this->present(...), $items),
|
'items' => array_map($this->present(...), $items),
|
||||||
|
|||||||
@@ -54,16 +54,25 @@ final class EditorController extends AbstractController
|
|||||||
}
|
}
|
||||||
unset($page);
|
unset($page);
|
||||||
|
|
||||||
return $this->render('admin/pages.html.twig', [
|
$view = [
|
||||||
'pages' => 'trash' === $status ? [] : $pages,
|
'pages' => 'trash' === $status ? [] : $pages,
|
||||||
'trash' => 'trash' === $status ? array_map(static fn (array $p): array => [...$p, 'deletedAt' => $p['deleted_at']], $pages) : [],
|
'trash' => 'trash' === $status ? array_map(static fn (array $p): array => [...$p, 'deletedAt' => $p['deleted_at']], $pages) : [],
|
||||||
'parentChoices' => $parentChoices, 'postLayouts' => PostLayouts::all(),
|
'parentChoices' => $parentChoices, 'postLayouts' => PostLayouts::all(),
|
||||||
'contentType' => $type, 'result' => $result, 'query' => $query, 'status' => $status,
|
'contentType' => $type, 'result' => $result, 'query' => $query, 'status' => $status,
|
||||||
'listRoute' => 'post' === $type ? 'admin_posts' : 'admin_pages',
|
'listRoute' => 'post' === $type ? 'admin_posts' : 'admin_pages',
|
||||||
'areaChoices' => ['header' => $this->partials->choices('header'), 'footer' => $this->partials->choices('footer')],
|
'areaChoices' => ['header' => $this->partials->choices('header'), 'footer' => $this->partials->choices('footer')],
|
||||||
|
];
|
||||||
|
if ($request->isXmlHttpRequest()) {
|
||||||
|
return $this->json([
|
||||||
|
'html' => $this->renderView('admin/content-results.html.twig', $view),
|
||||||
|
'total' => $result['total'],
|
||||||
|
'parameters' => ['q' => $query, 'status' => $status, 'limit' => $result['limit'], 'seite' => $result['page']],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $this->render('admin/pages.html.twig', $view);
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/api/content-choices', name: 'admin_content_choices', methods: ['GET'])]
|
#[Route('/api/content-choices', name: 'admin_content_choices', methods: ['GET'])]
|
||||||
public function contentChoices(Request $request): JsonResponse
|
public function contentChoices(Request $request): JsonResponse
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -24,13 +24,20 @@ final class MediaController extends AbstractController
|
|||||||
// The screen renders the images itself, page by page. The dialog stays what it is: the way
|
// The screen renders the images itself, page by page. The dialog stays what it is: the way
|
||||||
// to pick an image while editing, not the way to see what the library holds.
|
// to pick an image while editing, not the way to see what the library holds.
|
||||||
$archived = $request->query->getBoolean('archiv');
|
$archived = $request->query->getBoolean('archiv');
|
||||||
|
$query = mb_substr(trim($request->query->getString('q')), 0, 160);
|
||||||
return $this->render('admin/media.html.twig', [
|
$library = $this->media->page($request->query->getInt('seite', 1), $archived, query: $query);
|
||||||
'library' => $this->media->page($request->query->getInt('seite', 1), $archived),
|
$view = ['library' => $library, 'archived' => $archived, 'query' => $query];
|
||||||
'archived' => $archived,
|
if ($request->isXmlHttpRequest()) {
|
||||||
|
return $this->json([
|
||||||
|
'html' => $this->renderView('admin/media-results.html.twig', $view),
|
||||||
|
'total' => $library['total'],
|
||||||
|
'parameters' => ['q' => $query, 'archiv' => $archived ? '1' : '0', 'seite' => $library['page']],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $this->render('admin/media.html.twig', $view);
|
||||||
|
}
|
||||||
|
|
||||||
#[Route('/%kant.admin_path%/media/upload', name: 'admin_media_upload', methods: ['POST'])]
|
#[Route('/%kant.admin_path%/media/upload', name: 'admin_media_upload', methods: ['POST'])]
|
||||||
public function formUpload(Request $request): Response
|
public function formUpload(Request $request): Response
|
||||||
{
|
{
|
||||||
@@ -90,7 +97,8 @@ final class MediaController extends AbstractController
|
|||||||
$query = array_filter([
|
$query = array_filter([
|
||||||
'seite' => $request->request->getInt('seite', 1) > 1 ? $request->request->getInt('seite') : null,
|
'seite' => $request->request->getInt('seite', 1) > 1 ? $request->request->getInt('seite') : null,
|
||||||
'archiv' => $request->request->getBoolean('archiv') ? '1' : null,
|
'archiv' => $request->request->getBoolean('archiv') ? '1' : null,
|
||||||
]);
|
'q' => mb_substr(trim($request->request->getString('q')), 0, 160),
|
||||||
|
], static fn ($value): bool => null !== $value && '' !== $value);
|
||||||
|
|
||||||
return $this->redirectToRoute('admin_media', $query);
|
return $this->redirectToRoute('admin_media', $query);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,6 +21,9 @@ final class ConnectionFactory
|
|||||||
}
|
}
|
||||||
$connection = DriverManager::getConnection($params, $configuration);
|
$connection = DriverManager::getConnection($params, $configuration);
|
||||||
if ('pdo_sqlite' === $params['driver']) {
|
if ('pdo_sqlite' === $params['driver']) {
|
||||||
|
// SQLite's built-in LOWER only handles ASCII: searching for "über" must also find
|
||||||
|
// "Über uns", just as on MySQL. No database rewrite or search-specific schema needed.
|
||||||
|
$connection->getNativeConnection()->sqliteCreateFunction('lower', static fn (?string $value): ?string => null === $value ? null : mb_strtolower($value, 'UTF-8'), 1, \PDO::SQLITE_DETERMINISTIC);
|
||||||
$connection->executeStatement('PRAGMA foreign_keys = ON');
|
$connection->executeStatement('PRAGMA foreign_keys = ON');
|
||||||
$connection->executeStatement('PRAGMA busy_timeout = 5000');
|
$connection->executeStatement('PRAGMA busy_timeout = 5000');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie
|
Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie
|
||||||
nicht mehr. #}
|
nicht mehr. #}
|
||||||
<script type="importmap" nonce="{{ csp_nonce() }}">{"imports":{
|
<script type="importmap" nonce="{{ csp_nonce() }}">{"imports":{
|
||||||
{%- for modul in ['editor-model', 'media', 'design-panel', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider'] %}
|
{%- for modul in ['editor-model', 'media', 'design-panel', 'rich-text', 'canvas-overlay', 'library-previews', 'preview-containers', 'page-picker', 'motion', 'slider', 'live-search'] %}
|
||||||
"/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
|
"/assets/{{ modul }}.js": "/assets/{{ modul }}.js?v={{ kant_version() }}"{{ loop.last ? '' : ',' }}
|
||||||
{%- endfor %}
|
{%- endfor %}
|
||||||
}}</script>
|
}}</script>
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
{% set plural = contentType == 'post' ? 'Beiträge' : 'Seiten' %}
|
||||||
|
<div class="section-head"><h2>{{ status == 'trash' ? 'Papierkorb' : plural }} <span class="count">{{ result.total }}</span></h2><span class="muted">{{ result.total ? (result.page - 1) * result.limit + 1 : 0 }}–{{ min(result.page * result.limit, result.total) }} von {{ result.total }}</span></div>
|
||||||
|
{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten oben'} %}
|
||||||
|
<div class="rows">
|
||||||
|
{% for page in pages %}
|
||||||
|
{# Indentation by class, not by style attribute: the administration forbids inline styles, so
|
||||||
|
an attribute here would simply be dropped and the tree would look flat. #}
|
||||||
|
<article class="row row-stack depth-{{ min(page.depth, 3) }}">
|
||||||
|
<div class="row-line">
|
||||||
|
<span class="row-icon" aria-hidden="true">{{ page.type == 'post' ? '✎' : '▤' }}</span>
|
||||||
|
<div class="row-main">
|
||||||
|
<h3><a href="{{ path('admin_editor', {id: page.id}) }}">{{ page.title }}</a></h3>
|
||||||
|
<p>/{{ page.path == 'home' ? '' : page.path }} · {{ page.type == 'post' ? 'Beitrag' : 'Seite' }} · {{ page.locale|upper }}{% if page.publish_at %} · geplant für {{ page.publish_at|date('d.m.Y H:i') }} UTC{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
{% if page.published_revision_id is null %}
|
||||||
|
<span class="pill pill-off">Nicht veröffentlicht</span>
|
||||||
|
{% elseif page.published_revision_id == page.draft_revision_id %}
|
||||||
|
<span class="pill pill-live">Veröffentlicht</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="pill pill-draft">Entwurf geändert</span>
|
||||||
|
{% endif %}
|
||||||
|
<div class="row-actions">
|
||||||
|
<a class="secondary" href="{{ path('admin_editor', {id: page.id}) }}" aria-label="{{ page.title }} bearbeiten">Bearbeiten <span aria-hidden="true">→</span></a>
|
||||||
|
<a class="text-button" href="{{ path('admin_revisions', {id: page.id}) }}" aria-label="Fassungen von {{ page.title }}">Fassungen</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if is_granted('ROLE_EDITOR') or is_granted('ROLE_PUBLISHER') %}
|
||||||
|
{# One disclosure per row instead of six popovers next to each other: everything that is
|
||||||
|
not the daily „bearbeiten“ lives here and opens in place. #}
|
||||||
|
<details class="sheet">
|
||||||
|
<summary aria-label="Einstellungen von {{ page.title }}">Einstellungen</summary>
|
||||||
|
<div class="sheet-body">
|
||||||
|
{% if is_granted('ROLE_EDITOR') %}
|
||||||
|
<div class="sheet-part">
|
||||||
|
<h4>{{ page.type == 'post' ? 'Beitragsdaten' : 'Seitendaten' }}</h4>
|
||||||
|
<form action="{{ path('admin_post_meta', {id: page.id}) }}" method="post">
|
||||||
|
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||||
|
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||||
|
<div class="fields">
|
||||||
|
<div>
|
||||||
|
<label for="seotitle-{{ page.id }}">Titel für Suchergebnisse</label>
|
||||||
|
<input id="seotitle-{{ page.id }}" name="seoTitle" maxlength="160" value="{{ page.seo_title }}" placeholder="{{ page.document.title|default(page.path) }}">
|
||||||
|
<p class="field-help">Leer lassen, wenn der Titel der Seite auch in der Suche stehen soll. Google zeigt etwa 60 Zeichen und schneidet danach ab; was dahinter steht, liest niemand.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="robots-{{ page.id }}">Suchmaschinen</label>
|
||||||
|
<select id="robots-{{ page.id }}" name="robots">
|
||||||
|
<option value="index"{{ page.robots != 'noindex' ? ' selected' : '' }}>Darf indexiert werden</option>
|
||||||
|
<option value="noindex"{{ page.robots == 'noindex' ? ' selected' : '' }}>Nicht indexieren</option>
|
||||||
|
</select>
|
||||||
|
<p class="field-help">Nicht indexierte Seiten erscheinen auch nicht in der Sitemap.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="og-{{ page.id }}">Vorschaubild beim Teilen</label>
|
||||||
|
<input id="og-{{ page.id }}" name="ogMediaId" maxlength="32" value="{{ page.og_media_id }}" placeholder="ID aus der Mediathek">
|
||||||
|
</div>
|
||||||
|
{% if page.type == 'post' %}
|
||||||
|
<div>
|
||||||
|
<label for="date-{{ page.id }}">Veröffentlicht am</label>
|
||||||
|
<input id="date-{{ page.id }}" name="publishedAt" type="date" value="{{ page.published_at ? page.published_at|date('Y-m-d') : '' }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="author-{{ page.id }}">Autor</label>
|
||||||
|
<input id="author-{{ page.id }}" name="author" maxlength="120" value="{{ page.author }}">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="categories-{{ page.id }}">Kategorien</label>
|
||||||
|
<input id="categories-{{ page.id }}" name="categories" value="{{ page.terms.category }}" placeholder="Projekte, Referenzen">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="tags-{{ page.id }}">Schlagwörter</label>
|
||||||
|
<input id="tags-{{ page.id }}" name="tags" value="{{ page.terms.tag }}" placeholder="WordPress, Migration">
|
||||||
|
<p class="field-help">Mehrere durch Komma trennen. Unbekannte Namen werden angelegt.</p>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="media-{{ page.id }}">Beitragsbild</label>
|
||||||
|
<input id="media-{{ page.id }}" name="featuredMediaId" maxlength="32" value="{{ page.featured_media_id }}" placeholder="ID aus der Mediathek">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="excerpt-{{ page.id }}">Auszug</label>
|
||||||
|
<textarea id="excerpt-{{ page.id }}" name="excerpt" maxlength="500" rows="3">{{ page.excerpt }}</textarea>
|
||||||
|
<p class="field-help">Erscheint in der Übersicht und im Feed. Leer lassen, um die Seitenbeschreibung zu verwenden.</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<button class="primary" type="submit">Speichern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
|
||||||
|
<div class="sheet-part">
|
||||||
|
<h4>Adresse</h4>
|
||||||
|
<form action="{{ path('admin_move', {id: page.id}) }}" method="post">
|
||||||
|
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||||
|
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||||
|
<div class="fields">
|
||||||
|
<div>
|
||||||
|
<label for="slug-{{ page.id }}">Adresssegment</label>
|
||||||
|
<input id="slug-{{ page.id }}" name="slug" maxlength="80" required value="{{ page.slug }}" pattern="[a-z0-9]+(-[a-z0-9]+)*">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{% include 'admin/content-picker.html.twig' with {id: 'parent-' ~ page.id, name: 'parent_id', label: 'Übergeordnete Seite (optional)', parents: true, selected: parentChoices[page.parent_id]|default(null)} %}
|
||||||
|
<p class="field-help">Unterseiten ziehen mit. Von jeder alten Adresse wird dauerhaft weitergeleitet.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="secondary" type="submit">Adresse ändern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if is_granted('ROLE_EDITOR') %}
|
||||||
|
<div class="sheet-part">
|
||||||
|
<h4>Kopf- und Fußbereich</h4>
|
||||||
|
<p>Standard ist der gemeinsame Bereich. „Kein Bereich“ liefert die Seite ohne Kopf beziehungsweise Fuß aus – für eine Landingpage ohne Navigation. Weitere Bereiche legst du unter Bereiche an.</p>
|
||||||
|
<form action="{{ path('admin_page_areas', {id: page.id}) }}" method="post">
|
||||||
|
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||||
|
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||||
|
<div class="fields">
|
||||||
|
{% for kind, label in {header: 'Kopfbereich', footer: 'Fußbereich'} %}
|
||||||
|
<div>
|
||||||
|
<label for="{{ kind }}-{{ page.id }}">{{ label }}</label>
|
||||||
|
<select id="{{ kind }}-{{ page.id }}" name="{{ kind }}Area">
|
||||||
|
<option value=""{{ page[kind ~ '_area'] is null ? ' selected' : '' }}>Standard</option>
|
||||||
|
{% for slug, name in areaChoices[kind] %}{% if slug != 'default' %}
|
||||||
|
<option value="{{ slug }}"{{ page[kind ~ '_area'] == slug ? ' selected' : '' }}>{{ name }}</option>
|
||||||
|
{% endif %}{% endfor %}
|
||||||
|
<option value="none"{{ page[kind ~ '_area'] == 'none' ? ' selected' : '' }}>Kein Bereich</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button class="secondary" type="submit">Bereiche speichern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if is_granted('ROLE_PUBLISHER') %}
|
||||||
|
<div class="sheet-part">
|
||||||
|
<h4>Veröffentlichung planen</h4>
|
||||||
|
<form action="{{ path('admin_schedule', {id: page.id}) }}" method="post">
|
||||||
|
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||||
|
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||||
|
<div class="field">
|
||||||
|
<label for="publish-{{ page.id }}">Veröffentlichen am</label>
|
||||||
|
<input id="publish-{{ page.id }}" name="publishAt" type="datetime-local" value="{{ page.publish_at ? page.publish_at|date('Y-m-d\\TH:i') : '' }}">
|
||||||
|
<p class="field-help">Zeiten in UTC. Leer lassen, um die Planung zu entfernen. Veröffentlicht wird beim ersten Aufruf der Website nach diesem Zeitpunkt; wie oft nachgesehen wird, steht unter <a href="{{ path('admin_tasks') }}">Aufgaben</a>.</p>
|
||||||
|
</div>
|
||||||
|
<button class="secondary" type="submit">Zeitpunkt speichern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if (is_granted('ROLE_EDITOR') and page.path != 'home') or is_granted('ROLE_ADMIN') %}
|
||||||
|
<div class="sheet-part">
|
||||||
|
<h4>Entfernen</h4>
|
||||||
|
<div class="sheet-actions">
|
||||||
|
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
|
||||||
|
<form action="{{ path('admin_trash', {id: page.id}) }}" method="post">
|
||||||
|
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||||
|
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||||
|
<button class="secondary" type="submit">In den Papierkorb</button>
|
||||||
|
</form>
|
||||||
|
{% endif %}
|
||||||
|
{% if is_granted('ROLE_ADMIN') %}
|
||||||
|
{# The class is what the security test looks for: an editor must not even be
|
||||||
|
offered this. #}
|
||||||
|
<div class="delete-panel">
|
||||||
|
<form action="{{ path('admin_delete', {id: page.id}) }}" method="post">
|
||||||
|
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||||
|
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
||||||
|
<button class="subtle danger-text" type="submit">Endgültig löschen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
<p>Der Papierkorb ist umkehrbar: die Seite samt Unterseiten ist öffentlich nicht mehr erreichbar, alle Fassungen bleiben.{% if is_granted('ROLE_ADMIN') %} Endgültig löschen entfernt „{{ page.title }}“ mit allen Fassungen, ohne Papierkorb, und lässt sich nicht rückgängig machen.{% endif %}</p>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% else %}
|
||||||
|
{% if status != 'trash' %}<div class="empty"><h3>{{ result.total == 0 ? 'Keine passenden Einträge.' : '' }}</h3>{% if result.total == 0 %}<p>{{ query or status != 'all' ? 'Ändere die Suche oder den Statusfilter.' : 'Lege oben deinen ersten Inhalt an.' }}</p>{% endif %}</div>{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if trash is not empty %}
|
||||||
|
|
||||||
|
<div class="rows">
|
||||||
|
{% for entry in trash %}
|
||||||
|
<article class="row">
|
||||||
|
<span class="row-icon" aria-hidden="true">🗑</span>
|
||||||
|
<div class="row-main">
|
||||||
|
<h3>/{{ entry.path }}</h3>
|
||||||
|
<p>Im Papierkorb seit <time datetime="{{ entry.deletedAt|date('c') }}">{{ entry.deletedAt|date('d.m.Y H:i') }}</time> UTC</p>
|
||||||
|
</div>
|
||||||
|
{% if is_granted('ROLE_EDITOR') %}
|
||||||
|
<div class="row-actions">
|
||||||
|
<form action="{{ path('admin_restore_page', {id: entry.id}) }}" method="post">
|
||||||
|
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
||||||
|
<button class="secondary" type="submit">Wiederherstellen</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</article>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if status == 'trash' and trash is empty %}<div class="empty"><h3>Der Papierkorb ist leer.</h3></div>{% endif %}
|
||||||
|
{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten unten'} %}
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<div class="section-head">
|
||||||
|
<h2>{{ archived ? 'Alle Bilder einschließlich Archiv' : 'Bilder' }} <span class="count">{{ library.total }}</span></h2>
|
||||||
|
<span class="muted">{{ library.total ? (library.page - 1) * library.perPage + 1 : 0 }}–{{ min(library.page * library.perPage, library.total) }} von {{ library.total }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if library.items is empty %}
|
||||||
|
<div class="empty">
|
||||||
|
<h3>{{ query ? 'Keine passenden Bilder.' : (archived ? 'Keine Bilder vorhanden.' : 'Keine Bilder in der Mediathek.') }}</h3>
|
||||||
|
<p>{{ query ? 'Ändere oder leere die Suche.' : (is_granted('ROLE_EDITOR') ? 'Lade oben dein erstes Bild hoch.' : 'Ein Redakteur kann Bilder hochladen.') }}</p>
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<ul class="library-grid">
|
||||||
|
{% for item in library.items %}
|
||||||
|
<li class="library-tile">
|
||||||
|
{# The smallest variant is the thumbnail; the largest opens in a new tab. #}
|
||||||
|
<a href="{{ (item.variants|last).src }}" target="_blank" rel="noopener" aria-label="{{ item.title }} in Originalgröße öffnen">
|
||||||
|
<img src="{{ (item.variants|first).src }}" alt="" loading="lazy" width="{{ (item.variants|first).width }}" height="{{ (item.variants|first).height }}">
|
||||||
|
</a>
|
||||||
|
<div class="library-body">
|
||||||
|
<strong>{{ item.title }}</strong>
|
||||||
|
<span class="muted">{{ (item.variants|last).width }} × {{ (item.variants|last).height }} px{% if item.archived %} · archiviert{% endif %}</span>
|
||||||
|
<span class="muted">{{ item.alt ? 'Alternativtext gesetzt' : 'Kein Alternativtext' }}</span>
|
||||||
|
<code>{{ item.id }}</code>
|
||||||
|
</div>
|
||||||
|
{% if is_granted('ROLE_EDITOR') %}
|
||||||
|
<details class="sheet">
|
||||||
|
<summary aria-label="Angaben zu {{ item.title }}">Angaben</summary>
|
||||||
|
<div class="sheet-body">
|
||||||
|
<form action="{{ path('admin_media_save', {id: item.id}) }}" method="post">
|
||||||
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
|
||||||
|
<input type="hidden" name="version" value="{{ item.version }}">
|
||||||
|
<input type="hidden" name="seite" value="{{ library.page }}"><input type="hidden" name="q" value="{{ query }}">
|
||||||
|
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
|
||||||
|
<label for="title-{{ item.id }}">Bildtitel</label>
|
||||||
|
<input id="title-{{ item.id }}" name="title" maxlength="160" required value="{{ item.title }}">
|
||||||
|
<label for="alt-{{ item.id }}">Alternativtext</label>
|
||||||
|
<textarea id="alt-{{ item.id }}" name="alt" maxlength="300" rows="2">{{ item.alt }}</textarea>
|
||||||
|
<p class="field-help">Beschreibt, was zu sehen ist. Das ist, was Menschen mit Screenreader vom Bild haben. Bei rein dekorativen Bildern leer lassen.</p>
|
||||||
|
<label class="check-label" for="archived-{{ item.id }}"><input id="archived-{{ item.id }}" name="archived" type="checkbox" value="1"{{ item.archived ? ' checked' : '' }}> Archiviert</label>
|
||||||
|
<p class="field-help">Archivierte Bilder erscheinen nicht mehr in der Auswahl, bleiben aber ausgeliefert – eine Seite, die das Bild verwendet, verliert es nicht.</p>
|
||||||
|
<button class="primary" type="submit">Angaben speichern</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{% if library.pages > 1 %}
|
||||||
|
<nav class="pager" aria-label="Seiten der Mediathek">
|
||||||
|
{% if library.page > 1 %}<a class="secondary" href="{{ path('admin_media', {seite: library.page - 1, archiv: archived ? 1 : null, q: query}) }}" rel="prev"><span aria-hidden="true">←</span> Neuere</a>{% endif %}
|
||||||
|
<span class="muted">Seite {{ library.page }} von {{ library.pages }} · {{ library.total }} Bilder</span>
|
||||||
|
{% if library.page < library.pages %}<a class="secondary" href="{{ path('admin_media', {seite: library.page + 1, archiv: archived ? 1 : null, q: query}) }}" rel="next">Ältere <span aria-hidden="true">→</span></a>{% endif %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<p class="footnote">KantSites · Arbeitsstand</p>
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{% extends 'admin/shell.html.twig' %}
|
{% extends 'admin/shell.html.twig' %}
|
||||||
{% set nav = 'media' %}
|
{% set nav = 'media' %}
|
||||||
{% block title %}Mediathek{% endblock %}
|
{% block title %}Mediathek{% endblock %}
|
||||||
{% block scripts %}<script type="module" src="/assets/media-drop.js?v={{ kant_version() }}"></script>{% endblock %}
|
{% block scripts %}<script type="module" src="/assets/media-drop.js?v={{ kant_version() }}"></script><script type="module" src="/assets/live-search.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||||
{% block heading %}Mediathek{% endblock %}
|
{% block heading %}Mediathek{% endblock %}
|
||||||
{% block lead %}Alle Bilder, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus.{% endblock %}
|
{% block lead %}Alle Bilder, neueste zuerst. Beim Hochladen wird jedes Bild in WebP umgewandelt und in mehreren Breiten abgelegt; die Seite liefert dann die passende aus.{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
{% if is_granted('ROLE_EDITOR') %}
|
{% if is_granted('ROLE_EDITOR') %}
|
||||||
<form action="{{ path('admin_media_upload') }}" method="post" enctype="multipart/form-data" class="card card-body">
|
<form action="{{ path('admin_media_upload') }}" method="post" enctype="multipart/form-data" class="card card-body">
|
||||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
|
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
|
||||||
<input type="hidden" name="seite" value="{{ library.page }}">
|
<input type="hidden" name="seite" value="{{ library.page }}"><input type="hidden" name="q" value="{{ query }}">
|
||||||
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
|
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
|
||||||
{# A drop area rather than the browser's file widget. The input stays a real file input and
|
{# A drop area rather than the browser's file widget. The input stays a real file input and
|
||||||
stays focusable, so the form works by keyboard and without JavaScript; the script only adds
|
stays focusable, so the form works by keyboard and without JavaScript; the script only adds
|
||||||
@@ -30,66 +30,12 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<div class="section-head">
|
<form method="get" action="{{ path('admin_media') }}" class="list-toolbar" role="search" data-live-search>
|
||||||
<h2>{{ archived ? 'Alle Bilder einschließlich Archiv' : 'Bilder' }} <span class="count">{{ library.total }}</span></h2>
|
<div class="list-search"><label for="media-list-search">Bilder durchsuchen</label><input id="media-list-search" type="search" name="q" maxlength="160" value="{{ query }}" placeholder="Titel oder Alternativtext …"></div>
|
||||||
<span class="muted">
|
<div><label for="media-list-archive">Status</label><select id="media-list-archive" name="archiv" data-search-default="0"><option value="0"{{ not archived ? ' selected' : '' }}>Aktive Bilder</option><option value="1"{{ archived ? ' selected' : '' }}>Mit archivierten Bildern</option></select></div>
|
||||||
{% if archived %}<a href="{{ path('admin_media') }}">Archivierte ausblenden</a>{% else %}<a href="{{ path('admin_media', {archiv: 1}) }}">Archivierte anzeigen</a>{% endif %}
|
<button class="secondary search-submit" type="submit">Anwenden</button>
|
||||||
{% if library.pages > 1 %} · Seite {{ library.page }} von {{ library.pages }}{% endif %}
|
<a class="text-button" data-search-reset href="{{ path('admin_media') }}"{{ not query and not archived ? ' hidden' : '' }}>Zurücksetzen</a>
|
||||||
</span>
|
</form>
|
||||||
</div>
|
{% include 'admin/search-feedback.html.twig' %}
|
||||||
|
<div id="list-results" class="stack" role="region" aria-label="Suchergebnisse" tabindex="-1">{% include 'admin/media-results.html.twig' %}</div>
|
||||||
{% if library.items is empty %}
|
|
||||||
<div class="empty">
|
|
||||||
<h3>{{ archived ? 'Keine Bilder vorhanden.' : 'Keine Bilder in der Mediathek.' }}</h3>
|
|
||||||
<p>{{ is_granted('ROLE_EDITOR') ? 'Lade oben dein erstes Bild hoch.' : 'Ein Redakteur kann Bilder hochladen.' }}</p>
|
|
||||||
</div>
|
|
||||||
{% else %}
|
|
||||||
<ul class="library-grid">
|
|
||||||
{% for item in library.items %}
|
|
||||||
<li class="library-tile">
|
|
||||||
{# The smallest variant is the thumbnail; the largest opens in a new tab. #}
|
|
||||||
<a href="{{ (item.variants|last).src }}" target="_blank" rel="noopener" aria-label="{{ item.title }} in Originalgröße öffnen">
|
|
||||||
<img src="{{ (item.variants|first).src }}" alt="" loading="lazy" width="{{ (item.variants|first).width }}" height="{{ (item.variants|first).height }}">
|
|
||||||
</a>
|
|
||||||
<div class="library-body">
|
|
||||||
<strong>{{ item.title }}</strong>
|
|
||||||
<span class="muted">{{ (item.variants|last).width }} × {{ (item.variants|last).height }} px{% if item.archived %} · archiviert{% endif %}</span>
|
|
||||||
<span class="muted">{{ item.alt ? 'Alternativtext gesetzt' : 'Kein Alternativtext' }}</span>
|
|
||||||
<code>{{ item.id }}</code>
|
|
||||||
</div>
|
|
||||||
{% if is_granted('ROLE_EDITOR') %}
|
|
||||||
<details class="sheet">
|
|
||||||
<summary aria-label="Angaben zu {{ item.title }}">Angaben</summary>
|
|
||||||
<div class="sheet-body">
|
|
||||||
<form action="{{ path('admin_media_save', {id: item.id}) }}" method="post">
|
|
||||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('media-form') }}">
|
|
||||||
<input type="hidden" name="version" value="{{ item.version }}">
|
|
||||||
<input type="hidden" name="seite" value="{{ library.page }}">
|
|
||||||
<input type="hidden" name="archiv" value="{{ archived ? '1' : '' }}">
|
|
||||||
<label for="title-{{ item.id }}">Bildtitel</label>
|
|
||||||
<input id="title-{{ item.id }}" name="title" maxlength="160" required value="{{ item.title }}">
|
|
||||||
<label for="alt-{{ item.id }}">Alternativtext</label>
|
|
||||||
<textarea id="alt-{{ item.id }}" name="alt" maxlength="300" rows="2">{{ item.alt }}</textarea>
|
|
||||||
<p class="field-help">Beschreibt, was zu sehen ist. Das ist, was Menschen mit Screenreader vom Bild haben. Bei rein dekorativen Bildern leer lassen.</p>
|
|
||||||
<label class="check-label" for="archived-{{ item.id }}"><input id="archived-{{ item.id }}" name="archived" type="checkbox" value="1"{{ item.archived ? ' checked' : '' }}> Archiviert</label>
|
|
||||||
<p class="field-help">Archivierte Bilder erscheinen nicht mehr in der Auswahl, bleiben aber ausgeliefert – eine Seite, die das Bild verwendet, verliert es nicht.</p>
|
|
||||||
<button class="primary" type="submit">Angaben speichern</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
{% endif %}
|
|
||||||
</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
{% if library.pages > 1 %}
|
|
||||||
<nav class="pager" aria-label="Seiten der Mediathek">
|
|
||||||
{% if library.page > 1 %}<a class="secondary" href="{{ path('admin_media', {seite: library.page - 1, archiv: archived ? 1 : null}) }}" rel="prev"><span aria-hidden="true">←</span> Neuere</a>{% endif %}
|
|
||||||
<span class="muted">Seite {{ library.page }} von {{ library.pages }} · {{ library.total }} Bilder</span>
|
|
||||||
{% if library.page < library.pages %}<a class="secondary" href="{{ path('admin_media', {seite: library.page + 1, archiv: archived ? 1 : null}) }}" rel="next">Ältere <span aria-hidden="true">→</span></a>{% endif %}
|
|
||||||
</nav>
|
|
||||||
{% endif %}
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
<p class="footnote">KantSites · Arbeitsstand</p>
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
{% set singular = contentType == 'post' ? 'Beitrag' : 'Seite' %}
|
{% set singular = contentType == 'post' ? 'Beitrag' : 'Seite' %}
|
||||||
{% block title %}{{ plural }}{% endblock %}
|
{% block title %}{{ plural }}{% endblock %}
|
||||||
{% block heading %}{{ plural }}{% endblock %}
|
{% block heading %}{{ plural }}{% endblock %}
|
||||||
{% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script>{% endblock %}
|
{% block scripts %}<script type="module" src="/assets/live-search.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||||
{% block lead %}{{ plural }} suchen, bearbeiten und veröffentlichen. Entwürfe bleiben bis zur Veröffentlichung privat.{% endblock %}
|
{% block lead %}{{ plural }} suchen, bearbeiten und veröffentlichen. Entwürfe bleiben bis zur Veröffentlichung privat.{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
@@ -43,231 +43,13 @@
|
|||||||
</details>
|
</details>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
<form method="get" action="{{ path(listRoute) }}" class="list-toolbar" role="search">
|
<form method="get" action="{{ path(listRoute) }}" class="list-toolbar" role="search" data-live-search>
|
||||||
<div class="list-search"><label for="page-search">{{ plural }} durchsuchen</label><input id="page-search" type="search" name="q" value="{{ query }}" placeholder="Titel, Adresse oder Inhalt …"></div>
|
<div class="list-search"><label for="page-search">{{ plural }} durchsuchen</label><input id="page-search" type="search" name="q" maxlength="160" value="{{ query }}" placeholder="Titel, Adresse oder Inhalt …"></div>
|
||||||
<div><label for="page-status">Status</label><select id="page-status" name="status">{% for value, label in {all: 'Alle aktiven', draft: 'Entwürfe', published: 'Veröffentlicht', changed: 'Entwurf geändert', trash: 'Papierkorb'} %}<option value="{{ value }}"{{ status == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}</select></div>
|
<div><label for="page-status">Status</label><select id="page-status" name="status" data-search-default="all">{% for value, label in {all: 'Alle aktiven', draft: 'Entwürfe', published: 'Veröffentlicht', changed: 'Entwurf geändert', trash: 'Papierkorb'} %}<option value="{{ value }}"{{ status == value ? ' selected' : '' }}>{{ label }}</option>{% endfor %}</select></div>
|
||||||
<div><label for="page-limit">Pro Seite</label><select id="page-limit" name="limit">{% for size in [20, 50] %}<option value="{{ size }}"{{ result.limit == size ? ' selected' : '' }}>{{ size }}</option>{% endfor %}</select></div>
|
<div><label for="page-limit">Pro Seite</label><select id="page-limit" name="limit" data-search-default="20">{% for size in [20, 50] %}<option value="{{ size }}"{{ result.limit == size ? ' selected' : '' }}>{{ size }}</option>{% endfor %}</select></div>
|
||||||
<button class="secondary" type="submit">Anwenden</button>
|
<button class="secondary search-submit" type="submit">Anwenden</button>
|
||||||
{% if query or status != 'all' %}<a class="text-button" href="{{ path(listRoute) }}">Zurücksetzen</a>{% endif %}
|
<a class="text-button" data-search-reset href="{{ path(listRoute) }}"{{ not query and status == 'all' and result.limit == 20 ? ' hidden' : '' }}>Zurücksetzen</a>
|
||||||
</form>
|
</form>
|
||||||
<div class="section-head"><h2>{{ status == 'trash' ? 'Papierkorb' : plural }} <span class="count">{{ result.total }}</span></h2><span class="muted">{{ result.total ? (result.page - 1) * result.limit + 1 : 0 }}–{{ min(result.page * result.limit, result.total) }} von {{ result.total }}</span></div>
|
{% include 'admin/search-feedback.html.twig' %}
|
||||||
{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten oben'} %}
|
<div id="list-results" class="stack" role="region" aria-label="Suchergebnisse" tabindex="-1">{% include 'admin/content-results.html.twig' %}</div>
|
||||||
<div class="rows">
|
|
||||||
{% for page in pages %}
|
|
||||||
{# Indentation by class, not by style attribute: the administration forbids inline styles, so
|
|
||||||
an attribute here would simply be dropped and the tree would look flat. #}
|
|
||||||
<article class="row row-stack depth-{{ min(page.depth, 3) }}">
|
|
||||||
<div class="row-line">
|
|
||||||
<span class="row-icon" aria-hidden="true">{{ page.type == 'post' ? '✎' : '▤' }}</span>
|
|
||||||
<div class="row-main">
|
|
||||||
<h3><a href="{{ path('admin_editor', {id: page.id}) }}">{{ page.title }}</a></h3>
|
|
||||||
<p>/{{ page.path == 'home' ? '' : page.path }} · {{ page.type == 'post' ? 'Beitrag' : 'Seite' }} · {{ page.locale|upper }}{% if page.publish_at %} · geplant für {{ page.publish_at|date('d.m.Y H:i') }} UTC{% endif %}</p>
|
|
||||||
</div>
|
|
||||||
{% if page.published_revision_id is null %}
|
|
||||||
<span class="pill pill-off">Nicht veröffentlicht</span>
|
|
||||||
{% elseif page.published_revision_id == page.draft_revision_id %}
|
|
||||||
<span class="pill pill-live">Veröffentlicht</span>
|
|
||||||
{% else %}
|
|
||||||
<span class="pill pill-draft">Entwurf geändert</span>
|
|
||||||
{% endif %}
|
|
||||||
<div class="row-actions">
|
|
||||||
<a class="secondary" href="{{ path('admin_editor', {id: page.id}) }}" aria-label="{{ page.title }} bearbeiten">Bearbeiten <span aria-hidden="true">→</span></a>
|
|
||||||
<a class="text-button" href="{{ path('admin_revisions', {id: page.id}) }}" aria-label="Fassungen von {{ page.title }}">Fassungen</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if is_granted('ROLE_EDITOR') or is_granted('ROLE_PUBLISHER') %}
|
|
||||||
{# One disclosure per row instead of six popovers next to each other: everything that is
|
|
||||||
not the daily „bearbeiten“ lives here and opens in place. #}
|
|
||||||
<details class="sheet">
|
|
||||||
<summary aria-label="Einstellungen von {{ page.title }}">Einstellungen</summary>
|
|
||||||
<div class="sheet-body">
|
|
||||||
{% if is_granted('ROLE_EDITOR') %}
|
|
||||||
<div class="sheet-part">
|
|
||||||
<h4>{{ page.type == 'post' ? 'Beitragsdaten' : 'Seitendaten' }}</h4>
|
|
||||||
<form action="{{ path('admin_post_meta', {id: page.id}) }}" method="post">
|
|
||||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
|
||||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
|
||||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
|
||||||
<div class="fields">
|
|
||||||
<div>
|
|
||||||
<label for="seotitle-{{ page.id }}">Titel für Suchergebnisse</label>
|
|
||||||
<input id="seotitle-{{ page.id }}" name="seoTitle" maxlength="160" value="{{ page.seo_title }}" placeholder="{{ page.document.title|default(page.path) }}">
|
|
||||||
<p class="field-help">Leer lassen, wenn der Titel der Seite auch in der Suche stehen soll. Google zeigt etwa 60 Zeichen und schneidet danach ab; was dahinter steht, liest niemand.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="robots-{{ page.id }}">Suchmaschinen</label>
|
|
||||||
<select id="robots-{{ page.id }}" name="robots">
|
|
||||||
<option value="index"{{ page.robots != 'noindex' ? ' selected' : '' }}>Darf indexiert werden</option>
|
|
||||||
<option value="noindex"{{ page.robots == 'noindex' ? ' selected' : '' }}>Nicht indexieren</option>
|
|
||||||
</select>
|
|
||||||
<p class="field-help">Nicht indexierte Seiten erscheinen auch nicht in der Sitemap.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="og-{{ page.id }}">Vorschaubild beim Teilen</label>
|
|
||||||
<input id="og-{{ page.id }}" name="ogMediaId" maxlength="32" value="{{ page.og_media_id }}" placeholder="ID aus der Mediathek">
|
|
||||||
</div>
|
|
||||||
{% if page.type == 'post' %}
|
|
||||||
<div>
|
|
||||||
<label for="date-{{ page.id }}">Veröffentlicht am</label>
|
|
||||||
<input id="date-{{ page.id }}" name="publishedAt" type="date" value="{{ page.published_at ? page.published_at|date('Y-m-d') : '' }}">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="author-{{ page.id }}">Autor</label>
|
|
||||||
<input id="author-{{ page.id }}" name="author" maxlength="120" value="{{ page.author }}">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="categories-{{ page.id }}">Kategorien</label>
|
|
||||||
<input id="categories-{{ page.id }}" name="categories" value="{{ page.terms.category }}" placeholder="Projekte, Referenzen">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="tags-{{ page.id }}">Schlagwörter</label>
|
|
||||||
<input id="tags-{{ page.id }}" name="tags" value="{{ page.terms.tag }}" placeholder="WordPress, Migration">
|
|
||||||
<p class="field-help">Mehrere durch Komma trennen. Unbekannte Namen werden angelegt.</p>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="media-{{ page.id }}">Beitragsbild</label>
|
|
||||||
<input id="media-{{ page.id }}" name="featuredMediaId" maxlength="32" value="{{ page.featured_media_id }}" placeholder="ID aus der Mediathek">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label for="excerpt-{{ page.id }}">Auszug</label>
|
|
||||||
<textarea id="excerpt-{{ page.id }}" name="excerpt" maxlength="500" rows="3">{{ page.excerpt }}</textarea>
|
|
||||||
<p class="field-help">Erscheint in der Übersicht und im Feed. Leer lassen, um die Seitenbeschreibung zu verwenden.</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<button class="primary" type="submit">Speichern</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
|
|
||||||
<div class="sheet-part">
|
|
||||||
<h4>Adresse</h4>
|
|
||||||
<form action="{{ path('admin_move', {id: page.id}) }}" method="post">
|
|
||||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
|
||||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
|
||||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
|
||||||
<div class="fields">
|
|
||||||
<div>
|
|
||||||
<label for="slug-{{ page.id }}">Adresssegment</label>
|
|
||||||
<input id="slug-{{ page.id }}" name="slug" maxlength="80" required value="{{ page.slug }}" pattern="[a-z0-9]+(-[a-z0-9]+)*">
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
{% include 'admin/content-picker.html.twig' with {id: 'parent-' ~ page.id, name: 'parent_id', label: 'Übergeordnete Seite (optional)', parents: true, selected: parentChoices[page.parent_id]|default(null)} %}
|
|
||||||
<p class="field-help">Unterseiten ziehen mit. Von jeder alten Adresse wird dauerhaft weitergeleitet.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<button class="secondary" type="submit">Adresse ändern</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if is_granted('ROLE_EDITOR') %}
|
|
||||||
<div class="sheet-part">
|
|
||||||
<h4>Kopf- und Fußbereich</h4>
|
|
||||||
<p>Standard ist der gemeinsame Bereich. „Kein Bereich“ liefert die Seite ohne Kopf beziehungsweise Fuß aus – für eine Landingpage ohne Navigation. Weitere Bereiche legst du unter Bereiche an.</p>
|
|
||||||
<form action="{{ path('admin_page_areas', {id: page.id}) }}" method="post">
|
|
||||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
|
||||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
|
||||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
|
||||||
<div class="fields">
|
|
||||||
{% for kind, label in {header: 'Kopfbereich', footer: 'Fußbereich'} %}
|
|
||||||
<div>
|
|
||||||
<label for="{{ kind }}-{{ page.id }}">{{ label }}</label>
|
|
||||||
<select id="{{ kind }}-{{ page.id }}" name="{{ kind }}Area">
|
|
||||||
<option value=""{{ page[kind ~ '_area'] is null ? ' selected' : '' }}>Standard</option>
|
|
||||||
{% for slug, name in areaChoices[kind] %}{% if slug != 'default' %}
|
|
||||||
<option value="{{ slug }}"{{ page[kind ~ '_area'] == slug ? ' selected' : '' }}>{{ name }}</option>
|
|
||||||
{% endif %}{% endfor %}
|
|
||||||
<option value="none"{{ page[kind ~ '_area'] == 'none' ? ' selected' : '' }}>Kein Bereich</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
<button class="secondary" type="submit">Bereiche speichern</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if is_granted('ROLE_PUBLISHER') %}
|
|
||||||
<div class="sheet-part">
|
|
||||||
<h4>Veröffentlichung planen</h4>
|
|
||||||
<form action="{{ path('admin_schedule', {id: page.id}) }}" method="post">
|
|
||||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
|
||||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
|
||||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
|
||||||
<div class="field">
|
|
||||||
<label for="publish-{{ page.id }}">Veröffentlichen am</label>
|
|
||||||
<input id="publish-{{ page.id }}" name="publishAt" type="datetime-local" value="{{ page.publish_at ? page.publish_at|date('Y-m-d\\TH:i') : '' }}">
|
|
||||||
<p class="field-help">Zeiten in UTC. Leer lassen, um die Planung zu entfernen. Veröffentlicht wird beim ersten Aufruf der Website nach diesem Zeitpunkt; wie oft nachgesehen wird, steht unter <a href="{{ path('admin_tasks') }}">Aufgaben</a>.</p>
|
|
||||||
</div>
|
|
||||||
<button class="secondary" type="submit">Zeitpunkt speichern</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if (is_granted('ROLE_EDITOR') and page.path != 'home') or is_granted('ROLE_ADMIN') %}
|
|
||||||
<div class="sheet-part">
|
|
||||||
<h4>Entfernen</h4>
|
|
||||||
<div class="sheet-actions">
|
|
||||||
{% if is_granted('ROLE_EDITOR') and page.path != 'home' %}
|
|
||||||
<form action="{{ path('admin_trash', {id: page.id}) }}" method="post">
|
|
||||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
|
||||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
|
||||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
|
||||||
<button class="secondary" type="submit">In den Papierkorb</button>
|
|
||||||
</form>
|
|
||||||
{% endif %}
|
|
||||||
{% if is_granted('ROLE_ADMIN') %}
|
|
||||||
{# The class is what the security test looks for: an editor must not even be
|
|
||||||
offered this. #}
|
|
||||||
<div class="delete-panel">
|
|
||||||
<form action="{{ path('admin_delete', {id: page.id}) }}" method="post">
|
|
||||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
|
||||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
|
||||||
<input type="hidden" name="expectedVersion" value="{{ page.version }}">
|
|
||||||
<button class="subtle danger-text" type="submit">Endgültig löschen</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
<p>Der Papierkorb ist umkehrbar: die Seite samt Unterseiten ist öffentlich nicht mehr erreichbar, alle Fassungen bleiben.{% if is_granted('ROLE_ADMIN') %} Endgültig löschen entfernt „{{ page.title }}“ mit allen Fassungen, ohne Papierkorb, und lässt sich nicht rückgängig machen.{% endif %}</p>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
{% endif %}
|
|
||||||
</article>
|
|
||||||
{% else %}
|
|
||||||
{% if status != 'trash' %}<div class="empty"><h3>{{ result.total == 0 ? 'Keine passenden Einträge.' : '' }}</h3>{% if result.total == 0 %}<p>{{ query or status != 'all' ? 'Ändere die Suche oder den Statusfilter.' : 'Lege oben deinen ersten Inhalt an.' }}</p>{% endif %}</div>{% endif %}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{% if trash is not empty %}
|
|
||||||
|
|
||||||
<div class="rows">
|
|
||||||
{% for entry in trash %}
|
|
||||||
<article class="row">
|
|
||||||
<span class="row-icon" aria-hidden="true">🗑</span>
|
|
||||||
<div class="row-main">
|
|
||||||
<h3>/{{ entry.path }}</h3>
|
|
||||||
<p>Im Papierkorb seit <time datetime="{{ entry.deletedAt|date('c') }}">{{ entry.deletedAt|date('d.m.Y H:i') }}</time> UTC</p>
|
|
||||||
</div>
|
|
||||||
{% if is_granted('ROLE_EDITOR') %}
|
|
||||||
<div class="row-actions">
|
|
||||||
<form action="{{ path('admin_restore_page', {id: entry.id}) }}" method="post">
|
|
||||||
<input type="hidden" name="_list_type" value="{{ contentType }}"><input type="hidden" name="_list_query" value="{{ query }}"><input type="hidden" name="_list_status" value="{{ status }}"><input type="hidden" name="_list_page" value="{{ result.page }}"><input type="hidden" name="_list_limit" value="{{ result.limit }}">
|
|
||||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('pages') }}">
|
|
||||||
<button class="secondary" type="submit">Wiederherstellen</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</article>
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
|
|
||||||
{% if status == 'trash' and trash is empty %}<div class="empty"><h3>Der Papierkorb ist leer.</h3></div>{% endif %}
|
|
||||||
{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten unten'} %}
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<p id="search-status" class="field-help search-status" role="status" aria-live="polite" aria-atomic="true"></p>
|
||||||
|
<div id="search-error" class="notice error" hidden><p role="alert"></p><button id="search-retry" class="secondary" type="button">Erneut suchen</button></div>
|
||||||
@@ -142,6 +142,47 @@ final class EditorSecurityTest extends WebTestCase
|
|||||||
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
self::assertSame(1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM page'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testLiveSearchDoesNotExposeContentToAnonymousRequests(): void
|
||||||
|
{
|
||||||
|
$client = self::createClient();
|
||||||
|
foreach (['/admin', '/admin/posts', '/admin/media'] as $url) {
|
||||||
|
$client->request('GET', $url.'?q=secret', server: ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest', 'HTTP_ACCEPT' => 'application/json']);
|
||||||
|
self::assertResponseRedirects('http://localhost/admin/login');
|
||||||
|
self::assertStringNotContainsString('"html":', $client->getResponse()->getContent());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testLiveSearchReturnsFilteredEscapedResultsWithTheSamePermissionsAsTheFullScreen(): void
|
||||||
|
{
|
||||||
|
$client = $this->login('editor');
|
||||||
|
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||||||
|
foreach (['page', 'post'] as $type) {
|
||||||
|
$document = DemoContent::pages()['home'];
|
||||||
|
$document['title'] = 'Suchprobe Über uns <img src=x onerror=alert(1)> '.$type;
|
||||||
|
$pages->createPage('suchprobe-'.$type, null, $type, $document);
|
||||||
|
}
|
||||||
|
foreach (['/admin' => 'page', '/admin/posts' => 'post'] as $url => $type) {
|
||||||
|
$headers = ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest'];
|
||||||
|
$client->request('GET', $url.'?q=suchprobe&status=draft&seite=99&limit=50', server: $headers);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
self::assertResponseHeaderSame('Content-Type', 'application/json');
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertSame(1, $data['total']);
|
||||||
|
self::assertSame(['q' => 'suchprobe', 'status' => 'draft', 'limit' => 50, 'seite' => 1], $data['parameters']);
|
||||||
|
self::assertStringContainsString('suchprobe-'.$type, $data['html']);
|
||||||
|
self::assertStringNotContainsString('suchprobe-'.('page' === $type ? 'post' : 'page'), $data['html']);
|
||||||
|
self::assertStringNotContainsString('<img src=x', $data['html']);
|
||||||
|
self::assertStringContainsString('<img src=x', $data['html']);
|
||||||
|
self::assertStringNotContainsString('delete-panel', $data['html']);
|
||||||
|
self::assertStringNotContainsString('<html', $data['html']);
|
||||||
|
self::assertStringContainsString('name="_list_query" value="suchprobe"', $data['html']);
|
||||||
|
$client->request('GET', $url.'?q='.rawurlencode('über uns'), server: $headers);
|
||||||
|
self::assertSame(1, json_decode($client->getResponse()->getContent(), true)['total']);
|
||||||
|
$client->request('GET', $url.'?q=suchprobe&status=published', server: $headers);
|
||||||
|
self::assertSame(0, json_decode($client->getResponse()->getContent(), true)['total']);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function testCreatingAPageNeedsACsrfTokenAndStartsAsAPrivateDraft(): void
|
public function testCreatingAPageNeedsACsrfTokenAndStartsAsAPrivateDraft(): void
|
||||||
{
|
{
|
||||||
$client = $this->login('editor');
|
$client = $this->login('editor');
|
||||||
@@ -553,7 +594,7 @@ final class EditorSecurityTest extends WebTestCase
|
|||||||
$client->request('POST', '/admin/media/'.$id, ['_csrf_token' => $token, 'title' => 'Nochmal', 'alt' => '', 'version' => 1]);
|
$client->request('POST', '/admin/media/'.$id, ['_csrf_token' => $token, 'title' => 'Nochmal', 'alt' => '', 'version' => 1]);
|
||||||
self::assertResponseRedirects('/admin/media');
|
self::assertResponseRedirects('/admin/media');
|
||||||
self::assertSame('Bergblick', $this->db->fetchOne('SELECT title FROM media WHERE id = ?', [$id]));
|
self::assertSame('Bergblick', $this->db->fetchOne('SELECT title FROM media WHERE id = ?', [$id]));
|
||||||
self::assertCount(1, $client->followRedirect()->filter('.notice.error'));
|
self::assertCount(1, $client->followRedirect()->filter('.notice.error:not([hidden])'));
|
||||||
} finally {
|
} finally {
|
||||||
unlink($path);
|
unlink($path);
|
||||||
}
|
}
|
||||||
@@ -593,6 +634,41 @@ final class EditorSecurityTest extends WebTestCase
|
|||||||
self::assertResponseRedirects('/admin/media?seite=2');
|
self::assertResponseRedirects('/admin/media?seite=2');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testMediaLiveSearchCoversAllPagesAltTextArchiveAndLiteralWildcards(): void
|
||||||
|
{
|
||||||
|
$client = $this->login('editor');
|
||||||
|
for ($number = 1; $number <= 27; ++$number) {
|
||||||
|
$this->db->insert('media', [
|
||||||
|
'id' => str_pad((string) $number, 32, '0', STR_PAD_LEFT),
|
||||||
|
'title' => 1 === $number ? 'Seltenes Motiv 100%_!' : 'Bild '.$number,
|
||||||
|
'alt' => 2 === $number ? 'Einzigartiger Alternativtext Äußerer Bereich' : '',
|
||||||
|
'variants' => json_encode([['src' => '/media/x/480', 'width' => 480, 'height' => 320, 'bytes' => 1]]),
|
||||||
|
'archived' => 27 === $number ? 1 : 0,
|
||||||
|
'version' => 1,
|
||||||
|
'created_at' => gmdate('Y-m-d H:i:s', 1700000000 + $number),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
foreach ([['SELTENES', false, 1], ['ALTERNATIVTEXT', false, 1], ['äußerer', false, 1], ['%_!', false, 1], ['Bild', false, 25], ['Bild', true, 26], ['fehlt', false, 0]] as [$query, $archived, $total]) {
|
||||||
|
$parameters = ['q' => $query, 'archiv' => $archived ? 1 : 0, 'seite' => 99];
|
||||||
|
$client->request('GET', '/admin/media?'.http_build_query($parameters), server: ['HTTP_X_REQUESTED_WITH' => 'XMLHttpRequest']);
|
||||||
|
self::assertResponseIsSuccessful();
|
||||||
|
$data = json_decode($client->getResponse()->getContent(), true, 512, JSON_THROW_ON_ERROR);
|
||||||
|
self::assertSame($total, $data['total'], $query);
|
||||||
|
self::assertSame($total > 24 ? 2 : 1, $data['parameters']['seite']);
|
||||||
|
self::assertStringNotContainsString('<html', $data['html']);
|
||||||
|
}
|
||||||
|
// The HTML fallback uses the same global search, and saving retains its context.
|
||||||
|
$crawler = $client->request('GET', '/admin/media?q=Seltenes');
|
||||||
|
self::assertCount(1, $crawler->filter('.library-tile'));
|
||||||
|
$form = $crawler->filter('.library-tile form')->form(['alt' => 'Geändert']);
|
||||||
|
$client->submit($form);
|
||||||
|
self::assertResponseRedirects('/admin/media?q=Seltenes');
|
||||||
|
self::assertCount(1, $client->followRedirect()->filter('.library-tile'));
|
||||||
|
$crawler = $client->request('GET', '/admin/media?q=0');
|
||||||
|
$client->submit($crawler->filter('.library-tile form')->first()->form(['alt' => 'Ziffernsuche']));
|
||||||
|
self::assertResponseRedirects('/admin/media?q=0');
|
||||||
|
}
|
||||||
|
|
||||||
public function testTheMandatoryLegalPagesAreCreatedPublishedAndLinkedEverywhere(): void
|
public function testTheMandatoryLegalPagesAreCreatedPublishedAndLinkedEverywhere(): void
|
||||||
{
|
{
|
||||||
$client = $this->login('editor');
|
$client = $this->login('editor');
|
||||||
|
|||||||
Reference in new issue
Block a user