diff --git a/README.md b/README.md
index b7fd2a1..7107393 100644
--- a/README.md
+++ b/README.md
@@ -109,4 +109,4 @@ Serverkonfiguration über echte Umgebungsvariablen oder eine nicht öffentliche
Deployment später mit `composer install --no-dev --optimize-autoloader`, Backup, Migration und Cacheaufbau. Composer darf auf einem geeigneten Build-Rechner laufen, sodass das Hosting weder Node noch Composer benötigt; CLI-Zugriff oder ein verlässlich dokumentierter Deploymentweg für Datenbankmigrationen bleibt zu klären. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
-Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
+Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt. Wie ein Update später über einen Git-Tag laufen soll, steht als Plan in `docs/UPDATE.md` – gebaut ist davon noch nichts.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index cdb5080..0dc9c21 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -392,4 +392,6 @@ Umut: „Ich finde mediathek komisch das sollte direkt laden in pagination“. E
Jetzt rendert `/admin/media` die Bilder selbst, 24 je Seite, neueste zuerst, mit Blättern über `?seite=` und einem Schalter für archivierte. Hochladen und Bildangaben laufen über gewöhnliche Formulare statt über `fetch`, also ohne JavaScript; dieser Bildschirm lädt weder `media.js` noch `editor.css`. Die Bild-ID steht in jeder Karte, weil die Felder für Beitragsbild und Teilen-Vorschau genau diese ID erwarten.
+Hochgeladen wird über eine Ablagefläche, weil Umut das Auswahlfeld des Browsers zu Recht hässlich fand. Darunter liegt weiter ein echtes `input[type=file]`, nur unsichtbar und trotzdem fokussierbar; die Fläche ist dessen `label`, und der Fokusring wird über `:focus-within` auf die Fläche gezeichnet. Ohne JavaScript bleibt genau das übrig: ein Dateifeld mit Beschriftung. Das Skript ergänzt Ziehen, benennt die gewählten Dateien und überspringt alles, was kein JPEG, PNG oder WebP ist – abgeschickt wird nie von selbst, der Knopf bleibt die Stelle, an der etwas passiert. Ein `required` hat das Feld nicht: der Browser kann seine Meldung nicht an etwas Unsichtbares hängen, und der Server weist einen leeren Upload ohnehin mit einem lesbaren Satz ab.
+
Der Dialog bleibt, was er ist: der Weg, beim Bearbeiten ein Bild auszuwählen. Zwei Wege zum gleichen Bestand mit verschiedenen Aufgaben – `MediaLibrary::list()` bleibt für den Dialog neben `page()` bestehen, weil der Dialog seinen eigenen Filter mitbringt.
diff --git a/docs/AUSBAUPLAN.md b/docs/AUSBAUPLAN.md
index 823d6af..f6e998e 100644
--- a/docs/AUSBAUPLAN.md
+++ b/docs/AUSBAUPLAN.md
@@ -145,7 +145,7 @@ Abnahme: Templatewechsel erhält die Inhalte. Das Referenzplugin lässt sich akt
*Braucht: nichts; teils vorziehbar.*
-~~Benutzerverwaltung und Passwortänderung in der Oberfläche.~~ — **erledigt am 12.09.2026** (ADR 030). Offen bleibt der Rest des Pakets: Inhaltscache mit gezielter Erneuerung beim Veröffentlichen. Bildformate und Ressourcenpakete prüfen. Sicherung und Wiederherstellung erproben. Updateverfahren mit Datenbankmigration und Rückfallstrategie dokumentieren.
+~~Benutzerverwaltung und Passwortänderung in der Oberfläche.~~ — **erledigt am 12.09.2026** (ADR 030). Offen bleibt der Rest des Pakets: Inhaltscache mit gezielter Erneuerung beim Veröffentlichen. Bildformate und Ressourcenpakete prüfen. Sicherung und Wiederherstellung erproben. Updateverfahren mit Datenbankmigration und Rückfallstrategie dokumentieren — **geplant am 12.09.2026 in `docs/UPDATE.md`**: neue Version über einen Git-Tag ziehen, mit Vorprüfung, Sicherung, Wartungszustand, Migration, Rauchtest und Rückweg. Umsetzung offen; zwei Entscheidungen hängen am Hosting (Composer auf dem Server oder `vendor/` im Tag, und ob es Shell-Zugriff gibt).
Abnahme: Veröffentlichen macht die Änderung sofort sichtbar und erneuert nur die betroffenen Einträge. Eine Wiederherstellung aus dem Backup ist nachgewiesen.
diff --git a/docs/QUALITY.md b/docs/QUALITY.md
index c4ed657..8868fb7 100644
--- a/docs/QUALITY.md
+++ b/docs/QUALITY.md
@@ -8,7 +8,9 @@
- Acht abgewiesene Kontoanlagen erreichen die Datenbank nicht, darunter ein Passwort mit Nullbyte und eines mit 73 Bytes – letzteres, weil bcrypt alles über 72 Bytes stillschweigend ignoriert und ein längeres Passwort damit kürzer wäre, als es aussieht.
- Die Migration lief mit Sicherung (`var/backups/before-users-*.db`) und erst als Probelauf gegen die Entwicklungsdatenbank: drei `ALTER TABLE ... ADD COLUMN`, kein Tabellenneubau.
- **Dabei aufgefallen:** der lokale Vorschauserver lieferte 500 für die neuen Bildschirme, weil sein Prozess einen älteren kompilierten Container hielt – `cache:clear` ohne `--env=prod` räumt den nicht ab. Kein Codefehler, aber der Grund, warum eine Änderung im Browser gegen einen frisch gestarteten Server anzusehen ist.
-- **Nicht geprüft:** kein Screenreader; Upload und Blättern in der Mediathek sind mit zwei Bildern geprüft, nicht mit einer großen Bibliothek.
+- Mediathek über das Formular geprüft: zwei Dateien in einem Absenden, Upload ohne Token wird abgewiesen und legt nichts an, Bildangaben mit Versionskonflikt (zweites Speichern mit derselben Version ändert nichts und meldet es), Archivfilter, und Blättern mit 26 eingesetzten Bildern – 24 auf Seite eins, neueste zuerst, `?seite=99` wird auf die letzte Seite begrenzt, und Speichern von Seite zwei kommt auf Seite zwei zurück.
+- Die Ablagefläche ist zweimal geprüft: im Browsertest über das echte Dateifeld (die Auswahl erscheint im Feld und wird benannt) und von Hand mit einem echten Drop aus zwei Dateien, von denen die Nicht-Bilddatei übersprungen und gemeldet wurde. Ohne JavaScript bleibt ein gewöhnliches Dateifeld mit Beschriftung übrig; abgeschickt wird nie automatisch.
+- **Nicht geprüft:** kein Screenreader; die Mediathek ist mit 2 und mit 26 Einträgen geprüft, nicht mit einer wirklich großen Bibliothek. Vom Updateverfahren ist nichts geprüft, weil nichts gebaut ist – `docs/UPDATE.md` ist ein Plan.
## Ergänzung: Verwaltung neu gestaltet, 12.09.2026
diff --git a/docs/STATUS.md b/docs/STATUS.md
index bfc9e66..8a33eb3 100644
--- a/docs/STATUS.md
+++ b/docs/STATUS.md
@@ -26,7 +26,7 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- Seitenerstellung und -löschung im Browser: `POST /admin/pages` (ab `ROLE_EDITOR`) legt Seite oder Beitrag mit gültigem Startdokument als privaten Entwurf an; `POST /admin/pages/{id}/delete` (nur `ROLE_ADMIN`) entfernt eine Seite mit allen Revisionen. Beide mit CSRF-Token, Fehlermeldungen als Flash und ohne JavaScript bedienbar.
- Adresshierarchie: neue Spalten `path`, `parent_id`, `type`, `created_at` über Migration `20260912000400`; websiteweit eindeutiger Pfad statt eindeutigem Slug, Tiefe bis vier Ebenen, Beiträge ohne Unterseiten, reservierte Adressen und Adressableitung aus dem Titel. Öffentliche Route bedient verschachtelte Pfade wie `/leistungen/webdesign`; ein Lookahead hält `/admin`, `/media` und `/assets` aus dem Auffangmuster.
- **Benutzerverwaltung** (ADR 030): Konten anlegen, Rolle ändern, Passwort setzen, Konto löschen – nur für Administratoren. Eine Rolle je Konto (Administrator, Veröffentlicher, Redakteur). **Ein Passwort oder eine Rolle zu ändern meldet jede offene Sitzung dieses Kontos ab**, weil die Sitzung Hash und Rollen gegen die Datenbank prüft; nur die Sitzung, die ihr eigenes Passwort ändert, bleibt angemeldet. Niemand kann sein eigenes Konto löschen oder herabstufen, deshalb bleibt immer ein Administrator übrig. Das eigene Passwort darf jeder ändern, aber nur mit dem aktuellen. Die Liste zeigt letzte Anmeldung, Passwortdatum und Anlagedatum.
-- **Mediathek als Seite** (ADR 031): `/admin/media` zeigt die Bilder direkt, 24 je Seite, mit Blättern und Archivfilter. Hochladen und Bildangaben über gewöhnliche Formulare, ohne JavaScript. Der Dialog bleibt für die Bildauswahl im Editor.
+- **Mediathek als Seite** (ADR 031): `/admin/media` zeigt die Bilder direkt, 24 je Seite, mit Blättern und Archivfilter. Hochladen und Bildangaben über gewöhnliche Formulare, ohne JavaScript. Hochgeladen wird über eine **Ablagefläche**: Bilder hineinziehen oder klicken; darunter steht, welche Dateien gewählt sind und welche übersprungen wurden. Darunter liegt ein echtes, per Tastatur erreichbares Dateifeld – ohne JavaScript bleibt es der Dateidialog, und abgeschickt wird immer erst auf Knopfdruck. Der Dialog bleibt für die Bildauswahl im Editor.
- **Verwaltung neu gestaltet** (ADR 029): ein Rahmen für alle Bildschirme mit fester Seitenleiste, aktivem Eintrag über `aria-current`, Titel, einem Satz Erklärung und Platz für die Hauptaktion. Vorher trug jede Seite ihre eigene Kopfzeile und eine eigene, unvollständige Linkliste. Je Seite eine Zeile mit dem Alltäglichen und **einem** Aufklappfeld „Einstellungen“, das an seinem Platz öffnet, statt sechs Kästen, die über den Rand hinausragten. Tabellen scrollen seitlich, statt die Karte aus dem Fenster zu schieben. Die Editorregeln stehen jetzt in `editor.css`, das nur Editor und Mediathek laden.
- **Verwaltung neu gestaltet** (ADR 029): ein Rahmen für alle Bildschirme mit fester Seitenleiste, aktivem Eintrag über `aria-current`, Titel, einem Satz Erklärung und Platz für die Hauptaktion. Vorher trug jede Seite ihre eigene Kopfzeile und eine eigene, unvollständige Linkliste. Je Seite eine Zeile mit dem Alltäglichen und **einem** Aufklappfeld „Einstellungen“, das an seinem Platz öffnet, statt sechs Kästen, die über den Rand hinausragten. Tabellen scrollen seitlich. Der Editor behält seinen Aufbau und übernimmt die neue Gestaltung; seine Regeln stehen jetzt in `editor.css`, das nur Editor und Mediathek laden.
- **Eigenes CSS je Element** (Dokumentschema v13) im neuen Reiter „Erweitert“: Eigenschaften für das Element und verschachtelte Regeln mit `&`. Der Selektor wird vom Server gesetzt, die Ausgabe neu serialisiert – eine überzählige `}` kann den Block nicht verlassen. Abgewiesen: @-Regeln, Backslash-Escapes, `expression()`, fremde Selektoren und jede Adresse außer einem einzelnen `/` oder `https://`. Für jeden mit Editorrolle.
@@ -70,6 +70,8 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
- Verwaltung geprüft: axe ohne Verstöße auf Anmeldung, Seitenübersicht und Editor (Desktop und Mobil), 201 PHP-Tests grün. **Nicht geprüft:** kein Screenreader, und die Bildschirme Menüs, Formulare, Bereiche, Design, Einstellungen und Fassungen sind nur von Hand angesehen, nicht automatisiert auf Barrierefreiheit geprüft – die Browserprüfung deckt sie nicht ab.
- Verwaltung geprüft: axe ohne Verstöße auf **elf** Bildschirmen – Anmeldung, Seitenübersicht, Benutzer, eigenes Konto, Mediathek, Menüs, Formulare, Bereiche, Design, Einstellungen und Editor in Desktop und Mobil. Vorher waren es drei.
- Benutzerverwaltung geprüft: 16 Tests, darunter zwei gleichzeitige Sitzungen, die zeigen, dass ein zurückgesetztes Passwort und eine geänderte Rolle die andere Sitzung abmelden, während die handelnde angemeldet bleibt. Acht abgewiesene Kontoanlagen (zu kurzes Passwort, mehr als 72 Bytes, Nullbyte, drei ungültige Namen, Doppelname, unbekannte Rolle) legen kein Konto an. Jeder Schreibweg braucht ein CSRF-Token und ist ohne Administratorrolle 403.
+- Mediathek geprüft: Upload über das Formular mit zwei Dateien in einem Absenden, CSRF-Pflicht, Bildangaben mit Versionskonflikt, Archivfilter, Blättern bei 26 Bildern (24 auf Seite eins, korrekte Sortierung neueste zuerst, `?seite=99` wird auf die letzte Seite begrenzt) und Rückkehr auf dieselbe Seite nach dem Speichern. Die Ablagefläche ist im Browsertest geprüft (gewählte Dateien erscheinen im echten Dateifeld und werden benannt) und von Hand mit einem echten Drop, bei dem eine Nicht-Bilddatei übersprungen wurde.
+- **Updateverfahren geplant**, nicht gebaut: `docs/UPDATE.md` beschreibt, wie eine neue Version über einen Git-Tag gezogen wird – Vorprüfung, Sicherung, Wartungszustand, Checkout, Migration, Rauchtest, Rückweg. Zwei Entscheidungen hängen am Hosting: Composer auf dem Server oder `vendor/` im Tag, und ob es überhaupt Shell-Zugriff gibt.
- **Nicht geprüft:** kein Screenreader.
- Eigener Code geprüft: fünf CSS-Formen werden korrekt auf das Element bezogen, darunter `& .block-text` mit erhaltenem Nachfahren-Leerzeichen. **Zehn abgewiesene CSS-Eingaben** erreichen kein Dokument: Ausbruch mit `}`, fremder Selektor, `@import`, `@media`, `javascript:`-Adresse, protokollrelative Adresse, Backslash-Escape, `expression()`, Eigenschaft ohne Wert und unsinniger Eigenschaftsname. Eigenes CSS erscheint im Seiten-Stylesheet, eigenes Skript **nur** als Datei mit `script-src 'self'` ausschließlich auf dieser Seite; eine Seite ohne eigenes Skript behält `script-src 'none'` und liefert keine Datei. Ein Skript aus einem unveröffentlichten Entwurf wird nicht ausgeliefert. Der Rollenvergleich weist Ändern, Entfernen und Hinzufügen eines Skripts ab und lässt unveränderte Skripte durch.
- Formatierter Text geprüft: **dreizehn Fälle** durch den Filter, darunter erlaubte Auszeichnung und Listen, ein `script` samt Inhalt entfernt und der Text danach erhalten, Ereignisattribut entfernt, `class`/`style` entfernt und das Element ausgepackt, `javascript:`-Link zu Text geworden, Bild mit `onerror` entfernt, `iframe` entfernt, unausgeglichene Auszeichnung geschlossen, lokaler Link erhalten, externer Link mit `rel="noopener"`, Klartext maskiert und Umbruch zu ` `. Dazu: gefiltert wird beim Speichern (die Datenbank enthält schon die sichere Fassung), ein nach dem Filtern leerer Textblock wird abgewiesen ohne zu schreiben, ab v12 erscheint Auszeichnung als Auszeichnung in einem `div`, vor v12 maskiert in einem `p`, und in einem geteilten Bereich gilt derselbe Filter.
diff --git a/docs/UPDATE.md b/docs/UPDATE.md
new file mode 100644
index 0000000..e72c978
--- /dev/null
+++ b/docs/UPDATE.md
@@ -0,0 +1,69 @@
+# Updateverfahren: neue Version über Git ziehen
+
+Stand: 12.09.2026. **Das ist ein Plan, noch keine Umsetzung.** Nichts davon ist gebaut; die Reihenfolge und die Entscheidungen stehen hier, damit sie vor dem Bauen kritisierbar sind.
+
+## Was ein Update heißt
+
+Ein Update ist ein Wechsel des Codes auf einen bestimmten Git-Tag, gefolgt von einer Datenbankmigration. Drei Dinge werden dabei **nie** angefasst, weil in ihnen alles steckt, was nicht wiederherstellbar ist:
+
+- `var/cms.db` (beziehungsweise später MySQL) – die Inhalte,
+- der Medienordner aus `MEDIA_DIRECTORY` – die Originale und alle abgeleiteten Größen,
+- `.env` – Datenbankzugang, `APP_SECRET`, Ursprungsadresse.
+
+Alle drei stehen in `.gitignore`. Das ist die Voraussetzung dafür, dass ein `git checkout` sie nicht berührt, und der erste Punkt, den der Updater prüfen muss, statt ihn anzunehmen.
+
+## Die Version
+
+Es braucht eine Angabe, die man ohne Git lesen kann, sonst weiß die Oberfläche nicht, worauf sie steht:
+
+- Eine Datei `VERSION` im Projektstamm mit dem Tag, zum Beispiel `1.4.0`. Sie ist Teil des Tags selbst.
+- Tags heißen `v..`. Ein Tag, kein Branch: ein Branch verschiebt sich unter den Füßen, ein Tag nicht.
+- Die Verwaltung zeigt Version, Commit-Kurzhash und das Datum des Tags, sowie die höchste angewandte Migration. Das ist die Angabe, die im Fehlerfall zählt.
+
+## Ablauf
+
+Jeder Schritt hat eine Abbruchbedingung. Bricht ein Schritt ab, bleibt der Wartungszustand an und der Updater sagt, welcher Schritt gescheitert ist – er versucht nichts zu reparieren, was er nicht selbst angelegt hat.
+
+1. **Vorprüfung.** `git` vorhanden und aufrufbar (`proc_open` ist auf geteiltem Hosting oft abgeschaltet – dann endet der Updater hier mit einer klaren Meldung und dem Konsolenweg). Arbeitsverzeichnis unverändert (`git status --porcelain` leer), sonst würde ein Checkout lokale Änderungen verwerfen. Schreibrechte auf Projektstamm, `var/` und Medienordner. PHP-Version erfüllt `composer.json`. Freier Plattenplatz mindestens die Größe der Datenbank plus 100 MB.
+2. **Ziel bestimmen.** `git fetch --tags origin`, dann den höchsten `v*`-Tag nach Semantik, oder einen ausdrücklich gewählten. Die Herkunft (`origin`) steht in der Konfiguration, **nicht** in der Anfrage: ein Updater, der eine Adresse aus dem Formular zieht, ist eine Fernsteuerung für fremden Code.
+3. **Sichern.** Datenbank kopieren (bei SQLite Datei, bei MySQL `mysqldump`), dazu `.env` und den aktuellen Commit-Hash, nach `var/backups/update-/`. Der Medienordner wird **nicht** mitkopiert – er ist zu groß, und kein Update schreibt darin. Das steht auch so in der Oberfläche, damit niemand die Sicherung für vollständig hält.
+4. **Wartungszustand an.** Eine Datei `var/maintenance`. Öffentliche Anfragen antworten mit 503, einer erklärenden Seite und `Retry-After`; `/admin` bleibt erreichbar, sonst kann niemand das Update beenden. Suchmaschinen verstehen 503 als „später wieder“, nicht als „weg“.
+5. **Code wechseln.** `git checkout --force `. Danach prüfen, dass `VERSION` den erwarteten Wert hat – erst das beweist, dass der Wechsel wirklich stattgefunden hat.
+6. **Abhängigkeiten.** `composer install --no-dev --optimize-autoloader`, wenn Composer auf dem Server läuft. Wenn nicht – der wahrscheinliche Fall auf geteiltem Hosting – muss `vendor/` Teil des Tags sein. **Das ist die Entscheidung, die ansteht:** entweder Composer auf dem Server, oder ein Releasebranch, in dem `vendor/` mitgeliefert wird und den ein Build-Rechner erzeugt. Beides ist vertretbar, aber es muss vor dem ersten Livegang entschieden sein, weil der Updater danach anders aussieht.
+7. **Migrieren.** `doctrine-migrations migrate --no-interaction`. Die Migrationen sind bewusst nicht umkehrbar: `down()` würde Spalten entfernen, in denen Inhalte stehen. Der Rückweg ist die Sicherung aus Schritt 3, nicht ein `down()`.
+8. **Cache.** `cache:clear --env=prod` und Aufwärmen. Der Grund steht in `docs/QUALITY.md`: ein laufender Prozess kann einen älteren kompilierten Container halten und dann Seiten mit 500 beantworten, obwohl der Code richtig ist. Bei PHP-FPM gehört ein Neuladen dazu.
+9. **Rauchtest.** Intern `/`, `/admin/login` und `/sitemap.xml` abrufen und auf 200 prüfen, dazu eine Abfrage auf `page` und `revision`. Schlägt das fehl, gilt das Update als gescheitert, auch wenn jeder Schritt davor gemeldet hat, dass er fertig ist.
+10. **Wartungszustand aus, Protokoll schreiben.** Nach `var/updates.log`: Zeitstempel, alte und neue Version, angewandte Migrationen, Dauer, Ergebnis.
+
+## Wenn es schiefgeht
+
+Der Rückweg ist derselbe Weg rückwärts, in dieser Reihenfolge, und er ist genauso Teil des Plans wie der Hinweg:
+
+1. Wartungszustand bleibt an.
+2. `git checkout --force `.
+3. Abhängigkeiten des alten Stands wiederherstellen (derselbe Fall wie Schritt 6).
+4. **Datenbank aus der Sicherung zurückschreiben** – aber nur, wenn Schritt 7 begonnen hatte. Sonst nicht: ein Rückschreiben verliert alles, was seit dem Update entstanden ist, und wenn keine Migration lief, ist auch nichts zu reparieren.
+5. Cache räumen, Rauchtest, Wartungszustand aus.
+
+Der teuerste Fall ist ein Update, das durchläuft und erst Tage später als falsch erkannt wird. Dagegen hilft kein Updater, sondern die Versionsangabe in der Verwaltung und das Protokoll: man muss ohne Rätselraten sagen können, was wann eingespielt wurde.
+
+## Was gebaut wird
+
+- `src/Ops/Updater.php` – die Schritte als einzeln aufrufbare Methoden, jede mit einer Prüfung statt einer Annahme, und ein Protokoll je Schritt.
+- `src/Command/UpdateCommand.php` (`cms:update`) – der Weg, der immer funktioniert, auch ohne Weboberfläche. `--check` prüft nur, `--to=v1.4.0` wählt ein Ziel, `--dry-run` läuft ohne Checkout und Migration.
+- Ein Bildschirm unter „Einstellungen“ oder „System“, **nur für Administratoren**, mit CSRF: aktuelle Version, verfügbare Version, Protokoll der letzten Updates, ein Knopf. Kein automatisches Update nach Zeitplan – ein Update ist eine Entscheidung, nicht ein Hintergrunddienst.
+- Wartungszustand als Abonnent, der vor allem anderen greift.
+- Tests: Vorprüfung schlägt bei unsauberem Arbeitsverzeichnis fehl; ein gescheitertes Update lässt den Wartungszustand an; die Sicherung entsteht vor dem Checkout; ein erfolgreiches Update schreibt Protokoll und neue Version. Geprüft gegen ein wegwerfbares Git-Repository im Temp-Verzeichnis, nicht gegen das Projekt selbst.
+
+## Was ausdrücklich nicht gebaut wird
+
+- Kein Updater, der eine Adresse, einen Branch oder ein ZIP aus dem Formular lädt. Die Herkunft steht in der Konfiguration.
+- Kein automatisches Update im Hintergrund.
+- Kein `down()`-Rückweg für Migrationen. Der Rückweg ist die Sicherung, und das ist eine Entscheidung, nicht eine Lücke.
+- Kein Update ohne Sicherung, auch nicht mit Häkchen „ich weiß, was ich tue“.
+
+## Offen, bevor das gebaut wird
+
+1. **Composer auf dem Server oder `vendor/` im Tag?** Hängt am Hosting aus Arbeitspaket J.
+2. **Wie kommt der Code auf den Server?** Git-Klon im Projektstamm setzt Shell-Zugriff voraus. Gibt es den nicht, ist der Updater in dieser Form nicht möglich, und der Weg ist ein Deployment von außen (rsync eines Releaseverzeichnisses, Umschalten eines Symlinks). Das ist derselbe Ablauf, nur mit einem anderen Schritt 5.
+3. **Ein Repository oder zwei?** Für den Piloten reicht eines. Sobald mehrere Websites dasselbe CMS nutzen, gehört der Code in ein eigenes Repository und die Inhalte bleiben je Installation.
diff --git a/public/assets/admin.css b/public/assets/admin.css
index 73f90eb..3d123d7 100644
--- a/public/assets/admin.css
+++ b/public/assets/admin.css
@@ -368,6 +368,42 @@ details.card > summary:hover { background: #fafbf8 }
.library-tile .sheet-body { padding: 2px 14px 18px }
.library-tile .sheet-body label:first-of-type { margin-top: 12px }
+/* The upload field: a drop area instead of the browser's file widget. The input itself stays a
+ real, focusable file input - the ring is drawn on the area via :focus-within. */
+.dropzone {
+ position: relative;
+ border: 2px dashed #c3cec7;
+ border-radius: 12px;
+ background: #fafbf8;
+ margin-top: 8px;
+}
+.dropzone:hover { border-color: var(--accent); background: #f5f8f1 }
+.dropzone.is-dragging { border-color: var(--accent); background: #eef4e3 }
+.dropzone:focus-within { outline: 3px solid var(--ring); outline-offset: 3px }
+.dropzone-label {
+ display: grid;
+ justify-items: center;
+ gap: 5px;
+ margin: 0;
+ padding: 30px 20px;
+ cursor: pointer;
+ text-align: center;
+ font-weight: 400;
+}
+.dropzone-label strong { font-size: 14px; font-weight: 650 }
+.dropzone-icon { font-size: 24px; color: var(--accent); line-height: 1 }
+.dropzone-hint { font-size: 12px; color: var(--muted) }
+/* Hidden from sight, not from the keyboard or from assistive technology. */
+.dropzone-input {
+ position: absolute;
+ width: 1px; height: 1px;
+ padding: 0;
+ opacity: 0;
+ inset-inline-start: 50%;
+ inset-block-start: 50%;
+}
+.dropzone-chosen { font-size: 12px; line-height: 1.55; color: var(--ink); margin: 10px 0 0 }
+
.pager { display: flex; align-items: center; justify-content: center; gap: 16px; flex-wrap: wrap; margin-top: 10px }
.pager .muted { font-size: 12px }
diff --git a/public/assets/media-drop.js b/public/assets/media-drop.js
new file mode 100644
index 0000000..680dfcd
--- /dev/null
+++ b/public/assets/media-drop.js
@@ -0,0 +1,51 @@
+/**
+ * Dragging images onto the upload field, and saying which files are about to go up.
+ *
+ * Everything here is an addition. Without this script the field is still a file input with a
+ * label: clicking it opens the file picker and the form submits the same way. The script never
+ * uploads by itself - the button stays the moment where something is sent.
+ */
+const zone = document.querySelector('[data-dropzone]');
+const input = zone?.querySelector('input[type="file"]');
+const chosen = document.querySelector('[data-chosen]');
+if (zone && input && chosen) {
+ const ACCEPTED = ['image/jpeg', 'image/png', 'image/webp'];
+
+ const describe = (files, ignored = 0) => {
+ if (!files.length) {
+ chosen.textContent = ignored
+ ? `Keine Bilddatei dabei. ${ignored} Datei(en) übersprungen – erlaubt sind JPEG, PNG und WebP.`
+ : 'Noch keine Datei gewählt.';
+ return;
+ }
+ const names = [...files].map(file => file.name).join(', ');
+ const size = [...files].reduce((total, file) => total + file.size, 0);
+ chosen.textContent = `${files.length} Datei(en) gewählt (${(size / 1048576).toFixed(1)} MB): ${names}`
+ + (ignored ? ` · ${ignored} Datei(en) übersprungen, erlaubt sind JPEG, PNG und WebP.` : '');
+ };
+
+ input.addEventListener('change', () => describe(input.files));
+
+ // Without preventDefault the browser navigates to the dropped file and the page is gone.
+ for (const event of ['dragenter', 'dragover']) {
+ zone.addEventListener(event, dragged => {
+ dragged.preventDefault();
+ zone.classList.add('is-dragging');
+ });
+ }
+ for (const event of ['dragleave', 'dragend']) {
+ zone.addEventListener(event, () => zone.classList.remove('is-dragging'));
+ }
+
+ zone.addEventListener('drop', dropped => {
+ dropped.preventDefault();
+ zone.classList.remove('is-dragging');
+ const files = [...(dropped.dataTransfer?.files ?? [])];
+ const images = files.filter(file => ACCEPTED.includes(file.type));
+ // The input is what the form submits, so the dropped files have to end up in it.
+ const transfer = new DataTransfer();
+ images.forEach(file => transfer.items.add(file));
+ input.files = transfer.files;
+ describe(input.files, files.length - images.length);
+ });
+}
diff --git a/scripts/editor-check.mjs b/scripts/editor-check.mjs
index d47fe59..8a40150 100644
--- a/scripts/editor-check.mjs
+++ b/scripts/editor-check.mjs
@@ -365,6 +365,10 @@ try {
// The media screen renders its images itself, so the library has to be visible without the dialog.
await page.goto(origin + '/admin/media');
assert.equal(await page.locator('.library-tile').count(), 2);
+ // The drop area is an enhancement of a real file input: choosing files has to reach the input
+ // and be named back to the user. Nothing is submitted here.
+ await page.locator('#files').setInputFiles(fixture.images);
+ await page.locator('[data-chosen]').filter({ hasText: '2 Datei(en) gewählt' }).waitFor();
await page.screenshot({ path: 'reports/editor/media-screen.png', fullPage: true });
checks.push({ name: 'management screens reachable, media library rendered as a page', passed: true });
diff --git a/templates/admin/media.html.twig b/templates/admin/media.html.twig
index 6ab8630..c11362e 100644
--- a/templates/admin/media.html.twig
+++ b/templates/admin/media.html.twig
@@ -1,6 +1,7 @@
{% extends 'admin/shell.html.twig' %}
{% set nav = 'media' %}
{% block title %}Mediathek{% 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 %}
@@ -10,8 +11,20 @@
-
-
+ {# 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
+ dragging and the list of chosen files. No "required": the input is visually hidden, and a
+ browser cannot show its validation bubble on something invisible. The server refuses an
+ empty upload with a readable message instead. #}
+
+
+
+
+
Noch keine Datei gewählt.
JPEG, PNG oder WebP, höchstens 8 MB je Bild. Mehrere Dateien sind möglich; das Hosting kann ein kleineres Limit setzen.
diff --git a/tests/EditorSecurityTest.php b/tests/EditorSecurityTest.php
index 39afb4d..5358e3d 100644
--- a/tests/EditorSecurityTest.php
+++ b/tests/EditorSecurityTest.php
@@ -5,6 +5,7 @@ namespace App\Tests;
use App\Content\DemoContent;
use App\Content\DocumentValidator;
use App\Content\FormRepository;
+use App\Content\MediaLibrary;
use App\Content\MenuRepository;
use App\Content\PageRepository;
use App\Content\PartialRepository;
@@ -470,6 +471,93 @@ final class EditorSecurityTest extends WebTestCase
}
}
+ public function testTheMediaScreenUploadsAndEditsWithoutJavaScript(): void
+ {
+ $client = $this->login('editor');
+ $crawler = $client->request('GET', '/admin/media');
+ self::assertResponseIsSuccessful();
+ // The images are on the page itself, not behind a button that opens a dialog.
+ self::assertCount(0, $crawler->filter('.library-tile'));
+ self::assertCount(1, $crawler->filter('form[action="/admin/media/upload"] input[type="file"]'));
+ $token = $crawler->filter('form[action="/admin/media/upload"] input[name="_csrf_token"]')->attr('value');
+
+ $path = tempnam(sys_get_temp_dir(), 'kant-form-image-');
+ try {
+ $image = imagecreatetruecolor(900, 600);
+ imagepng($image, $path);
+ imagedestroy($image);
+
+ $client->request('POST', '/admin/media/upload', ['_csrf_token' => 'wrong'], ['files' => [new UploadedFile($path, 'ohne-token.png', test: true)]]);
+ self::assertResponseRedirects('/admin/media');
+ self::assertSame(0, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
+
+ // Two files in one submit, the way the drop area collects them.
+ $client->request('POST', '/admin/media/upload', ['_csrf_token' => $token], ['files' => [
+ new UploadedFile($path, 'erstes.png', test: true),
+ new UploadedFile($path, 'zweites.png', test: true),
+ ]]);
+ self::assertResponseRedirects('/admin/media');
+ self::assertSame(2, (int) $this->db->fetchOne('SELECT COUNT(*) FROM media'));
+
+ $crawler = $client->followRedirect();
+ self::assertCount(2, $crawler->filter('.library-tile'));
+ self::assertCount(1, $crawler->filter('.notice.ok'));
+ self::assertStringContainsString('2 Bild(er) hochgeladen', $crawler->filter('.notice.ok')->text());
+
+ $id = (string) $this->db->fetchOne('SELECT id FROM media LIMIT 1');
+ $client->request('POST', '/admin/media/'.$id, ['_csrf_token' => $token, 'title' => 'Bergblick', 'alt' => 'Grüne Berglandschaft', 'archived' => '1', 'version' => 1]);
+ self::assertResponseRedirects('/admin/media');
+ self::assertSame(['Bergblick', 'Grüne Berglandschaft', 1], array_values($this->db->fetchNumeric('SELECT title, alt, archived FROM media WHERE id = ?', [$id])));
+
+ // An archived image is out of the default listing but still delivered, and the archive
+ // view shows it again.
+ self::assertCount(1, $client->request('GET', '/admin/media')->filter('.library-tile'));
+ self::assertCount(2, $client->request('GET', '/admin/media?archiv=1')->filter('.library-tile'));
+
+ // A second save with the same version is refused instead of overwriting the newer one.
+ $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'));
+ } finally {
+ unlink($path);
+ }
+ }
+
+ public function testTheMediaScreenPaginatesAndKeepsTheRequestedPageAfterASave(): void
+ {
+ $client = $this->login('editor');
+ for ($number = 1; $number <= 26; ++$number) {
+ $this->db->insert('media', [
+ 'id' => str_pad((string) $number, 32, '0', STR_PAD_LEFT),
+ 'title' => 'Bild '.$number,
+ 'alt' => '',
+ 'variants' => json_encode([['src' => '/media/x/480', 'width' => 480, 'height' => 320, 'bytes' => 1]]),
+ 'archived' => 0,
+ 'version' => 1,
+ // Ascending dates, so "newest first" puts number 26 on the first page.
+ 'created_at' => gmdate('Y-m-d H:i:s', strtotime('2026-09-01 00:00:00') + $number * 60),
+ ]);
+ }
+ $first = $client->request('GET', '/admin/media');
+ self::assertCount(MediaLibrary::PER_PAGE, $first->filter('.library-tile'));
+ self::assertStringContainsString('Bild 26', $first->filter('.library-tile')->first()->text());
+ self::assertCount(1, $first->filter('.pager a[rel="next"]'));
+ self::assertCount(0, $first->filter('.pager a[rel="prev"]'));
+
+ $second = $client->request('GET', '/admin/media?seite=2');
+ self::assertCount(2, $second->filter('.library-tile'));
+ self::assertStringContainsString('Bild 2', $second->filter('.library-tile')->first()->text());
+ // A page beyond the end is clamped instead of showing an empty screen.
+ self::assertCount(2, $client->request('GET', '/admin/media?seite=99')->filter('.library-tile'));
+
+ // Saving from page two comes back to page two.
+ $token = $second->filter('form[action^="/admin/media/000"] input[name="_csrf_token"]')->first()->attr('value');
+ $id = str_pad('2', 32, '0', STR_PAD_LEFT);
+ $client->request('POST', '/admin/media/'.$id, ['_csrf_token' => $token, 'title' => 'Umbenannt', 'alt' => '', 'version' => 1, 'seite' => 2]);
+ self::assertResponseRedirects('/admin/media?seite=2');
+ }
+
public function testPasswordUpgradeRefreshesTheSessionUser(): void
{
$provider = new UserProvider($this->db);