Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b943af5fe7 | ||
|
|
9add795d6b | ||
|
|
f2c04b311f | ||
|
|
d75c295f59 |
No files matched your search
@@ -229,6 +229,26 @@
|
|||||||
],
|
],
|
||||||
"port": 8107
|
"port": 8107
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "luximmo",
|
||||||
|
"runtimeExecutable": "powershell",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/luximmo.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/luximmo-media'; $env:SITE_ORIGIN='http://127.0.0.1:8108'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8108 -t public bin/dev-router.php"
|
||||||
|
],
|
||||||
|
"port": 8108
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "luximmo-prod",
|
||||||
|
"runtimeExecutable": "powershell",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/luximmo.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/luximmo-media'; $env:SITE_ORIGIN='http://127.0.0.1:8109'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8109 -t public bin/dev-router.php"
|
||||||
|
],
|
||||||
|
"port": 8109
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "zentrale",
|
"name": "zentrale",
|
||||||
"runtimeExecutable": "C:/php84/php.exe",
|
"runtimeExecutable": "C:/php84/php.exe",
|
||||||
|
|||||||
@@ -407,6 +407,10 @@ Die Website der Garten- und Landschaftsbau Hüfner GmbH (Iffezheim) liegt in `va
|
|||||||
|
|
||||||
Der Nachbau von www.ebi-immobilien.de liegt in `var/ebi-immobilien.db` mit Medien in `var/ebi-immobilien-media`; die Startkonfiguration `ebi-immobilien` nutzt Port 8106, `ebi-immobilien-prod` denselben Bestand im Produktionsmodus auf Port 8107 zum Messen. Die Originalbilder der alten Website liegen in `var/ebi-immobilien-quelle`; eine Datei `startbild.jpg/.png/.webp` dort ersetzt das Startbild. `sh scripts/reset-ebi-immobilien.sh [Quellordner]` bereitet die Bilder vor (`scripts/build-ebi-immobilien-assets.php`, ohne Argument nimmt es die schon vorbereiteten aus `var/ebi-immobilien-assets`), wirft die Installation weg, baut mit `scripts/seed-ebi-immobilien.php` Gestaltung, Kopf- und Fußbereich, Startseite, Suchauftrag, Immobilienbewertung, Kontakt, Impressum, Datenschutz, 404, Weiterleitungen der alten Adressen, Menü und die Formulare `suchauftrag`, `bewertung`, `kontakt` auf, holt Manrope und Lora auf den eigenen Server und schreibt das Inhaltspaket `var/ebi-immobilien-paket.zip`. Das Passwort für „umut“ steht nach jedem Reset in `var/ebi-immobilien-zugang.txt`. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8107 CMS_PATHS=/,/suchauftrag,/immobilienbewertung,/kontakt CMS_AUDIT_DIR=reports/lighthouse-ebi-immobilien node scripts/audit.mjs`. Die bewussten Abweichungen vom Original stehen im Kopf des Seed-Skripts.
|
Der Nachbau von www.ebi-immobilien.de liegt in `var/ebi-immobilien.db` mit Medien in `var/ebi-immobilien-media`; die Startkonfiguration `ebi-immobilien` nutzt Port 8106, `ebi-immobilien-prod` denselben Bestand im Produktionsmodus auf Port 8107 zum Messen. Die Originalbilder der alten Website liegen in `var/ebi-immobilien-quelle`; eine Datei `startbild.jpg/.png/.webp` dort ersetzt das Startbild. `sh scripts/reset-ebi-immobilien.sh [Quellordner]` bereitet die Bilder vor (`scripts/build-ebi-immobilien-assets.php`, ohne Argument nimmt es die schon vorbereiteten aus `var/ebi-immobilien-assets`), wirft die Installation weg, baut mit `scripts/seed-ebi-immobilien.php` Gestaltung, Kopf- und Fußbereich, Startseite, Suchauftrag, Immobilienbewertung, Kontakt, Impressum, Datenschutz, 404, Weiterleitungen der alten Adressen, Menü und die Formulare `suchauftrag`, `bewertung`, `kontakt` auf, holt Manrope und Lora auf den eigenen Server und schreibt das Inhaltspaket `var/ebi-immobilien-paket.zip`. Das Passwort für „umut“ steht nach jedem Reset in `var/ebi-immobilien-zugang.txt`. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8107 CMS_PATHS=/,/suchauftrag,/immobilienbewertung,/kontakt CMS_AUDIT_DIR=reports/lighthouse-ebi-immobilien node scripts/audit.mjs`. Die bewussten Abweichungen vom Original stehen im Kopf des Seed-Skripts.
|
||||||
|
|
||||||
|
### Luximmo Hausverwaltung (Gaggenau)
|
||||||
|
|
||||||
|
Der Nachbau des Entwurfs `luximmo-website.zip` liegt in `var/luximmo.db` mit Medien in `var/luximmo-media`; die Startkonfiguration `luximmo` nutzt Port 8108, `luximmo-prod` denselben Bestand im Produktionsmodus auf Port 8109. `sh scripts/reset-luximmo.sh [assets-Ordner des Pakets]` holt ein Standbild aus dem Hero-Video (`scripts/build-luximmo-poster.mjs`, Playwright), bereitet Logo, Seitensymbol und Fotos vor (`scripts/build-luximmo-assets.php`; ohne Argument nimmt es `var/luximmo-assets`), baut mit `scripts/seed-luximmo.php` Startseite (Hintergrundvideo im Hero), Verwaltungsanfrage, Impressum, Datenschutz, 404, Menü und die Formulare `kontakt`, `verwaltungsanfrage` und `bewertung` (je drei Schritte mit Bildkarten und Zusammenfassung; Bewertung auf `/immobilienbewertung`) auf, holt Instrument Sans auf den eigenen Server und schreibt `var/luximmo-paket.zip`. Formulare gehen an info@lux-immo.net (abweichend per `LUXIMMO_MAIL`). Passwort für „umut“ in `var/luximmo-zugang.txt`. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8109 CMS_PATHS=/,/verwaltungsanfrage,/impressum CMS_AUDIT_DIR=reports/lighthouse-luximmo node scripts/audit.mjs`.
|
||||||
|
|
||||||
Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`.
|
Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`.
|
||||||
|
|
||||||
Die Livesuche prüft `npm run test:search` mit derselben isolierten Pilotkopie: Eingabe ohne Enter, langsame Antworten, Fehler/Wiederholen, Filter, Browserhistorie, Mediathek, Tastaturfokus, mobile Ansicht und axe. Ergebnisse stehen in `reports/live-search/`. Die allgemeine Verwaltungsprüfung ohne erneuten öffentlichen Bewegungstest startet `npm run test:admin -- --admin-only`.
|
Die Livesuche prüft `npm run test:search` mit derselben isolierten Pilotkopie: Eingabe ohne Enter, langsame Antworten, Fehler/Wiederholen, Filter, Browserhistorie, Mediathek, Tastaturfokus, mobile Ansicht und axe. Ergebnisse stehen in `reports/live-search/`. Die allgemeine Verwaltungsprüfung ohne erneuten öffentlichen Bewegungstest startet `npm run test:admin -- --admin-only`.
|
||||||
@@ -2,6 +2,14 @@
|
|||||||
|
|
||||||
Stand: 27.10.2026. Arbeitstitel KantSites; Produktname offen.
|
Stand: 27.10.2026. Arbeitstitel KantSites; Produktname offen.
|
||||||
|
|
||||||
|
## ADR 126 – Unternehmensart, Standort und Logo in den strukturierten Daten (09.11.2026)
|
||||||
|
|
||||||
|
**Kontext:** Bei Luximmo fiel auf, dass LocalBusiness immer der allgemeine Typ war und weder Logo noch Koordinaten kannte.
|
||||||
|
|
||||||
|
**Entscheidung:** Drei Einstellungen unter „Einstellungen“: `businessType` aus einer festen Liste von LocalBusiness-Unterarten, `latitude`/`longitude` als Paar, `organisationLogoMediaId` aus der Mediathek. Das Logo ist bewusst getrennt vom sichtbaren Logo im Kopfbereich: Das kann eingebettetes SVG sein und hat dann keine eigene Adresse. Die Liste ist fest, damit kein Tippfehler einen ungültigen Typ erzeugt.
|
||||||
|
|
||||||
|
**Folgen:** Keine Migration: Die Werte liegen im JSON der Einstellungen, fehlende Schlüssel fallen auf die Standardwerte, ausgegeben wird dann genau das Bisherige. Typ und Koordinaten gelten nur für ein lokales Unternehmen mit Anschrift, das Logo auch für eine Organisation. Eine Installation älter als diese Fassung ignoriert die Werte aus einem Inhaltspaket und gibt sie nicht aus.
|
||||||
|
|
||||||
## ADR 125 – Videos: eigene Bibliothek, Baustein und Sektionshintergrund (Dokumentschema v28, 04.11.2026)
|
## ADR 125 – Videos: eigene Bibliothek, Baustein und Sektionshintergrund (Dokumentschema v28, 04.11.2026)
|
||||||
|
|
||||||
**Kontext:** Der Hero von kant-it.de soll ein Video im Hintergrund bekommen. Die Mediathek nahm nur Bilder und PDFs, die Alpaka-Videos von Hüfner und die Drews-Videos blieben deshalb draußen. Ein `<video>` in „Eigenes HTML“ ginge, verlangt aber Dateien von Hand auf dem Server und kann weder Bewegung reduzieren noch ein Anhalten anbieten.
|
**Kontext:** Der Hero von kant-it.de soll ein Video im Hintergrund bekommen. Die Mediathek nahm nur Bilder und PDFs, die Alpaka-Videos von Hüfner und die Drews-Videos blieben deshalb draußen. Ein `<video>` in „Eigenes HTML“ ginge, verlangt aber Dateien von Hand auf dem Server und kann weder Bewegung reduzieren noch ein Anhalten anbieten.
|
||||||
|
|||||||
+39
-1
@@ -1,5 +1,41 @@
|
|||||||
# Projektstatus
|
# Projektstatus
|
||||||
|
|
||||||
|
## Kern: Unternehmensart, Standort und Logo in den strukturierten Daten (09.11.2026)
|
||||||
|
|
||||||
|
**Anlass:** SEO-Durchsicht bei Luximmo. Neu unter Einstellungen (ADR 126): Art des Unternehmens, Breiten-/Längengrad, Logo aus der Mediathek. Ohne Angaben bleibt die Ausgabe wie bisher, keine Migration.
|
||||||
|
|
||||||
|
**Luximmo:** RealEstateAgent, Koordinaten aus OpenStreetMap (Gebäudetreffer), Logo-Bild für die Daten, Seitentitel „Hausverwaltung & Immobilienverkauf in Gaggenau | LUXIMMO“, Paket neu gebaut.
|
||||||
|
|
||||||
|
**Geprüft:** PHPUnit 687 grün (PHP 8.4, neu `testBusinessTypeLocationAndLogoReachTheStructuredData`), `npm test` 41, Twig-Lint, php-cs-fixer, Felder in der Verwaltung per Playwright (Laden, Logo entfernen und wählen, Speichern, Ausgabe auf der Website), Paketimport. Lighthouse nach dieser Änderung nicht erneut gelaufen.
|
||||||
|
|
||||||
|
Die Ausgabe auf einer Live-Seite braucht mindestens Fassung 1.38.0; 1.37.0 ignoriert die Werte.
|
||||||
|
|
||||||
|
## Neue Installation „Luximmo Hausverwaltung“, Gaggenau (06.11.2026)
|
||||||
|
|
||||||
|
**Anlass:** Umut lieferte `luximmo-website.zip` (Onepager der Luximmo UG, Eslem Kurt, Hauptstr. 169, 76571 Gaggenau): „in KantSites nachbauen und wie die anderen Ablagen, gerne etwas ausbauen, und wie bei Immo DEEG ein Formular für Immo auf einer separaten Seite“.
|
||||||
|
|
||||||
|
**Erledigt (nur Inhalt, kein Kerncode):** Datenbank `var/luximmo.db`, Medien `var/luximmo-media`, Bildquellen `var/luximmo-assets`, Startkonfigurationen `luximmo` (8108) und `luximmo-prod` (8109), Inhaltspaket `var/luximmo-paket.zip`. Aufbau nur per `sh scripts/reset-luximmo.sh [assets-Ordner]` (Standbild aus dem Video: `build-luximmo-poster.mjs`; Logo, Seitensymbol aus dem goldenen Zeichen, WebP: `build-luximmo-assets.php`; Inhalte: `seed-luximmo.php`).
|
||||||
|
|
||||||
|
- **Startseite wie die Vorlage:** Kopf mit Menü links, Logo mittig, Knopf rechts; Hero mit dem Video als Hintergrundvideo der Sektion (ADR 125, WebM, Standbild als Hintergrundbild, Anhalten-Knopf oben rechts), gerundet; Intro mit Etikett und drei Kärtchen; drei Leistungen mit Foto; Über uns; Kontakt mit Formular `kontakt`. Farben, Instrument Sans (eigener Server) und Texte übernommen.
|
||||||
|
- **Ausgebaut:** je Leistung „Was dazugehört“ zum Aufklappen, „So einfach wechseln Sie zu uns“ in vier Schritten, häufige Fragen (FAQ-Auszeichnung), Fuß mit Logo, Anschrift und Leistungen; Impressum, Datenschutz, 404.
|
||||||
|
- **Separate Seite `/verwaltungsanfrage`** nach dem Muster des DEEG-Preisfinders: Formular `verwaltungsanfrage` in drei Schritten – Verwaltungsart als Bildkarten (die drei Leistungsfotos) und heutige Verwaltung; Objekt mit Feldern je Art (Einheiten/Stellplätze bei Miet- und WEG-Verwaltung, Beschluss nur bei WEG, Wohnungen/Vermietung nur bei Sondereigentum), Fläche, Baujahr, Ausstattung, Beginn; Lage und Kontakt; Zusammenfassung. Seitenspalte mit Ablauf und Karte der Ansprechpartnerin. Hero-Knopf, Menü „Anfrage“, Ablauf und FAQ verlinken darauf.
|
||||||
|
|
||||||
|
**Geprüft:** Bildschirmfotos 1440 und 390 px (kein waagrechtes Scrollen, keine Konsolenfehler, Klappmenü). Formular per Playwright durchgespielt: WEG blendet Einheiten und Beschluss ein, Sondereigentum-Felder aus; Zusammenfassung vollständig; Absenden mit Erfolgsmeldung und Eingang in `form_submission` (danach per Reset entfernt). `test:links` (4 Seiten, 24 Adressen, kein toter Verweis), `test:browser` gegen 8108 grün (axe, Tastatur, ohne JS; HTML 70 KB, 3 Skripte). Lighthouse (`audit.mjs`, je drei Läufe) auf 8109 für `/`, `/verwaltungsanfrage`, `/impressum`: mobil und Desktop überall 100 (`reports/lighthouse-luximmo`). php-cs-fixer auf den beiden neuen PHP-Skripten angewandt. Nicht geprüft: Mailversand, Safari/iOS (Video nur als WebM, kein ffmpeg zum Umwandeln in MP4), Editor auf diesen Dokumenten.
|
||||||
|
|
||||||
|
**Nachtrag (06.11.2026):** Domain lux-immo.net, E-Mail info@lux-immo.net (Umut). Beide Formulare gehen an info@, die Adresse steht im Kontaktbereich, im Fuß, in der Seitenspalte der Anfrage, in Impressum, Datenschutz und den strukturierten Daten. `test:links` und `test:browser` danach erneut grün.
|
||||||
|
|
||||||
|
**Nachtrag 2 (06.11.2026):** Umut: „Das Menü springt nicht zu den Bereichen“ und „ein Formular ‚Was ist Ihre Immobilie wert?‘“. *Menü:* Das eigene Menü-CSS setzte das Klappmenü auf `z-index:20`, die Klick-daneben-Fläche des Kerns (`.menu-burger[open]>summary::before`, z-index 29) lag darüber und fing jeden Klick ab – auf Tablet und Telefon kam kein Link an. Jetzt `z-index:30` wie im Kern. Per Playwright auf 1440 px, 900 px und iPhone 13 geprüft: Leistungen, Über uns und Bewertung springen bzw. öffnen, das Menü schließt. *Bewertung:* neue Seite `/immobilienbewertung` mit Formular `bewertung` (Objektart Wohnung/Haus/Mehrfamilienhaus/Grundstück als Bildkarten mit goldenen Piktogrammen aus `build-luximmo-assets.php`, Anlass; Eckdaten je Objektart – Grundstück ohne Wohnfläche/Zimmer mit Nutzbarkeit, Mehrfamilienhaus mit Einheiten und Jahresmiete, Wohnung mit Etage; Lage und Kontakt; Zusammenfassung; an info@lux-immo.net). Auf der Startseite ein dunkles Band „Was ist Ihre Immobilie wert?“ mit den vier Objektarten (Anker `#wert`), Menüpunkt „Bewertung“, Link im Fuß, Datenschutz ergänzt. Geprüft: Bedingungen für Grundstück und Mehrfamilienhaus, Absenden mit Eingang (danach per Reset entfernt), `test:links` (5 Seiten, 29 Adressen), `test:browser` grün (HTML 83 KB), Lighthouse `/` und `/immobilienbewertung` mobil und Desktop überall 100.
|
||||||
|
|
||||||
|
**Nachtrag 3 (09.11.2026):** Umuts Korrekturen und der Flyer. Linie unter dem Kopf abgedeckt (Kopfsektion `margin-bottom:-1px`, die Linie zeichnet der Kern an `.site-header`). „So einfach wechseln“ und „Was ist Ihre Immobilie wert?“ sind jetzt Karten in einer normalen Sektion, gleiche Breite wie die Leistungen (bei 2000 px gemessen: 200–1800). Abstand zwischen Sektionen 112 statt 180 px, mobil 64 statt 88 px. Optionsfelder der Bildkarten rund (die Feldregeln gaben ihnen Polster und Mindesthöhe). Vom Flyer: Slogan „Ein Lichtblick für Ihre Immobilie.“ als H1 (ersetzt „Hausverwaltung, die man nicht merkt.“), „Ihr neuer Immobilienpartner vor Ort“, Immobilienverkauf als erste Leistung (Foto: Standbild des Videos, ein eigenes fehlt), WEG-Text, Regionsband, Telefon 07225 5080756, Hauptstraße 169. Das erfundene Zitat auf der Monogramm-Karte ist durch den Slogan ersetzt, „feste Preise je Einheit“ aus der FAQ entfernt. Noch vom Kunden zu bestätigen: „kostenlos“ bei der Bewertung, die Aussage zur Erlaubnis nach § 34c GewO.
|
||||||
|
|
||||||
|
**Livegang-Paket (09.11.2026):** `var/luximmo-paket.zip` (Fassung 1.37.0, 6 Seiten, 26 Mediendateien, Video, 3 Formulare, ohne Konten und Anfragen). In eine frische Testdatenbank importiert (braucht wie jede Installation den Website-Eintrag des Installers; nur migriert scheitert der Import am Fremdschlüssel, ohne etwas zu ändern). Darauf geprüft: alle Seiten, sitemap.xml, llms.txt und Video 200, `test:links` ohne toten Verweis, `test:browser` im Entwicklungsmodus grün (im Produktionsmodus nicht möglich, die Prüfung braucht `X-Kant-Queries`), Lighthouse für `/`, `/immobilienbewertung`, `/verwaltungsanfrage`, `/impressum` mobil und Desktop überall 100. Nicht geprüft: Hosting, Mailversand, Schriftenabruf auf dem Server, Safari/iOS.
|
||||||
|
|
||||||
|
**Logo als SVG (09.11.2026):** Kundendatei `var/luximmo-assets/logo-original.svg`; `seed-luximmo.php` schneidet sie zu (ohne weiße Fläche und IDs) und setzt sie als „Eigenes HTML“ in Kopf und Fuß, die Mediathek nimmt keine SVGs an. Das Seitensymbol bleibt PNG. Tausch künftig im Baustein (nur Administrator). Paket neu gebaut.
|
||||||
|
|
||||||
|
**Offen (von Umut/Kunde):** im Impressum Geschäftsführung, Register, USt-ID, § 34c-Aufsicht und Bildnachweis als „NOCH ZU ERGÄNZEN“, ein Portrait von Eslem Kurt (jetzt Monogramm-Karte), eine MP4-Fassung des Videos für ältere Safari.
|
||||||
|
|
||||||
|
**Nächster Schritt:** Impressum vervollständigen, dann Livegang nach `docs/LIVEGANG.md`.
|
||||||
|
|
||||||
## Videos: Mediathek, Video-Baustein, Sektionshintergrund (04.11.2026)
|
## Videos: Mediathek, Video-Baustein, Sektionshintergrund (04.11.2026)
|
||||||
|
|
||||||
**Anlass:** Umut: Hero-Video für kant-it.de, „baust du video embedding gleich ein“.
|
**Anlass:** Umut: Hero-Video für kant-it.de, „baust du video embedding gleich ein“.
|
||||||
@@ -14,7 +50,9 @@
|
|||||||
|
|
||||||
**Geprüft:** PHPUnit 686/686 grün unter PHP 8.4 (auch `UpdaterTest`) und 8.2 (dort nur die bekannte Warnung in `UpdaterTest::testTrackedEnvironmentFromAnOlderTagCannotReplaceInstallationSettings`); php-cs-fixer ohne Befund, `lint:twig`, `npm test` 41/41, `test:editor` grün, `test:browser` gegen 8080 grün (die zwei bekannten Kontrastbefunde auf `/grundlagen`, Inhalt). Lokale `var/cms.db` auf `Version20261104000100` migriert.
|
**Geprüft:** PHPUnit 686/686 grün unter PHP 8.4 (auch `UpdaterTest`) und 8.2 (dort nur die bekannte Warnung in `UpdaterTest::testTrackedEnvironmentFromAnOlderTagCannotReplaceInstallationSettings`); php-cs-fixer ohne Befund, `lint:twig`, `npm test` 41/41, `test:editor` grün, `test:browser` gegen 8080 grün (die zwei bekannten Kontrastbefunde auf `/grundlagen`, Inhalt). Lokale `var/cms.db` auf `Version20261104000100` migriert.
|
||||||
|
|
||||||
**Nächster Schritt:** Release 1.37.0, dann auf kant-it.de hochladen und in der Hero-Sektion als Hintergrundvideo wählen.
|
**Release:** Tag `v1.37.0` auf `main` (Zweig `videos` per Fast-Forward übernommen), Releaseeintrag mit Zip (3685 Dateien, 5,7 MB), `.sha256` und `install.php`; anonymer Download stimmt mit der Prüfsumme überein (ec59d058…), `releases/latest` meldet v1.37.0.
|
||||||
|
|
||||||
|
**Nächster Schritt:** In der Zentrale auf 1.37.0 aktualisieren, dann auf kant-it.de ein Video hochladen und in der Hero-Sektion als Hintergrundvideo wählen; dabei Safari/iOS und Lighthouse mit Video prüfen.
|
||||||
|
|
||||||
## Sicherheitsupdate symfony/mime, Release 1.36.2 (03.11.2026)
|
## Sicherheitsupdate symfony/mime, Release 1.36.2 (03.11.2026)
|
||||||
|
|
||||||
|
|||||||
@@ -843,6 +843,7 @@ details.card > summary:hover { background: #fafbf8 }
|
|||||||
.favicon-current{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--muted)}
|
.favicon-current{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--muted)}
|
||||||
.favicon-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
|
.favicon-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
|
||||||
.favicon-preview{border-radius:6px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
|
.favicon-preview{border-radius:6px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
|
||||||
|
.logo-preview{object-fit:contain}
|
||||||
.favicon-editor{display:grid;grid-template-columns:minmax(0,1fr) 220px;gap:20px;align-items:start;margin-top:14px}
|
.favicon-editor{display:grid;grid-template-columns:minmax(0,1fr) 220px;gap:20px;align-items:start;margin-top:14px}
|
||||||
.favicon-editor[hidden]{display:none}
|
.favicon-editor[hidden]{display:none}
|
||||||
.favicon-stage{position:relative;justify-self:start;max-width:100%;line-height:0;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/16px 16px;user-select:none;touch-action:none}
|
.favicon-stage{position:relative;justify-self:start;max-width:100%;line-height:0;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/16px 16px;user-select:none;touch-action:none}
|
||||||
|
|||||||
@@ -46,3 +46,7 @@
|
|||||||
@font-face{font-family:'Lora';font-style:normal;font-weight:500 600;font-display:swap;src:url(/assets/fonts/lora-500-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
@font-face{font-family:'Lora';font-style:normal;font-weight:500 600;font-display:swap;src:url(/assets/fonts/lora-500-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||||
/* latin */
|
/* latin */
|
||||||
@font-face{font-family:'Lora';font-style:normal;font-weight:500 600;font-display:swap;src:url(/assets/fonts/lora-500-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
@font-face{font-family:'Lora';font-style:normal;font-weight:500 600;font-display:swap;src:url(/assets/fonts/lora-500-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face{font-family:'Instrument Sans';font-style:normal;font-weight:400 600;font-display:swap;src:url(/assets/fonts/instrument-sans-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||||
|
/* latin */
|
||||||
|
@font-face{font-family:'Instrument Sans';font-style:normal;font-weight:400 600;font-display:swap;src:url(/assets/fonts/instrument-sans-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,41 @@
|
|||||||
|
import { openMedia } from './media.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
* The logo for the structured data: one image from the media library, shown as a small preview.
|
||||||
|
* It is saved with the form, like every other value on this screen.
|
||||||
|
*/
|
||||||
|
const field = document.querySelector('[data-logo-field]');
|
||||||
|
if (field) {
|
||||||
|
const input = field.querySelector('[name="organisationLogoMediaId"]');
|
||||||
|
const current = field.querySelector('.favicon-current');
|
||||||
|
const choose = field.querySelector('[data-logo-choose]');
|
||||||
|
const remove = field.querySelector('[data-logo-remove]');
|
||||||
|
|
||||||
|
const show = (label, src = '') => {
|
||||||
|
current.replaceChildren();
|
||||||
|
if (src) {
|
||||||
|
const image = new Image(64, 64);
|
||||||
|
image.className = 'favicon-preview logo-preview';
|
||||||
|
image.alt = '';
|
||||||
|
image.src = src;
|
||||||
|
current.append(image);
|
||||||
|
}
|
||||||
|
const text = document.createElement('span');
|
||||||
|
text.textContent = label;
|
||||||
|
current.append(text);
|
||||||
|
choose.textContent = input.value ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen';
|
||||||
|
remove.hidden = !input.value;
|
||||||
|
};
|
||||||
|
|
||||||
|
choose.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: items => {
|
||||||
|
if (!items.length) return;
|
||||||
|
input.value = items[0].id;
|
||||||
|
show(`„${items[0].title}“ – wird beim Speichern übernommen.`, items[0].variants[0].src);
|
||||||
|
choose.focus();
|
||||||
|
} }));
|
||||||
|
remove.addEventListener('click', () => {
|
||||||
|
input.value = '';
|
||||||
|
show('Kein Logo gewählt – wird beim Speichern übernommen.');
|
||||||
|
choose.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bereitet die Bilder des Luximmo-Entwurfs (luximmo-website.zip) für die Mediathek vor.
|
||||||
|
*
|
||||||
|
* Aus dem Ordner „assets“ des Pakets entstehen:
|
||||||
|
* - logo.png: das Logo ohne Rand, 640 px breit (Original 2596 × 2274, Zeichen und Schriftzug),
|
||||||
|
* - favicon.png: nur das goldene Zeichen (Kreis mit Häusern), quadratisch auf Weiß, 512 px,
|
||||||
|
* - mietverwaltung.webp, weg.webp, sonder.webp: die drei Leistungsfotos als WebP,
|
||||||
|
* - hero.webm: das Hero-Video unverändert,
|
||||||
|
* - hero-poster.webp: das Standbild aus dem Video (vorher mit build-luximmo-poster.mjs als PNG).
|
||||||
|
*
|
||||||
|
* Aufruf: php scripts/build-luximmo-assets.php <assets-Ordner des Pakets> <Zielordner> [poster.png]
|
||||||
|
*/
|
||||||
|
$quelle = rtrim($argv[1] ?? '', '/\\');
|
||||||
|
$ziel = rtrim($argv[2] ?? '', '/\\');
|
||||||
|
$poster = $argv[3] ?? '';
|
||||||
|
if ('' === $quelle || '' === $ziel || !is_file($quelle.'/luximmo-logo.png')) {
|
||||||
|
fwrite(STDERR, "Aufruf: php scripts/build-luximmo-assets.php <Ordner mit luximmo-logo.png usw.> <Zielordner> [poster.png]\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
if (!is_dir($ziel) && !mkdir($ziel, 0775, true)) {
|
||||||
|
fwrite(STDERR, "Zielordner lässt sich nicht anlegen: $ziel\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Verkleinert ein Bild auf eine Zielbreite und speichert es als WebP. */
|
||||||
|
$webp = static function (GdImage $bild, string $name, int $breite) use ($ziel): void {
|
||||||
|
$breite = min($breite, imagesx($bild));
|
||||||
|
$hoehe = (int) round(imagesy($bild) * $breite / imagesx($bild));
|
||||||
|
$neu = imagecreatetruecolor($breite, $hoehe);
|
||||||
|
imagecopyresampled($neu, $bild, 0, 0, 0, 0, $breite, $hoehe, imagesx($bild), imagesy($bild));
|
||||||
|
imagewebp($neu, $ziel.'/'.$name, 80);
|
||||||
|
echo "$name: {$breite} × {$hoehe}\n";
|
||||||
|
};
|
||||||
|
|
||||||
|
foreach (['mietverwaltung', 'weg', 'sonder'] as $foto) {
|
||||||
|
$bild = imagecreatefromjpeg($quelle.'/'.$foto.'.jpg') ?: exit("Foto fehlt: $foto.jpg\n");
|
||||||
|
$webp($bild, $foto.'.webp', 1600);
|
||||||
|
}
|
||||||
|
if ('' !== $poster) {
|
||||||
|
$webp(imagecreatefrompng($poster) ?: exit("Standbild lässt sich nicht lesen: $poster\n"), 'hero-poster.webp', 1920);
|
||||||
|
}
|
||||||
|
copy($quelle.'/hero.webm', $ziel.'/hero.webm');
|
||||||
|
echo "hero.webm übernommen\n";
|
||||||
|
|
||||||
|
// Das Logo: Grenzen aller nicht weißen, nicht durchsichtigen Punkte, dazu die des Goldtons allein.
|
||||||
|
$logo = imagecreatefrompng($quelle.'/luximmo-logo.png');
|
||||||
|
imagepalettetotruecolor($logo);
|
||||||
|
$alles = [PHP_INT_MAX, PHP_INT_MAX, 0, 0];
|
||||||
|
$gold = [PHP_INT_MAX, PHP_INT_MAX, 0, 0];
|
||||||
|
for ($y = 0; $y < imagesy($logo); $y += 2) {
|
||||||
|
for ($x = 0; $x < imagesx($logo); $x += 2) {
|
||||||
|
$c = imagecolorat($logo, $x, $y);
|
||||||
|
[$a, $r, $g, $b] = [$c >> 24 & 0x7F, $c >> 16 & 0xFF, $c >> 8 & 0xFF, $c & 0xFF];
|
||||||
|
if ($a > 100 || ($r > 235 && $g > 235 && $b > 235)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$alles = [min($alles[0], $x), min($alles[1], $y), max($alles[2], $x), max($alles[3], $y)];
|
||||||
|
if ($r - $b > 40) {
|
||||||
|
$gold = [min($gold[0], $x), min($gold[1], $y), max($gold[2], $x), max($gold[3], $y)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ein Ausschnitt des Logos auf eine Fläche gesetzt: durchsichtig oder weiß. */
|
||||||
|
$ausschnitt = static function (array $kasten, int $breite, int $hoehe, int $rand, bool $weiss) use ($logo): GdImage {
|
||||||
|
$neu = imagecreatetruecolor($breite, $hoehe);
|
||||||
|
imagealphablending($neu, false);
|
||||||
|
imagesavealpha($neu, true);
|
||||||
|
imagefill($neu, 0, 0, $weiss ? imagecolorallocate($neu, 255, 255, 255) : imagecolorallocatealpha($neu, 255, 255, 255, 127));
|
||||||
|
imagealphablending($neu, true);
|
||||||
|
[$x1, $y1, $x2, $y2] = $kasten;
|
||||||
|
$b = $x2 - $x1 + 1;
|
||||||
|
$h = $y2 - $y1 + 1;
|
||||||
|
$faktor = min(($breite - 2 * $rand) / $b, ($hoehe - 2 * $rand) / $h);
|
||||||
|
$zb = (int) round($b * $faktor);
|
||||||
|
$zh = (int) round($h * $faktor);
|
||||||
|
imagecopyresampled($neu, $logo, (int) (($breite - $zb) / 2), (int) (($hoehe - $zh) / 2), $x1, $y1, $zb, $zh, $b, $h);
|
||||||
|
|
||||||
|
return $neu;
|
||||||
|
};
|
||||||
|
|
||||||
|
$breite = 640;
|
||||||
|
$hoehe = (int) round(($alles[3] - $alles[1] + 1) * $breite / ($alles[2] - $alles[0] + 1));
|
||||||
|
imagepng($ausschnitt($alles, $breite, $hoehe, 0, false), $ziel.'/logo.png', 9);
|
||||||
|
echo "logo.png: {$breite} × {$hoehe}\n";
|
||||||
|
|
||||||
|
imagepng($ausschnitt($gold, 512, 512, 36, true), $ziel.'/favicon.png', 9);
|
||||||
|
echo "favicon.png: 512 × 512 (Zeichen ohne Schriftzug)\n";
|
||||||
|
|
||||||
|
// Piktogramme für die Objektart der Immobilienbewertung: goldene Linien auf Creme, wie das Logo.
|
||||||
|
// Vierfach gezeichnet und verkleinert, damit die dicken Linien glatte Kanten bekommen.
|
||||||
|
$piktogramm = static function (string $name, callable $zeichnen) use ($ziel): void {
|
||||||
|
$s = 4;
|
||||||
|
$gross = imagecreatetruecolor(480 * $s, 360 * $s);
|
||||||
|
imagefill($gross, 0, 0, imagecolorallocate($gross, 0xF7, 0xF4, 0xEF));
|
||||||
|
$gold = imagecolorallocate($gross, 0xBD, 0xA0, 0x76);
|
||||||
|
imagesetthickness($gross, 5 * $s);
|
||||||
|
$p = static fn (array $punkte): array => array_map(static fn (int $v): int => $v * $s, $punkte);
|
||||||
|
$linie = static fn (int $x1, int $y1, int $x2, int $y2) => imageline($gross, $x1 * $s, $y1 * $s, $x2 * $s, $y2 * $s, $gold);
|
||||||
|
$rahmen = static fn (int $x1, int $y1, int $x2, int $y2) => imagepolygon($gross, $p([$x1, $y1, $x2, $y1, $x2, $y2, $x1, $y2]), $gold);
|
||||||
|
$flaeche = static fn (int $x1, int $y1, int $x2, int $y2) => imagefilledrectangle($gross, $x1 * $s, $y1 * $s, $x2 * $s, $y2 * $s, $gold);
|
||||||
|
$zeichnen($linie, $rahmen, $flaeche, static fn (array $punkte) => imagepolygon($gross, $p($punkte), $gold));
|
||||||
|
$klein = imagecreatetruecolor(480, 360);
|
||||||
|
imagecopyresampled($klein, $gross, 0, 0, 0, 0, 480, 360, 480 * $s, 360 * $s);
|
||||||
|
imagewebp($klein, $ziel.'/'.$name, 90);
|
||||||
|
echo "$name: 480 × 360\n";
|
||||||
|
};
|
||||||
|
|
||||||
|
$piktogramm('option-wohnung.webp', static function ($linie, $rahmen, $flaeche): void {
|
||||||
|
$linie(110, 300, 370, 300);
|
||||||
|
$rahmen(180, 80, 300, 300);
|
||||||
|
foreach ([110, 150, 190, 230] as $y) {
|
||||||
|
foreach ([200, 228, 256] as $x) {
|
||||||
|
150 === $y && 228 === $x ? $flaeche($x, $y, $x + 22, $y + 22) : $rahmen($x, $y, $x + 22, $y + 22);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$rahmen(225, 262, 255, 300);
|
||||||
|
});
|
||||||
|
$piktogramm('option-haus.webp', static function ($linie, $rahmen, $flaeche, $vieleck): void {
|
||||||
|
$linie(110, 300, 370, 300);
|
||||||
|
$vieleck([150, 175, 240, 98, 330, 175]);
|
||||||
|
$rahmen(170, 175, 310, 300);
|
||||||
|
$rahmen(226, 240, 256, 300);
|
||||||
|
$rahmen(186, 198, 214, 226);
|
||||||
|
$rahmen(268, 198, 296, 226);
|
||||||
|
});
|
||||||
|
$piktogramm('option-mehrfamilienhaus.webp', static function ($linie, $rahmen): void {
|
||||||
|
$linie(100, 300, 380, 300);
|
||||||
|
$rahmen(140, 100, 340, 300);
|
||||||
|
$linie(140, 122, 340, 122);
|
||||||
|
foreach ([140, 178, 216] as $y) {
|
||||||
|
foreach ([162, 202, 256, 296] as $x) {
|
||||||
|
$rahmen($x, $y, $x + 22, $y + 22);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$rahmen(224, 256, 256, 300);
|
||||||
|
});
|
||||||
|
$piktogramm('option-grundstueck.webp', static function ($linie, $rahmen, $flaeche, $vieleck): void {
|
||||||
|
$vieleck([100, 290, 380, 290, 325, 195, 155, 195]);
|
||||||
|
$linie(240, 245, 240, 120);
|
||||||
|
$vieleck([240, 120, 290, 138, 240, 156]);
|
||||||
|
foreach ([[100, 290], [380, 290], [325, 195], [155, 195]] as [$x, $y]) {
|
||||||
|
$flaeche($x - 7, $y - 7, $x + 7, $y + 7);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
// Holt ein Standbild aus dem Hero-Video von Luximmo: Es steht als Hintergrundbild der Sektion,
|
||||||
|
// bis das Video läuft (und für immer bei „Bewegung reduzieren“ oder Datensparen). ffmpeg gibt es
|
||||||
|
// hier nicht, Chromium spielt WebM ab und zeichnet das Bild in ein Canvas.
|
||||||
|
//
|
||||||
|
// Aufruf: node scripts/build-luximmo-poster.mjs <hero.webm> <Ziel.png> [Sekunde]
|
||||||
|
import { readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { chromium } from 'playwright';
|
||||||
|
|
||||||
|
const [quelle, ziel, sekunde = '0.4'] = process.argv.slice(2);
|
||||||
|
if (!quelle || !ziel) {
|
||||||
|
console.error('Aufruf: node scripts/build-luximmo-poster.mjs <hero.webm> <Ziel.png> [Sekunde]');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const browser = await chromium.launch();
|
||||||
|
try {
|
||||||
|
const page = await browser.newPage();
|
||||||
|
await page.setContent('<video muted playsinline></video>');
|
||||||
|
const daten = readFileSync(quelle).toString('base64');
|
||||||
|
const png = await page.evaluate(async ([b64, t]) => {
|
||||||
|
const bytes = Uint8Array.from(atob(b64), (c) => c.charCodeAt(0));
|
||||||
|
const video = document.querySelector('video');
|
||||||
|
video.src = URL.createObjectURL(new Blob([bytes], { type: 'video/webm' }));
|
||||||
|
await new Promise((ok, fehler) => { video.onloadeddata = ok; video.onerror = () => fehler(new Error('Video lässt sich nicht lesen')); });
|
||||||
|
video.currentTime = Math.min(Number(t), Math.max(0, video.duration - 0.1));
|
||||||
|
await new Promise((ok) => { video.onseeked = ok; });
|
||||||
|
const canvas = document.createElement('canvas');
|
||||||
|
canvas.width = video.videoWidth;
|
||||||
|
canvas.height = video.videoHeight;
|
||||||
|
canvas.getContext('2d').drawImage(video, 0, 0);
|
||||||
|
return { url: canvas.toDataURL('image/png'), breite: video.videoWidth, hoehe: video.videoHeight, dauer: video.duration };
|
||||||
|
}, [daten, sekunde]);
|
||||||
|
writeFileSync(ziel, Buffer.from(png.url.split(',')[1], 'base64'));
|
||||||
|
console.log(`Standbild ${png.breite} × ${png.hoehe} (Video ${png.dauer.toFixed(1)} s) → ${ziel}`);
|
||||||
|
} finally {
|
||||||
|
await browser.close();
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Wirft die Luximmo-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
|
||||||
|
# Erstes Argument (optional): der Ordner „assets“ aus luximmo-website.zip (Logo, Fotos, hero.webm).
|
||||||
|
# Dann entstehen Logo, Seitensymbol, WebP-Fotos und das Standbild des Videos neu.
|
||||||
|
# Empfaenger der Formulare per LUXIMMO_MAIL, sonst eine Platzhalteradresse.
|
||||||
|
set -e
|
||||||
|
root="C:/Django Project/KantSites"
|
||||||
|
php="${PHP_BINARY:-C:/php84/php.exe}"
|
||||||
|
export DATABASE_URL="sqlite:///$root/var/luximmo.db"
|
||||||
|
export MEDIA_DIRECTORY="$root/var/luximmo-media"
|
||||||
|
assets="$root/var/luximmo-assets"
|
||||||
|
if [ -n "$1" ]; then
|
||||||
|
mkdir -p "$assets"
|
||||||
|
node scripts/build-luximmo-poster.mjs "$1/hero.webm" "$assets/hero-poster.png" 0.4
|
||||||
|
"$php" scripts/build-luximmo-assets.php "$1" "$assets" "$assets/hero-poster.png"
|
||||||
|
rm -f "$assets/hero-poster.png"
|
||||||
|
fi
|
||||||
|
if [ ! -f "$assets/hero-poster.webp" ] || [ ! -f "$assets/logo-original.svg" ] || [ ! -f "$assets/hero.webm" ]; then
|
||||||
|
echo "Bilder fehlen: erst mit dem assets-Ordner des Pakets als Argument aufrufen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -f "$root/var/luximmo.db"
|
||||||
|
rm -rf "$root/var/luximmo-media"
|
||||||
|
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||||
|
"$php" -d memory_limit=512M scripts/seed-luximmo.php "$assets"
|
||||||
|
# Instrument Sans vom eigenen Server; der Abruf ergaenzt fonts.css nur.
|
||||||
|
if [ -f "$root/var/cacert.pem" ]; then
|
||||||
|
"$php" -d curl.cainfo="$root/var/cacert.pem" -d openssl.cafile="$root/var/cacert.pem" bin/console cms:fonts:fetch
|
||||||
|
else
|
||||||
|
"$php" bin/console cms:fonts:fetch
|
||||||
|
fi
|
||||||
|
# Das Inhaltspaket fuer eine Live-Seite (ADR 118): dort unter System -> Inhalte uebertragen importieren.
|
||||||
|
rm -f "$root/var/luximmo-paket.zip"
|
||||||
|
"$php" bin/console cms:paket:export --ziel="$root/var/luximmo-paket.zip"
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -14,6 +14,26 @@ use Doctrine\DBAL\Exception\TableNotFoundException;
|
|||||||
*/
|
*/
|
||||||
final class SiteSettings
|
final class SiteSettings
|
||||||
{
|
{
|
||||||
|
/** Die Unterarten von schema.org/LocalBusiness, die sich in den Einstellungen wählen lassen. */
|
||||||
|
public const BUSINESS_TYPES = [
|
||||||
|
'LocalBusiness' => 'Allgemein (LocalBusiness)',
|
||||||
|
'RealEstateAgent' => 'Immobilien (RealEstateAgent)',
|
||||||
|
'HomeAndConstructionBusiness' => 'Handwerk und Bau allgemein',
|
||||||
|
'Electrician' => 'Elektriker',
|
||||||
|
'Plumber' => 'Sanitär',
|
||||||
|
'HVACBusiness' => 'Heizung und Klima',
|
||||||
|
'HousePainter' => 'Maler',
|
||||||
|
'RoofingContractor' => 'Dachdecker',
|
||||||
|
'GeneralContractor' => 'Bauunternehmen',
|
||||||
|
'AccountingService' => 'Steuern und Buchhaltung',
|
||||||
|
'FinancialService' => 'Finanzdienstleistung',
|
||||||
|
'LegalService' => 'Rechtsberatung',
|
||||||
|
'MedicalBusiness' => 'Medizin und Pflege',
|
||||||
|
'TravelAgency' => 'Reisebüro',
|
||||||
|
'Restaurant' => 'Restaurant',
|
||||||
|
'Store' => 'Geschäft',
|
||||||
|
];
|
||||||
|
|
||||||
public const DEFAULTS = [
|
public const DEFAULTS = [
|
||||||
'legalName' => '',
|
'legalName' => '',
|
||||||
'description' => '',
|
'description' => '',
|
||||||
@@ -32,6 +52,12 @@ final class SiteSettings
|
|||||||
'openingHours' => [],
|
'openingHours' => [],
|
||||||
'areaServed' => [],
|
'areaServed' => [],
|
||||||
'localBusiness' => false,
|
'localBusiness' => false,
|
||||||
|
// Genauer als „lokales Unternehmen“, dazu der Standort und ein Logo mit eigener Adresse:
|
||||||
|
// alles nur in den strukturierten Daten, leer bleibt es dort weg (ADR 126).
|
||||||
|
'businessType' => 'LocalBusiness',
|
||||||
|
'latitude' => '',
|
||||||
|
'longitude' => '',
|
||||||
|
'organisationLogoMediaId' => '',
|
||||||
'llmsTxt' => false,
|
'llmsTxt' => false,
|
||||||
// Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und
|
// Der Kopfbereich bleibt beim Scrollen oben stehen. Eine Eigenschaft der Website und
|
||||||
// nicht des Dokuments: kleben kann nur das Element, das den Bereich umschliesst.
|
// nicht des Dokuments: kleben kann nur das Element, das den Bereich umschliesst.
|
||||||
@@ -240,6 +266,19 @@ final class SiteSettings
|
|||||||
if ($values['localBusiness'] && ('' === $values['street'] || '' === $values['city'])) {
|
if ($values['localBusiness'] && ('' === $values['street'] || '' === $values['city'])) {
|
||||||
throw new InvalidPage('Ein lokales Unternehmen braucht mindestens Straße und Ort.');
|
throw new InvalidPage('Ein lokales Unternehmen braucht mindestens Straße und Ort.');
|
||||||
}
|
}
|
||||||
|
$values['businessType'] = (string) ($input['businessType'] ?? 'LocalBusiness');
|
||||||
|
if (!isset(self::BUSINESS_TYPES[$values['businessType']])) {
|
||||||
|
throw new InvalidPage('Diese Art von Unternehmen steht nicht zur Auswahl.');
|
||||||
|
}
|
||||||
|
$values['latitude'] = $this->coordinate($input['latitude'] ?? '', 90, 'Breitengrad');
|
||||||
|
$values['longitude'] = $this->coordinate($input['longitude'] ?? '', 180, 'Längengrad');
|
||||||
|
if (('' === $values['latitude']) !== ('' === $values['longitude'])) {
|
||||||
|
throw new InvalidPage('Für den Standort braucht es Breiten- und Längengrad, oder keinen von beiden.');
|
||||||
|
}
|
||||||
|
$values['organisationLogoMediaId'] = trim((string) ($input['organisationLogoMediaId'] ?? ''));
|
||||||
|
if ('' !== $values['organisationLogoMediaId'] && (1 !== preg_match('/^[a-f0-9]{32}$/D', $values['organisationLogoMediaId']) || null === $this->media->find($values['organisationLogoMediaId']))) {
|
||||||
|
throw new InvalidPage('Das gewählte Logo gibt es nicht (mehr).');
|
||||||
|
}
|
||||||
$values['llmsTxt'] = (bool) ($input['llmsTxt'] ?? false);
|
$values['llmsTxt'] = (bool) ($input['llmsTxt'] ?? false);
|
||||||
$values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false);
|
$values['stickyHeader'] = (bool) ($input['stickyHeader'] ?? false);
|
||||||
$values['headerOverlay'] = (bool) ($input['headerOverlay'] ?? false);
|
$values['headerOverlay'] = (bool) ($input['headerOverlay'] ?? false);
|
||||||
@@ -512,6 +551,20 @@ final class SiteSettings
|
|||||||
return $storedPath === ($homePath ?? $this->homePath()) ? '/' : '/'.$storedPath;
|
return $storedPath === ($homePath ?? $this->homePath()) ? '/' : '/'.$storedPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Eine Koordinate als Dezimalzahl; das Komma der deutschen Schreibweise wird zum Punkt. */
|
||||||
|
private function coordinate(mixed $input, int $limit, string $label): string
|
||||||
|
{
|
||||||
|
$value = str_replace(',', '.', trim((string) $input));
|
||||||
|
if ('' === $value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (1 !== preg_match('/^-?\d{1,3}(?:\.\d{1,7})?$/D', $value) || abs((float) $value) > $limit) {
|
||||||
|
throw new InvalidPage(sprintf('Der %s ist keine gültige Koordinate, zum Beispiel 48.79381.', $label));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Structured data for the organisation, built from the maintained values only. Returns null
|
* Structured data for the organisation, built from the maintained values only. Returns null
|
||||||
* while nothing is maintained: an empty Organization block would be noise, not data.
|
* while nothing is maintained: an empty Organization block would be noise, not data.
|
||||||
@@ -522,9 +575,10 @@ final class SiteSettings
|
|||||||
if ('' === $settings['legalName'] && !$hasAddress && '' === $settings['phone']) {
|
if ('' === $settings['legalName'] && !$hasAddress && '' === $settings['phone']) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
$lokal = $settings['localBusiness'] && $hasAddress;
|
||||||
$data = [
|
$data = [
|
||||||
'@context' => 'https://schema.org',
|
'@context' => 'https://schema.org',
|
||||||
'@type' => $settings['localBusiness'] && $hasAddress ? 'LocalBusiness' : 'Organization',
|
'@type' => $lokal ? ($settings['businessType'] ?? 'LocalBusiness') : 'Organization',
|
||||||
'name' => '' !== $settings['legalName'] ? $settings['legalName'] : $siteName,
|
'name' => '' !== $settings['legalName'] ? $settings['legalName'] : $siteName,
|
||||||
'url' => $origin.'/',
|
'url' => $origin.'/',
|
||||||
];
|
];
|
||||||
@@ -542,6 +596,14 @@ final class SiteSettings
|
|||||||
'addressCountry' => $settings['country'],
|
'addressCountry' => $settings['country'],
|
||||||
], static fn (string $value): bool => '' !== $value);
|
], static fn (string $value): bool => '' !== $value);
|
||||||
}
|
}
|
||||||
|
if ($lokal && '' !== ($settings['latitude'] ?? '') && '' !== ($settings['longitude'] ?? '')) {
|
||||||
|
$data['geo'] = ['@type' => 'GeoCoordinates', 'latitude' => (float) $settings['latitude'], 'longitude' => (float) $settings['longitude']];
|
||||||
|
}
|
||||||
|
// Eine gelöschte Datei darf keine tote Adresse in die Daten bringen.
|
||||||
|
$logo = '' !== ($settings['organisationLogoMediaId'] ?? '') ? $this->media->find($settings['organisationLogoMediaId']) : null;
|
||||||
|
if (null !== $logo && [] !== $logo['variants']) {
|
||||||
|
$data['logo'] = $origin.end($logo['variants'])['src'];
|
||||||
|
}
|
||||||
// Die sozialen Profile sind dieselbe Aussage wie sameAs - "dieses Unternehmen ist auch
|
// Die sozialen Profile sind dieselbe Aussage wie sameAs - "dieses Unternehmen ist auch
|
||||||
// das hier" -, deshalb stehen sie ohne zweite Pflege mit in der Liste.
|
// das hier" -, deshalb stehen sie ohne zweite Pflege mit in der Liste.
|
||||||
$sameAs = array_values(array_unique([...$settings['sameAs'], ...array_values($settings['social'] ?? [])]));
|
$sameAs = array_values(array_unique([...$settings['sameAs'], ...array_values($settings['social'] ?? [])]));
|
||||||
|
|||||||
@@ -57,6 +57,7 @@ final class SettingsController extends AbstractController
|
|||||||
|
|
||||||
return $this->render('admin/settings.html.twig', [
|
return $this->render('admin/settings.html.twig', [
|
||||||
'settings' => $settings,
|
'settings' => $settings,
|
||||||
|
'businessTypes' => SiteSettings::BUSINESS_TYPES,
|
||||||
'legalSelection' => $legal,
|
'legalSelection' => $legal,
|
||||||
'notFoundSelection' => $this->notFoundPage->selected(),
|
'notFoundSelection' => $this->notFoundPage->selected(),
|
||||||
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
|
'redirects' => $this->redirects->all($request->query->getInt('seite', 1)),
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
{% block heading %}Unternehmen und Adressen{% endblock %}
|
{% block heading %}Unternehmen und Adressen{% endblock %}
|
||||||
{% block lead %}Aus diesen Angaben entstehen die strukturierten Daten der Startseite. Was hier steht, ist genau das, was Suchmaschinen lesen – es gibt keine zweite, unsichtbare Fassung.{% endblock %}
|
{% block lead %}Aus diesen Angaben entstehen die strukturierten Daten der Startseite. Was hier steht, ist genau das, was Suchmaschinen lesen – es gibt keine zweite, unsichtbare Fassung.{% endblock %}
|
||||||
{% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
|
{% block styles %}<link rel="stylesheet" href="/assets/media-dialog.css?v={{ kant_version() }}">{% endblock %}
|
||||||
{% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script><script type="module" src="/assets/favicon-field.js?v={{ kant_version() }}"></script>{% endblock %}
|
{% block scripts %}<script type="module" src="/assets/page-picker.js?v={{ kant_version() }}"></script><script type="module" src="/assets/favicon-field.js?v={{ kant_version() }}"></script><script type="module" src="/assets/logo-field.js?v={{ kant_version() }}"></script>{% endblock %}
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
{% if missingLegal is not empty %}
|
{% if missingLegal is not empty %}
|
||||||
@@ -175,6 +175,32 @@
|
|||||||
<p class="field-help">Ein Ort je Zeile, höchstens vierzig. Erscheint als <code>areaServed</code> in den strukturierten Daten.</p>
|
<p class="field-help">Ein Ort je Zeile, höchstens vierzig. Erscheint als <code>areaServed</code> in den strukturierten Daten.</p>
|
||||||
<label class="check-label" for="localBusiness"><input id="localBusiness" name="localBusiness" type="checkbox" value="1"{{ settings.localBusiness ? ' checked' : '' }}> Als lokales Unternehmen auszeichnen</label>
|
<label class="check-label" for="localBusiness"><input id="localBusiness" name="localBusiness" type="checkbox" value="1"{{ settings.localBusiness ? ' checked' : '' }}> Als lokales Unternehmen auszeichnen</label>
|
||||||
<p class="field-help">Braucht mindestens Straße und Ort. Sinnvoll bei einem Standort mit Kundenverkehr.</p>
|
<p class="field-help">Braucht mindestens Straße und Ort. Sinnvoll bei einem Standort mit Kundenverkehr.</p>
|
||||||
|
<label for="businessType">Art des Unternehmens</label>
|
||||||
|
<select id="businessType" name="businessType">
|
||||||
|
{% for wert, name in businessTypes %}<option value="{{ wert }}"{{ settings.businessType|default('LocalBusiness') == wert ? ' selected' : '' }}>{{ name }}</option>{% endfor %}
|
||||||
|
</select>
|
||||||
|
<p class="field-help">Gilt nur für ein lokales Unternehmen und steht nur in den strukturierten Daten.</p>
|
||||||
|
<div class="fields">
|
||||||
|
<div>
|
||||||
|
<label for="latitude">Breitengrad</label>
|
||||||
|
<input id="latitude" name="latitude" inputmode="decimal" value="{{ settings.latitude|default('') }}" placeholder="48.79381">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="longitude">Längengrad</label>
|
||||||
|
<input id="longitude" name="longitude" inputmode="decimal" value="{{ settings.longitude|default('') }}" placeholder="8.33237">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="field-help">Der Standort als Koordinaten, freiwillig. Beide Werte oder keinen.</p>
|
||||||
|
<h3 id="logo-title">Logo für Suchmaschinen</h3>
|
||||||
|
<div class="favicon-field" data-logo-field aria-labelledby="logo-title" role="group">
|
||||||
|
<input type="hidden" name="organisationLogoMediaId" value="{{ settings.organisationLogoMediaId|default('') }}">
|
||||||
|
<div class="favicon-current"><span>{{ settings.organisationLogoMediaId|default('') ? 'Logo gewählt.' : 'Kein Logo gewählt.' }}</span></div>
|
||||||
|
<div class="favicon-actions">
|
||||||
|
<button type="button" class="secondary" data-logo-choose>{{ settings.organisationLogoMediaId|default('') ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen' }}</button>
|
||||||
|
<button type="button" class="text-button danger-text" data-logo-remove{{ settings.organisationLogoMediaId|default('') ? '' : ' hidden' }}>Logo entfernen</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<p class="field-help">Ein Bild aus der Mediathek, mindestens 112 Pixel breit und hoch. Es steht nur in den strukturierten Daten; das sichtbare Logo im Kopfbereich bleibt davon unberührt.</p>
|
||||||
<label class="check-label" for="llmsTxt"><input id="llmsTxt" name="llmsTxt" type="checkbox" value="1"{{ settings.llmsTxt ? ' checked' : '' }}> <code>llms.txt</code> ausliefern</label>
|
<label class="check-label" for="llmsTxt"><input id="llmsTxt" name="llmsTxt" type="checkbox" value="1"{{ settings.llmsTxt ? ' checked' : '' }}> <code>llms.txt</code> ausliefern</label>
|
||||||
<p class="field-help">Eine maschinenlesbare Liste der veröffentlichten, indexierbaren Seiten. Optional. Kein Rankingfaktor und keine Voraussetzung für irgendeine Suchfunktion; ohne Aktivierung antwortet die Adresse mit 404.</p>
|
<p class="field-help">Eine maschinenlesbare Liste der veröffentlichten, indexierbaren Seiten. Optional. Kein Rankingfaktor und keine Voraussetzung für irgendeine Suchfunktion; ohne Aktivierung antwortet die Adresse mit 404.</p>
|
||||||
<div class="section-head"><h2 id="favicon-title">Symbol der Website</h2><span class="muted">Favicon im Browser-Reiter und auf dem Startbildschirm</span></div>
|
<div class="section-head"><h2 id="favicon-title">Symbol der Website</h2><span class="muted">Favicon im Browser-Reiter und auf dem Startbildschirm</span></div>
|
||||||
|
|||||||
@@ -4450,6 +4450,53 @@ display: flex', '#b1{display:flex;color:red}'];
|
|||||||
self::assertSame(['Baden-Baden', 'Rastatt', 'Karlsruhe', 'Offenburg'], $data['areaServed']);
|
self::assertSame(['Baden-Baden', 'Rastatt', 'Karlsruhe', 'Offenburg'], $data['areaServed']);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testBusinessTypeLocationAndLogoReachTheStructuredData(): void
|
||||||
|
{
|
||||||
|
$this->pages->publish('page-1', $this->pages->saveDraft('page-1', DemoContent::pages()['home'], 0), 1);
|
||||||
|
$basis = ['country' => 'DE', 'homePath' => 'home', 'legalName' => 'Luximmo UG', 'street' => 'Hauptstraße 169', 'city' => 'Gaggenau', 'localBusiness' => '1'];
|
||||||
|
$daten = function (): array {
|
||||||
|
preg_match('#<script type="application/ld\+json">(.*?)</script>#s', $this->request('/')->getContent(), $match);
|
||||||
|
|
||||||
|
return json_decode(html_entity_decode($match[1], ENT_QUOTES), true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Ohne Angaben bleibt alles, wie es war: bestehende Websites ändern sich nicht.
|
||||||
|
$this->site->save($basis);
|
||||||
|
$vorher = $daten();
|
||||||
|
self::assertSame('LocalBusiness', $vorher['@type']);
|
||||||
|
self::assertArrayNotHasKey('geo', $vorher);
|
||||||
|
self::assertArrayNotHasKey('logo', $vorher);
|
||||||
|
|
||||||
|
$mediaId = bin2hex(random_bytes(16));
|
||||||
|
$this->db->insert('media', ['id' => $mediaId, 'title' => 'Logo', 'alt' => '', 'variants' => json_encode([['src' => '/media/'.$mediaId.'/480', 'width' => 480, 'height' => 420], ['src' => '/media/'.$mediaId.'/640', 'width' => 640, 'height' => 561]], JSON_THROW_ON_ERROR), 'archived' => 0, 'version' => 1, 'created_at' => '2026-11-09 00:00:00']);
|
||||||
|
$eingabe = [...$basis, 'businessType' => 'RealEstateAgent', 'latitude' => '48,79381', 'longitude' => '8.33237', 'organisationLogoMediaId' => $mediaId];
|
||||||
|
$this->site->save($eingabe);
|
||||||
|
$nachher = $daten();
|
||||||
|
self::assertSame('RealEstateAgent', $nachher['@type']);
|
||||||
|
self::assertSame(['@type' => 'GeoCoordinates', 'latitude' => 48.79381, 'longitude' => 8.33237], $nachher['geo']);
|
||||||
|
self::assertStringEndsWith('/media/'.$mediaId.'/640', $nachher['logo']);
|
||||||
|
|
||||||
|
// Erneutes Speichern der geladenen Werte verliert nichts.
|
||||||
|
$this->site->save([...$this->site->load(), 'sameAs' => '']);
|
||||||
|
self::assertSame($nachher, $daten());
|
||||||
|
|
||||||
|
// Ohne „lokal“ gibt es weder Unterart noch Standort; das Logo gilt für jede Organisation.
|
||||||
|
$this->site->save([...$eingabe, 'localBusiness' => '']);
|
||||||
|
$organisation = $daten();
|
||||||
|
self::assertSame('Organization', $organisation['@type']);
|
||||||
|
self::assertArrayNotHasKey('geo', $organisation);
|
||||||
|
self::assertArrayHasKey('logo', $organisation);
|
||||||
|
|
||||||
|
foreach ([['businessType' => 'Geheimdienst'], ['latitude' => '91', 'longitude' => '8'], ['latitude' => 'abc', 'longitude' => '8'], ['latitude' => '48.1'], ['organisationLogoMediaId' => str_repeat('a', 32)]] as $falsch) {
|
||||||
|
try {
|
||||||
|
$this->site->save([...$basis, ...$falsch]);
|
||||||
|
self::fail('Ungültige Angabe angenommen: '.json_encode($falsch));
|
||||||
|
} catch (InvalidPage) {
|
||||||
|
$this->addToAssertionCount(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public function testReSavingLoadedSettingsKeepsHoursAndServiceArea(): void
|
public function testReSavingLoadedSettingsKeepsHoursAndServiceArea(): void
|
||||||
{
|
{
|
||||||
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'hours' => ['Monday' => ['opens' => '07:30', 'closes' => '17:00']], 'areaServed' => 'Bühl
|
$this->site->save(['country' => 'DE', 'homePath' => 'home', 'hours' => ['Monday' => ['opens' => '07:30', 'closes' => '17:00']], 'areaServed' => 'Bühl
|
||||||
|
|||||||
Reference in new issue
Block a user