From 41d87f62a49f6b9fae1d9a5f90a0be712129a69b Mon Sep 17 00:00:00 2001 From: Umut Kantarci Date: Mon, 14 Sep 2026 11:09:24 +0200 Subject: [PATCH] =?UTF-8?q?Erg=C3=A4nzt=20AJAX-Livesuche=20f=C3=BCr=20Seit?= =?UTF-8?q?en,=20Beitr=C3=A4ge=20und=20Mediathek?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 4 + VERSION | 2 +- docs/ARCHITECTURE.md | 10 + docs/STATUS.md | 8 + package.json | 3 +- public/assets/admin.css | 1 + public/assets/live-search.js | 94 +++++++++ scripts/admin-quality-check.mjs | 5 +- scripts/live-search-check.mjs | 162 +++++++++++++++ src/Content/MediaLibrary.php | 19 +- src/Controller/EditorController.php | 13 +- src/Controller/MediaController.php | 18 +- src/Infrastructure/ConnectionFactory.php | 3 + templates/admin/base.html.twig | 2 +- templates/admin/content-results.html.twig | 221 ++++++++++++++++++++ templates/admin/media-results.html.twig | 59 ++++++ templates/admin/media.html.twig | 74 +------ templates/admin/pages.html.twig | 236 +--------------------- templates/admin/search-feedback.html.twig | 2 + tests/EditorSecurityTest.php | 78 ++++++- 20 files changed, 703 insertions(+), 311 deletions(-) create mode 100644 public/assets/live-search.js create mode 100644 scripts/live-search-check.mjs create mode 100644 templates/admin/content-results.html.twig create mode 100644 templates/admin/media-results.html.twig create mode 100644 templates/admin/search-feedback.html.twig diff --git a/README.md b/README.md index a7aec70..d82bf6e 100644 --- a/README.md +++ b/README.md @@ -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. +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. **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. 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`. diff --git a/VERSION b/VERSION index bd8bf88..943f9cb 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.7.0 +1.7.1 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 5935d0a..bafb492 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -2,6 +2,16 @@ 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) 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. diff --git a/docs/STATUS.md b/docs/STATUS.md index b5a9b48..9d76ffe 100644 --- a/docs/STATUS.md +++ b/docs/STATUS.md @@ -1,5 +1,13 @@ # 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) **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. diff --git a/package.json b/package.json index 3acbf77..e3e69b5 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "test:browser": "node scripts/browser-check.mjs", "test:links": "node scripts/link-check.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": { "@axe-core/playwright": "^4.10.0", diff --git a/public/assets/admin.css b/public/assets/admin.css index 3b894db..99eee49 100644 --- a/public/assets/admin.css +++ b/public/assets/admin.css @@ -708,6 +708,7 @@ details.card > summary:hover { background: #fafbf8 } /* 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} +.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>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} diff --git a/public/assets/live-search.js b/public/assets/live-search.js new file mode 100644 index 0000000..ef8414b --- /dev/null +++ b/public/assets/live-search.js @@ -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(); +} diff --git a/scripts/admin-quality-check.mjs b/scripts/admin-quality-check.mjs index f4626b7..a87bb8d 100644 --- a/scripts/admin-quality-check.mjs +++ b/scripts/admin-quality-check.mjs @@ -102,14 +102,15 @@ try { assert.equal(await rowLinks().count(), 20); 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.waitForFunction(() => document.querySelectorAll('.rows article h3 a').length === 9); assert.equal(await rowLinks().count(), 9); 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); 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); 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); 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); diff --git a/scripts/live-search-check.mjs b/scripts/live-search-check.mjs new file mode 100644 index 0000000..d0f8ea6 --- /dev/null +++ b/scripts/live-search-check.mjs @@ -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(); } diff --git a/src/Content/MediaLibrary.php b/src/Content/MediaLibrary.php index e24e65e..863783e 100644 --- a/src/Content/MediaLibrary.php +++ b/src/Content/MediaLibrary.php @@ -39,17 +39,22 @@ final class MediaLibrary * * @return array{items: list>, 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'; - $total = (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'.$where); + $qb = $this->db->createQueryBuilder()->from('media'); + 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)); $pages = max(1, (int) ceil($total / $perPage)); $page = max(1, min($pages, $page)); - $items = $this->db->fetchAllAssociative( - 'SELECT * FROM media'.$where.' ORDER BY created_at DESC, id DESC LIMIT ? OFFSET ?', - [$perPage, ($page - 1) * $perPage], - ); + $items = $qb->select('*')->orderBy('created_at', 'DESC')->addOrderBy('id', 'DESC') + ->setMaxResults($perPage)->setFirstResult(($page - 1) * $perPage)->executeQuery()->fetchAllAssociative(); return [ 'items' => array_map($this->present(...), $items), diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php index e81a8bb..d827b55 100644 --- a/src/Controller/EditorController.php +++ b/src/Controller/EditorController.php @@ -54,14 +54,23 @@ final class EditorController extends AbstractController } unset($page); - return $this->render('admin/pages.html.twig', [ + $view = [ 'pages' => 'trash' === $status ? [] : $pages, 'trash' => 'trash' === $status ? array_map(static fn (array $p): array => [...$p, 'deletedAt' => $p['deleted_at']], $pages) : [], 'parentChoices' => $parentChoices, 'postLayouts' => PostLayouts::all(), 'contentType' => $type, 'result' => $result, 'query' => $query, 'status' => $status, 'listRoute' => 'post' === $type ? 'admin_posts' : 'admin_pages', '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'])] diff --git a/src/Controller/MediaController.php b/src/Controller/MediaController.php index 6b6467c..fefa8db 100644 --- a/src/Controller/MediaController.php +++ b/src/Controller/MediaController.php @@ -24,11 +24,18 @@ final class MediaController extends AbstractController // 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. $archived = $request->query->getBoolean('archiv'); + $query = mb_substr(trim($request->query->getString('q')), 0, 160); + $library = $this->media->page($request->query->getInt('seite', 1), $archived, query: $query); + $view = ['library' => $library, 'archived' => $archived, 'query' => $query]; + 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', [ - 'library' => $this->media->page($request->query->getInt('seite', 1), $archived), - 'archived' => $archived, - ]); + return $this->render('admin/media.html.twig', $view); } #[Route('/%kant.admin_path%/media/upload', name: 'admin_media_upload', methods: ['POST'])] @@ -90,7 +97,8 @@ final class MediaController extends AbstractController $query = array_filter([ 'seite' => $request->request->getInt('seite', 1) > 1 ? $request->request->getInt('seite') : 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); } diff --git a/src/Infrastructure/ConnectionFactory.php b/src/Infrastructure/ConnectionFactory.php index 24ff6b6..0519453 100644 --- a/src/Infrastructure/ConnectionFactory.php +++ b/src/Infrastructure/ConnectionFactory.php @@ -21,6 +21,9 @@ final class ConnectionFactory } $connection = DriverManager::getConnection($params, $configuration); 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 busy_timeout = 5000'); } diff --git a/templates/admin/base.html.twig b/templates/admin/base.html.twig index e3a39ab..e0269c0 100644 --- a/templates/admin/base.html.twig +++ b/templates/admin/base.html.twig @@ -18,7 +18,7 @@ Importzeile sie kennen muss. Sie steht vor jedem Modul-Skript; spaeter geladen wirkt sie nicht mehr. #} diff --git a/templates/admin/content-results.html.twig b/templates/admin/content-results.html.twig new file mode 100644 index 0000000..8ffd873 --- /dev/null +++ b/templates/admin/content-results.html.twig @@ -0,0 +1,221 @@ +{% set plural = contentType == 'post' ? 'Beiträge' : 'Seiten' %} +

{{ status == 'trash' ? 'Papierkorb' : plural }} {{ result.total }}

{{ result.total ? (result.page - 1) * result.limit + 1 : 0 }}–{{ min(result.page * result.limit, result.total) }} von {{ result.total }}
+{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten oben'} %} +
+{% 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. #} +
+
+ +
+

{{ page.title }}

+

/{{ 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 %}

+
+ {% if page.published_revision_id is null %} + Nicht veröffentlicht + {% elseif page.published_revision_id == page.draft_revision_id %} + Veröffentlicht + {% else %} + Entwurf geändert + {% endif %} + +
+ + {% 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. #} +
+ Einstellungen +
+ {% if is_granted('ROLE_EDITOR') %} +
+

{{ page.type == 'post' ? 'Beitragsdaten' : 'Seitendaten' }}

+
+ + + +
+
+ + +

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.

+
+
+ + +

Nicht indexierte Seiten erscheinen auch nicht in der Sitemap.

+
+
+ + +
+ {% if page.type == 'post' %} +
+ + +
+
+ + +
+
+ + +
+
+ + +

Mehrere durch Komma trennen. Unbekannte Namen werden angelegt.

+
+
+ + +
+
+ + +

Erscheint in der Übersicht und im Feed. Leer lassen, um die Seitenbeschreibung zu verwenden.

+
+ {% endif %} +
+ +
+
+ {% endif %} + + {% if is_granted('ROLE_EDITOR') and page.path != 'home' %} +
+

Adresse

+
+ + + +
+
+ + +
+
+ {% 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)} %} +

Unterseiten ziehen mit. Von jeder alten Adresse wird dauerhaft weitergeleitet.

+
+
+ +
+
+ {% endif %} + + {% if is_granted('ROLE_EDITOR') %} +
+

Kopf- und Fußbereich

+

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.

+
+ + + +
+ {% for kind, label in {header: 'Kopfbereich', footer: 'Fußbereich'} %} +
+ + +
+ {% endfor %} +
+ +
+
+ {% endif %} + + {% if is_granted('ROLE_PUBLISHER') %} +
+

Veröffentlichung planen

+
+ + + +
+ + +

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 Aufgaben.

+
+ +
+
+ {% endif %} + + {% if (is_granted('ROLE_EDITOR') and page.path != 'home') or is_granted('ROLE_ADMIN') %} +
+

Entfernen

+
+ {% if is_granted('ROLE_EDITOR') and page.path != 'home' %} +
+ + + + +
+ {% endif %} + {% if is_granted('ROLE_ADMIN') %} + {# The class is what the security test looks for: an editor must not even be + offered this. #} +
+
+ + + + +
+
+ {% endif %} +
+

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 %}

+
+ {% endif %} +
+
+ {% endif %} +
+{% else %} + {% if status != 'trash' %}

{{ result.total == 0 ? 'Keine passenden Einträge.' : '' }}

{% if result.total == 0 %}

{{ query or status != 'all' ? 'Ändere die Suche oder den Statusfilter.' : 'Lege oben deinen ersten Inhalt an.' }}

{% endif %}
{% endif %} +{% endfor %} +
+ +{% if trash is not empty %} + +
+ {% for entry in trash %} +
+ +
+

/{{ entry.path }}

+

Im Papierkorb seit UTC

+
+ {% if is_granted('ROLE_EDITOR') %} +
+
+ + + +
+
+ {% endif %} +
+ {% endfor %} +
+{% endif %} + +{% if status == 'trash' and trash is empty %}

Der Papierkorb ist leer.

{% 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'} %} diff --git a/templates/admin/media-results.html.twig b/templates/admin/media-results.html.twig new file mode 100644 index 0000000..4d0032f --- /dev/null +++ b/templates/admin/media-results.html.twig @@ -0,0 +1,59 @@ +
+

{{ archived ? 'Alle Bilder einschließlich Archiv' : 'Bilder' }} {{ library.total }}

+ {{ library.total ? (library.page - 1) * library.perPage + 1 : 0 }}–{{ min(library.page * library.perPage, library.total) }} von {{ library.total }} +
+ +{% if library.items is empty %} +
+

{{ query ? 'Keine passenden Bilder.' : (archived ? 'Keine Bilder vorhanden.' : 'Keine Bilder in der Mediathek.') }}

+

{{ query ? 'Ändere oder leere die Suche.' : (is_granted('ROLE_EDITOR') ? 'Lade oben dein erstes Bild hoch.' : 'Ein Redakteur kann Bilder hochladen.') }}

+
+{% else %} + + +{% if library.pages > 1 %} + +{% endif %} +{% endif %} + +

KantSites · Arbeitsstand

diff --git a/templates/admin/media.html.twig b/templates/admin/media.html.twig index 4c9a90e..ff0dc82 100644 --- a/templates/admin/media.html.twig +++ b/templates/admin/media.html.twig @@ -1,7 +1,7 @@ {% extends 'admin/shell.html.twig' %} {% set nav = 'media' %} {% block title %}Mediathek{% endblock %} -{% block scripts %}{% endblock %} +{% block scripts %}{% 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 content %} @@ -9,7 +9,7 @@ {% if is_granted('ROLE_EDITOR') %}
- + {# 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 @@ -30,66 +30,12 @@
{% endif %} -
-

{{ archived ? 'Alle Bilder einschließlich Archiv' : 'Bilder' }} {{ library.total }}

- - {% if archived %}Archivierte ausblenden{% else %}Archivierte anzeigen{% endif %} - {% if library.pages > 1 %} · Seite {{ library.page }} von {{ library.pages }}{% endif %} - -
- -{% if library.items is empty %} -
-

{{ archived ? 'Keine Bilder vorhanden.' : 'Keine Bilder in der Mediathek.' }}

-

{{ is_granted('ROLE_EDITOR') ? 'Lade oben dein erstes Bild hoch.' : 'Ein Redakteur kann Bilder hochladen.' }}

-
-{% else %} - - -{% if library.pages > 1 %} - -{% endif %} -{% endif %} - -

KantSites · Arbeitsstand

+ +{% include 'admin/search-feedback.html.twig' %} +
{% include 'admin/media-results.html.twig' %}
{% endblock %} diff --git a/templates/admin/pages.html.twig b/templates/admin/pages.html.twig index 223da2e..016a5cb 100644 --- a/templates/admin/pages.html.twig +++ b/templates/admin/pages.html.twig @@ -4,7 +4,7 @@ {% set singular = contentType == 'post' ? 'Beitrag' : 'Seite' %} {% block title %}{{ plural }}{% endblock %} {% block heading %}{{ plural }}{% endblock %} -{% block scripts %}{% endblock %} +{% block scripts %}{% endblock %} {% block lead %}{{ plural }} suchen, bearbeiten und veröffentlichen. Entwürfe bleiben bis zur Veröffentlichung privat.{% endblock %} {% block content %} @@ -43,231 +43,13 @@ {% endif %} - -

{{ status == 'trash' ? 'Papierkorb' : plural }} {{ result.total }}

{{ result.total ? (result.page - 1) * result.limit + 1 : 0 }}–{{ min(result.page * result.limit, result.total) }} von {{ result.total }}
-{% include 'admin/pager.html.twig' with {route: listRoute, params: {q: query, status: status, limit: result.limit}, previousLabel: 'Zurück', nextLabel: 'Weiter', label: 'Ergebnisseiten oben'} %} -
-{% 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. #} -
-
- -
-

{{ page.title }}

-

/{{ 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 %}

-
- {% if page.published_revision_id is null %} - Nicht veröffentlicht - {% elseif page.published_revision_id == page.draft_revision_id %} - Veröffentlicht - {% else %} - Entwurf geändert - {% endif %} - -
- - {% 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. #} -
- Einstellungen -
- {% if is_granted('ROLE_EDITOR') %} -
-

{{ page.type == 'post' ? 'Beitragsdaten' : 'Seitendaten' }}

-
- - - -
-
- - -

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.

-
-
- - -

Nicht indexierte Seiten erscheinen auch nicht in der Sitemap.

-
-
- - -
- {% if page.type == 'post' %} -
- - -
-
- - -
-
- - -
-
- - -

Mehrere durch Komma trennen. Unbekannte Namen werden angelegt.

-
-
- - -
-
- - -

Erscheint in der Übersicht und im Feed. Leer lassen, um die Seitenbeschreibung zu verwenden.

-
- {% endif %} -
- -
-
- {% endif %} - - {% if is_granted('ROLE_EDITOR') and page.path != 'home' %} -
-

Adresse

-
- - - -
-
- - -
-
- {% 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)} %} -

Unterseiten ziehen mit. Von jeder alten Adresse wird dauerhaft weitergeleitet.

-
-
- -
-
- {% endif %} - - {% if is_granted('ROLE_EDITOR') %} -
-

Kopf- und Fußbereich

-

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.

-
- - - -
- {% for kind, label in {header: 'Kopfbereich', footer: 'Fußbereich'} %} -
- - -
- {% endfor %} -
- -
-
- {% endif %} - - {% if is_granted('ROLE_PUBLISHER') %} -
-

Veröffentlichung planen

-
- - - -
- - -

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 Aufgaben.

-
- -
-
- {% endif %} - - {% if (is_granted('ROLE_EDITOR') and page.path != 'home') or is_granted('ROLE_ADMIN') %} -
-

Entfernen

-
- {% if is_granted('ROLE_EDITOR') and page.path != 'home' %} -
- - - - -
- {% endif %} - {% if is_granted('ROLE_ADMIN') %} - {# The class is what the security test looks for: an editor must not even be - offered this. #} -
-
- - - - -
-
- {% endif %} -
-

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 %}

-
- {% endif %} -
-
- {% endif %} -
-{% else %} - {% if status != 'trash' %}

{{ result.total == 0 ? 'Keine passenden Einträge.' : '' }}

{% if result.total == 0 %}

{{ query or status != 'all' ? 'Ändere die Suche oder den Statusfilter.' : 'Lege oben deinen ersten Inhalt an.' }}

{% endif %}
{% endif %} -{% endfor %} -
- -{% if trash is not empty %} - -
- {% for entry in trash %} -
- -
-

/{{ entry.path }}

-

Im Papierkorb seit UTC

-
- {% if is_granted('ROLE_EDITOR') %} -
-
- - - -
-
- {% endif %} -
- {% endfor %} -
-{% endif %} - -{% if status == 'trash' and trash is empty %}

Der Papierkorb ist leer.

{% 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'} %} +{% include 'admin/search-feedback.html.twig' %} +
{% include 'admin/content-results.html.twig' %}
{% endblock %} diff --git a/templates/admin/search-feedback.html.twig b/templates/admin/search-feedback.html.twig new file mode 100644 index 0000000..6d12dfe --- /dev/null +++ b/templates/admin/search-feedback.html.twig @@ -0,0 +1,2 @@ +

+ diff --git a/tests/EditorSecurityTest.php b/tests/EditorSecurityTest.php index 226e764..5bdfc21 100644 --- a/tests/EditorSecurityTest.php +++ b/tests/EditorSecurityTest.php @@ -142,6 +142,47 @@ final class EditorSecurityTest extends WebTestCase 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 '.$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('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 { $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]); self::assertResponseRedirects('/admin/media'); 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 { unlink($path); } @@ -593,6 +634,41 @@ final class EditorSecurityTest extends WebTestCase 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('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 { $client = $this->login('editor');