Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
22e4ada830 | ||
|
|
af5a2cef86 | ||
|
|
d295757ce0 | ||
|
|
4153ba0f65 |
No files matched your search
@@ -440,6 +440,47 @@ Vom Verweis bleibt nur der Host. Die vollständige Adresse, aus der jemand kam,
|
||||
|
||||
**Aufbewahrung:** nach 60 Tagen werden die Stunden eines Tages zu einer Zeile verschmolzen, nach 425 Tagen wird gelöscht. Gut ein Jahr, damit ein Vergleich mit dem Vorjahresmonat möglich bleibt. `cms:stats:prune` macht beides und ist für einen Cronjob gedacht.
|
||||
|
||||
## ADR 072 – Drei Fehler einer Familie: dieses Hosting ist nicht mein Rechner (13.09.2026)
|
||||
|
||||
Umut, während er 1.4.4 in Betrieb nahm: „Das buggt doch immer noch" und „Bei Sicherung anlegen kommt auch Fehler 500 – wieso ist das alles verbuggt?" Drei Befunde an einem Abend, und sie gehören zusammen: **Jeder einzelne war auf meinem Rechner unsichtbar.**
|
||||
|
||||
**1. `git` installiert ist nicht dasselbe wie „Arbeitskopie".** `Updater::state()` gab unter dem Schlüssel `git` zurück, ob der *Befehl* `git` aufrufbar ist. Auf Umuts Hosting ist er das (Version 1.8.3.1), nur ist das Verzeichnis eine Installation aus dem Releasearchiv. Also lief jeder Git-Aufruf ins Leere, und in der Verwaltung stand dort, wo eine Fassungsnummer hingehört: `fatal: Not a git repository`. Dazu eine Vorprüfung voller roter Ausrufezeichen für Bedingungen, die für diese Installation gar nicht gelten. Die Software war in Ordnung und sah kaputt aus – für den Betreiber ist das dasselbe. Der Zustand heißt jetzt `worktree` und wird getrennt von `git` erhoben; ohne Arbeitskopie verschwinden die Git-Karten vollständig, und an ihrer Stelle steht ein Satz: „Eingerichtet über das Releasearchiv."
|
||||
|
||||
**2. `VACUUM INTO` gibt es erst seit SQLite 3.27.** Die Sicherung benutzt es, weil eine Dateikopie während eines Schreibvorgangs unbrauchbar sein kann (ADR 038). Auf Umuts Hosting läuft eine ältere Fassung: alt genug für die Installation (die braucht 3.16, ADR aus 1.4.2), zu alt für die Sicherung. Ergebnis: 500 beim Knopf „Sicherung jetzt anlegen". Jetzt wird bei älteren Fassungen unter einer Schreibsperre kopiert – `PRAGMA wal_checkpoint`, `BEGIN IMMEDIATE`, kopieren, `COMMIT` –, und der Test setzt die Schwelle künstlich hoch, damit dieser Weg wirklich läuft und nicht nur auf fremden Servern.
|
||||
|
||||
**3. Ein unerwarteter Fehler darf keine 500-Seite sein.** Der Sicherungsknopf fing `InvalidPage` ab, also die *erwarteten* Fälle. Alles andere – alte Datenbankfassung, fehlendes Schreibrecht, voller Datenträger – fiel durch. Genau das sind aber die Fälle, die nur auf fremden Servern auftreten. Der Knopf fängt jetzt jeden Fehler und sagt, woran es lag.
|
||||
|
||||
**Was ich daraus mitnehme:** Jeder dieser Fehler war eine *Umgebungsdifferenz*, und keiner davon war durch mehr Tests auf meinem Rechner zu finden. Was hilft, ist zweierlei – eine nachgestellte Installation aus dem echten Releasearchiv (so wurde ADR 071 gefunden) und die Regel, dass ein Knopf in der Verwaltung **niemals** mit einer 500-Seite enden darf, sondern mit einem Satz. Das Zweite ist billiger und fängt auch die Fälle, an die ich nicht gedacht habe.
|
||||
|
||||
## ADR 071 – Ein Buchstabe, der die ganze Funktion unbrauchbar machte (13.09.2026)
|
||||
|
||||
Umut hat 1.4.3 installiert, den Updateknopf gedrückt und eine **500-Seite** bekommen. Sein Satz dazu: „Das gefällt mir alles ehrlich gesagt nicht. Ich möchte es so einfach wie WordPress." Beides berechtigt.
|
||||
|
||||
Nachgestellt wurde es mit einer echten 1.4.3-Installation aus dem Releasearchiv, eigener Datenbank, eigenem Port. Der Fehler stand dann im Protokoll:
|
||||
|
||||
```
|
||||
Twig\Error\RuntimeError: Key "step" for sequence/mapping with keys
|
||||
"label, ok, detail" does not exist in "admin/system.html.twig" at line 137
|
||||
```
|
||||
|
||||
Der Updater über Git nennt einen Schritt `step`, der neue über das Archiv nannte ihn `label`, und beide legen ihr Ergebnis unter demselben Sitzungsschlüssel ab. Die Vorlage liest `step`. **Das Update lief vollständig durch** – alle neun Schritte in Ordnung, Dateien getauscht, `var/` und `.env` unberührt – und dann starb die Seite, die das berichten sollte. Damit war auch der zweite Knopf unerreichbar, der die Migrationen ausführt, und die Website blieb im Wartungszustand.
|
||||
|
||||
**Warum kein Test das gefunden hat.** Zehn Tests prüfen den Archiv-Updater, und alle prüfen sein Rückgabe-Array. Keiner hat die Seite gerendert, die dieses Array anzeigt. Ein Ergebnis, das stimmt, und eine Anzeige, die es nicht lesen kann – das ist dieselbe Familie wie das `og:image`, das gespeichert wurde und nie ankam (ADR 056), und wie das Stylesheet, das nichts mehr tat (ADR 064). **Die Naht zwischen zwei richtigen Hälften ist die Stelle, an der es bricht.** Der Test dazu rendert jetzt `/admin/system` mit den Schritten eines echten Laufs in der Sitzung; gegengeprüft, indem der Fehler noch einmal eingebaut wurde – der Test wird rot.
|
||||
|
||||
Der Schlüssel heißt jetzt in beiden Updatern `step`. Nicht die Vorlage wurde nachsichtig gemacht: Zwei Dinge derselben Art beschreiben sich gleich, sonst wiederholt sich das beim nächsten Anzeigeort.
|
||||
|
||||
**Und der zweite Teil von Umuts Satz.** Nach dem Tausch stand die Verwaltung mit zwei Karten da, die beide dasselbe wollten – „Migrationen ausführen und freigeben" und „Migrationen stehen aus". Solange das Archiv-Update auf seinen zweiten Schritt wartet, gehört der Knopf an eine Stelle. Zwei Knöpfe, die dasselbe tun, sind eine Frage zu viel.
|
||||
|
||||
## ADR 070 – Die zwei Lücken, die erst das Veröffentlichen zeigte (13.09.2026)
|
||||
|
||||
Umut, kurz nach dem Release: „Wie spiele ich das Update manuell ein?" Die Frage war berechtigt, und die Antwort hatte zwei Lücken.
|
||||
|
||||
**Erstens: Es gab keinen Weg, Migrationen im Browser auszuführen.** Wer die Dateien über FTP hochlädt – und das muss genau einmal jeder tun, dessen Installation die Updatekarte noch nicht kennt –, hat danach neuen Code und eine alte Datenbank. Dafür gab es nur die Konsole, also genau das, was auf geteiltem Hosting fehlt. Der Bildschirm „System" zeigt jetzt eine Karte, **sobald etwas aussteht**, nennt die Namen der Migrationen und führt sie auf Knopfdruck aus. Das Lesen der Liste ist gekapselt: Eine Datenbank, die die Migrationstabelle noch nicht hat, darf nicht der Grund sein, dass dieser Bildschirm gar nicht mehr erscheint.
|
||||
|
||||
**Zweitens: „Die Releaseseite antwortet nicht" war drei Fehler in einem Satz.** Beim Gegenprüfen des frischen Releases holte der Updater von diesem Rechner gar nichts – nicht weil die Leitung zu wäre, sondern weil diesem PHP ein **Zertifikatsspeicher** fehlt (`curl.cainfo` leer). Auf einem Webhosting ist das der häufigste der drei Gründe und der einzige, den man in einer Minute behebt; er muss also dastehen. `Download` merkt sich seither, woran es lag, und der Schritt in der Verwaltung trägt den Grund mit.
|
||||
|
||||
**Und daraus folgte gleich noch etwas:** Bei genau diesem Fehler lohnt der zweite Weg. Der Dateizeiger benutzt die Einstellungen von OpenSSL, und die sind manchmal gesetzt, wo curl nichts findet – auf diesem Rechner **funktioniert der Abruf dadurch jetzt**. Was nicht passiert, ist das Naheliegende: Die Prüfung wird nicht abgeschaltet. Ein Update, das ohne Zertifikatsprüfung geladen wird, kann unterwegs ausgetauscht worden sein, und dann installiert der Knopf fremden Code.
|
||||
|
||||
## ADR 069 – Das größte Bild über der Falz ist gar kein Bild (13.09.2026)
|
||||
|
||||
Nachgemessen vor dem Livegang, auf der Startseite des Piloten statt auf einer Testseite: **Leistung 96 (Desktop) und 98 (Mobil)**, alles andere 100. Der Grund stand im Bericht: `LCP request discovery` – das größte Element über der Falz ist dort kein `<img>`, sondern der **Hintergrund des ersten Abschnitts**.
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
# Abgleich mit dem Flatsome UX Builder
|
||||
|
||||
Stand: 14.09.2026, KantSites 1.5.0 (Dokumentschema v19).
|
||||
Quelle des Vergleichs: Flatsome-Theme im Download-Ordner, `inc/builder/shortcodes/` (69 Definitionen)
|
||||
und `inc/shortcodes/`. Ziel laut Auftrag: alle Funktionen übernehmen und verbessern, die
|
||||
Bedienbarkeit übernehmen und verbessern, die Oberfläche mobil nutzbar machen — der Builder
|
||||
bleibt dabei Desktop-first.
|
||||
|
||||
## 1. Wo KantSites heute schon besser ist
|
||||
|
||||
Diese Punkte sind bereits gelöst und dürfen beim Nachbau nicht verschlechtert werden.
|
||||
|
||||
| Thema | Flatsome | KantSites heute |
|
||||
| --- | --- | --- |
|
||||
| Datenhaltung | Shortcode-Text im `post_content`, per Regex geparst | Validierter Blockbaum mit stabilen IDs, versioniert (v19), Migrationsweg |
|
||||
| Gestaltung | Feste Optionslisten je Element, wenige Werte je Gerät | 93 freie Eigenschaften, je Block, je Gerät **und** für Hover |
|
||||
| Öffentliche Ausgabe | jQuery, mehrere Bundles, Slider/Tabs brauchen JS | Tabs/Slider/Fortschritt ohne JavaScript, Editorcode nicht im Frontend |
|
||||
| Entwurf/Live | Revisionen von WordPress | Entwurf und Veröffentlichung getrennt, Fassungsverlauf, geplante Veröffentlichung, Papierkorb |
|
||||
| Recht | Plugins nötig | Einwilligung mit echtem Zurückhalten, Statistik ohne Wiedererkennung, Pflichtseiten |
|
||||
| Eigener Code | `ux_html`-Element | CSS je Element für alle, Skript nur für Administratoren, als eigene Datei |
|
||||
|
||||
**Folgerung:** Der Gestaltungsteil muss nicht nachgebaut werden. Zu tun sind der
|
||||
Elementkatalog und die Bedienung.
|
||||
|
||||
## 2. Elementkatalog — Lücke
|
||||
|
||||
Flatsome hat 69 Builder-Elemente, davon 12 reine Shop-Elemente (WooCommerce).
|
||||
KantSites hat 31 Blocktypen.
|
||||
|
||||
### Bereits abgedeckt (21)
|
||||
|
||||
`section` `row` `col` `title` `text` `ux_image` `button` `ux_gallery` `blog_posts`
|
||||
`featured_box` `testimonial` `accordion` `accordion_item` `tabgroup` `tab` `contact`
|
||||
`divider` `gap` `block` `ux_menu` `ux_slider`
|
||||
|
||||
### Offen, ohne Shop (20)
|
||||
|
||||
| Flatsome | Was es kann | Bewertung |
|
||||
| --- | --- | --- |
|
||||
| `ux_banner` | Bild/Video-Hintergrund, Overlay, Höhe, Parallax, Textposition | **hoch** — das meistgenutzte Element überhaupt |
|
||||
| `text_box` | frei positionierter Textkasten über einem Banner | **hoch** — gehört zu `ux_banner` |
|
||||
| `ux_stack` | Elemente nebeneinander, mit Abstand und Ausrichtung | **hoch** — löst viele Zeile/Spalte-Verrenkungen |
|
||||
| `ux_image_box` | Bild über Text als Karte | hoch |
|
||||
| `col_grid` / `ux_banner_grid` | Rasterlayout mit ungleichen Kacheln | mittel |
|
||||
| `message_box` | Hinweiskasten mit Symbol und Farbe | mittel |
|
||||
| `price_table` | Preistabelle | mittel |
|
||||
| `team_member` | Person mit Bild, Rolle, Sozialsymbolen | mittel |
|
||||
| `ux_pages` | Unterseiten dieser Seite auflisten | mittel |
|
||||
| `ux_video` | eingebettetes Video | teilweise über `embed` |
|
||||
| `video_button` | Video im Leuchtkasten öffnen | mittel |
|
||||
| `map` | Karte | teilweise über `embed` |
|
||||
| `follow` | Sozialsymbole zum Folgen | mittel |
|
||||
| `share` | Teilen-Symbole | niedrig |
|
||||
| `search` | Suchfeld | mittel |
|
||||
| `logo` | Logo der Website als Block | hoch (Kopfbereich) |
|
||||
| `scroll_to` | Ankerziel und Sprunglink | mittel |
|
||||
| `ux_countdown` | Countdown | niedrig |
|
||||
| `ux_hotspot` | Punkte auf einem Bild | niedrig |
|
||||
| `ux_lottie` / `ux_instagram_feed` / `ux_portfolio` | Animation, Instagram, Portfolio | niedrig |
|
||||
| `ux_the_title` | dynamischer Seitentitel | mittel (Vorlagen) |
|
||||
|
||||
### Bewusst ausgelassen (12)
|
||||
|
||||
Alle WooCommerce-Elemente (`products`, `ux_products`, `ux_products_list`,
|
||||
`ux_product_categories`, `ux_product_flip`, `ux_bestseller_products`, `ux_payment_icons`,
|
||||
`ux_product_gallery`, `woocommerce_cart`, `woocommerce_checkout`, `ux_badge`,
|
||||
`custom-product`). KantSites hat keinen Shop; ohne Warenkorb, Bestellung und Zahlung
|
||||
wären diese Blöcke leere Hüllen.
|
||||
|
||||
## 3. Bedienung — die eigentliche Lücke
|
||||
|
||||
Der Auftrag nennt die Bedienbarkeit ausdrücklich. Hier ist Flatsome heute voraus.
|
||||
|
||||
| Flatsome | KantSites heute | Verbesserung |
|
||||
| --- | --- | --- |
|
||||
| Element aus der Liste **in die Vorschau ziehen**, mit Einfügemarke | Einfügen „hinter das gewählte Element“ | Ziehen mit sichtbarer Marke, **zusätzlich** die heutige Tastaturbedienung behalten |
|
||||
| **Werkzeugleiste am Element** in der Vorschau (bearbeiten, duplizieren, löschen, Elternelement) | nur über den Strukturbaum links | Leiste am Element, Auswahl bleibt mit dem Baum verbunden |
|
||||
| **Pluszeichen zwischen Elementen** | — | Einfügepunkt genau dort, wo geklickt wird |
|
||||
| Elementwahl als **Bilderraster** mit Suche | Textliste mit Symbolzeichen | Raster mit echten Vorschaubildern, Suche, Kategorien |
|
||||
| **Vorlagen je Element** mit Vorschaubild (Sektion: 20+) | 4 Textvorlagen gesamt | Vorlagen je Element, mit Bild, erweiterbar |
|
||||
| **Je Option ein Geräteschalter** | ein globaler Geräteschalter | Schalter direkt an der Option, zeigt wo ein abweichender Wert gesetzt ist |
|
||||
| **Zahl durch Ziehen ändern** (`scrubfield`) | Eingabefeld | Ziehen plus Eingabe plus Pfeiltasten |
|
||||
| **Innen-/Außenabstand als Rahmenbild** | vier Felder | Rahmenbild mit verbundenen Seiten |
|
||||
| **Spaltenbreite in der Vorschau ziehen** | Zahl eingeben | Ziehgriff zwischen Spalten |
|
||||
| Vorschau **sofort** (clientseitige Vorlagen) | Server-Neuladen, entprellt | Sofortige optimistische Anzeige, Server bleibt die Wahrheit |
|
||||
| Kopieren/Einfügen zwischen Seiten | — | Zwischenablage für Blöcke, seitenübergreifend |
|
||||
| Mobil | Builder nicht bedienbar | Verwaltung vollständig mobil; Builder mobil im Lesemodus mit Inhaltsbearbeitung |
|
||||
|
||||
## 4. Vorgeschlagene Reihenfolge
|
||||
|
||||
1. **Arbeitspaket H — Bedienung des Builders.** Ziehen und Ablegen, Werkzeugleiste am
|
||||
Element, Einfügepunkte, Bilderraster, optimistische Vorschau. Größter Nutzen je Aufwand,
|
||||
betrifft jedes vorhandene Element sofort.
|
||||
2. **Arbeitspaket I — Elementkatalog Stufe 1.** `banner`, `textbox`, `stack`, `imagebox`,
|
||||
`logo`, `messagebox`. Die Blöcke, ohne die eine Startseite Umwege braucht.
|
||||
3. **Arbeitspaket J — Optionsbedienung.** Geräteschalter je Option, Ziehfelder,
|
||||
Abstandsrahmen, Spaltenbreite ziehen, Vorlagen je Element.
|
||||
4. **Arbeitspaket K — Elementkatalog Stufe 2.** Raster, Preistabelle, Team, Unterseiten,
|
||||
Video-Leuchtkasten, Suche, Sozialsymbole, Ankersprung.
|
||||
5. **Arbeitspaket L — Mobile Oberfläche.** Verwaltung und Builder auf dem Telefon.
|
||||
|
||||
Jede Stufe endet mit einem prüfbaren Ergebnis und darf die Qualitätsziele aus AGENTS.md
|
||||
nicht verschlechtern.
|
||||
+59
-2
@@ -1,6 +1,6 @@
|
||||
# Projektstatus
|
||||
|
||||
Stand: 13.09.2026, Fassung **1.4.3**. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
Stand: 14.09.2026, Fassung **1.5.0**. Das System heißt **KantSites** (vorher Arbeitstitel „Kant CMS“). **Startseite von 18 auf 8 Datenbankabfragen, gemessenes Ladebudget, alle langen Listen blättern. Sicherung und Wiederherstellung, letztere nachgewiesen. Globale Seitenbreite wirkt jetzt auch nach oben, vier Menüplätze mit drei Menüarten, und jede Seite wählt ihren Kopf- und Fußbereich – auch „keiner“. 93 Gestaltungseigenschaften, neun Einblendungen, sieben Hover-Effekte, Symbol, Fortschritt und ein Slider ohne JavaScript. Update über Git mit Vorprüfung, Sicherung und Rückweg. Statistik ohne Wiedererkennung und damit ohne Einwilligung. Einwilligung mit tatsächlich zurückgehaltenen Einbettungen, Datenschutz und Impressum als Pflichtseiten. Benutzerverwaltung in der Oberfläche, Mediathek als eigene Seite mit Blättern. Die Verwaltung hat einen gemeinsamen Rahmen mit einer Navigation, die zeigt, wo man ist. Eigenes CSS je Element für alle, eigenes Skript nur für Administratoren und nur als eigene Datei. Formatierter Text mit Werkzeugleiste und HTML-Ansicht, serverseitig gegen eine Positivliste gefiltert. Kopf- und Fußbereich sind frei gestaltbare Blockdokumente, dazu verknüpfte wiederverwendbare Sektionen (Arbeitspaket G, Teil 1). Frei wählbare Startseite; `/sitemap.xml` und Geschwister waren lokal nie erreichbar und sind es jetzt. Seiten verschieben mit automatischen Weiterleitungen, Papierkorb, Fassungsverlauf und geplante Veröffentlichung (Arbeitspaket F). Browsertests wiederhergestellt und erweitert; dabei drei echte Fehler gefunden und behoben, darunter die in der Vorschau wirkungslose Gestaltung. Direktbearbeitung in der Vorschau, Kontrastwarnung, Gestaltungsvorlagen und Bildauswahl für Hintergrundbilder. Der Editor gestaltet frei statt über feste Auswahllisten: eigene Farbpalette, Schriften, und jede Eigenschaft je Block, je Gerät und für den Hover-Zustand frei einstellbar (Dokumentschema v10). SEO-Grundlage mit Sitemap, robots.txt, Weiterleitungen und strukturierten Daten (Arbeitspaket E). Formulare mit serverseitiger Validierung, Einwilligung, E-Mail-Versand und Spamschutz (Arbeitspaket D). Tabs als fortschreitende Verbesserung mit JavaScript (Dokumentschema v7). Zehn neue Blöcke für die Abschnitte der Pilotstartseite (Arbeitspaket C). Beiträge mit Datum, Autor, Auszug, Beitragsbild, Kategorien, Schlagwörtern, Archiven, einbettbarer Beitragsliste und RSS (Arbeitspaket B). Menüs mit Seiten-, Anker- und externen Zielen ersetzen die fest verdrahtete Navigation (Arbeitspaket A). Seiten und Beiträge lassen sich im Browser anlegen und löschen, mit Adresshierarchie bis vier Ebenen.** Die vollständige Phase-1-Abnahme bleibt wegen des fehlenden UX-Builder-Referenzabgleichs offen.
|
||||
|
||||
Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt. Noch kein Remote und kein Push.
|
||||
|
||||
@@ -39,6 +39,12 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- **Das erste Bild einer Seite lädt sofort**, alles darunter weiterhin verzögert. Es ist meistens das größte Ding über der Falz.
|
||||
- **Slider mit Punkten ohne Skript** (ADR 044): `::scroll-marker` zeichnet je Folie einen Punkt, der aktive ist erkennbar, die Rollleiste entfällt; ältere Browser behalten das wischbare Schiebefeld. Beitragskarten sind echte Karten (Bild 16:10, 18 px Ecken). Überschriften dürfen ein Wort hervorheben (`<mark>`, in Akzentfarbe). Behoben: die Theme-Schriftskala überschrieb die Größen in Karten und Aufruf-Bändern.
|
||||
- **„Weiterlesen" ist jetzt ein Link.** In der wechselseitigen Beitragsliste stand dort eine reine Beschriftung: sie sah aus wie ein Knopf und war keiner. Der Titel steckt für Vorlesesoftware im Linktext, damit nicht zwölf Links gleich heißen.
|
||||
- **Behoben in 1.4.5: die Verwaltung zeigte Git-Fehler auf einer Installation ohne Git** (ADR 072). `git` installiert und „Arbeitskopie" waren derselbe Schalter; auf Umuts Hosting ist Git vorhanden, die Installation kam aber aus dem Releasearchiv. Dort stand `fatal: Not a git repository` an der Stelle der Fassungsnummer und eine rote Vorprüfung für Bedingungen, die gar nicht gelten. Ohne Arbeitskopie verschwinden die Git-Karten jetzt.
|
||||
- **Behoben in 1.4.5: „Sicherung anlegen" endete mit 500** (ADR 072). `VACUUM INTO` gibt es erst seit SQLite 3.27; Umuts Hosting hat eine ältere Fassung. Bei älteren wird unter einer Schreibsperre kopiert, und der Sicherungsknopf fängt jeden Fehler ab, statt mit einer 500-Seite zu enden.
|
||||
- **Das Update schreibt mit** (1.4.6, ADR 071): Jeder Schritt des Archiv-Updaters geht sofort nach `var/updates.log` – während des Laufs, nicht am Ende. Auf Umuts Hosting war die Ergebnisseite tot, und damit war auch verloren, was der Updater berichtet hatte; genau so wurde der nächste Fehlschlag dann doch noch lesbar („`bin` ließ sich nicht beiseitelegen – der alte Stand wurde zurückgeschoben"). Die Ergebnisseite versteht zusätzlich die Schreibweise bis 1.4.4: Beim Update schreibt der **alte** Code die Schritte und der **neue** zeigt sie an.
|
||||
- **Behoben in 1.4.5: der Updateknopf endete mit 500** (ADR 071). Auf Umuts Hosting gefunden, mit einer echten 1.4.3-Installation nachgestellt: Das Update lief vollständig durch – alle neun Schritte, Dateien getauscht, `var/` und `.env` unberührt –, aber die **Ergebnisseite** starb daran. Der Updater über Git nennt einen Schritt `step`, der über das Archiv nannte ihn `label`, beide legen ihn unter demselben Sitzungsschlüssel ab, und die Vorlage liest `step`. Damit war auch der zweite Knopf unerreichbar und die Website blieb im Wartungszustand. Zehn Tests prüften das Rückgabe-Array, keiner die Seite, die es anzeigt; der neue Test rendert sie und wurde gegengeprüft, indem der Fehler noch einmal eingebaut wurde.
|
||||
- **Migrationen im Browser ausführen** (1.4.4, ADR 070): Wer ein Update von Hand über FTP einspielt, hat danach neuen Code und eine alte Datenbank. Der Bildschirm „System" zeigt jetzt eine Karte, sobald etwas aussteht, nennt die Namen und führt sie auf Knopfdruck aus – vorher ging das nur über eine Konsole, also genau das, was auf geteiltem Hosting fehlt. Der Weg von Hand steht Schritt für Schritt in `docs/UPDATE.md`.
|
||||
- **Ein Fehlschlag beim Laden sagt, woran es lag** (1.4.4, ADR 070): „Die Releaseseite antwortet nicht" war drei Ursachen in einem Satz. Beim Gegenprüfen des Releases fiel auf, dass diesem PHP der **Zertifikatsspeicher** fehlt – auf einem Webhosting der häufigste Grund und der einzige, den man in einer Minute behebt. Die Meldung nennt ihn jetzt, und bei genau diesem Fehler wird der zweite Weg über den Dateizeiger versucht. Abgeschaltet wird die Prüfung nicht.
|
||||
- **Das Hintergrundbild des ersten Abschnitts wird vorab geholt** (1.4.3, ADR 069): Auf der Startseite des Piloten ist das größte Element über der Falz kein `<img>`, sondern der Hintergrund des Heros – und der steht nur im Stylesheet, das der Browser erst nach dem HTML holt. Drei Vorabverweise mit denselben Gerätegrenzen wie der Compiler holen **genau die Variante, die die Regel auch nimmt**; ein `imagesrcset` hätte bei manchen Fensterbreiten ein zweites Bild geladen. Gemessen: LCP 1,3 s → 1,1 s, Leistung Desktop 96 → 97, Mobil 98 → 99 (auf dem Entwicklungsserver ohne Kompression). Dabei gefunden: dasselbe Bild wurde je Geräteregel neu abgefragt – die Mediathek merkt sich jetzt, wonach sie in dieser Anfrage schon gefragt wurde, **Startseite 19 → 15 Abfragen**.
|
||||
- **Update aus dem Releasearchiv** (1.4.3, ADR 068): der Weg für Installationen ohne Git – bisher stand dort nur „Dieses Verzeichnis ist keine Git-Arbeitskopie". Zwei Knöpfe im Bildschirm „System": der erste lädt, prüft die Prüfsumme, sichert und ersetzt die Dateien, der zweite führt die Migrationen aus – in einer eigenen Anfrage, weil ein Prozess, der sich selbst ausgetauscht hat, mit dem alten Code weiterarbeitet. `var/`, `.env`, hochgeladene Schriften und das erzeugte Stylesheet-Bündel bleiben unberührt, `public/` wird dazu Datei für Datei zusammengeführt. Der alte Stand liegt danach unter `var/update/`. Geprüft mit acht Tests gegen eine Releaseseite im Speicher (`tests/ArchiveUpdateTest.php`), einer davon hält die Ersetzungsliste gegen die Mitnahmeliste des Releaseskripts.
|
||||
- **Eigene Symbole in der Verwaltung** (1.4.3, ADR 067): zwölf SVG-Pfade statt Unicode-Zeichen, die auf dem Telefon als Emoji in fremdem Stil erscheinen. Ein Test findet Emoji-Zeichen in der Navigationsvorlage und zwei Einträge mit demselben Pfad – beides war vorhanden.
|
||||
@@ -214,9 +220,60 @@ Erster Commit angelegt (`82b0782`); das Projekt hat damit einen Rückfallpunkt.
|
||||
- Noch keine repräsentativen Kundeninhalte/Bilder/Blog-/Formularseite, keine Felddaten und keine vollständige manuelle Barrierefreiheitsprüfung.
|
||||
- Die vor der Hostingantwort erzeugte `.venv` bleibt ungenutzt und von Git ausgeschlossen. Die automatische Freigabeprüfung blockierte ihren Löschversuch mit „blocked by policy“; keine Umgehung versucht. Auch `var/fresh-check`, DB, Secrets und Berichte sind lokal ignoriert.
|
||||
|
||||
## Arbeitspaket H, Teil 1: die Vorschau wird zur Arbeitsfläche
|
||||
|
||||
**Erledigt.** Der Abgleich mit dem Flatsome UX Builder steht in `docs/FLATSOME-ABGLEICH.md`:
|
||||
die Gestaltung ist Flatsome bereits voraus (93 freie Eigenschaften je Gerät und für Hover gegen
|
||||
feste Optionslisten), die Lücken liegen beim Elementkatalog (20 offene Elemente ohne Shop) und
|
||||
bei der Bedienung. Umuts Entscheidung: Bedienung zuerst, Shop-Elemente ausgelassen.
|
||||
|
||||
Umgesetzt ist die erste Hälfte der Bedienung:
|
||||
|
||||
- **Werkzeugleiste am Element** in der Vorschau: Name des Elements, Elternelement auswählen,
|
||||
nach oben, nach unten, duplizieren, löschen. Sie trägt den Namen des Elements
|
||||
(„Sektion duplizieren“) statt des nackten Verbs – die Seitenleiste hat dieselben fünf
|
||||
Aktionen, und zwei gleichnamige Bedienelemente auf einem Bildschirm sind für eine
|
||||
Sprachausgabe nicht unterscheidbar. Beide Wege rufen dieselben Funktionen auf.
|
||||
- **Auswahl- und Überfahrrahmen** mit Beschriftung direkt in der Vorschau.
|
||||
- **Einfügepunkte**: ein Pluszeichen an der Ober- und Unterkante des gewählten Elements setzt
|
||||
eine Marke; das nächste Element aus dem Werkzeugkasten landet genau dort statt am Ende.
|
||||
- **Ziehen aus dem Werkzeugkasten in die Vorschau**, mit Ablegemarke zwischen den Elementen und
|
||||
Rahmen um einen Behälter, der das Element aufnimmt. Der Strukturbaum zieht in dieselbe Fläche.
|
||||
- Im Modell dafür neu: `insertBlockAt`, `moveBlockTo` und `dropList` – punktgenaues Einfügen
|
||||
und Verschieben statt „hinter das Gewählte“.
|
||||
|
||||
Die Ebene liegt im Editordokument über dem Rahmen, nicht darin: die Vorschau wird bei jeder
|
||||
Änderung vollständig ersetzt, und eingezeichnete Bedienelemente wären damit weg und stünden
|
||||
außerdem in der Auszeichnung, die die Vorschau wahrheitsgetreu zeigen soll. Sie trägt denselben
|
||||
Skalierungsfaktor wie der Rahmen; Leiste und Pluszeichen werden gegenskaliert und bleiben in der
|
||||
Tablet- und Mobilvorschau fingergroß.
|
||||
|
||||
**Geprüft.** `npm test` 22 von 22 (vier neue Tests für Einfügen, Verschieben, Behälterregeln und
|
||||
die oberste Ebene). `php vendor/bin/phpunit` 434 von 434. `npm run test:editor` vollständig,
|
||||
mit einem neuen Abschnitt für Werkzeugleiste, Einfügepunkt und Ziehen; alle axe-Prüfungen ohne
|
||||
Verstoß, auch die mobile Editoransicht. Screenshot unter `reports/editor/canvas-overlay.png`.
|
||||
|
||||
**Dabei gefunden und behoben:** die ganze Ebene war zunächst `aria-hidden` und enthielt
|
||||
fokussierbare Schaltflächen – axe hat das gemeldet. `canContain` las `block.type` an einem
|
||||
fehlenden Block und zerbrach beim Ziehen aus dem Werkzeugkasten, wo der Block noch nicht
|
||||
existiert. Beim Verschieben ans Listenende wurde der Index begrenzt, bevor die Entnahme
|
||||
verrechnet war; der Block landete eine Stelle zu früh.
|
||||
|
||||
**Bekannte Grenzen.** Die Vorschau wird weiterhin nach 250 ms serverseitig neu erzeugt – das
|
||||
Ablegen fühlt sich dadurch nicht so unmittelbar an wie bei Flatsome, die Wahrheit bleibt aber
|
||||
der Server. Die Werkzeugleiste ist nicht mit einem Screenreader erprobt, nur mit axe. Spalten
|
||||
lassen sich noch nicht in der Vorschau breiter ziehen, der Werkzeugkasten ist weiterhin eine
|
||||
Textliste ohne Vorschaubilder, und es gibt je Option noch keinen eigenen Geräteschalter.
|
||||
|
||||
## Nächster konkreter Schritt
|
||||
|
||||
Den Editor mit einem Screenreader prüfen und die übrigen Verwaltungsbildschirme in die automatische Barrierefreiheitsprüfung aufnehmen. Danach der Rest von Arbeitspaket G (Inline-Ausbau, globale Bereiche für Header und Footer, wiederverwendbare Sektionen). Danach Arbeitspaket F aus `docs/AUSBAUPLAN.md`: Seiten umbenennen und verschieben mit Aktualisierung aller Kindpfade und automatischer Weiterleitung, frei wählbare Startseite, geplantes Veröffentlichen, Revisionsliste mit Wiederherstellung und ein Papierkorb statt sofortigem Löschen.
|
||||
Arbeitspaket H, Teil 2: den Werkzeugkasten auf ein Bilderraster mit Suche und Kategorien
|
||||
umstellen, Vorlagen je Element mit Vorschaubild, Geräteschalter an der einzelnen Option,
|
||||
Ziehfelder für Zahlen, Abstandsrahmen und das Ziehen der Spaltenbreite in der Vorschau. Danach
|
||||
Arbeitspaket I: `banner`, `textbox`, `stack`, `imagebox`, `logo`, `messagebox`. Reihenfolge und
|
||||
Begründung in `docs/FLATSOME-ABGLEICH.md`.
|
||||
|
||||
Unverändert offen: den Editor mit einem Screenreader prüfen und die übrigen Verwaltungsbildschirme in die automatische Barrierefreiheitsprüfung aufnehmen. Danach der Rest von Arbeitspaket G (Inline-Ausbau, globale Bereiche für Header und Footer, wiederverwendbare Sektionen). Danach Arbeitspaket F aus `docs/AUSBAUPLAN.md`: Seiten umbenennen und verschieben mit Aktualisierung aller Kindpfade und automatischer Weiterleitung, frei wählbare Startseite, geplantes Veröffentlichen, Revisionsliste mit Wiederherstellung und ein Papierkorb statt sofortigem Löschen.
|
||||
|
||||
`kant-it.de` ist seit 12.09.2026 als Pilotwebsite bestimmt. Der öffentliche Bestand wurde erhoben (WordPress 7.1, Flatsome 3.20.9 mit eigenem Child-Theme, WPForms Lite, Complianz, Google-Rezensionen, eigenes Plugin `kant-nova-chat`) und die noch offene Arbeit daraus in `docs/AUSBAUPLAN.md` in elf Arbeitspakete aufgeschlüsselt. Erhoben wurde ausschließlich die öffentlich ausgelieferte Startseite; ohne Backendzugang sind Beitragsbestand, Formularkonfiguration, Benutzer und Flatsome-Shortcodes nicht inventarisierbar. Keine externe Veröffentlichung erfolgt.
|
||||
|
||||
|
||||
@@ -81,6 +81,22 @@ Unberührt bleiben in jedem Fall `var/`, `.env` und `.env.local`. Scheitert das
|
||||
|
||||
**Geprüft wird ohne Netz** (`tests/ArchiveUpdateTest.php`): eine Releaseseite im Speicher, echte Zip-Archive, echte Prüfsummen, echte Umbenennungen in einem Wegwerfverzeichnis. Ein Test hält zusätzlich die Ersetzungsliste gegen die Mitnahmeliste von `scripts/build-release.php`: Was neu ins Archiv kommt, muss beim Update auch ersetzt werden.
|
||||
|
||||
## Der dritte Weg: von Hand über FTP
|
||||
|
||||
Der Weg für den einen Fall, in dem die Karte im Bildschirm „System" noch nicht existiert: **das Update auf die Fassung, die sie mitbringt.** Eine Installation der Fassung 1.4.2 kennt sie nicht – sie muss einmal von Hand nachgezogen werden, danach gibt es den Knopf.
|
||||
|
||||
1. **Sichern.** Verwaltung → System → „Sicherung anlegen". Zusätzlich per FTP `var/` und `.env` herunterladen. Migrationen sind nicht umkehrbar; der Rückweg ist die Sicherung.
|
||||
2. **Archiv holen und auspacken.** `kantsites-<fassung>.zip` von der Releaseseite, auf dem eigenen Rechner entpacken. Darin liegt ein Ordner `kantsites-<fassung>/`.
|
||||
3. **Wartungszustand einschalten.** Verwaltung → System → „Wartung". Sonst sieht ein Besucher währenddessen eine halb ersetzte Anwendung.
|
||||
4. **Diese Einträge hochladen und ersetzen:** `bin`, `config`, `migrations`, `src`, `templates`, `vendor`, `composer.json`, `composer.lock`, `migrations.php`, `cli-config.php`, `VERSION`. Verzeichnisse ersetzen, nicht hineinkopieren – eine Datei, die es in der neuen Fassung nicht mehr gibt, muss weg.
|
||||
5. **`public/` Datei für Datei** – und **`public/assets/fonts` und `public/assets/build` auslassen**. Dort liegen die hochgeladenen Schriften und das erzeugte Stylesheet-Bündel; beide gehören dem Betreiber und stehen in keinem Archiv.
|
||||
6. **Nicht anfassen:** `var/`, `.env`, `.env.local`. Dort stecken Inhalte, Medien und `APP_SECRET`.
|
||||
7. **`var/cache` vollständig löschen.** Sonst läuft die neue Fassung mit den Wegweisern der alten und antwortet mit 500, obwohl der Code richtig ist.
|
||||
8. **Migrationen ausführen.** Verwaltung → System → Karte „Migrationen stehen aus" → Knopf. Die Karte erscheint nur, wenn welche ausstehen. Wer eine Konsole hat, kann stattdessen `php vendor/bin/doctrine-migrations migrate --no-interaction` aufrufen.
|
||||
9. **Wartungszustand aus**, Startseite und Verwaltung einmal aufrufen, Fassungsnummer im Bildschirm „System" prüfen.
|
||||
|
||||
Das ist einmalig: Ab 1.4.3 kennt jede Installation den Weg über das Archiv, und dann entfällt das alles. Dann steht im selben Bildschirm die Karte „Update aus dem Releasearchiv", und die Schritte 2 bis 8 erledigt sie selbst.
|
||||
|
||||
## Ein Release veröffentlichen
|
||||
|
||||
`php scripts/build-release.php` baut auf einem Rechner mit Composer das Archiv – Anwendung plus `vendor/` ohne Entwicklungspakete – und schreibt die SHA-256-Summe in eine Kopie von `install.php`. Beides gehört anschließend an das Git-Release des passenden Tags:
|
||||
|
||||
@@ -0,0 +1,351 @@
|
||||
import { blockLabel, locate, dropList, isContainer } from './editor-model.js';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The layer above the preview.
|
||||
//
|
||||
// The preview is a sandboxed iframe without scripts of its own, and every change replaces its
|
||||
// whole document. Anything drawn inside it would therefore be wiped on the next keystroke and
|
||||
// would also end up in the markup we are supposed to be showing faithfully. So the outline, the
|
||||
// element toolbar, the insert points and the drop marker all live in the editor document, in an
|
||||
// absolutely positioned layer that sits exactly on top of the frame.
|
||||
//
|
||||
// Geometry: the layer is the same box as the frame and carries the same scale transform, so a
|
||||
// rectangle measured inside the frame can be used here unchanged. Measurements come from
|
||||
// getBoundingClientRect, which is relative to the frame's own viewport - that is why scrolling
|
||||
// the preview only needs a re-measure and no offset arithmetic.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Diameter of a plus sign, in the editor's own pixels. */
|
||||
const INSERT_SIZE = 22;
|
||||
|
||||
/**
|
||||
* What the toolbar offers, in the order it appears.
|
||||
*
|
||||
* Each label names the element it acts on - "Sektion duplizieren", not "Duplizieren". The
|
||||
* sidebar carries the same five actions, and two controls with the same name on one screen are
|
||||
* indistinguishable to anyone listening rather than looking. Naming the subject also says more
|
||||
* than the generic verb did: the toolbar floats next to the element, the announcement should
|
||||
* carry that context instead of assuming it was seen.
|
||||
*/
|
||||
const TOOLS = [
|
||||
{ action: 'parent', glyph: '⤴', title: label => `Element über ${label} auswählen` },
|
||||
{ action: 'up', glyph: '↑', title: label => `${label} nach oben verschieben` },
|
||||
{ action: 'down', glyph: '↓', title: label => `${label} nach unten verschieben` },
|
||||
{ action: 'duplicate', glyph: '⧉', title: label => `${label} duplizieren` },
|
||||
{ action: 'delete', glyph: '✕', title: label => `${label} löschen`, danger: true },
|
||||
];
|
||||
|
||||
export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSelected, onSelect, onAction, onInsert, onDrop }) {
|
||||
const layer = document.createElement('div');
|
||||
layer.className = 'canvas-overlay';
|
||||
const hover = element('div', 'ov-box ov-hover');
|
||||
const hoverLabel = element('span', 'ov-label');
|
||||
hover.append(hoverLabel);
|
||||
const select = element('div', 'ov-box ov-select');
|
||||
const selectName = document.createElement('span');
|
||||
selectName.className = 'ov-toolbar-name';
|
||||
selectName.setAttribute('aria-hidden', 'true');
|
||||
const toolbar = element('div', 'ov-toolbar');
|
||||
toolbar.removeAttribute('aria-hidden');
|
||||
toolbar.setAttribute('role', 'toolbar');
|
||||
toolbar.setAttribute('aria-label', 'Aktionen für das gewählte Element');
|
||||
const drop = element('div', 'ov-drop');
|
||||
const insertBefore = insertButton('before', 'Element davor einfügen');
|
||||
const insertAfter = insertButton('after', 'Element dahinter einfügen');
|
||||
toolbar.append(selectName);
|
||||
layer.append(hover, select, toolbar, insertBefore, insertAfter, drop);
|
||||
stage.append(layer);
|
||||
|
||||
for (const tool of TOOLS) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `ov-tool${tool.danger ? ' ov-tool-danger' : ''}`;
|
||||
button.dataset.action = tool.action;
|
||||
button.textContent = tool.glyph;
|
||||
button.addEventListener('mousedown', event => event.preventDefault());
|
||||
button.addEventListener('click', () => onAction(tool.action, getSelected()));
|
||||
toolbar.append(button);
|
||||
}
|
||||
|
||||
/**
|
||||
* A part that only draws something. The whole layer must not be aria-hidden - it carries real
|
||||
* buttons, and a focusable control inside an aria-hidden subtree is exactly the contradiction
|
||||
* screen readers cannot resolve. So the decoration is hidden one piece at a time.
|
||||
*/
|
||||
function element(tag, className) {
|
||||
const node = document.createElement(tag);
|
||||
node.className = className;
|
||||
node.setAttribute('aria-hidden', 'true');
|
||||
node.hidden = true;
|
||||
return node;
|
||||
}
|
||||
|
||||
function insertButton(where, title) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'ov-insert';
|
||||
button.title = title;
|
||||
button.setAttribute('aria-label', title);
|
||||
button.textContent = '+';
|
||||
button.hidden = true;
|
||||
button.addEventListener('mousedown', event => event.preventDefault());
|
||||
button.addEventListener('click', () => onInsert(getSelected(), where));
|
||||
return button;
|
||||
}
|
||||
|
||||
let hovered = null, dragging = null, dropPlan = null;
|
||||
|
||||
const doc = () => frame.contentDocument;
|
||||
/** Is this id a block of the current document rather than some id in the markup? */
|
||||
const known = id => Boolean(id) && Boolean(locate(getDocument().blocks, id));
|
||||
|
||||
/** The nearest element that stands for a block of the document. */
|
||||
function blockElement(node) {
|
||||
for (let current = node; current && current !== doc(); current = current.parentElement) {
|
||||
if (current.id && known(current.id)) return current;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rectOf(id) {
|
||||
const node = id ? doc()?.getElementById(id) : null;
|
||||
if (!node) return null;
|
||||
const box = node.getBoundingClientRect();
|
||||
return box.width || box.height ? box : null;
|
||||
}
|
||||
|
||||
/** Puts a box on a measured rectangle, or hides it when the block is not on screen. */
|
||||
function place(node, box, inset = 0) {
|
||||
if (!box) { node.hidden = true; return false; }
|
||||
node.hidden = false;
|
||||
node.style.left = `${box.left + inset}px`;
|
||||
node.style.top = `${box.top + inset}px`;
|
||||
node.style.width = `${Math.max(0, box.width - inset * 2)}px`;
|
||||
node.style.height = `${Math.max(0, box.height - inset * 2)}px`;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the chrome legible when the preview is scaled down. A tablet preview on a narrow
|
||||
* screen runs at about 0.6; without this the toolbar would shrink with it and the buttons
|
||||
* would fall under the size a finger or an eye can deal with.
|
||||
*/
|
||||
function counterScale(node, originX, originY) {
|
||||
const scale = getScale();
|
||||
node.style.transformOrigin = `${originX} ${originY}`;
|
||||
node.style.transform = scale < 1 ? `scale(${1 / scale})` : '';
|
||||
}
|
||||
|
||||
function positionToolbar(box, hasInsert) {
|
||||
if (!box) { toolbar.hidden = true; return; }
|
||||
toolbar.hidden = false;
|
||||
const scale = getScale();
|
||||
// The toolbar is drawn at its natural size and then counter-scaled, so the room it needs
|
||||
// inside the frame's coordinates shrinks as the preview shrinks.
|
||||
const needed = toolbar.offsetHeight / scale;
|
||||
// The upper plus sign straddles the top edge. On a narrow element - a button, a heading of
|
||||
// two words - it sits under the middle of the toolbar, so the toolbar makes room for it.
|
||||
const clearance = hasInsert ? INSERT_SIZE / scale / 2 : 0;
|
||||
const above = box.top >= needed + clearance + 2;
|
||||
toolbar.style.left = `${box.left}px`;
|
||||
toolbar.style.top = `${above
|
||||
? box.top - needed - clearance - 2
|
||||
: Math.min(box.bottom + clearance + 2, frame.clientHeight - needed)}px`;
|
||||
counterScale(toolbar, 'left', 'top');
|
||||
}
|
||||
|
||||
function positionInsert(button, box, where) {
|
||||
if (!box) { button.hidden = true; return; }
|
||||
const scale = getScale();
|
||||
const size = INSERT_SIZE / scale;
|
||||
button.hidden = false;
|
||||
button.style.left = `${box.left + box.width / 2 - size / 2}px`;
|
||||
button.style.top = `${(where === 'before' ? box.top : box.bottom) - size / 2}px`;
|
||||
button.style.width = `${size}px`;
|
||||
button.style.height = `${size}px`;
|
||||
counterScale(button, 'center', 'center');
|
||||
}
|
||||
|
||||
/** Renames the toolbar for whatever is selected right now. */
|
||||
function nameTools(id) {
|
||||
const block = locate(getDocument().blocks, id)?.block;
|
||||
const label = block ? blockLabel(block) : 'Element';
|
||||
selectName.textContent = label;
|
||||
for (const button of toolbar.children) {
|
||||
const tool = TOOLS.find(entry => entry.action === button.dataset.action);
|
||||
if (!tool) continue;
|
||||
button.title = tool.title(label);
|
||||
button.setAttribute('aria-label', tool.title(label));
|
||||
}
|
||||
}
|
||||
|
||||
function paintLabel(node, id) {
|
||||
const block = locate(getDocument().blocks, id)?.block;
|
||||
node.textContent = block ? blockLabel(block) : '';
|
||||
counterScale(node, 'left', 'bottom');
|
||||
}
|
||||
|
||||
/** Re-measures everything. Cheap enough to call on every scroll frame. */
|
||||
function sync() {
|
||||
const scale = getScale();
|
||||
layer.style.width = `${frame.clientWidth}px`;
|
||||
layer.style.height = `${frame.clientHeight}px`;
|
||||
layer.style.transform = `scale(${scale})`;
|
||||
if (!doc()) return;
|
||||
const selectedId = getSelected();
|
||||
const selectedBox = rectOf(selectedId);
|
||||
place(select, selectedBox);
|
||||
if (selectedBox) nameTools(selectedId);
|
||||
// Insert points only make sense for a block that has somewhere to sit next to.
|
||||
const item = selectedId ? locate(getDocument().blocks, selectedId) : null;
|
||||
const sibling = item && dropList(getDocument(), item.parent?.id ?? null, item.block);
|
||||
positionInsert(insertBefore, sibling ? selectedBox : null, 'before');
|
||||
positionInsert(insertAfter, sibling ? selectedBox : null, 'after');
|
||||
// The toolbar is placed last because it has to keep clear of the upper plus sign, and only
|
||||
// now is it settled whether there is one.
|
||||
positionToolbar(selectedBox, Boolean(sibling));
|
||||
const hoverBox = hovered && hovered !== selectedId ? rectOf(hovered) : null;
|
||||
if (place(hover, hoverBox)) paintLabel(hoverLabel, hovered);
|
||||
if (dragging) paintDrop();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Dropping.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/** The child elements of a block, in document order, that stand for its children. */
|
||||
function childElements(id) {
|
||||
const block = id ? locate(getDocument().blocks, id)?.block : null;
|
||||
const children = id ? (block?.children ?? []) : getDocument().blocks;
|
||||
return children.map(child => ({ id: child.id, box: rectOf(child.id) })).filter(entry => entry.box);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a drop would land inside a container. Children that sit side by side - the columns of
|
||||
* a row, the images of a gallery - are compared left to right, everything else top to bottom,
|
||||
* so the insertion line always runs across the gap the pointer is actually in.
|
||||
*/
|
||||
function indexInside(parentId, x, y) {
|
||||
const kids = childElements(parentId);
|
||||
if (!kids.length) return { index: 0, line: null };
|
||||
const sideBySide = kids.length > 1 && kids.some(kid => kid.box.left >= kids[0].box.right - 1);
|
||||
for (let index = 0; index < kids.length; index++) {
|
||||
const box = kids[index].box;
|
||||
const middle = sideBySide ? box.left + box.width / 2 : box.top + box.height / 2;
|
||||
if ((sideBySide ? x : y) < middle) return { index, line: { box, edge: sideBySide ? 'left' : 'top' }, sideBySide };
|
||||
}
|
||||
const last = kids[kids.length - 1].box;
|
||||
return { index: kids.length, line: { box: last, edge: sideBySide ? 'right' : 'bottom' }, sideBySide };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a pointer position into a concrete destination. The rule, from the inside out: land
|
||||
* inside the hovered block when it is a container that takes this kind of child, otherwise
|
||||
* next to it, otherwise try the same two answers one level up.
|
||||
*/
|
||||
function planDrop(x, y, block) {
|
||||
const document_ = getDocument();
|
||||
if (x < 0 || y < 0 || x > frame.clientWidth || y > frame.clientHeight) return null;
|
||||
const start = blockElement(doc()?.elementFromPoint(x, y));
|
||||
for (let node = start; ; ) {
|
||||
const id = node?.id ?? null;
|
||||
const item = id ? locate(document_.blocks, id) : null;
|
||||
// Never offer to drop a block into itself or into its own subtree.
|
||||
const ownBranch = block && id && (id === block.id || Boolean(locate(block.children || [], id)));
|
||||
if (id && !ownBranch && isContainer(item?.block) && dropList(document_, id, block)) {
|
||||
const inside = indexInside(id, x, y);
|
||||
return { parentId: id, index: inside.index, line: inside.line ?? { box: rectOf(id), edge: 'inside' } };
|
||||
}
|
||||
const parentId = item?.parent?.id ?? null;
|
||||
if (id && !ownBranch && dropList(document_, parentId, block)) {
|
||||
const box = rectOf(id);
|
||||
const after = box ? y > box.top + box.height / 2 : false;
|
||||
return { parentId, index: item.index + (after ? 1 : 0), line: box ? { box, edge: after ? 'bottom' : 'top' } : null };
|
||||
}
|
||||
if (!node) break;
|
||||
node = item?.parent ? doc()?.getElementById(item.parent.id) : null;
|
||||
if (!node) {
|
||||
// Fell out of the tree: the top level is the last thing left to try.
|
||||
if (!dropList(document_, null, block)) break;
|
||||
const inside = indexInside(null, x, y);
|
||||
return { parentId: null, index: inside.index, line: inside.line };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function paintDrop() {
|
||||
const line = dropPlan?.line;
|
||||
if (!line?.box) { drop.hidden = true; return; }
|
||||
drop.hidden = false;
|
||||
const scale = getScale();
|
||||
const thickness = 3 / scale;
|
||||
const { box, edge } = line;
|
||||
drop.classList.toggle('ov-drop-inside', edge === 'inside');
|
||||
if (edge === 'inside') {
|
||||
drop.style.left = `${box.left}px`; drop.style.top = `${box.top}px`;
|
||||
drop.style.width = `${box.width}px`; drop.style.height = `${box.height}px`;
|
||||
return;
|
||||
}
|
||||
const vertical = edge === 'left' || edge === 'right';
|
||||
drop.style.left = `${edge === 'right' ? box.right : box.left}px`;
|
||||
drop.style.top = `${edge === 'bottom' ? box.bottom : box.top}px`;
|
||||
drop.style.width = `${vertical ? thickness : box.width}px`;
|
||||
drop.style.height = `${vertical ? box.height : thickness}px`;
|
||||
if (vertical) drop.style.marginLeft = `${-thickness / 2}px`; else drop.style.marginLeft = '0';
|
||||
if (!vertical) drop.style.marginTop = `${-thickness / 2}px`; else drop.style.marginTop = '0';
|
||||
}
|
||||
|
||||
/** Attaches to a freshly loaded preview document. Called after every preview refresh. */
|
||||
function attach() {
|
||||
const document_ = doc();
|
||||
if (!document_) return;
|
||||
document_.addEventListener('mousemove', event => {
|
||||
if (dragging) return;
|
||||
const node = blockElement(event.target);
|
||||
const id = node?.id ?? null;
|
||||
if (id === hovered) return;
|
||||
hovered = id;
|
||||
sync();
|
||||
});
|
||||
document_.documentElement.addEventListener('mouseleave', () => { hovered = null; sync(); });
|
||||
document_.addEventListener('scroll', sync, { passive: true, capture: true });
|
||||
document_.defaultView?.addEventListener('scroll', sync, { passive: true });
|
||||
// Inside the frame an event already carries frame coordinates, and those are exactly the
|
||||
// coordinates the layer draws in - no conversion, and none of the rounding a trip through
|
||||
// the editor viewport and back through the scale factor would add.
|
||||
document_.addEventListener('dragover', event => {
|
||||
if (!dragging) return;
|
||||
event.preventDefault();
|
||||
event.dataTransfer.dropEffect = dragging.kind === 'move' ? 'move' : 'copy';
|
||||
dropPlan = planDrop(event.clientX, event.clientY, dragging.block);
|
||||
paintDrop();
|
||||
});
|
||||
document_.addEventListener('drop', event => {
|
||||
if (!dragging) return;
|
||||
event.preventDefault();
|
||||
// Both are read before the clean-up clears them.
|
||||
const payload = dragging, plan = dropPlan;
|
||||
endDrag();
|
||||
if (plan) onDrop(payload, plan);
|
||||
});
|
||||
sync();
|
||||
}
|
||||
|
||||
function startDrag(payload) {
|
||||
dragging = payload;
|
||||
layer.classList.add('is-dragging');
|
||||
hovered = null;
|
||||
sync();
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
dragging = null;
|
||||
dropPlan = null;
|
||||
drop.hidden = true;
|
||||
layer.classList.remove('is-dragging');
|
||||
sync();
|
||||
}
|
||||
|
||||
return { sync, attach, startDrag, endDrag, get dragPayload() { return dragging; } };
|
||||
}
|
||||
@@ -10,10 +10,13 @@ export const isContainer = block => ['section', 'row', 'column', 'gallery', 'log
|
||||
// Nothing may be dropped into a plugin block: the core does not know its layout contract.
|
||||
// Containers that accept only one kind of child, mirrored by the server side validator.
|
||||
export const ONLY_CHILD = { gallery: 'image', logos: 'image', accordion: 'accordionitem', tabs: 'tabitem' };
|
||||
/** Types DocumentValidator refuses outside their group: an entry without its accordion, a tab
|
||||
without its tab group. An image is not one of them - it stands on its own anywhere. */
|
||||
export const NEEDS_GROUP = ['accordionitem', 'tabitem'];
|
||||
export const canContain = (parent, block) => isContainer(parent) && !isPluginBlock(parent)
|
||||
&& (ONLY_CHILD[parent.type] === undefined || ONLY_CHILD[parent.type] === block.type)
|
||||
&& (block.type !== 'accordionitem' || parent.type === 'accordion')
|
||||
&& (block.type !== 'tabitem' || parent.type === 'tabs');
|
||||
&& (ONLY_CHILD[parent.type] === undefined || ONLY_CHILD[parent.type] === block?.type)
|
||||
&& (block?.type !== 'accordionitem' || parent.type === 'accordion')
|
||||
&& (block?.type !== 'tabitem' || parent.type === 'tabs');
|
||||
export const clone = value => structuredClone(value);
|
||||
|
||||
export function locate(blocks, id, parent = null) {
|
||||
@@ -59,8 +62,8 @@ export function createBlock(type) {
|
||||
return block;
|
||||
}
|
||||
|
||||
export function insertBlock(document, type, selected) {
|
||||
const block = createBlock(type);
|
||||
/** Raises the document schema to the version that knows this block type. */
|
||||
export function upgradeForType(document, type) {
|
||||
if (type === 'gallery') upgradeDocument(document, 3);
|
||||
if (type === 'postlist') upgradeDocument(document, 5);
|
||||
if (type === 'postnav') upgradeDocument(document, 18);
|
||||
@@ -72,6 +75,11 @@ export function insertBlock(document, type, selected) {
|
||||
if (type === 'menu' || type === 'partialref') upgradeDocument(document, 11);
|
||||
if (type === 'embed') upgradeDocument(document, 14);
|
||||
if (['icon', 'progress', 'slider'].includes(type)) upgradeDocument(document, 15);
|
||||
}
|
||||
|
||||
export function insertBlock(document, type, selected) {
|
||||
const block = createBlock(type);
|
||||
upgradeForType(document, type);
|
||||
let target = locate(document.blocks, selected);
|
||||
if (target?.block.type === 'gallery' && type !== 'image') { target.siblings.splice(target.index + 1, 0, block); return block.id; }
|
||||
if (target?.parent?.type === 'gallery' && type !== 'image') target = locate(document.blocks, target.parent.id);
|
||||
@@ -109,6 +117,55 @@ export function moveBlock(document, selected, targetId, inside = false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a block may land. `parentId` of null means the top level. Returns the list that would
|
||||
* hold the block, or null when the parent refuses this kind of child - a gallery takes images
|
||||
* and nothing else, an accordion takes entries.
|
||||
*/
|
||||
export function dropList(document, parentId, block) {
|
||||
// The top level mirrors DocumentValidator: everything is allowed there except the two types
|
||||
// that only ever exist inside their group. Being stricter here than the validator would make
|
||||
// dragging refuse what the buttons happily insert.
|
||||
if (parentId === null) return NEEDS_GROUP.includes(block?.type) ? null : document.blocks;
|
||||
const parent = locate(document.blocks, parentId)?.block;
|
||||
return parent && canContain(parent, block) ? (parent.children ??= []) : null;
|
||||
}
|
||||
|
||||
/** Inserts a new block at an exact position instead of behind the selection. */
|
||||
export function insertBlockAt(document, type, parentId, index) {
|
||||
const block = createBlock(type);
|
||||
upgradeForType(document, type);
|
||||
const list = dropList(document, parentId, block);
|
||||
if (!list) return null;
|
||||
list.splice(Math.max(0, Math.min(index, list.length)), 0, block);
|
||||
return block.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves an existing block to an exact position. Unlike moveBlock this keeps the index, so a
|
||||
* block dragged between two others lands between them and not at the end of the container.
|
||||
*/
|
||||
export function moveBlockTo(document, selected, parentId, index) {
|
||||
const source = locate(document.blocks, selected);
|
||||
if (!source) return false;
|
||||
// Dropping a container into itself or into its own child would detach the branch.
|
||||
if (parentId === selected || (parentId !== null && locate(source.block.children || [], parentId))) return false;
|
||||
if (!dropList(document, parentId, source.block)) return false;
|
||||
const from = source.siblings, fromIndex = source.index;
|
||||
from.splice(fromIndex, 1);
|
||||
// Re-read: removing the block may have shifted the target list or emptied a parent.
|
||||
const list = dropList(document, parentId, source.block);
|
||||
if (!list) { from.splice(fromIndex, 0, source.block); return false; }
|
||||
// The index still counts in the list as it was before the block was taken out, so the shift
|
||||
// has to be settled first and only then clamped - the other way round an index pointing past
|
||||
// the end is clamped to the old length and then pulled one step too far forward.
|
||||
let at = index;
|
||||
if (list === from && fromIndex < index) at -= 1;
|
||||
at = Math.max(0, Math.min(at, list.length));
|
||||
list.splice(at, 0, source.block);
|
||||
return true;
|
||||
}
|
||||
|
||||
export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' };
|
||||
|
||||
// Called only inside a history transaction: all other fields and IDs stay untouched.
|
||||
|
||||
@@ -209,3 +209,40 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.row-library button:hover:not(:disabled){border-color:var(--accent)}
|
||||
.row-library span{letter-spacing:-2px;font-size:13px}
|
||||
#design-search{font-size:12px;min-height:36px;padding:9px}
|
||||
|
||||
/* --------------------------------------------------------------------------------------------
|
||||
The working layer above the preview.
|
||||
|
||||
It sits in the editor document, not in the preview, and carries the same scale transform as
|
||||
the frame - so a rectangle measured inside the frame can be used here without conversion. The
|
||||
layer itself lets the mouse through; only the controls on it catch a click, and each of them
|
||||
is counter-scaled from JavaScript so it stays finger-sized when the preview is shrunk.
|
||||
-------------------------------------------------------------------------------------------- */
|
||||
.canvas-overlay{position:absolute;top:0;left:0;transform-origin:top left;pointer-events:none;overflow:hidden;z-index:2}
|
||||
.ov-box{position:absolute;pointer-events:none}
|
||||
.ov-hover{outline:1px dashed #7c8a80;outline-offset:-1px}
|
||||
.ov-select{outline:2px solid var(--ring,#b55b12);outline-offset:-2px}
|
||||
.ov-label{position:absolute;left:0;top:0;transform-origin:left bottom;background:var(--ring,#b55b12);color:#fff;font:700 10px/1 system-ui,sans-serif;letter-spacing:.3px;padding:4px 6px;border-radius:3px 3px 0 0;white-space:nowrap;translate:0 -100%}
|
||||
.ov-label{background:#5c6b62;font-weight:600}
|
||||
.ov-toolbar{position:absolute;display:flex;align-items:center;gap:2px;padding:3px;background:#243029;border-radius:7px;box-shadow:0 3px 12px #0000003d;pointer-events:auto}
|
||||
.ov-toolbar-name{color:#e7ece8;font:700 10px/1 system-ui,sans-serif;letter-spacing:.4px;padding:0 7px 0 5px;white-space:nowrap;max-width:150px;overflow:hidden;text-overflow:ellipsis;border-right:1px solid #ffffff24;margin-right:2px;align-self:stretch;display:flex;align-items:center}
|
||||
.ov-tool{width:26px;height:26px;display:grid;place-items:center;border:0;border-radius:5px;background:transparent;color:#e7ece8;font-size:13px;line-height:1;cursor:pointer;padding:0;min-height:0}
|
||||
.ov-tool:hover:not(:disabled){background:#3b4c42}
|
||||
.ov-tool:focus-visible{outline:2px solid #fff;outline-offset:-2px}
|
||||
.ov-tool-danger:hover:not(:disabled){background:#8d2f24}
|
||||
.ov-insert{position:absolute;display:grid;place-items:center;padding:0;min-height:0;border:2px solid #fff;border-radius:50%;background:var(--ring,#b55b12);color:#fff;font:700 14px/1 system-ui,sans-serif;cursor:pointer;box-shadow:0 2px 7px #0000003d;pointer-events:auto;opacity:.55;transition:opacity .12s}
|
||||
.ov-insert:hover,.ov-insert:focus-visible{opacity:1}
|
||||
.ov-drop{position:absolute;background:var(--ring,#b55b12);border-radius:2px;box-shadow:0 0 0 1px #fff8;pointer-events:none}
|
||||
.ov-drop-inside{background:color-mix(in srgb, var(--ring,#b55b12) 15%, transparent);border:2px dashed var(--ring,#b55b12);box-shadow:none}
|
||||
/* While something is being dragged the outlines would only add noise. */
|
||||
.canvas-overlay.is-dragging .ov-hover,.canvas-overlay.is-dragging .ov-toolbar,.canvas-overlay.is-dragging .ov-insert{display:none}
|
||||
|
||||
#library button[draggable=true]{cursor:grab}
|
||||
#library button[draggable=true]:active{cursor:grabbing}
|
||||
.insert-hint{background:color-mix(in srgb, var(--ring,#b55b12) 9%, #fff);border:1px solid var(--ring,#b55b12);border-radius:7px;padding:9px 11px;font-size:11px;line-height:1.5;margin:0 0 12px}
|
||||
.insert-hint .text-button{margin-left:4px}
|
||||
#toolbox.awaiting-insert .library button[data-add]{box-shadow:inset 0 0 0 1px var(--ring,#b55b12)}
|
||||
|
||||
/* The reduced-motion setting is honoured: nothing here animates by itself anyway, but the
|
||||
fade on the insert points is a transition and is switched off with the rest. */
|
||||
@media(prefers-reduced-motion:reduce){.ov-insert{transition:none;opacity:1}}
|
||||
+117
-12
@@ -1,7 +1,8 @@
|
||||
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
|
||||
import { EditorHistory, labels, icons, blockLabel, blockIcon, isPluginBlock, setStyle, effectiveStyle, locate, clone, isContainer, canContain, insertBlock, insertBlockAt, moveBlockTo, duplicateBlock, moveBlock, devices, effectiveDesign, setDesign, blockPath, insertPreset, insertRow, setEffect, insertMedia, upgradeDocument } from './editor-model.js';
|
||||
import { openMedia } from './media.js';
|
||||
import { createDesignPanel } from './design-panel.js';
|
||||
import { createRichText } from './rich-text.js';
|
||||
import { createCanvasOverlay } from './canvas-overlay.js';
|
||||
|
||||
// Mirrors DocumentValidator::ICONS; the server rejects anything outside this list.
|
||||
const ICONS = ['app', 'web', 'design', 'network', 'hardware', 'shop', 'support', 'shield', 'clock', 'star', 'phone', 'mail', 'pin', 'check'];
|
||||
@@ -17,6 +18,9 @@ const mayScript = 'true' === root.dataset.canScript;
|
||||
let history, selected = null, version, revisionId, publishedRevisionId, csrfToken;
|
||||
let busy = false, conflict = false, previewTimer, previewController, previewSequence = 0;
|
||||
let device = 'desktop', propertyPanel = 'content', designTokens, designSchema, designPanel, pendingPreset = null;
|
||||
// The preview is drawn at a fixed width and scaled down to fit. The overlay above it needs the
|
||||
// same factor, so it is kept here instead of being recomputed from the transform string.
|
||||
let previewScale = 1, overlay = null, pendingInsert = null;
|
||||
const collapsed = new Set();
|
||||
|
||||
function notify(message = '', recover = false) {
|
||||
@@ -121,6 +125,7 @@ function highlight(scroll = false) {
|
||||
doc.querySelectorAll('.editor-selected').forEach(element => element.classList.remove('editor-selected'));
|
||||
const element = selected ? doc.getElementById(selected) : null;
|
||||
if (element) { element.classList.add('editor-selected'); if (scroll) element.scrollIntoView({ block: 'nearest' }); }
|
||||
overlay?.sync();
|
||||
}
|
||||
|
||||
function renderProperties() {
|
||||
@@ -545,9 +550,11 @@ function resizePreview() {
|
||||
const available = Math.max(100, container.clientWidth - padding);
|
||||
const scale = Math.min(1, available / width);
|
||||
const height = Math.max(550, (container.clientHeight - padding) / scale);
|
||||
previewScale = scale;
|
||||
frame.style.width = `${width}px`; frame.style.height = `${height}px`; frame.style.transform = `scale(${scale})`;
|
||||
stage.style.width = `${width * scale}px`; stage.style.height = `${height * scale}px`;
|
||||
frame.title = `Vorschau ${devices[device]} · ${width} px`;
|
||||
overlay?.sync();
|
||||
}
|
||||
|
||||
function schedulePreview() {
|
||||
@@ -606,10 +613,12 @@ async function updatePreview() {
|
||||
if (block && INLINE_TEXT[block.type]) { event.preventDefault(); choose(element.id, false); startInlineEdit(element.id); }
|
||||
});
|
||||
doc.addEventListener('submit', event => event.preventDefault());
|
||||
// Every refresh is a new document, so the layer above it has to bind to it again.
|
||||
overlay?.attach();
|
||||
frame.contentWindow.scrollTo(0, top); highlight();
|
||||
// Erst jetzt ansehen lassen: bis hierher zeigte das Fenster eine Seite ohne Gestaltung.
|
||||
frame.style.visibility = '';
|
||||
$('#preview-status').textContent = 'Entwurf · Zum Auswählen direkt auf ein Element klicken.';
|
||||
$('#preview-status').textContent = 'Entwurf · Klicken wählt aus, Doppelklick bearbeitet Text, Elemente lassen sich hierher ziehen.';
|
||||
};
|
||||
// Solange die Gestaltung fehlt, bleibt die Fläche leer statt falsch.
|
||||
frame.style.visibility = 'hidden';
|
||||
@@ -692,11 +701,36 @@ async function save(publish = false) {
|
||||
finally { busy = false; controls(); }
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds an element. Without a marked insertion point this behaves as it always did - behind the
|
||||
* selection. With one, set by the plus signs in the preview, it lands exactly there.
|
||||
*/
|
||||
function addElement(type) {
|
||||
if (busy || !history) return;
|
||||
const at = pendingInsert;
|
||||
setPendingInsert(null);
|
||||
selected = history.change(doc => at
|
||||
? insertBlockAt(doc, type, at.parentId, at.index) ?? insertBlock(doc, type, selected)
|
||||
: insertBlock(doc, type, selected));
|
||||
changed();
|
||||
}
|
||||
|
||||
/** Marks where the next added element goes, and says so instead of leaving it invisible. */
|
||||
function setPendingInsert(target) {
|
||||
pendingInsert = target;
|
||||
$('#toolbox').classList.toggle('awaiting-insert', Boolean(target));
|
||||
$('#insert-hint').hidden = !target;
|
||||
if (target) {
|
||||
if ($('#toolbox').hidden) $('#toggle-library').click();
|
||||
$('#toolbox').scrollIntoView({ block: 'nearest' });
|
||||
}
|
||||
}
|
||||
|
||||
$('#library').addEventListener('click', event => {
|
||||
const type = event.target.closest('[data-add]')?.dataset.add;
|
||||
if (!type || busy || !history) return;
|
||||
selected = history.change(doc => insertBlock(doc, type, selected)); changed();
|
||||
if (type) addElement(type);
|
||||
});
|
||||
$('#cancel-insert').addEventListener('click', () => setPendingInsert(null));
|
||||
// A row with columns, in one click: the piece people actually look for when they want a row.
|
||||
$('#rows').addEventListener('click', event => {
|
||||
const columns = event.target.closest('[data-row]')?.dataset.row;
|
||||
@@ -729,8 +763,13 @@ document.querySelectorAll('[data-panel]').forEach(button => button.addEventListe
|
||||
$('#structure').addEventListener('dragstart', event => {
|
||||
if (busy) { event.preventDefault(); return; }
|
||||
const id = event.target.closest('[data-block-id]')?.dataset.blockId;
|
||||
if (id) { event.dataTransfer.setData('text/plain', id); event.dataTransfer.effectAllowed = 'move'; }
|
||||
if (!id) return;
|
||||
event.dataTransfer.setData('text/plain', id);
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
const block = locate(history.document.blocks, id)?.block;
|
||||
if (block) overlay.startDrag({ kind: 'move', type: block.type, block });
|
||||
});
|
||||
$('#structure').addEventListener('dragend', () => overlay.endDrag());
|
||||
$('#structure').addEventListener('dragover', event => { if (event.target.closest('[data-block-id]') && !busy) event.preventDefault(); });
|
||||
$('#structure').addEventListener('drop', event => {
|
||||
event.preventDefault(); if (busy) return;
|
||||
@@ -740,17 +779,83 @@ $('#structure').addEventListener('drop', event => {
|
||||
history.change(doc => moveBlock(doc, source, target, isContainer(locate(doc.blocks, target)?.block)));
|
||||
selected = source; changed();
|
||||
});
|
||||
$('#duplicate').addEventListener('click', () => { selected = history.change(doc => duplicateBlock(doc, selected)); changed(); });
|
||||
$('#delete').addEventListener('click', () => {
|
||||
selected = history.change(doc => { const item = locate(doc.blocks, selected); if (!item) return null; item.siblings.splice(item.index, 1); return item.siblings[Math.min(item.index, item.siblings.length - 1)]?.id || item.parent?.id || null; }); changed();
|
||||
});
|
||||
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => {
|
||||
history.change(doc => { const item = locate(doc.blocks, selected); const next = item.index + direction; if (next < 0 || next >= item.siblings.length) return; [item.siblings[item.index], item.siblings[next]] = [item.siblings[next], item.siblings[item.index]]; }); changed();
|
||||
});
|
||||
// The actions a block offers. The sidebar buttons and the toolbar in the preview are two ways
|
||||
// into the same functions, so the two can never drift apart.
|
||||
const actions = {
|
||||
duplicate: id => { selected = history.change(doc => duplicateBlock(doc, id)); changed(); },
|
||||
delete: id => {
|
||||
selected = history.change(doc => {
|
||||
const item = locate(doc.blocks, id);
|
||||
if (!item) return null;
|
||||
item.siblings.splice(item.index, 1);
|
||||
return item.siblings[Math.min(item.index, item.siblings.length - 1)]?.id || item.parent?.id || null;
|
||||
});
|
||||
changed();
|
||||
},
|
||||
move: (id, direction) => {
|
||||
history.change(doc => {
|
||||
const item = locate(doc.blocks, id);
|
||||
const next = item.index + direction;
|
||||
if (next < 0 || next >= item.siblings.length) return;
|
||||
[item.siblings[item.index], item.siblings[next]] = [item.siblings[next], item.siblings[item.index]];
|
||||
});
|
||||
selected = id; changed();
|
||||
},
|
||||
parent: id => choose(locate(history.document.blocks, id)?.parent?.id ?? null),
|
||||
};
|
||||
|
||||
$('#duplicate').addEventListener('click', () => actions.duplicate(selected));
|
||||
$('#delete').addEventListener('click', () => actions.delete(selected));
|
||||
for (const [id, direction] of [['move-up', -1], ['move-down', 1]]) $(`#${id}`).addEventListener('click', () => actions.move(selected, direction));
|
||||
$('#nest').addEventListener('click', () => { history.change(doc => { const item = locate(doc.blocks, selected); const previous = item.siblings[item.index - 1]; if (previous) moveBlock(doc, selected, previous.id, true); }); changed(); });
|
||||
$('#outdent').addEventListener('click', () => {
|
||||
history.change(doc => { const item = locate(doc.blocks, selected); if (!item?.parent) return; const parent = locate(doc.blocks, item.parent.id); item.siblings.splice(item.index, 1); parent.siblings.splice(parent.index + 1, 0, item.block); }); changed();
|
||||
});
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The preview as a working surface: outline, element toolbar, insert points, drag and drop.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
overlay = createCanvasOverlay({
|
||||
stage: $('#preview-stage'),
|
||||
frame: $('#preview'),
|
||||
getScale: () => previewScale,
|
||||
getDocument: () => history.document,
|
||||
getSelected: () => selected,
|
||||
onAction: (action, id) => {
|
||||
if (!id || busy || !history) return;
|
||||
if (action === 'up') actions.move(id, -1);
|
||||
else if (action === 'down') actions.move(id, 1);
|
||||
else actions[action]?.(id);
|
||||
},
|
||||
onInsert: (id, where) => {
|
||||
const item = locate(history.document.blocks, id);
|
||||
if (!item) return;
|
||||
setPendingInsert({ parentId: item.parent?.id ?? null, index: item.index + (where === 'after' ? 1 : 0) });
|
||||
},
|
||||
onDrop: (payload, plan) => {
|
||||
if (busy || !history) return;
|
||||
if (payload.kind === 'add') {
|
||||
const id = history.change(doc => insertBlockAt(doc, payload.type, plan.parentId, plan.index));
|
||||
if (id) { selected = id; changed(); }
|
||||
return;
|
||||
}
|
||||
const moved = history.change(doc => moveBlockTo(doc, payload.block.id, plan.parentId, plan.index) ? payload.block.id : null);
|
||||
if (moved) { selected = moved; changed(); }
|
||||
},
|
||||
});
|
||||
|
||||
// Elements are dragged out of the toolbox straight onto the page. The click route stays, so the
|
||||
// toolbox remains usable with the keyboard alone.
|
||||
$('#library').addEventListener('dragstart', event => {
|
||||
const button = event.target.closest('[data-add]');
|
||||
if (!button || busy || !history) { event.preventDefault(); return; }
|
||||
event.dataTransfer.effectAllowed = 'copy';
|
||||
event.dataTransfer.setData('text/plain', button.dataset.add);
|
||||
// A stand-in carrying only the type: the drop rules ask nothing else of a block, and the real
|
||||
// one does not exist until it lands.
|
||||
overlay.startDrag({ kind: 'add', type: button.dataset.add, block: { type: button.dataset.add } });
|
||||
});
|
||||
$('#library').addEventListener('dragend', () => overlay.endDrag());
|
||||
|
||||
$('#select-page').addEventListener('click', () => choose(null));
|
||||
$('#undo').addEventListener('click', () => { history.undo(); changed(); });
|
||||
$('#redo').addEventListener('click', () => { history.redo(); changed(); });
|
||||
|
||||
@@ -64,7 +64,7 @@ try {
|
||||
console.log('Preview status:', await page.locator('#preview-status').innerText());
|
||||
const frame = page.frameLocator('#preview');
|
||||
await frame.locator('h1').waitFor();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Zum Auswählen' }).waitFor();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await frame.locator('h1').click();
|
||||
assert.equal(await page.locator('#properties-title').innerText(), 'Überschrift');
|
||||
await page.getByLabel('Text', { exact: true }).fill('Meine bearbeitete Website');
|
||||
@@ -76,6 +76,55 @@ try {
|
||||
await frame.getByRole('heading', { name: 'Meine bearbeitete Website', exact: true }).waitFor();
|
||||
checks.push({ name: 'preview selection, property edit, undo and redo', passed: true });
|
||||
|
||||
// --- Die Vorschau als Arbeitsflaeche -------------------------------------------------------
|
||||
// Rahmen, Werkzeugleiste am Element, Einfuegepunkte und Ziehen aus dem Werkzeugkasten.
|
||||
const inPreview = selector => page.evaluate(
|
||||
sel => document.querySelector('#preview').contentDocument?.querySelector(sel)?.id ?? null, selector);
|
||||
await frame.locator('h1').click();
|
||||
await page.locator('.ov-select').waitFor({ state: 'visible' });
|
||||
assert.equal(await page.locator('.ov-toolbar-name').innerText(), 'Überschrift');
|
||||
await page.getByRole('toolbar', { name: 'Aktionen für das gewählte Element' }).waitFor();
|
||||
// Die Leiste benennt das Element, auf das sie wirkt - sonst haette sie denselben Namen wie
|
||||
// die gleichnamige Schaltflaeche in der Seitenleiste.
|
||||
await page.getByRole('button', { name: 'Überschrift duplizieren', exact: true }).waitFor();
|
||||
await page.getByRole('button', { name: 'Element über Überschrift auswählen', exact: true }).click();
|
||||
assert.notEqual(await page.locator('#properties-title').innerText(), 'Überschrift');
|
||||
|
||||
// Einfuegepunkt: das naechste Element landet genau dort und nicht am Ende.
|
||||
await frame.locator('h1').click();
|
||||
const headingId = await inPreview('h1');
|
||||
await page.getByRole('button', { name: 'Element dahinter einfügen', exact: true }).click();
|
||||
await page.locator('#insert-hint').waitFor({ state: 'visible' });
|
||||
await page.locator('#library button[data-add="text"]').click();
|
||||
assert.equal(await page.locator('#properties-title').innerText(), 'Text');
|
||||
assert.equal(await page.locator('#insert-hint').isVisible(), false);
|
||||
await page.waitForFunction(id => {
|
||||
const next = document.querySelector('#preview').contentDocument?.querySelector(`#${id}`)?.nextElementSibling;
|
||||
return Boolean(next?.classList.contains('block-text'));
|
||||
}, headingId);
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
|
||||
// Ziehen aus dem Werkzeugkasten auf die Seite.
|
||||
await frame.locator('h1').waitFor();
|
||||
const beforeButtons = await page.locator('#structure [data-block-id]').count();
|
||||
await page.locator('#library button[data-add="button"]').dragTo(frame.locator('h1'), { targetPosition: { x: 12, y: 4 } });
|
||||
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count + 1, beforeButtons);
|
||||
assert.equal(await page.locator('#properties-title').innerText(), 'Button');
|
||||
// Oben abgelegt heisst davor, nicht dahinter.
|
||||
await page.waitForFunction(id => {
|
||||
const previous = document.querySelector('#preview').contentDocument?.querySelector(`#${id}`)?.previousElementSibling;
|
||||
return Boolean(previous?.classList.contains('button'));
|
||||
}, headingId);
|
||||
await page.getByRole('button', { name: 'Rückgängig', exact: true }).click();
|
||||
await page.waitForFunction(count => document.querySelectorAll('#structure [data-block-id]').length === count, beforeButtons);
|
||||
checks.push({ name: 'canvas toolbar, insert points and drag from the toolbox', passed: true });
|
||||
await page.screenshot({ path: 'reports/editor/canvas-overlay.png' });
|
||||
// Zurueck in den Zustand, den dieser Abschnitt vorgefunden hat: die folgenden Pruefungen
|
||||
// setzen eine bestehende Auswahl voraus.
|
||||
await frame.getByRole('heading', { name: 'Meine bearbeitete Website', exact: true }).click();
|
||||
await page.locator('#duplicate:not([disabled])').waitFor();
|
||||
console.log('Canvas overlay, insert points and drag and drop passed.');
|
||||
|
||||
// Die Vorschau darf nie sichtbar sein, solange ihre Gestaltung fehlt. Vorher hing das Anwenden
|
||||
// am Ladeereignis des Rahmens - und das feuert erst, wenn das letzte Bild geladen ist. Auf der
|
||||
// nachgebauten Startseite von kant-it.de stand die Vorschau dadurch 1440 ms lang mit falschen
|
||||
@@ -109,7 +158,7 @@ try {
|
||||
assert.ok(verlauf.includes('hidden'), 'Die Vorschau wird beim Aufbau nicht verdeckt.');
|
||||
assert.equal(verlauf[verlauf.length - 1], 'sichtbar', 'Die Vorschau bleibt verdeckt.');
|
||||
await page.click('#undo');
|
||||
await page.locator('#preview-status').filter({ hasText: 'Zum Auswählen' }).waitFor();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
checks.push({ name: 'preview never shows unstyled content', passed: true });
|
||||
console.log('Preview edits and history passed.');
|
||||
|
||||
@@ -473,7 +522,7 @@ try {
|
||||
await page.locator('#structure [data-block-id="headline"]').waitFor();
|
||||
await page.locator('#structure [data-block-id="headline"]').click();
|
||||
await page.getByRole('button', { name: 'Design', exact: true }).click();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Zum Auswählen' }).waitFor();
|
||||
await page.locator('#preview-status').filter({ hasText: 'Entwurf ·' }).waitFor();
|
||||
await axe('desktop editor');
|
||||
await page.screenshot({ path: 'reports/editor/editor-desktop.png', fullPage: true });
|
||||
await page.getByRole('button', { name: 'Mobil', exact: true }).click();
|
||||
|
||||
@@ -44,6 +44,10 @@ final class SystemController extends AbstractController
|
||||
// Der Weg ohne Git: das Releasearchiv. Auf geteiltem Hosting ist das der einzige.
|
||||
'archiveVersion' => $request->query->getBoolean('pruefen') ? $this->archive->updateAvailable() : null,
|
||||
'archivePending' => $this->archive->pending(),
|
||||
// Wer die Dateien von Hand hochgeladen hat - über FTP, ohne Git und ohne das
|
||||
// Releasearchiv -, hat danach neuen Code und eine alte Datenbank. Dieser Bildschirm
|
||||
// ist die einzige Stelle, an der das jemand merken kann.
|
||||
'pendingMigrations' => $this->pendingMigrations(),
|
||||
]);
|
||||
}
|
||||
|
||||
@@ -54,6 +58,44 @@ final class SystemController extends AbstractController
|
||||
* Migrationen aus. Dazwischen liegt eine neue Anfrage, denn erst die startet mit dem Code der
|
||||
* neuen Fassung; ein Prozess, der sich selbst ausgetauscht hat, arbeitet mit dem alten weiter.
|
||||
*/
|
||||
/**
|
||||
* Ausstehende Migrationen ausführen - ohne Git, ohne Archiv, ohne Konsole.
|
||||
*
|
||||
* Der Weg für ein Update, das von Hand eingespielt wurde: Dateien liegen neu, die Datenbank
|
||||
* ist alt. Ohne diesen Knopf bräuchte es eine Konsole, und genau die gibt es auf geteiltem
|
||||
* Hosting nicht.
|
||||
*/
|
||||
#[Route('/%kant.admin_path%/system/migrationen', name: 'admin_system_migrate', methods: ['POST'])]
|
||||
public function migrate(Request $request): Response
|
||||
{
|
||||
$this->denyAccessUnlessGranted('ROLE_ADMIN');
|
||||
if (!$this->isCsrfTokenValid('system', $request->request->getString('_csrf_token'))) {
|
||||
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
|
||||
}
|
||||
@set_time_limit(0);
|
||||
|
||||
try {
|
||||
$namen = $this->installation->migrate();
|
||||
$this->addFlash('success', [] === $namen
|
||||
? 'Es stand keine Migration an - die Datenbank war bereits auf dem Stand des Codes.'
|
||||
: sprintf('%d Migration(en) ausgeführt: %s.', count($namen), implode(', ', $namen)));
|
||||
} catch (\Throwable $fehler) {
|
||||
$this->addFlash('error', 'Die Migration ist fehlgeschlagen: '.$fehler->getMessage());
|
||||
}
|
||||
|
||||
return $this->redirectToRoute('admin_system');
|
||||
}
|
||||
|
||||
/** Das Lesen der Liste darf nie der Grund sein, dass dieser Bildschirm nicht erscheint. */
|
||||
private function pendingMigrations(): array
|
||||
{
|
||||
try {
|
||||
return $this->installation->pendingMigrations();
|
||||
} catch (\Throwable) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
#[Route('/%kant.admin_path%/system/archiv', name: 'admin_system_archive', methods: ['POST'])]
|
||||
public function archive(Request $request): Response
|
||||
{
|
||||
@@ -125,6 +167,12 @@ final class SystemController extends AbstractController
|
||||
$result = $this->backup->create('Verwaltung');
|
||||
} catch (InvalidPage $error) {
|
||||
return $this->back($error->getMessage());
|
||||
} catch (\Throwable $error) {
|
||||
// Alles andere ist kein erwarteter Fall, sondern einer, den dieses Hosting mitbringt -
|
||||
// eine alte SQLite-Fassung, ein fehlendes Schreibrecht, ein voller Datentraeger. Eine
|
||||
// 500-Seite laesst den Betreiber damit allein; der Satz hier sagt wenigstens, woran es
|
||||
// lag.
|
||||
return $this->back('Die Sicherung ist fehlgeschlagen: '.$error->getMessage());
|
||||
}
|
||||
$removed = $this->backup->rotate();
|
||||
|
||||
|
||||
@@ -38,6 +38,9 @@ final class ArchiveUpdate
|
||||
*/
|
||||
private const MARKE = 'var/update/offen.json';
|
||||
|
||||
/** Das Protokoll - dieselbe Datei, in die auch der Updater über Git schreibt. */
|
||||
private const PROTOKOLL = 'var/updates.log';
|
||||
|
||||
public function __construct(
|
||||
private string $root,
|
||||
private Updater $updater,
|
||||
@@ -91,20 +94,28 @@ final class ArchiveUpdate
|
||||
/**
|
||||
* Spielt die angegebene (oder die neueste) Fassung ein.
|
||||
*
|
||||
* @return array{ok: bool, steps: list<array{label: string, ok: bool, detail: string}>}
|
||||
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>}
|
||||
*/
|
||||
public function run(?string $version = null): array
|
||||
{
|
||||
$schritte = [];
|
||||
$melden = static function (array &$schritte, string $label, bool $ok, string $detail = ''): bool {
|
||||
$schritte[] = ['label' => $label, 'ok' => $ok, 'detail' => $detail];
|
||||
// Jeder Schritt geht sofort ins Protokoll, nicht erst am Ende.
|
||||
//
|
||||
// Der Grund steht in ADR 071: Wenn die Seite, die das Ergebnis anzeigt, nicht erscheint -
|
||||
// aus welchem Grund auch immer -, weiß niemand mehr, was passiert ist. Eine Zeile in
|
||||
// einer Datei überlebt das, und auf einem Hosting ohne Konsole ist sie manchmal das
|
||||
// Einzige, was von einem gescheiterten Update übrig bleibt.
|
||||
$melden = function (array &$schritte, string $schritt, bool $ok, string $detail = ''): bool {
|
||||
$schritte[] = ['step' => $schritt, 'ok' => $ok, 'detail' => $detail];
|
||||
$this->protokoll(sprintf('%s %s: %s', $ok ? 'ok ' : 'FEHLER', $schritt, $detail));
|
||||
|
||||
return $ok;
|
||||
};
|
||||
$this->protokoll(sprintf('--- Update aus dem Archiv, %s UTC', gmdate('Y-m-d H:i:s')));
|
||||
|
||||
$neueste = $this->latest();
|
||||
if (null === $neueste) {
|
||||
$melden($schritte, 'Fassung ermitteln', false, 'Die Releaseseite antwortet nicht. Ohne sie ist nicht feststellbar, was aktuell ist.');
|
||||
$melden($schritte, 'Fassung ermitteln', false, trim('Die Releaseseite antwortet nicht. Ohne sie ist nicht feststellbar, was aktuell ist. '.$this->download->reason()));
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
@@ -136,7 +147,7 @@ final class ArchiveUpdate
|
||||
$archiv = $arbeit.'/kantsites-'.$ziel.'.zip';
|
||||
$inhalt = $this->download->get(sprintf('%s/v%s/kantsites-%s.zip', $this->releaseBase, $ziel, $ziel));
|
||||
if (null === $inhalt || '' === $inhalt) {
|
||||
$melden($schritte, 'Archiv laden', false, 'Das Archiv ließ sich nicht herunterladen. Darf dieser Server nach außen verbinden?');
|
||||
$melden($schritte, 'Archiv laden', false, trim('Das Archiv ließ sich nicht herunterladen. '.$this->download->reason()));
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
@@ -254,25 +265,28 @@ final class ArchiveUpdate
|
||||
*
|
||||
* Läuft in einer eigenen Anfrage, also mit dem Code der neuen Fassung.
|
||||
*
|
||||
* @return array{ok: bool, steps: list<array{label: string, ok: bool, detail: string}>}
|
||||
* @return array{ok: bool, steps: list<array{step: string, ok: bool, detail: string}>}
|
||||
*/
|
||||
public function finish(Installation $installation): array
|
||||
{
|
||||
$schritte = [];
|
||||
$this->protokoll(sprintf('--- Migrationen nach dem Update, %s UTC', gmdate('Y-m-d H:i:s')));
|
||||
try {
|
||||
$namen = $installation->migrate();
|
||||
$schritte[] = ['label' => 'Migrationen', 'ok' => true, 'detail' => [] === $namen
|
||||
$schritte[] = ['step' => 'Migrationen', 'ok' => true, 'detail' => [] === $namen
|
||||
? 'Es stand keine an - die Datenbank war bereits auf dem Stand der neuen Fassung.'
|
||||
: sprintf('%d ausgeführt: %s.', count($namen), implode(', ', $namen))];
|
||||
} catch (\Throwable $fehler) {
|
||||
$schritte[] = ['label' => 'Migrationen', 'ok' => false, 'detail' => $fehler->getMessage().' Die Website bleibt im Wartungszustand, bis das geklärt ist.'];
|
||||
$schritte[] = ['step' => 'Migrationen', 'ok' => false, 'detail' => $fehler->getMessage().' Die Website bleibt im Wartungszustand, bis das geklärt ist.'];
|
||||
$this->protokoll('FEHLER Migrationen: '.$fehler->getMessage());
|
||||
|
||||
return ['ok' => false, 'steps' => $schritte];
|
||||
}
|
||||
|
||||
@unlink($this->root.'/'.self::MARKE);
|
||||
$this->updater->maintenance(false);
|
||||
$schritte[] = ['label' => 'Fertig', 'ok' => true, 'detail' => sprintf(
|
||||
$this->protokoll('ok Migrationen: '.$schritte[0]['detail']);
|
||||
$schritte[] = ['step' => 'Fertig', 'ok' => true, 'detail' => sprintf(
|
||||
'Fassung %s läuft. Der vorherige Stand liegt unter var/update/, bis du ihn löschst.',
|
||||
$this->updater->version(),
|
||||
)];
|
||||
@@ -280,6 +294,17 @@ final class ArchiveUpdate
|
||||
return ['ok' => true, 'steps' => $schritte];
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine Zeile ins Protokoll, und zwar sofort.
|
||||
*
|
||||
* Ohne Ausnahme bei Fehlschlag: Ein Update, das daran scheitert, dass sein Protokoll nicht
|
||||
* schreibbar ist, wäre absurd.
|
||||
*/
|
||||
private function protokoll(string $zeile): void
|
||||
{
|
||||
@file_put_contents($this->root.'/'.self::PROTOKOLL, $zeile.PHP_EOL, FILE_APPEND);
|
||||
}
|
||||
|
||||
/** Kopiert das neue public/ über das alte, ohne das zu berühren, was dort entstanden ist. */
|
||||
private function mergePublic(string $von, string $nach): void
|
||||
{
|
||||
|
||||
+67
-3
@@ -35,6 +35,14 @@ final class Backup
|
||||
private string $mediaDirectory,
|
||||
private Connection $db,
|
||||
private CommandRunner $runner,
|
||||
/**
|
||||
* Ab welcher SQLite-Fassung `VACUUM INTO` benutzt wird.
|
||||
*
|
||||
* Als Parameter und nicht als Konstante, damit der Weg fuer aeltere Fassungen im Test
|
||||
* wirklich laeuft. Ein Ersatzweg, der nur auf fremden Servern ausgefuehrt wird, ist ein
|
||||
* ungeprueftes Versprechen.
|
||||
*/
|
||||
private string $vacuumSince = '3.27.0',
|
||||
) {
|
||||
}
|
||||
|
||||
@@ -214,9 +222,19 @@ final class Backup
|
||||
throw new InvalidPage('Sicherung über mysqldump ist noch nicht umgesetzt (Arbeitspaket J).');
|
||||
}
|
||||
$target = $directory.'/'.basename($file);
|
||||
// VACUUM INTO instead of copy(): a copy taken while something writes can be unusable, and
|
||||
// it would not say so until the day it is needed. The path is ours, not user input.
|
||||
$this->db->executeStatement(sprintf("VACUUM INTO '%s'", str_replace("'", "''", str_replace('\\', '/', $target))));
|
||||
// VACUUM INTO statt copy(): Eine Kopie, die waehrend eines Schreibvorgangs entsteht, kann
|
||||
// unbrauchbar sein - und sie sagt das nicht, sondern erst an dem Tag, an dem sie gebraucht
|
||||
// wird. Der Pfad kommt von uns, nicht aus einer Eingabe.
|
||||
//
|
||||
// **Es gibt VACUUM INTO aber erst seit SQLite 3.27.** Auf Umuts Hosting ist die Fassung
|
||||
// aelter: Die Installation lief (dafuer reicht 3.16), die Sicherung endete mit 500. Auf
|
||||
// einer solchen Installation wird stattdessen unter einer Schreibsperre kopiert - das ist
|
||||
// der Weg, den SQLite selbst fuer diesen Fall nennt, und er ist ebenso konsistent.
|
||||
if (version_compare($this->sqliteVersion(), $this->vacuumSince, '>=')) {
|
||||
$this->db->executeStatement(sprintf("VACUUM INTO '%s'", str_replace("'", "''", str_replace('\\', '/', $target))));
|
||||
} else {
|
||||
$this->copyUnderLock($file, $target);
|
||||
}
|
||||
if (!is_readable($target)) {
|
||||
throw new InvalidPage('Die Datenbank ließ sich nicht sichern.');
|
||||
}
|
||||
@@ -224,6 +242,52 @@ final class Backup
|
||||
return ['datei' => basename($file), 'bytes' => (int) filesize($target), 'sha256' => (string) hash_file('sha256', $target)];
|
||||
}
|
||||
|
||||
/** Die Fassung von SQLite, oder '0' wenn das keine SQLite-Verbindung ist. */
|
||||
private function sqliteVersion(): string
|
||||
{
|
||||
try {
|
||||
return (string) $this->db->fetchOne('SELECT sqlite_version()');
|
||||
} catch (\Throwable) {
|
||||
return '0';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Eine konsistente Kopie ohne VACUUM INTO.
|
||||
*
|
||||
* `BEGIN IMMEDIATE` haelt jeden anderen Schreibvorgang an, solange kopiert wird; der
|
||||
* Checkpoint davor schreibt zurueck, was noch im Write-Ahead-Log steht, damit die Kopie
|
||||
* vollstaendig ist und nicht die Haelfte der letzten Aenderungen fehlt.
|
||||
*
|
||||
* @throws InvalidPage
|
||||
*/
|
||||
private function copyUnderLock(string $file, string $target): void
|
||||
{
|
||||
try {
|
||||
$this->db->executeStatement('PRAGMA wal_checkpoint(TRUNCATE)');
|
||||
} catch (\Throwable) {
|
||||
// Ohne WAL gibt es nichts zurueckzuschreiben - das ist kein Fehler.
|
||||
}
|
||||
|
||||
try {
|
||||
$this->db->executeStatement('BEGIN IMMEDIATE');
|
||||
} catch (\Throwable $fehler) {
|
||||
throw new InvalidPage('Die Datenbank ließ sich fuer die Sicherung nicht sperren: '.$fehler->getMessage());
|
||||
}
|
||||
|
||||
try {
|
||||
if (!@copy($file, $target)) {
|
||||
throw new InvalidPage('Die Datenbankdatei ließ sich nicht kopieren. Schreibrechte auf var/backups pruefen.');
|
||||
}
|
||||
} finally {
|
||||
try {
|
||||
$this->db->executeStatement('COMMIT');
|
||||
} catch (\Throwable) {
|
||||
// Die Sperre faellt spaetestens mit der Verbindung.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** @return array{count: int, bytes: int} */
|
||||
private function copyMedia(string $target): array
|
||||
{
|
||||
|
||||
+57
-3
@@ -15,18 +15,46 @@ namespace App\Ops;
|
||||
*/
|
||||
class Download
|
||||
{
|
||||
/**
|
||||
* Die curl-Fehler, die „ich kann das Zertifikat nicht prüfen" heißen.
|
||||
*
|
||||
* Als Zahlen und nicht als Konstanten: `CURLE_PEER_FAILED_VERIFICATION` gibt es nicht in
|
||||
* jedem PHP-Bau, und ein fehlender Konstantenname wäre ein Absturz genau an der Stelle, an
|
||||
* der eigentlich eine hilfreiche Meldung stehen soll.
|
||||
*/
|
||||
private const ZERTIFIKATSFEHLER = [51, 60, 77];
|
||||
|
||||
/** Warum der letzte Versuch nichts gebracht hat - in einem Satz, der jemandem weiterhilft. */
|
||||
private string $grund = '';
|
||||
|
||||
public function __construct(
|
||||
private int $connectTimeout = 15,
|
||||
private int $timeout = 900,
|
||||
) {
|
||||
}
|
||||
|
||||
/**
|
||||
* Der Grund des letzten Fehlschlags.
|
||||
*
|
||||
* Ohne ihn steht in der Verwaltung „die Releaseseite antwortet nicht", und das ist in drei
|
||||
* Fällen dieselbe Meldung: kein Weg nach draußen, kein Zertifikatsspeicher, falsche Adresse.
|
||||
* Der mittlere ist auf einem Webhosting der häufigste und der einzige, den man in einer
|
||||
* Minute behebt - er muss also dastehen.
|
||||
*/
|
||||
public function reason(): string
|
||||
{
|
||||
return $this->grund;
|
||||
}
|
||||
|
||||
/** Den Inhalt der Adresse, oder null wenn sie nicht mit 200 antwortet. */
|
||||
public function get(string $url): ?string
|
||||
{
|
||||
$this->grund = '';
|
||||
// Kein http, kein file, kein ftp: ein Update, das über eine unverschlüsselte Verbindung
|
||||
// kommt, kann unterwegs ausgetauscht worden sein.
|
||||
if (!str_starts_with($url, 'https://')) {
|
||||
$this->grund = 'Nur HTTPS-Adressen werden geholt.';
|
||||
|
||||
return null;
|
||||
}
|
||||
if (function_exists('curl_init')) {
|
||||
@@ -43,11 +71,29 @@ class Download
|
||||
]);
|
||||
$antwort = curl_exec($curl);
|
||||
$status = curl_getinfo($curl, CURLINFO_RESPONSE_CODE);
|
||||
$fehler = curl_errno($curl);
|
||||
$text = curl_error($curl);
|
||||
curl_close($curl);
|
||||
|
||||
return is_string($antwort) && 200 === $status ? $antwort : null;
|
||||
if (is_string($antwort) && 200 === $status) {
|
||||
return $antwort;
|
||||
}
|
||||
// Ein fehlender Zertifikatsspeicher ist keine kaputte Leitung, sondern eine
|
||||
// Einstellung - und er sieht sonst genauso aus wie eine gesperrte Verbindung.
|
||||
$this->grund = match (true) {
|
||||
in_array($fehler, self::ZERTIFIKATSFEHLER, true) => 'Das Zertifikat der Gegenstelle lässt sich nicht prüfen: Diesem PHP fehlt ein Zertifikatsspeicher (curl.cainfo in der php.ini). Geholt wird trotzdem nur mit Prüfung - ein Update ohne sie könnte unterwegs ausgetauscht worden sein.',
|
||||
0 !== $fehler => 'Die Verbindung kam nicht zustande: '.$text,
|
||||
0 === $status => 'Der Server hat nicht geantwortet.',
|
||||
default => sprintf('Die Adresse antwortet mit %d statt 200.', $status),
|
||||
};
|
||||
// Bei einem Zertifikatsproblem lohnt der zweite Weg: Der Dateizeiger benutzt die
|
||||
// Einstellungen von OpenSSL, und die sind manchmal gesetzt, wo curl nichts findet.
|
||||
if (!in_array($fehler, self::ZERTIFIKATSFEHLER, true)) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (!filter_var(ini_get('allow_url_fopen'), FILTER_VALIDATE_BOOL)) {
|
||||
$this->grund = '' !== $this->grund ? $this->grund : 'Dieser Server darf keine Adressen abrufen: curl fehlt und allow_url_fopen ist abgeschaltet.';
|
||||
|
||||
return null;
|
||||
}
|
||||
$context = stream_context_create([
|
||||
@@ -55,7 +101,15 @@ class Download
|
||||
'ssl' => ['verify_peer' => true, 'verify_peer_name' => true],
|
||||
]);
|
||||
$antwort = @file_get_contents($url, false, $context);
|
||||
if (is_string($antwort)) {
|
||||
$this->grund = '';
|
||||
|
||||
return is_string($antwort) ? $antwort : null;
|
||||
return $antwort;
|
||||
}
|
||||
if ('' === $this->grund) {
|
||||
$this->grund = 'Die Adresse war über den Dateizeiger nicht erreichbar.';
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
+12
-3
@@ -46,14 +46,23 @@ final class Updater
|
||||
public function state(): array
|
||||
{
|
||||
$git = $this->runner->available() && $this->runner->has('git', $this->root);
|
||||
$status = $git ? $this->git(['status', '--porcelain']) : null;
|
||||
// Zwei verschiedene Fragen, und die Verwechslung war ein echter Fehler: Ob der Befehl
|
||||
// `git` existiert, sagt nichts darueber, ob dieses Verzeichnis eine Arbeitskopie ist. Ein
|
||||
// Hosting hat oft beides - git installiert, aber die Installation kam aus dem
|
||||
// Releasearchiv. Dann lief jeder Aufruf ins Leere, und die Verwaltung zeigte dem
|
||||
// Betreiber „fatal: Not a git repository“ an einer Stelle, an der eine Fassungsnummer
|
||||
// stehen sollte.
|
||||
$arbeitskopie = $git && is_dir($this->root.'/.git');
|
||||
$status = $arbeitskopie ? $this->git(['status', '--porcelain']) : null;
|
||||
|
||||
return [
|
||||
'version' => $this->version(),
|
||||
'commit' => $git ? substr($this->git(['rev-parse', 'HEAD'])['output'], 0, 8) : 'unbekannt',
|
||||
'describe' => $git ? $this->git(['describe', '--tags', '--always'])['output'] : 'unbekannt',
|
||||
'commit' => $arbeitskopie ? substr($this->git(['rev-parse', 'HEAD'])['output'], 0, 8) : 'unbekannt',
|
||||
'describe' => $arbeitskopie ? $this->git(['describe', '--tags', '--always'])['output'] : 'unbekannt',
|
||||
'maintenance' => is_file($this->root.'/'.self::MAINTENANCE_FILE),
|
||||
'git' => $git,
|
||||
// Der Weg ueber Git steht nur einer Arbeitskopie offen.
|
||||
'worktree' => $arbeitskopie,
|
||||
'clean' => null === $status ? null : ('' === $status['output'] && $status['ok']),
|
||||
'log' => $this->log(),
|
||||
];
|
||||
|
||||
@@ -19,15 +19,15 @@
|
||||
<div id="recovery" class="recovery-bar" hidden><button id="download" class="secondary">Änderungen als JSON sichern</button><button id="reload" class="secondary">Serverstand laden</button><a href="{{ path('admin_login') }}" target="_blank" rel="noopener">Erneut anmelden ↗</a></div>
|
||||
<main id="admin-main" class="editor-layout" tabindex="-1">
|
||||
<aside class="left-panel" aria-label="Elemente und Seitenstruktur">
|
||||
<section class="panel-section"><div class="panel-title"><h2>Werkzeugkasten</h2><button id="toggle-library" class="text-button" aria-expanded="true" aria-controls="toolbox">− <span class="visually-hidden">Werkzeugkasten einklappen</span></button></div><div id="toolbox"><h3 class="preset-heading">Element hinzufügen</h3>
|
||||
<p class="panel-hint">In die gewählte Sektion oder hinter das gewählte Element. Der Aufbau ist <strong>Sektion → Zeile → Spalte → Inhalt</strong>: eine Zeile hält Spalten nebeneinander.</p>
|
||||
<section class="panel-section"><div class="panel-title"><h2>Werkzeugkasten</h2><button id="toggle-library" class="text-button" aria-expanded="true" aria-controls="toolbox">− <span class="visually-hidden">Werkzeugkasten einklappen</span></button></div><div id="toolbox"><p id="insert-hint" class="insert-hint" role="status" hidden>Einfügepunkt gesetzt — das nächste Element landet dort. <button id="cancel-insert" class="text-button" type="button">Abbrechen</button></p><h3 class="preset-heading">Element hinzufügen</h3>
|
||||
<p class="panel-hint">Ziehen und in der Vorschau ablegen, oder klicken: dann landet es in der gewählten Sektion beziehungsweise hinter dem gewählten Element. Der Aufbau ist <strong>Sektion → Zeile → Spalte → Inhalt</strong>: eine Zeile hält Spalten nebeneinander.</p>
|
||||
<div id="rows" class="row-library">
|
||||
{% for count, label in {2: 'Zeile, 2 Spalten', 3: 'Zeile, 3 Spalten', 4: 'Zeile, 4 Spalten'} %}
|
||||
<button type="button" data-row="{{ count }}" disabled><span aria-hidden="true">{{ {2: '▥▥', 3: '▥▥▥', 4: '▥▥▥▥'}[count] }}</span> {{ label }}</button>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div id="library" class="library">
|
||||
{% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', postnav:'Beitragsnavigation', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', form:'Formular', breadcrumb:'Brotkrumen', menu:'Navigation', legal:'Rechtszeile', partialref:'Verkn. Sektion', embed:'Externer Inhalt', icon:'Symbol', progress:'Fortschritt', slider:'Slider', divider:'Trennlinie', spacer:'Abstand'} %}<button type="button" data-add="{{ type }}" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦',postlist:'☷',postnav:'⇄',iconcard:'◈',stat:'№',quote:'❝',logos:'⬚',cta:'◉',contact:'✆',accordion:'⌵',accordionitem:'·',tabs:'▭',tabitem:'▫',form:'✉',breadcrumb:'⟩',menu:'☰',legal:'§',partialref:'⧉',embed:'⬓',icon:'◆',progress:'▰',slider:'⇄',divider:'—',spacer:'⇳'}[type] }}</span>{{ label }}</button>{% endfor %}
|
||||
{% for type, label in {section:'Sektion', row:'Zeile', column:'Spalte', heading:'Überschrift', text:'Text', image:'Bild', button:'Button', gallery:'Galerie', postlist:'Beitragsliste', postnav:'Beitragsnavigation', iconcard:'Symbolkarte', stat:'Kennzahl', quote:'Kundenstimme', logos:'Logoleiste', cta:'Aufruf-Band', contact:'Kontaktdaten', accordion:'Akkordeon', accordionitem:'Eintrag', tabs:'Tabs', tabitem:'Tab', form:'Formular', breadcrumb:'Brotkrumen', menu:'Navigation', legal:'Rechtszeile', partialref:'Verkn. Sektion', embed:'Externer Inhalt', icon:'Symbol', progress:'Fortschritt', slider:'Slider', divider:'Trennlinie', spacer:'Abstand'} %}<button type="button" data-add="{{ type }}" draggable="true" disabled><span aria-hidden="true">{{ {section:'▣',row:'☰',column:'▥',heading:'H',text:'T',image:'▧',button:'↗',gallery:'▦',postlist:'☷',postnav:'⇄',iconcard:'◈',stat:'№',quote:'❝',logos:'⬚',cta:'◉',contact:'✆',accordion:'⌵',accordionitem:'·',tabs:'▭',tabitem:'▫',form:'✉',breadcrumb:'⟩',menu:'☰',legal:'§',partialref:'⧉',embed:'⬓',icon:'◆',progress:'▰',slider:'⇄',divider:'—',spacer:'⇳'}[type] }}</span>{{ label }}</button>{% endfor %}
|
||||
</div>
|
||||
<button id="open-media-library" class="secondary full-width media-launch" disabled>Medienverwaltung öffnen</button>
|
||||
<h3 class="preset-heading">Fertige Abschnitte</h3><p class="panel-hint">Wird nach dem aktuellen Seitenabschnitt eingefügt.</p>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
{% set nav = 'system' %}
|
||||
{% block title %}System{% endblock %}
|
||||
{% block heading %}System und Update{% endblock %}
|
||||
{% block lead %}Welche Fassung läuft, ob ein Update möglich ist, und der Weg dorthin: sichern, Wartungszustand, Code wechseln, migrieren, Rauchtest.{% endblock %}
|
||||
{% block lead %}Welche Fassung läuft, ob ein Update möglich ist, und der Weg dorthin: sichern, Wartungszustand, Dateien tauschen, migrieren.{% endblock %}
|
||||
{% block content %}
|
||||
|
||||
<div class="card">
|
||||
@@ -22,7 +22,13 @@
|
||||
<span class="muted">– geändert. Sie wird in der <code>robots.txt</code> nicht genannt; notiere sie dir an einem sicheren Ort. Zurück mit <code>php bin/console cms:admin:path admin</code>.</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% if state.worktree %}
|
||||
<dt>Stand</dt><dd><code>{{ state.describe }}</code>{% if state.commit != 'unbekannt' %} · Commit <code>{{ state.commit }}</code>{% endif %}</dd>
|
||||
{% else %}
|
||||
<dt>Eingerichtet über</dt>
|
||||
<dd>das <strong>Releasearchiv</strong> <span class="muted">– kein Git in diesem Verzeichnis, und dafür ist es auch nicht gedacht. Updates laufen über die Karte weiter unten.</span></dd>
|
||||
{% endif %}
|
||||
{% if state.worktree %}
|
||||
<dt>Verfügbar</dt>
|
||||
<dd>
|
||||
{% if available %}
|
||||
@@ -33,6 +39,7 @@
|
||||
<a href="{{ path('admin_system', {pruefen: 1}) }}">Beim Repository nachfragen</a>
|
||||
{% endif %}
|
||||
</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
||||
{% if state.maintenance %}
|
||||
@@ -49,6 +56,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{# Die Vorprüfung gehört zum Updateweg über Git: Arbeitsverzeichnis sauber, Datenbank
|
||||
außerhalb von Git, Tag erreichbar. Auf einer Installation aus dem Releasearchiv prüft sie
|
||||
Bedingungen, die dort keine sind - und zeigt lauter rote Ausrufezeichen für etwas, das
|
||||
vollkommen in Ordnung ist. #}
|
||||
{% if state.worktree %}
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Vorprüfung</h2><span class="muted">Jede Bedingung wird geprüft, nicht angenommen</span></div>
|
||||
<div class="card-body">
|
||||
@@ -62,11 +74,32 @@
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{# Waehrend das Archiv-Update auf seinen zweiten Schritt wartet, gehoert der Knopf dorthin -
|
||||
zwei Knoepfe, die dasselbe tun, sind eine Frage zu viel. #}
|
||||
{% if pendingMigrations|default([]) and not archivePending %}
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Migrationen stehen aus</h2>
|
||||
<span class="muted">{{ pendingMigrations|length }} Stück</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="notice">Der Code ist neuer als die Datenbank. Das passiert, wenn ein Update von Hand über FTP eingespielt wurde – dann fehlt der Schritt, den sonst der Updater übernimmt.</p>
|
||||
<ul class="log-list">{% for name in pendingMigrations %}<li><code>{{ name }}</code></li>{% endfor %}</ul>
|
||||
<p class="field-help">Lege vorher eine Sicherung an: Migrationen sind nicht umkehrbar, der Rückweg ist die Sicherung.</p>
|
||||
<form action="{{ path('admin_system_migrate') }}" method="post">
|
||||
<input type="hidden" name="_csrf_token" value="{{ csrf_token('system') }}">
|
||||
<button class="primary" type="submit">Migrationen jetzt ausführen →</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-head">
|
||||
<h2>Update aus dem Releasearchiv</h2>
|
||||
<span class="muted">{{ state.git ? 'Alternative zum Weg über Git' : 'der Weg ohne Git' }}</span>
|
||||
<h2>{{ state.worktree ? 'Update aus dem Releasearchiv' : 'Update einspielen' }}</h2>
|
||||
<span class="muted">{{ state.worktree ? 'Alternative zum Weg über Git' : 'holt die neue Fassung und tauscht die Dateien' }}</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if archivePending %}
|
||||
@@ -101,6 +134,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if state.worktree %}
|
||||
<div class="card">
|
||||
<div class="card-head"><h2>Update einspielen</h2><span class="muted">{{ ready ? 'Vorprüfung bestanden' : 'erst die Vorprüfung erfüllen' }}</span></div>
|
||||
<div class="card-body">
|
||||
@@ -125,6 +159,7 @@
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if steps is not empty %}
|
||||
<div class="card">
|
||||
@@ -134,7 +169,12 @@
|
||||
{% for step in steps %}
|
||||
<li class="{{ step.ok ? 'check-ok' : 'check-bad' }}">
|
||||
<span class="check-mark" aria-hidden="true">{{ step.ok ? '✓' : '!' }}</span>
|
||||
<span><strong>{{ step.step }}</strong> {{ step.detail }}</span>
|
||||
{# `step.label` ist die Schreibweise bis 1.4.4. Sie muss hier stehen bleiben: Beim
|
||||
Update von 1.4.4 auf 1.4.5 schreibt der **alte** Code die Schritte in die
|
||||
Sitzung und der **neue** zeigt sie an - eine Fassung, die nur die neue
|
||||
Schreibweise kennt, könnte das Update, das sie selbst einspielt, nicht
|
||||
berichten. #}
|
||||
<span><strong>{{ step.step|default(step.label|default('Schritt')) }}</strong> {{ step.detail }}</span>
|
||||
</li>
|
||||
{% endfor %}
|
||||
</ol>
|
||||
|
||||
@@ -77,7 +77,7 @@ final class ArchiveUpdateTest extends TestCase
|
||||
self::assertTrue($ergebnis['migrate'] ?? false, 'Nach dem Ersetzen fehlen noch die Migrationen.');
|
||||
self::assertSame(
|
||||
['Fassung ermitteln', 'Sicherung', 'Archiv laden', 'Prüfsumme', 'Entpacken', 'Wartungszustand', 'Dateien ersetzen', 'Zwischenspeicher', 'Dateien sind ersetzt'],
|
||||
array_column($ergebnis['steps'], 'label'),
|
||||
array_column($ergebnis['steps'], 'step'),
|
||||
'Die Reihenfolge ist der Punkt: gesichert wird vor dem ersten Handgriff, geprüft vor dem Entpacken.',
|
||||
);
|
||||
|
||||
@@ -127,7 +127,7 @@ final class ArchiveUpdateTest extends TestCase
|
||||
$ergebnis = $this->updater()->run();
|
||||
|
||||
self::assertFalse($ergebnis['ok']);
|
||||
self::assertSame('Prüfsumme', $this->firstFailed($ergebnis)['label']);
|
||||
self::assertSame('Prüfsumme', $this->firstFailed($ergebnis)['step']);
|
||||
self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION')), 'Nichts wurde ersetzt.');
|
||||
self::assertFileExists($this->root.'/src/Alt.php');
|
||||
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE, 'Die Website darf davon nichts merken.');
|
||||
@@ -142,7 +142,7 @@ final class ArchiveUpdateTest extends TestCase
|
||||
$ergebnis = $this->updater()->run();
|
||||
|
||||
self::assertFalse($ergebnis['ok']);
|
||||
self::assertSame('Entpacken', $this->firstFailed($ergebnis)['label']);
|
||||
self::assertSame('Entpacken', $this->firstFailed($ergebnis)['step']);
|
||||
self::assertStringContainsString('vendor', $this->firstFailed($ergebnis)['detail']);
|
||||
self::assertSame('1.0.0', trim((string) file_get_contents($this->root.'/VERSION')));
|
||||
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE);
|
||||
@@ -172,7 +172,7 @@ final class ArchiveUpdateTest extends TestCase
|
||||
|
||||
self::assertTrue($ergebnis['ok'], $this->explain($ergebnis));
|
||||
self::assertFalse($ergebnis['migrate'] ?? false);
|
||||
self::assertSame('Nichts zu tun', end($ergebnis['steps'])['label']);
|
||||
self::assertSame('Nichts zu tun', end($ergebnis['steps'])['step']);
|
||||
self::assertSame([], glob($this->root.'/var/backups/*') ?: []);
|
||||
}
|
||||
|
||||
@@ -195,9 +195,46 @@ final class ArchiveUpdateTest extends TestCase
|
||||
$download = new Download();
|
||||
foreach (['http://releases.example.invalid/a.zip', 'ftp://releases.example.invalid/a.zip', 'file:///etc/passwd', '/tmp/a.zip'] as $url) {
|
||||
self::assertNull($download->get($url), $url.' darf nicht geholt werden.');
|
||||
self::assertStringContainsString('HTTPS', $download->reason(), 'Und es muss dastehen, woran es lag.');
|
||||
}
|
||||
}
|
||||
|
||||
public function testAFailedDownloadSaysWhatWentWrong(): void
|
||||
{
|
||||
// Drei Ursachen sehen in der Verwaltung sonst gleich aus: kein Weg nach draußen, kein
|
||||
// Zertifikatsspeicher, falsche Adresse. Die mittlere ist auf einem Webhosting die
|
||||
// häufigste und in einer Minute behoben - wenn jemand sie liest.
|
||||
$download = new Download(connectTimeout: 2, timeout: 5);
|
||||
self::assertNull($download->get('https://kein-server.invalid/datei.zip'));
|
||||
self::assertNotSame('', $download->reason(), 'Ein Fehlschlag ohne Grund ist eine Sackgasse.');
|
||||
|
||||
// Und der Weg durch den Updater: Die Meldung im Schritt trägt den Grund mit.
|
||||
$this->releases->serve(self::API.'/releases/latest', json_encode(['tag_name' => 'v1.1.0']));
|
||||
$this->releases->serve(sprintf('%s/v1.1.0/kantsites-1.1.0.zip.sha256', self::BASE), str_repeat('a', 64));
|
||||
$ergebnis = $this->updater()->run();
|
||||
|
||||
self::assertFalse($ergebnis['ok']);
|
||||
$gescheitert = $this->firstFailed($ergebnis);
|
||||
self::assertSame('Archiv laden', $gescheitert['step']);
|
||||
self::assertStringContainsString('herunterladen', $gescheitert['detail']);
|
||||
}
|
||||
|
||||
public function testEveryStepIsWrittenToTheLogWhileItHappens(): void
|
||||
{
|
||||
// Auf Umuts Hosting endete die Ergebnisseite mit 500, und damit war auch verloren, was
|
||||
// der Updater berichtet hatte. Eine Zeile in einer Datei überlebt das - und auf einem
|
||||
// Hosting ohne Konsole ist sie manchmal das Einzige, was von einem gescheiterten Update
|
||||
// übrig bleibt. Geschrieben wird während des Laufs, nicht am Ende: Ein Lauf, der
|
||||
// mittendrin abbricht, hat sonst nichts hinterlassen.
|
||||
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'), str_repeat('a', 64));
|
||||
$this->updater()->run();
|
||||
|
||||
$protokoll = (string) file_get_contents($this->root.'/var/updates.log');
|
||||
self::assertStringContainsString('Update aus dem Archiv', $protokoll);
|
||||
self::assertStringContainsString('ok Fassung ermitteln', $protokoll);
|
||||
self::assertStringContainsString('FEHLER Prüfsumme', $protokoll, 'Gerade der gescheiterte Schritt muss dort stehen.');
|
||||
}
|
||||
|
||||
public function testTheUpdaterReplacesEveryEntryTheReleaseArchiveShips(): void
|
||||
{
|
||||
// Der Fehler, der schon einmal auf dem Hosting aufgeschlagen ist, andersherum: Kommt eine
|
||||
@@ -288,7 +325,7 @@ final class ArchiveUpdateTest extends TestCase
|
||||
file_put_contents($this->root.'/'.$relativ, $inhalt);
|
||||
}
|
||||
|
||||
/** @param array{steps: list<array{label: string, ok: bool, detail: string}>} $ergebnis */
|
||||
/** @param array{steps: list<array{step: string, ok: bool, detail: string}>} $ergebnis */
|
||||
private function firstFailed(array $ergebnis): array
|
||||
{
|
||||
foreach ($ergebnis['steps'] as $schritt) {
|
||||
@@ -302,7 +339,7 @@ final class ArchiveUpdateTest extends TestCase
|
||||
private function explain(array $ergebnis): string
|
||||
{
|
||||
return implode("\n", array_map(
|
||||
static fn (array $schritt): string => sprintf('%s %s: %s', $schritt['ok'] ? 'ok' : 'FEHLER', $schritt['label'], $schritt['detail']),
|
||||
static fn (array $schritt): string => sprintf('%s %s: %s', $schritt['ok'] ? 'ok' : 'FEHLER', $schritt['step'], $schritt['detail']),
|
||||
$ergebnis['steps'],
|
||||
));
|
||||
}
|
||||
|
||||
@@ -40,6 +40,36 @@ final class BackupTest extends TestCase
|
||||
$this->backup = new Backup($this->root, $url, $this->root.'/var/media', $this->db, new CommandRunner());
|
||||
}
|
||||
|
||||
public function testABackupWorksOnAnOlderSqliteToo(): void
|
||||
{
|
||||
// Auf Umuts Hosting endete „Sicherung anlegen" mit 500. Der Grund: `VACUUM INTO` gibt es
|
||||
// erst seit SQLite 3.27, und dort läuft eine ältere Fassung - alt genug für die
|
||||
// Installation (die braucht 3.16), zu alt für die Sicherung. Statt VACUUM wird dann unter
|
||||
// einer Schreibsperre kopiert. Geprüft wird das hier wirklich und nicht nur versprochen:
|
||||
// die Schwelle wird so hoch gesetzt, dass der Ersatzweg läuft.
|
||||
$url = 'sqlite:///'.str_replace('\\', '/', $this->root).'/var/cms.db';
|
||||
$alt = new Backup($this->root, $url, $this->root.'/var/media', $this->db, new CommandRunner(), '99.0.0');
|
||||
|
||||
$sicherung = $alt->create('auf altem SQLite');
|
||||
|
||||
$datei = $sicherung['path'].'/cms.db';
|
||||
self::assertFileExists($datei);
|
||||
self::assertGreaterThan(0, (int) filesize($datei));
|
||||
|
||||
// Und es ist eine vollständige, lesbare Datenbank - nicht ein halb geschriebener Torso.
|
||||
$kopie = new \PDO('sqlite:'.$datei);
|
||||
self::assertSame(
|
||||
(int) $this->db->fetchOne('SELECT COUNT(*) FROM revision'),
|
||||
(int) $kopie->query('SELECT COUNT(*) FROM revision')->fetchColumn(),
|
||||
'Die Kopie muss denselben Inhalt haben wie das Original.',
|
||||
);
|
||||
self::assertSame(
|
||||
$sicherung['manifest']['datenbank']['sha256'],
|
||||
hash_file('sha256', $datei),
|
||||
'Die Prüfsumme im Manifest gehört zu genau dieser Datei.',
|
||||
);
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
$this->db->close();
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace App\Tests;
|
||||
|
||||
use App\Content\DemoContent;
|
||||
use App\Content\DocumentValidator;
|
||||
use App\Content\MediaLibrary;
|
||||
use App\Content\PageRepository;
|
||||
use App\Content\PartialRepository;
|
||||
use App\Content\SiteSettings;
|
||||
use App\Infrastructure\ConnectionFactory;
|
||||
use App\Kernel;
|
||||
use App\Ops\ArchiveUpdate;
|
||||
use App\Ops\Backup;
|
||||
use App\Ops\CommandRunner;
|
||||
use App\Ops\Download;
|
||||
use App\Ops\Updater;
|
||||
use Doctrine\DBAL\Connection;
|
||||
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||
|
||||
/**
|
||||
* Der Bildschirm „System“ - die einzige Stelle, an der eine Installation ohne Konsole erfährt,
|
||||
* dass etwas mit ihr nicht stimmt.
|
||||
*
|
||||
* Geprüft wird hier nicht das Aussehen, sondern zwei Eigenschaften, die schweigend verschwinden
|
||||
* können: dass ausstehende Migrationen dort **auffallen**, und dass niemand sie auslösen kann,
|
||||
* der nicht angemeldeter Administrator ist. Beides fiel beim Veröffentlichen von 1.4.3 auf -
|
||||
* siehe ADR 070.
|
||||
*/
|
||||
final class SystemScreenTest extends WebTestCase
|
||||
{
|
||||
private const FIXTURE_PASSWORD = 'Fixture-only-password';
|
||||
|
||||
private Connection $db;
|
||||
private string $path;
|
||||
private array $previous;
|
||||
|
||||
protected static function getKernelClass(): string
|
||||
{
|
||||
return Kernel::class;
|
||||
}
|
||||
|
||||
protected function setUp(): void
|
||||
{
|
||||
$this->path = tempnam(sys_get_temp_dir(), 'kant-system-');
|
||||
$url = 'sqlite:///'.str_replace('\\', '/', $this->path);
|
||||
$this->previous = [$_SERVER, $_ENV];
|
||||
foreach (['APP_SECRET' => 'isolated-test-secret', 'DATABASE_URL' => $url, 'SITE_ORIGIN' => 'https://cms.test', 'MEDIA_DIRECTORY' => $this->path.'-media'] as $key => $value) {
|
||||
$_SERVER[$key] = $_ENV[$key] = $value;
|
||||
}
|
||||
$this->db = ConnectionFactory::create($url);
|
||||
// Die Tabellen entstehen hier über das Testschema und nicht über die Migrationen - genau
|
||||
// die Lage einer Installation, deren Dateien von Hand hochgeladen wurden: Der Code ist
|
||||
// neu, die Migrationsliste der Datenbank kennt ihn nicht.
|
||||
TestSchema::build($this->db);
|
||||
$this->db->insert('website', ['id' => 'default', 'name' => 'Test', 'default_locale' => 'de', 'theme' => '{}']);
|
||||
$this->db->insert('page', ['id' => 'p1', 'slug' => 'home', 'path' => 'home', 'type' => 'page', 'locale' => 'de', 'website_id' => 'default', 'version' => 0, 'created_at' => gmdate('Y-m-d H:i:s')]);
|
||||
$pages = new PageRepository($this->db, new DocumentValidator(), new SiteSettings($this->db, new MediaLibrary($this->db, $this->path.'-media')));
|
||||
$pages->publish('p1', $pages->saveDraft('p1', DemoContent::pages()['home'], 0), 1);
|
||||
$partials = new PartialRepository($this->db, new DocumentValidator());
|
||||
foreach (DemoContent::areas() as $kind => $area) {
|
||||
$partials->create($kind, 'default', $area['name'], $area['document']);
|
||||
}
|
||||
foreach (['admin' => 'ROLE_ADMIN', 'editor' => 'ROLE_EDITOR'] as $username => $role) {
|
||||
$this->db->insert('cms_user', [
|
||||
'id' => bin2hex(random_bytes(8)),
|
||||
'username' => $username,
|
||||
'password_hash' => password_hash(self::FIXTURE_PASSWORD, PASSWORD_BCRYPT, ['cost' => 4]),
|
||||
'roles' => json_encode([$role], JSON_THROW_ON_ERROR),
|
||||
'created_at' => gmdate('Y-m-d H:i:s'),
|
||||
'password_changed_at' => gmdate('Y-m-d H:i:s'),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
protected function tearDown(): void
|
||||
{
|
||||
parent::tearDown();
|
||||
$this->db->close();
|
||||
gc_collect_cycles();
|
||||
@unlink($this->path);
|
||||
foreach (glob($this->path.'-media/*') ?: [] as $file) {
|
||||
unlink($file);
|
||||
}
|
||||
if (is_dir($this->path.'-media')) {
|
||||
rmdir($this->path.'-media');
|
||||
}
|
||||
[$_SERVER, $_ENV] = $this->previous;
|
||||
}
|
||||
|
||||
public function testOutstandingMigrationsAreImpossibleToOverlookAndCanBeRunFromThere(): void
|
||||
{
|
||||
$client = $this->login();
|
||||
$client->request('GET', '/admin/system');
|
||||
self::assertSame(200, $client->getResponse()->getStatusCode());
|
||||
$html = (string) $client->getResponse()->getContent();
|
||||
|
||||
// Vorher gab es dafür nur die Konsole - also genau das, was auf geteiltem Hosting fehlt.
|
||||
self::assertStringContainsString('Migrationen stehen aus', $html);
|
||||
self::assertStringContainsString('Version20260912000100', $html, 'Die Namen gehören dazu: sie sagen, was passieren wird.');
|
||||
self::assertStringContainsString('/admin/system/migrationen', $html);
|
||||
// Eine Sicherung vorher ist keine Höflichkeit: Migrationen sind nicht umkehrbar.
|
||||
self::assertStringContainsString('Sicherung', $html);
|
||||
}
|
||||
|
||||
public function testTheScreenShowsWhatTheArchiveUpdaterDidInsteadOfDyingOnIt(): void
|
||||
{
|
||||
// Der Fehler, den Umut auf seinem Hosting gesehen hat: Das Update lief, legte seine
|
||||
// Schritte ab – und die Seite, die sie anzeigen soll, starb daran. Der Updater über Git
|
||||
// nennt einen Schritt `step`, der über das Archiv nannte ihn `label`, und die Vorlage
|
||||
// liest `step`. Ergebnis: 500 statt Ergebnis, und der zweite Knopf unerreichbar. Ein
|
||||
// Test, der nur das Ergebnis-Array prüft, findet so etwas nie – dieser rendert die Seite.
|
||||
$archiv = new ArchiveUpdate(
|
||||
dirname(__DIR__),
|
||||
new Updater(dirname(__DIR__), (string) $_ENV['DATABASE_URL'], new CommandRunner(), $this->db),
|
||||
new Backup(dirname(__DIR__), (string) $_ENV['DATABASE_URL'], $this->path.'-media', $this->db, new CommandRunner()),
|
||||
new Download(connectTimeout: 1, timeout: 2),
|
||||
// Eine Adresse, die es nicht gibt: Der Lauf endet nach dem ersten Schritt, ohne
|
||||
// irgendetwas anzufassen – und genau dieser eine Schritt muss auf der Seite stehen.
|
||||
'https://kein-server.invalid/download',
|
||||
'https://kein-server.invalid/api',
|
||||
);
|
||||
$ergebnis = $archiv->run();
|
||||
self::assertFalse($ergebnis['ok']);
|
||||
self::assertSame(
|
||||
['step', 'ok', 'detail'],
|
||||
array_keys($ergebnis['steps'][0]),
|
||||
'Beide Updater beschreiben ihre Schritte gleich - die Vorlage kennt nur eine Form.',
|
||||
);
|
||||
|
||||
$client = $this->login();
|
||||
$client->request('GET', '/admin/system');
|
||||
$sitzung = $client->getRequest()->getSession();
|
||||
$sitzung->set('update_steps', $ergebnis['steps']);
|
||||
$sitzung->save();
|
||||
|
||||
$client->request('GET', '/admin/system');
|
||||
self::assertSame(200, $client->getResponse()->getStatusCode(), 'Die Ergebnisseite darf an den eigenen Schritten nicht sterben.');
|
||||
self::assertStringContainsString('Fassung ermitteln', (string) $client->getResponse()->getContent());
|
||||
}
|
||||
|
||||
public function testNobodyButAnAdministratorCanStartAMigration(): void
|
||||
{
|
||||
// Ein Knopf, der die Datenbank umbaut, ist das lohnendste Ziel auf diesem Bildschirm.
|
||||
self::ensureKernelShutdown();
|
||||
$anonym = self::createClient();
|
||||
$anonym->followRedirects(false);
|
||||
$anonym->request('POST', '/admin/system/migrationen');
|
||||
self::assertSame('http://localhost/admin/login', $anonym->getResponse()->headers->get('Location'));
|
||||
|
||||
$redaktion = $this->login('editor');
|
||||
$redaktion->request('POST', '/admin/system/migrationen');
|
||||
self::assertSame(403, $redaktion->getResponse()->getStatusCode(), 'Eine Redakteurin darf das nicht.');
|
||||
|
||||
// Und selbst als Administrator nicht ohne gültiges Formular aus dieser Sitzung.
|
||||
$admin = $this->login();
|
||||
$admin->request('POST', '/admin/system/migrationen', ['_csrf_token' => 'erfunden']);
|
||||
$admin->followRedirect();
|
||||
self::assertStringContainsString('Sitzung ist abgelaufen', (string) $admin->getResponse()->getContent());
|
||||
self::assertStringContainsString('Migrationen stehen aus', (string) $admin->getResponse()->getContent(), 'Und es steht immer noch aus.');
|
||||
}
|
||||
|
||||
private function login(string $username = 'admin'): KernelBrowser
|
||||
{
|
||||
self::ensureKernelShutdown();
|
||||
$client = self::createClient();
|
||||
$client->followRedirects(false);
|
||||
$crawler = $client->request('GET', '/admin/login');
|
||||
$client->submit($crawler->selectButton('Anmelden')->form(['_username' => $username, '_password' => self::FIXTURE_PASSWORD]));
|
||||
$client->followRedirect();
|
||||
|
||||
return $client;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { EditorHistory, createBlock, locate, insertBlock, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, insertRow, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer, setStyle, effectiveStyle, hoverStyle, resetStyleDevice, clearStyle, copyStyle, SCHEMA_VERSION } from '../public/assets/editor-model.js';
|
||||
import { EditorHistory, createBlock, locate, insertBlock, insertBlockAt, moveBlockTo, dropList, duplicateBlock, moveBlock, upgradeDocument, effectiveDesign, setDesign, insertPreset, insertRow, blockPath, insertMedia, setEffect, isPluginBlock, blockLabel, blockIcon, canContain, isContainer, setStyle, effectiveStyle, hoverStyle, resetStyleDevice, clearStyle, copyStyle, SCHEMA_VERSION } from '../public/assets/editor-model.js';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { designCss } from '../scripts/build-design.mjs';
|
||||
|
||||
@@ -364,3 +364,64 @@ test('a current document still accepts new blocks, and every block type can be i
|
||||
assert.throws(() => upgradeDocument({ schemaVersion: 99, blocks: [] }, 6), /Unbekanntes Dokumentschema/);
|
||||
assert.throws(() => upgradeDocument({ schemaVersion: 5, blocks: [] }, SCHEMA_VERSION + 1), /Unbekanntes Dokumentschema/);
|
||||
});
|
||||
|
||||
// --- Punktgenaues Einfuegen und Verschieben -------------------------------------------------
|
||||
// Die Grundlage fuer das Ablegen in der Vorschau: dort zaehlt die Stelle, nicht die Auswahl.
|
||||
|
||||
test('a drop lands at the exact position, also at the very end of a list', () => {
|
||||
const document = fixture();
|
||||
const section = locate(document.blocks, 's1').block;
|
||||
insertBlockAt(document, 'text', 's1', 0);
|
||||
assert.deepEqual(section.children.map(block => block.type), ['text', 'heading', 'text']);
|
||||
insertBlockAt(document, 'button', 's1', section.children.length);
|
||||
assert.equal(section.children.at(-1).type, 'button');
|
||||
// Ein Index jenseits des Endes haengt an, statt zu scheitern.
|
||||
assert.ok(insertBlockAt(document, 'button', 's1', 99));
|
||||
assert.equal(section.children.length, 5);
|
||||
});
|
||||
|
||||
test('moving to an exact position settles the shift before it clamps', () => {
|
||||
const order = document => document.blocks[0].children.map(block => block.id);
|
||||
const document = fixture();
|
||||
insertBlockAt(document, 'button', 's1', 2);
|
||||
const ids = order(document);
|
||||
// Nach hinten: die Entnahme verschiebt jede spaetere Stelle um eins.
|
||||
assert.equal(moveBlockTo(document, ids[0], 's1', 2), true);
|
||||
assert.deepEqual(order(document), [ids[1], ids[0], ids[2]]);
|
||||
// Ganz ans Ende. Ohne die richtige Reihenfolge von Verrechnen und Begrenzen landete der
|
||||
// Block hier eine Stelle zu frueh.
|
||||
assert.equal(moveBlockTo(document, ids[1], 's1', 3), true);
|
||||
assert.deepEqual(order(document), [ids[0], ids[2], ids[1]]);
|
||||
// Nach vorne: dort wird nichts verrechnet.
|
||||
assert.equal(moveBlockTo(document, ids[1], 's1', 0), true);
|
||||
assert.deepEqual(order(document), [ids[1], ids[0], ids[2]]);
|
||||
});
|
||||
|
||||
test('a drop never detaches a branch and never breaks a container rule', () => {
|
||||
const document = fixture();
|
||||
upgradeDocument(document, SCHEMA_VERSION);
|
||||
// Eine Sektion in ihr eigenes Kind: das wuerde den Ast abtrennen.
|
||||
assert.equal(moveBlockTo(document, 's1', 'h1', 0), false);
|
||||
assert.equal(moveBlockTo(document, 's1', 's1', 0), false);
|
||||
assert.equal(document.blocks.length, 2);
|
||||
// Eine Galerie nimmt nur Bilder.
|
||||
const galleryId = insertBlock(document, 'gallery', null);
|
||||
assert.equal(moveBlockTo(document, 'h1', galleryId, 0), false);
|
||||
assert.equal(locate(document.blocks, 'h1').parent.id, 's1');
|
||||
assert.equal(insertBlockAt(document, 'text', galleryId, 0), null);
|
||||
assert.ok(insertBlockAt(document, 'image', galleryId, 0));
|
||||
});
|
||||
|
||||
test('the top level takes everything the validator takes there, and nothing else', () => {
|
||||
const document = fixture();
|
||||
upgradeDocument(document, SCHEMA_VERSION);
|
||||
// Ein Akkordeon-Eintrag und ein Tab gehoeren in ihre Gruppe - der Validator lehnt sie sonst ab.
|
||||
assert.equal(dropList(document, null, { type: 'accordionitem' }), null);
|
||||
assert.equal(dropList(document, null, { type: 'tabitem' }), null);
|
||||
// Alles andere darf dort stehen, genau wie ueber die Schaltflaechen.
|
||||
for (const type of ['section', 'heading', 'image', 'text']) {
|
||||
assert.equal(dropList(document, null, { type }), document.blocks, type);
|
||||
}
|
||||
// Beim Ziehen aus dem Werkzeugkasten gibt es den Block noch nicht.
|
||||
assert.doesNotThrow(() => dropList(document, 's1', null));
|
||||
});
|
||||
Reference in new issue
Block a user