Compare commits
3
Commits
v1.23.0
...
e1753fa7e4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1753fa7e4 | ||
|
|
5bfe1f3c6e | ||
|
|
0ba82b7d33 |
No files matched your search
@@ -148,6 +148,26 @@
|
|||||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-markt.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-markt-media'; $env:SITE_ORIGIN='http://127.0.0.1:8101'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8101 -t public bin/dev-router.php"
|
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-markt.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-markt-media'; $env:SITE_ORIGIN='http://127.0.0.1:8101'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8101 -t public bin/dev-router.php"
|
||||||
],
|
],
|
||||||
"port": 8101
|
"port": 8101
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "huefner",
|
||||||
|
"runtimeExecutable": "powershell",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/huefner.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8102'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8102 -t public bin/dev-router.php"
|
||||||
|
],
|
||||||
|
"port": 8102
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "huefner-prod",
|
||||||
|
"runtimeExecutable": "powershell",
|
||||||
|
"runtimeArgs": [
|
||||||
|
"-NoProfile",
|
||||||
|
"-Command",
|
||||||
|
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/huefner.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8103'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8103 -t public bin/dev-router.php"
|
||||||
|
],
|
||||||
|
"port": 8103
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -387,7 +387,11 @@ Die Landingpage „KANTARCI Immobilien – Mietanfrage“ (KANTARCI Immobilien G
|
|||||||
|
|
||||||
### Achte lokale Referenzwebsite: Kantarci Markt (Vorab-Seite zur Neueröffnung)
|
### Achte lokale Referenzwebsite: Kantarci Markt (Vorab-Seite zur Neueröffnung)
|
||||||
|
|
||||||
Die Seite „Kantarci Markt – Neueröffnung“ (Wilhelm-Drapp-Straße, Baden-Baden) liegt in `var/kantarci-markt.db` mit Medien in `var/kantarci-markt-media`; die Startkonfiguration `kantarci-markt` in `.claude/launch.json` nutzt Port 8100, `kantarci-markt-prod` denselben Bestand im Produktionsmodus auf Port 8101 zum Messen. `sh scripts/reset-kantarci-markt.sh [Bilderordner]` (Standard `var/kantarci-markt-assets` mit `kantarci-markt-logo.png`, `market.jpg` und `vegetables.jpg` aus dem gelieferten Paket) schneidet das Logo zu und rastert Seitensymbol und Stempel (`node scripts/build-kantarci-markt-assets.mjs`), wirft die Installation weg, baut mit `scripts/seed-kantarci-markt.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404 und Einstellungen auf und holt DM Sans auf den eigenen Server (`cms:fonts:fetch`, ersetzt in `fonts.css` nur die Regeln für DM Sans). Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8101 CMS_PATHS=/,/impressum,/datenschutz CMS_AUDIT_DIR=reports/lighthouse-kantarci-markt node scripts/audit.mjs` (in Git Bash verhindert `MSYS_NO_PATHCONV`, dass `/impressum` zu einem Windows-Pfad wird). Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts.
|
Die Seite „Kantarci Markt – Neueröffnung“ (Wilhelm-Drapp-Straße, Baden-Baden) liegt in `var/kantarci-markt.db` mit Medien in `var/kantarci-markt-media`; die Startkonfiguration `kantarci-markt` in `.claude/launch.json` nutzt Port 8100, `kantarci-markt-prod` denselben Bestand im Produktionsmodus auf Port 8101 zum Messen. `sh scripts/reset-kantarci-markt.sh [Bilderordner]` (Standard `var/kantarci-markt-assets` mit `kantarci-markt-logo.png`, `market.jpg` und `vegetables.jpg` aus dem gelieferten Paket) schneidet das Logo zu und rastert Seitensymbol und Stempel (`node scripts/build-kantarci-markt-assets.mjs`), wirft die Installation weg, baut mit `scripts/seed-kantarci-markt.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404 und Einstellungen auf und holt DM Sans auf den eigenen Server (`cms:fonts:fetch`, ersetzt in `fonts.css` nur die Regeln für DM Sans). Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8101 CMS_PATHS=/,/impressum,/datenschutz CMS_AUDIT_DIR=reports/lighthouse-kantarci-markt node scripts/audit.mjs` (in Git Bash verhindert `MSYS_NO_PATHCONV`, dass `/impressum` zu einem Windows-Pfad wird). Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts. Die Demo der Eröffnungsseite liegt unter `/eroeffnung` (noindex) und bettet die Angebote aus `app.kantarci-markt.de` ein; die App erlaubt das nur von `localhost`, deshalb misst man sie mit `CMS_HOST_RULES="MAP localhost 127.0.0.1" CMS_URL=http://localhost:8101 CMS_PATHS=/eroeffnung`.
|
||||||
|
|
||||||
|
### Neunte lokale Referenzwebsite: Hüfner Gartenbau
|
||||||
|
|
||||||
|
Die Website der Garten- und Landschaftsbau Hüfner GmbH (Iffezheim) liegt in `var/huefner.db` mit Medien in `var/huefner-media`; die Startkonfiguration `huefner` in `.claude/launch.json` nutzt Port 8102, `huefner-prod` denselben Bestand im Produktionsmodus auf Port 8103 zum Messen. `sh scripts/reset-huefner.sh [Entwurfsordner]` kopiert die Fotos aus dem gelieferten Entwurf nach `var/huefner-assets` und schneidet den Baum aus dem Logo als Seitensymbol (`scripts/build-huefner-assets.php`; ohne Argument nimmt es die schon vorbereiteten Bilder), wirft die Installation weg, baut mit `scripts/seed-huefner.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Menü, Einstellungen und das Formular `anfrage` auf und holt Barlow und Barlow Condensed auf den eigenen Server. Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8103 CMS_PATHS=/,/impressum,/datenschutz npm run audit`. Die bewussten Abweichungen vom Entwurf stehen im Kopf des Skripts.
|
||||||
|
|
||||||
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/`.
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,18 @@
|
|||||||
|
|
||||||
Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen.
|
Stand: 15.09.2026. Arbeitstitel KantSites; Produktname offen.
|
||||||
|
|
||||||
|
## ADR 107 – Knöpfe dürfen anrufen und schreiben, der Pfeil hat die Schriftfarbe (06.10.2026)
|
||||||
|
|
||||||
|
Beim Aufbau von Hüfner Gartenbau fielen zwei Lücken auf, die Umut im Kern geschlossen haben wollte.
|
||||||
|
|
||||||
|
**`tel:` und `mailto:` als Linkziel von Bausteinen.** Menüeinträge (`MenuRepository::url`), Links im formatierten Text (`RichText::safeHref`) und Plugin-Felder kannten beide längst. Nur die Linkziele der Bausteine – Button, Symbolkarte, Handlungsaufruf, Symbol, verlinktes Bild – liefen über `DocumentValidator::url()` und nahmen bloß Pfad, Sprungmarke und HTTPS. Ein Knopf „Jetzt anrufen“ ging deshalb nur als Textlink im Knopf-Aussehen. Jetzt prüft `link()` diese fünf Stellen: zuerst wie bisher, dazu `tel:` mit Ziffern, `+`, Klammern und Bindestrich (keine Leerzeichen, wie im Menü) und `mailto:` mit einer gültigen Adresse und höchstens einer prozentkodierten Abfrage (`?subject=…`). Bildquellen und eingebettete Inhalte bleiben bei `url()`: `tel:` ist dort kein sinnvolles Ziel.
|
||||||
|
|
||||||
|
Das Dokumentschema bleibt bei v24. Die Änderung erlaubt nur zusätzliche Werte in einem bestehenden Feld und braucht keinen Migrationsweg. Ein Dokument mit `tel:` an einem Knopf lässt sich aber auf einem Kern vor dieser Fassung nicht speichern. Eine Installation, die solche Dokumente bekommt, braucht also zuerst das Update.
|
||||||
|
|
||||||
|
**Der Pfeil im Knopf folgt der Schriftfarbe.** Das Pfeilsymbol trägt die Klasse `block-icon`, und die färbt jedes Symbol in der Akzentfarbe. Auf einem roten Knopf mit weißer Schrift stand so ein grüner Pfeil, und auch die Farbe beim Überfahren erreichte ihn nicht. `.button-arrow .block-icon{color:inherit}` in `site.css` setzt das richtig. Wo eine Website ihren Pfeil absichtlich anders färbt, tut sie das weiter über das eigene CSS des Knopfs; das geht vor.
|
||||||
|
|
||||||
|
**Geprüft:** `CmsTest::testButtonsAndLinkedBlocksMayCallOrWrite` (speichern, ausgeben, Bildquelle mit `tel:` abgewiesen), neue Ungültig-Fälle in `invalidDocuments` (Buchstaben oder Leerzeichen in der Nummer, `mailto:` ohne Adresse oder mit Anführungszeichen bzw. `<`), `testTheButtonArrowTakesTheColourOfTheButtonText`.
|
||||||
|
|
||||||
## ADR 106 – „Eigenes HTML“ als Baustein, und warum der Rahmen trotzdem „Externer Inhalt“ bleibt (16.09.2026)
|
## ADR 106 – „Eigenes HTML“ als Baustein, und warum der Rahmen trotzdem „Externer Inhalt“ bleibt (16.09.2026)
|
||||||
|
|
||||||
Umut: „Als Baustein im Editor, dass man individuelles HTML einfügen kann. Z. B. möchte ich eine neue Angebotsseite bei DEEG, da soll ein iframe rein.“ Das sind zwei Wünsche, und nur einer davon fehlte.
|
Umut: „Als Baustein im Editor, dass man individuelles HTML einfügen kann. Z. B. möchte ich eine neue Angebotsseite bei DEEG, da soll ein iframe rein.“ Das sind zwei Wünsche, und nur einer davon fehlte.
|
||||||
|
|||||||
@@ -1,5 +1,45 @@
|
|||||||
# Projektstatus
|
# Projektstatus
|
||||||
|
|
||||||
|
## Fassung 1.23.1: neunte Installation „Hüfner Gartenbau“, Knöpfe mit tel: und mailto:, Pfeil in der Schriftfarbe (06.10.2026)
|
||||||
|
|
||||||
|
**Anlass:** Umut: „Hier liegt ein Designentwurf für Hüfner Gartenbau. Kannst du mir db und media erstellen für KantSites bitte. […] Effekte rein. Nichts am Code ändern, sondern alles an KantSites-Designelementen anpassen/bauen. Falls was nicht geht, Rückmeldung.“ Vorlage: `Downloads/huefner-website/huefner-website` (statisches `index.html`, 16 Fotos, Logo).
|
||||||
|
|
||||||
|
**Neu (nur Inhalt, kein Kerncode):** Datenbank `var/huefner.db`, Medien `var/huefner-media`, Bildquellen `var/huefner-assets`, Startkonfigurationen `huefner` (Port 8102, dev) und `huefner-prod` (Port 8103). `sh scripts/reset-huefner.sh <Ordner des Entwurfs>` kopiert die Bilder und schneidet den Baum aus dem Logo als Seitensymbol (`build-huefner-assets.php`), baut mit `seed-huefner.php` alles neu und holt Barlow und Barlow Condensed auf den eigenen Server. Ohne Argument nimmt es die schon vorbereiteten Bilder.
|
||||||
|
|
||||||
|
- **Startseite** mit allen elf Abschnitten des Entwurfs: Hero mit Verlauf und Vorteilsband, Collage, Leistungs- und Referenzkacheln (Foto zoomt, Kachel bekommt Schatten), Chips, Ablauf mit gestrichelter Linie, Fuhrpark, Über uns, Karriere (Stellen als Links mit E-Mail-Betreff), Instagram-Raster, Abschlussband, Kontakt mit dem CMS-Formular `anfrage` (Versand an info@huefner-gartenbau.de). Dazu Impressum, Datenschutz, 404, Menü mit Sprungmarken, klebender Kopf, Einblendungen beim Scrollen.
|
||||||
|
- **Inhaltliche Ergänzungen** statt der Platzhalter der Vorlage: Öffnungszeiten Mo–Fr 8–17, Sa 8–12 (von Umut, auch als strukturierte Daten), Firmengeschichte und Impressum aus dem Handelsregister (HRB 742159, AG Mannheim, GF Florian Hüfner; Simone Hüfner war nur bis 27.04.2026 Prokuristin), Hoster IONOS, alle Fotos eigene Aufnahmen. Kennzahlen „[XX] Jahre“/„[XXX] Projekte“ ersetzt durch Aussagen der Vorlage (100 % Detailarbeit, Bagger bis 15 t, ein Team). Keine USt-IdNr. bekannt, deshalb keine Zeile.
|
||||||
|
|
||||||
|
**Grenzen des Kerns:**
|
||||||
|
|
||||||
|
- Nachtrag, gleicher Tag – auf Umuts Wunsch im Kern behoben (ADR 107): Knöpfe und andere verlinkbare Bausteine nehmen `tel:` und `mailto:` an, und der Pfeil im Knopf hat die Schriftfarbe statt der Akzentfarbe. Die Anrufknöpfe bei Hüfner sind jetzt echte Knöpfe, der Pfeil braucht keine eigene Regel mehr. Vorher: Textlink im Knopf-Aussehen und `color:inherit` im CSS des Knopfs.
|
||||||
|
- Kein LKW im Symbolsatz: Das Symbol steht als „Eigenes HTML“ mit dem SVG der Vorlage.
|
||||||
|
- Leiste und Kopf kleben gemeinsam (die Einstellung gilt für den ganzen Kopfbereich).
|
||||||
|
- Zeilen sind durchsichtig, farbige Karten deshalb Spalten mit Flex.
|
||||||
|
- Eigenes CSS ohne `@media`/`*`: gerätespezifische Werte über die Gestaltungswerte (Bildhöhe, Raster-Spalten, Ränder je Gerät).
|
||||||
|
- Das HTML-Budget von 150 KB (`test:browser`) war mit dem CSS je Kachel überschritten (152 KB). Die gemeinsamen Regeln stehen jetzt einmal am Abschnitt, Ergebnis 147,5 KB.
|
||||||
|
|
||||||
|
**Geprüft:** Lighthouse im Produktionsmodus (Port 8103, je drei Läufe, Median): Startseite mobil 97 / Desktop 98, Impressum und Datenschutz 100 / 100; Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100. `test:links` ohne toten Verweis, `test:browser` grün (axe in sechs Ansichten, Tastatur, ohne JavaScript, Budget). Ganzseitige Bildschirmfotos bei 1440, 820 und 375 px gegen den Entwurf verglichen, kein waagerechtes Scrollen, keine Konsolenfehler. Zoom, Schatten, Chip- und Knopf-Hover sowie das Klappmenü per Playwright nachgemessen. Keine Felddaten, kein INP. Das Formular wurde nicht abgeschickt (Mailversand nicht ausprobiert).
|
||||||
|
|
||||||
|
**Nächster Schritt:** Livegang nach `docs/LIVEGANG.md` (Mailversand einmal echt testen). Die Hüfner-Datenbank braucht einen Kern mit ADR 107 (`tel:` an Knöpfen).
|
||||||
|
|
||||||
|
## Kantarci Markt: Demo der Eröffnungsseite mit Angeboten aus der App (24.09.2026)
|
||||||
|
|
||||||
|
**Anlass:** Umut: „Setze mal die demoseite um wie du es dir vorstellst ich überlasse dir alles. Links und logo zu playstore und ios app, wilhelm drapp str. in baden baden, Rest Platzhalter“. Die Angebote kommen aus der Web-Ansicht seiner App: `https://app.kantarci-markt.de/embed/angebote.php?ton=dunkel`, dazu sein Skript für die Höhe.
|
||||||
|
|
||||||
|
**Neu (nur Inhalt, kein Kern):** `/eroeffnung` im Seed-Skript, veröffentlicht, aber `noindex` und nirgends verlinkt. Aufbau: Einstieg „Jetzt geöffnet“ mit zwei Wegen (Angebote, Anfahrt) und Öffnungszeiten, Angebote als eingebettete App-Ansicht, App-Bereich mit Knöpfen für App Store und Google Play, das Versprechen, Besuch (Adresse mit „Route planen“, Öffnungszeiten, Kontakt), Abschluss. Zur Eröffnung soll dieser Inhalt als geplanter Entwurf auf die Startseite, `/eroeffnung` fällt dann weg.
|
||||||
|
|
||||||
|
- **Einwilligung:** keine nötig. `Consent::granted()` lässt jede Gruppe durch, solange die Website keine Einwilligungsleiste hat (bei Kantarci Markt aus). Meine frühere Aussage, der Baustein verlange immer eine Einwilligung, war falsch. Eine Option „Eigener Dienst“ braucht es erst, wenn die Seite einmal eine Leiste bekommt.
|
||||||
|
- **Höhe:** Umuts Skript hängt als eigenes Skript am Baustein. Es lief zu spät: Die App meldet ihre Höhe beim Laden (gemessen 332 ms), das Skript lief als `defer`-Datei erst kurz vor DOMContentLoaded (402 ms), danach meldet die App nur noch Änderungen. Jetzt fragt das Skript nach dem Start per `km-angebote-height-request` nach und lädt den Rahmen als Rückfall nach 600 ms einmal neu, falls keine Meldung kam. Die App kennt die Nachfrage noch nicht, deshalb greift derzeit der Rückfall (zweiter Abruf der Web-Ansicht).
|
||||||
|
- **Hintergrund:** Die Web-Ansicht ist für hellen Grund gebaut (dunkle Überschrift, weiße Karten), `ton=dunkel` und `ton=hell` liefern dieselbe Seite. Der Angebotsbereich steht deshalb auf Sand statt auf Waldgrün. `& iframe{border-radius:0;background:transparent}` am Baustein nimmt Rundung und Tönung aus `site.css`.
|
||||||
|
- **Platzhalter:** Öffnungszeiten (Mo–Sa 8–20 Uhr, So geschlossen), „Telefon folgt“, Store-Links auf die Suche nach „Kantarci Markt“ im jeweiligen Store, Store-Knöpfe als eigene schwarze Knöpfe ohne die Logos von Apple und Google. Alles oben im Abschnitt des Seed-Skripts.
|
||||||
|
- **`scripts/audit.mjs`** kennt `CMS_HOST_RULES` (etwa `MAP localhost 127.0.0.1`): Die App erlaubt das Einbetten nur von `kantarci-markt.de` und `http://localhost:*`, der Server lauscht auf 127.0.0.1.
|
||||||
|
|
||||||
|
**Geprüft:** Lighthouse im Produktionsmodus über `localhost:8101`, Rahmen geladen: `/eroeffnung` mobil 96 / Desktop 100, Barrierefreiheit, Best Practices und agentisches Browsing 100. SEO 69 nur wegen des gewollten `noindex` (`is-crawlable`), keine Konsolenfehler. Die Startseite blieb bei 98 / 100 und überall sonst 100. Im Browser: Rahmen 448 px (Desktop) bzw. 971 px (375 px) hoch, kein Scrollbalken, kein waagerechtes Scrollen. Sicherheitsrichtlinie der Seite: `frame-src https://app.kantarci-markt.de`, `script-src 'self'`.
|
||||||
|
|
||||||
|
**Nachtrag, gleicher Tag – Popup statt Link (App-Repository `C:\Django Project\Supermarkt`):** Umut: Angebote ohne Bild wirkten durchsichtig, „Unsere Angebote“ war weiß, und statt eines Links soll ein Popup die Einzelheiten hier zeigen. Die App-Ansicht hatte inzwischen `ton`, `titel`, `app`, `bg` bekommen; `ton=dunkel` machte die Überschrift weiß. Eingebunden ist sie jetzt mit `?titel=0&app=0` (eigene Überschrift der Seite, kein Knopf nach außen). In der App (`public/embed/angebote.php`, noch nicht committet, nicht ausgeliefert): Karten sind `<article>` mit einem Knopf im Namen statt `<a>`, ein natives `<dialog>` zeigt Bild, Kategorie, Name, Gültigkeit, Beschreibung und Preise auf Höhe der Karte (das iframe ist so hoch wie sein Inhalt) und meldet per `km-angebote-focus`, wo es steht. Die Ansicht beantwortet `km-angebote-height-request`, Angebote ohne Bild haben zartes Grün statt Creme, und ohne Datenbank gibt es die leere Ansicht statt eines Fatal Errors (`count(null)`). `embed/angebote.js` und das Skript im Seed kennen Nachfrage und Hinscrollen. Geprüft mit der lokalen App-Fassung (Anfragen im Browser umgeleitet, Beispieldaten im lokalen Cache): keine Links mehr, Popup im sichtbaren Bereich (auch nach Scrollen), Fokus auf „Schließen“ und zurück zur Karte, Esc und Klick auf den Rand schließen, axe ohne Befund, nur ein Abruf der Ansicht (kein Rückfall-Neuladen). Live wirkt es erst nach Push und App-Update auf dem Server.
|
||||||
|
|
||||||
|
**Offen, von Umut:** echte Store-Adressen (und auf Wunsch die offiziellen Badges), Öffnungszeiten, Telefon, Eröffnungsdatum, Impressumsangaben. App-Änderungen committen, pushen und auf dem Server aktualisieren.
|
||||||
|
|
||||||
## Fassung 1.23.0: achte Installation „Kantarci Markt – Neueröffnung“, content im eigenen CSS, Schriften ergänzt statt überschrieben (23.09.2026)
|
## Fassung 1.23.0: achte Installation „Kantarci Markt – Neueröffnung“, content im eigenen CSS, Schriften ergänzt statt überschrieben (23.09.2026)
|
||||||
|
|
||||||
**Anlass:** Umut: „erstellst du bitte bald verfügbar site nach dem prinzip kannst gerne dazu implementieren“ – dazu das Paket `Kantarci-Markt-Landingpage.zip` (statische Seite: Logo-Kopf, Hero „Frische bekommt eine neue Adresse.“ mit Marktfoto, Tomatenkarte und Stempel, Versprechen auf Waldgrün, Abschluss „Frische. Qualität. Faire Preise.“, Fuß).
|
**Anlass:** Umut: „erstellst du bitte bald verfügbar site nach dem prinzip kannst gerne dazu implementieren“ – dazu das Paket `Kantarci-Markt-Landingpage.zip` (statische Seite: Logo-Kopf, Hero „Frische bekommt eine neue Adresse.“ mit Marktfoto, Tomatenkarte und Stempel, Versprechen auf Waldgrün, Abschluss „Frische. Qualität. Faire Preise.“, Fuß).
|
||||||
|
|||||||
@@ -402,7 +402,7 @@ function renderContent(block, panel) {
|
|||||||
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
|
field('Symbol', 'icon', block.props.icon, { choices: Object.fromEntries(ICONS.map(name => [name, name])) });
|
||||||
field('Titel', 'title', block.props.title, { maxLength: 120 });
|
field('Titel', 'title', block.props.title, { maxLength: 120 });
|
||||||
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
|
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
|
||||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse – oder ein PDF.' });
|
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' });
|
||||||
}
|
}
|
||||||
if (block.type === 'stat') {
|
if (block.type === 'stat') {
|
||||||
field('Zahl', 'value', block.props.value, { maxLength: 16, help: 'Zum Beispiel 15+, 24/7 oder 100 %.' });
|
field('Zahl', 'value', block.props.value, { maxLength: 16, help: 'Zum Beispiel 15+, 24/7 oder 100 %.' });
|
||||||
@@ -418,7 +418,7 @@ function renderContent(block, panel) {
|
|||||||
field('Titel', 'title', block.props.title, { maxLength: 160 });
|
field('Titel', 'title', block.props.title, { maxLength: 160 });
|
||||||
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
|
field('Text', 'text', block.props.text, { multiline: true, maxLength: 600 });
|
||||||
field('Beschriftung der Schaltfläche', 'buttonText', block.props.buttonText, { maxLength: 80 });
|
field('Beschriftung der Schaltfläche', 'buttonText', block.props.buttonText, { maxLength: 80 });
|
||||||
field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF.' });
|
field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' });
|
||||||
}
|
}
|
||||||
if (block.type === 'contact') {
|
if (block.type === 'contact') {
|
||||||
field('Telefon', 'phone', block.props.phone || '', { maxLength: 80, help: 'Wird als anrufbarer Link ausgegeben.' });
|
field('Telefon', 'phone', block.props.phone || '', { maxLength: 80, help: 'Wird als anrufbarer Link ausgegeben.' });
|
||||||
@@ -439,7 +439,7 @@ function renderContent(block, panel) {
|
|||||||
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Feste Stufen, weil die Größe eine Klasse ist – ein freier Wert hätte keine und das Symbol wäre unsichtbar.' });
|
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Feste Stufen, weil die Größe eine Klasse ist – ein freier Wert hätte keine und das Symbol wäre unsichtbar.' });
|
||||||
field('Nur Dekoration', 'decorative', block.props.decorative, { checkbox: true, help: 'Dekorative Symbole werden Screenreadern verschwiegen. Trägt das Symbol eine Aussage, nimm das Häkchen weg und beschrifte es.' });
|
field('Nur Dekoration', 'decorative', block.props.decorative, { checkbox: true, help: 'Dekorative Symbole werden Screenreadern verschwiegen. Trägt das Symbol eine Aussage, nimm das Häkchen weg und beschrifte es.' });
|
||||||
if (!block.props.decorative) field('Beschriftung', 'label', block.props.label, { maxLength: 120 });
|
if (!block.props.decorative) field('Beschriftung', 'label', block.props.label, { maxLength: 120 });
|
||||||
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse – oder ein PDF.' });
|
field('Linkziel', 'href', block.props.href || '', { help: 'Optional. Lokaler Pfad, Sprungmarke oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' });
|
||||||
}
|
}
|
||||||
if (block.type === 'social') {
|
if (block.type === 'social') {
|
||||||
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Die Adressen stehen unter Einstellungen → Unternehmensdaten → Soziale Profile. Farbe, Abstand und Ausrichtung unter Design.' });
|
field('Größe', 'size', String(block.props.size), { number: true, choices: Object.fromEntries(ICON_SIZES.map(size => [String(size), `${size} px`])), help: 'Die Adressen stehen unter Einstellungen → Unternehmensdaten → Soziale Profile. Farbe, Abstand und Ausrichtung unter Design.' });
|
||||||
@@ -556,7 +556,7 @@ function renderContent(block, panel) {
|
|||||||
field('Auszug zeigen', 'showExcerpt', block.props.showExcerpt !== false, { checkbox: true });
|
field('Auszug zeigen', 'showExcerpt', block.props.showExcerpt !== false, { checkbox: true });
|
||||||
field('Kategorie zeigen', 'showTerms', block.props.showTerms !== false, { checkbox: true, help: 'Alle drei aus: ein Raster aus Bild und Titel, wie es zu Projekten passt.' });
|
field('Kategorie zeigen', 'showTerms', block.props.showTerms !== false, { checkbox: true, help: 'Alle drei aus: ein Raster aus Bild und Titel, wie es zu Projekten passt.' });
|
||||||
}
|
}
|
||||||
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF.' });
|
if (block.type === 'button') field('Linkziel', 'href', block.props.href, { help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF.' });
|
||||||
if (block.type === 'image') {
|
if (block.type === 'image') {
|
||||||
renderImageWidth(block, panel);
|
renderImageWidth(block, panel);
|
||||||
imagePreview = createMediaPreview({ src: block.props.sources?.[0]?.src || block.props.src, label: block.props.alt || block.props.caption });
|
imagePreview = createMediaPreview({ src: block.props.sources?.[0]?.src || block.props.src, label: block.props.alt || block.props.caption });
|
||||||
@@ -568,7 +568,7 @@ function renderContent(block, panel) {
|
|||||||
const galleryZoom = holder?.type === 'gallery' && holder.props?.lightbox === true;
|
const galleryZoom = holder?.type === 'gallery' && holder.props?.lightbox === true;
|
||||||
const clickAction = block.props?.click || (block.props?.href ? 'link' : (galleryZoom ? 'vergroessern' : 'keine'));
|
const clickAction = block.props?.click || (block.props?.href ? 'link' : (galleryZoom ? 'vergroessern' : 'keine'));
|
||||||
field('Beim Klick', 'click', clickAction, { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, onInput: showLinkOptions });
|
field('Beim Klick', 'click', clickAction, { choices: { keine: 'Nichts tun', vergroessern: 'Bild vergrößern', link: 'Zu einer Adresse führen' }, onInput: showLinkOptions });
|
||||||
field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse – oder ein PDF. Für ein Logo im Kopfbereich meist /.' });
|
field('Linkziel', 'href', block.props.href || '', { container: linkOptions, help: 'Lokaler Pfad (/kontakt), Sprungmarke (#abschnitt) oder HTTPS-Adresse, Telefon (tel:+49…) oder E-Mail (mailto:…) – oder ein PDF. Für ein Logo im Kopfbereich meist /.' });
|
||||||
showLinkOptions(clickAction); panel.append(linkOptions);
|
showLinkOptions(clickAction); panel.append(linkOptions);
|
||||||
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
|
const picker = document.createElement('button'); picker.className = 'secondary'; picker.textContent = 'Bild aus Mediathek wählen';
|
||||||
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
|
picker.addEventListener('click', () => chooseMedia(false)); panel.append(picker);
|
||||||
|
|||||||
@@ -28,6 +28,10 @@ in `dm-sans-OFL.txt`. Copyright 2014 The DM Sans Project Authors (github.com/goo
|
|||||||
DM Sans ist eine variable Schrift: `dm-sans-400-…`, `-500-…` und `-700-…` sind dieselbe Datei,
|
DM Sans ist eine variable Schrift: `dm-sans-400-…`, `-500-…` und `-700-…` sind dieselbe Datei,
|
||||||
`fonts.css` nennt nur die erste, mit dem Gewichtsbereich 400–700.
|
`fonts.css` nennt nur die erste, mit dem Gewichtsbereich 400–700.
|
||||||
|
|
||||||
|
**Barlow** und **Barlow Condensed** (Fließtext und Überschriften der Seite „Hüfner Gartenbau“)
|
||||||
|
stehen unter der SIL Open Font License 1.1, Text in `barlow-OFL.txt` (für beide Familien gleich).
|
||||||
|
Copyright 2017 The Barlow Project Authors (github.com/jpt/barlow).
|
||||||
|
|
||||||
Wer hier andere Schriften ablegt, prüft deren Lizenz selbst: nicht jede Schrift, die Google Fonts
|
Wer hier andere Schriften ablegt, prüft deren Lizenz selbst: nicht jede Schrift, die Google Fonts
|
||||||
ausliefert, steht unter der OFL – einige stehen unter der Apache-Lizenz 2.0, und eine gekaufte
|
ausliefert, steht unter der OFL – einige stehen unter der Apache-Lizenz 2.0, und eine gekaufte
|
||||||
Schrift eines Schriftenhauses darf meist gar nicht weitergegeben werden. Die Lizenzdatei gehört in
|
Schrift eines Schriftenhauses darf meist gar nicht weitergegeben werden. Die Lizenzdatei gehört in
|
||||||
|
|||||||
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,93 @@
|
|||||||
|
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
|
||||||
|
|
||||||
|
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||||
|
This license is copied below, and is also available with a FAQ at:
|
||||||
|
http://scripts.sil.org/OFL
|
||||||
|
|
||||||
|
|
||||||
|
-----------------------------------------------------------
|
||||||
|
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||||
|
-----------------------------------------------------------
|
||||||
|
|
||||||
|
PREAMBLE
|
||||||
|
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||||
|
development of collaborative font projects, to support the font creation
|
||||||
|
efforts of academic and linguistic communities, and to provide a free and
|
||||||
|
open framework in which fonts may be shared and improved in partnership
|
||||||
|
with others.
|
||||||
|
|
||||||
|
The OFL allows the licensed fonts to be used, studied, modified and
|
||||||
|
redistributed freely as long as they are not sold by themselves. The
|
||||||
|
fonts, including any derivative works, can be bundled, embedded,
|
||||||
|
redistributed and/or sold with any software provided that any reserved
|
||||||
|
names are not used by derivative works. The fonts and derivatives,
|
||||||
|
however, cannot be released under any other type of license. The
|
||||||
|
requirement for fonts to remain under this license does not apply
|
||||||
|
to any document created using the fonts or their derivatives.
|
||||||
|
|
||||||
|
DEFINITIONS
|
||||||
|
"Font Software" refers to the set of files released by the Copyright
|
||||||
|
Holder(s) under this license and clearly marked as such. This may
|
||||||
|
include source files, build scripts and documentation.
|
||||||
|
|
||||||
|
"Reserved Font Name" refers to any names specified as such after the
|
||||||
|
copyright statement(s).
|
||||||
|
|
||||||
|
"Original Version" refers to the collection of Font Software components as
|
||||||
|
distributed by the Copyright Holder(s).
|
||||||
|
|
||||||
|
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||||
|
or substituting -- in part or in whole -- any of the components of the
|
||||||
|
Original Version, by changing formats or by porting the Font Software to a
|
||||||
|
new environment.
|
||||||
|
|
||||||
|
"Author" refers to any designer, engineer, programmer, technical
|
||||||
|
writer or other person who contributed to the Font Software.
|
||||||
|
|
||||||
|
PERMISSION & CONDITIONS
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining
|
||||||
|
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||||
|
redistribute, and sell modified and unmodified copies of the Font
|
||||||
|
Software, subject to the following conditions:
|
||||||
|
|
||||||
|
1) Neither the Font Software nor any of its individual components,
|
||||||
|
in Original or Modified Versions, may be sold by itself.
|
||||||
|
|
||||||
|
2) Original or Modified Versions of the Font Software may be bundled,
|
||||||
|
redistributed and/or sold with any software, provided that each copy
|
||||||
|
contains the above copyright notice and this license. These can be
|
||||||
|
included either as stand-alone text files, human-readable headers or
|
||||||
|
in the appropriate machine-readable metadata fields within text or
|
||||||
|
binary files as long as those fields can be easily viewed by the user.
|
||||||
|
|
||||||
|
3) No Modified Version of the Font Software may use the Reserved Font
|
||||||
|
Name(s) unless explicit written permission is granted by the corresponding
|
||||||
|
Copyright Holder. This restriction only applies to the primary font name as
|
||||||
|
presented to the users.
|
||||||
|
|
||||||
|
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||||
|
Software shall not be used to promote, endorse or advertise any
|
||||||
|
Modified Version, except to acknowledge the contribution(s) of the
|
||||||
|
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||||
|
permission.
|
||||||
|
|
||||||
|
5) The Font Software, modified or unmodified, in part or in whole,
|
||||||
|
must be distributed entirely under this license, and must not be
|
||||||
|
distributed under any other license. The requirement for fonts to
|
||||||
|
remain under this license does not apply to any document created
|
||||||
|
using the Font Software.
|
||||||
|
|
||||||
|
TERMINATION
|
||||||
|
This license becomes null and void if any of the above conditions are
|
||||||
|
not met.
|
||||||
|
|
||||||
|
DISCLAIMER
|
||||||
|
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||||
|
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||||
|
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||||
|
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||||
|
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||||
|
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||||
|
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||||
|
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||||
|
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -18,3 +18,23 @@
|
|||||||
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-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-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-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-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||||
/* latin */
|
/* latin */
|
||||||
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-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}
|
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-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}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face{font-family:'Barlow';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/barlow-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:'Barlow';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/barlow-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}
|
||||||
|
/* latin-ext */
|
||||||
|
@font-face{font-family:'Barlow';font-style:normal;font-weight:600;font-display:swap;src:url(/assets/fonts/barlow-600-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:'Barlow';font-style:normal;font-weight:600;font-display:swap;src:url(/assets/fonts/barlow-600-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:'Barlow';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-700-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:'Barlow';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-700-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:'Barlow Condensed';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-condensed-700-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:'Barlow Condensed';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-condensed-700-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:'Barlow Condensed';font-style:normal;font-weight:800;font-display:swap;src:url(/assets/fonts/barlow-condensed-800-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:'Barlow Condensed';font-style:normal;font-weight:800;font-display:swap;src:url(/assets/fonts/barlow-condensed-800-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}
|
||||||
@@ -267,6 +267,9 @@ body.consent-open{padding-bottom:320px}
|
|||||||
/* Das eingebettete SVG traegt dieselbe Klasse wie seine Huelle und setzte deshalb die
|
/* Das eingebettete SVG traegt dieselbe Klasse wie seine Huelle und setzte deshalb die
|
||||||
Akzentfarbe erneut - eine am Symbolblock eingestellte Farbe kam nie an. */
|
Akzentfarbe erneut - eine am Symbolblock eingestellte Farbe kam nie an. */
|
||||||
.block-icon .block-icon{color:inherit}
|
.block-icon .block-icon{color:inherit}
|
||||||
|
/* Der Pfeil im Knopf ist dasselbe Symbol und nahm deshalb die Akzentfarbe statt der Schriftfarbe
|
||||||
|
des Knopfs: grün auf Rot, auch beim Überfahren (Hüfner, 06.10.2026). */
|
||||||
|
.button-arrow .block-icon{color:inherit}
|
||||||
.icon-size-12{width:12px;height:12px}.icon-size-16{width:16px;height:16px}.icon-size-20{width:20px;height:20px}
|
.icon-size-12{width:12px;height:12px}.icon-size-16{width:16px;height:16px}.icon-size-20{width:20px;height:20px}
|
||||||
.icon-size-24{width:24px;height:24px}.icon-size-32{width:32px;height:32px}.icon-size-40{width:40px;height:40px}
|
.icon-size-24{width:24px;height:24px}.icon-size-32{width:32px;height:32px}.icon-size-40{width:40px;height:40px}
|
||||||
.icon-size-48{width:48px;height:48px}.icon-size-56{width:56px;height:56px}.icon-size-64{width:64px;height:64px}
|
.icon-size-48{width:48px;height:48px}.icon-size-56{width:56px;height:56px}.icon-size-64{width:64px;height:64px}
|
||||||
|
|||||||
+3
-1
@@ -18,7 +18,9 @@ const portProbe = net.createServer();
|
|||||||
await new Promise(resolve => portProbe.listen(0, '127.0.0.1', resolve));
|
await new Promise(resolve => portProbe.listen(0, '127.0.0.1', resolve));
|
||||||
const port = portProbe.address().port;
|
const port = portProbe.address().port;
|
||||||
await new Promise(resolve => portProbe.close(resolve));
|
await new Promise(resolve => portProbe.close(resolve));
|
||||||
const chrome = await chromium.launch({ args: [`--remote-debugging-port=${port}`] });
|
// CMS_HOST_RULES, etwa "MAP localhost 127.0.0.1": für eingebettete Seiten, die das Einbetten nur
|
||||||
|
// von localhost erlauben, während der Server auf 127.0.0.1 lauscht (Kantarci-Markt-App).
|
||||||
|
const chrome = await chromium.launch({ args: [`--remote-debugging-port=${port}`, ...(process.env.CMS_HOST_RULES ? [`--host-resolver-rules=${process.env.CMS_HOST_RULES}`] : [])] });
|
||||||
const summary = [];
|
const summary = [];
|
||||||
try {
|
try {
|
||||||
for (const path of paths) {
|
for (const path of paths) {
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bereitet die Bilder des Hüfner-Entwurfs für die Mediathek vor.
|
||||||
|
*
|
||||||
|
* Kopiert die Fotos und das Logo aus dem gelieferten Ordner (images/) und schneidet aus dem Logo
|
||||||
|
* den Baum mit den Steinen als quadratisches Seitensymbol (favicon.png) heraus. Der Baum ist im
|
||||||
|
* Logo links, der Schriftzug rechts; ein Favicon mit Schrift wäre in 32 px unlesbar.
|
||||||
|
*
|
||||||
|
* Aufruf: php scripts/build-huefner-assets.php <Quellordner mit images/> <Zielordner>
|
||||||
|
*/
|
||||||
|
$quelle = rtrim($argv[1] ?? '', '/\\');
|
||||||
|
$ziel = rtrim($argv[2] ?? '', '/\\');
|
||||||
|
if ('' === $quelle || '' === $ziel || !is_dir($quelle.'/images')) {
|
||||||
|
fwrite(STDERR, "Aufruf: php scripts/build-huefner-assets.php <Ordner mit images/> <Zielordner>\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
if (!is_dir($ziel) && !mkdir($ziel, 0775, true)) {
|
||||||
|
fwrite(STDERR, "Zielordner lässt sich nicht anlegen: $ziel\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (glob($quelle.'/images/*.{jpg,png}', GLOB_BRACE) ?: [] as $datei) {
|
||||||
|
copy($datei, $ziel.'/'.basename($datei));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Baum und Steine: im 1600 x 607 px großen Logo etwa x 15–500, y 10–560.
|
||||||
|
$logo = imagecreatefrompng($quelle.'/images/logo.png');
|
||||||
|
if (false === $logo) {
|
||||||
|
fwrite(STDERR, "logo.png lässt sich nicht lesen.\n");
|
||||||
|
exit(1);
|
||||||
|
}
|
||||||
|
$kante = 512;
|
||||||
|
$symbol = imagecreatetruecolor($kante, $kante);
|
||||||
|
imagesavealpha($symbol, true);
|
||||||
|
imagealphablending($symbol, false);
|
||||||
|
imagefill($symbol, 0, 0, imagecolorallocatealpha($symbol, 0, 0, 0, 127));
|
||||||
|
imagealphablending($symbol, true);
|
||||||
|
[$x, $y, $b, $h] = [15, 10, 490, 550];
|
||||||
|
$massstab = min(($kante - 24) / $b, ($kante - 24) / $h);
|
||||||
|
$zb = (int) round($b * $massstab);
|
||||||
|
$zh = (int) round($h * $massstab);
|
||||||
|
imagecopyresampled($symbol, $logo, (int) (($kante - $zb) / 2), (int) (($kante - $zh) / 2), $x, $y, $zb, $zh, $b, $h);
|
||||||
|
imagepng($symbol, $ziel.'/favicon.png');
|
||||||
|
echo "Bilder kopiert, Seitensymbol geschnitten: $ziel\n";
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Wirft die Hüfner-Installation weg und baut sie neu auf. Nur fuer den Aufbau gedacht.
|
||||||
|
# Erstes Argument: der entpackte Entwurf (Ordner mit index.html und images/).
|
||||||
|
set -e
|
||||||
|
root="C:/Django Project/KantSites"
|
||||||
|
php="${PHP_BINARY:-C:/php84/php.exe}"
|
||||||
|
export DATABASE_URL="sqlite:///$root/var/huefner.db"
|
||||||
|
export MEDIA_DIRECTORY="$root/var/huefner-media"
|
||||||
|
assets="$root/var/huefner-assets"
|
||||||
|
if [ -n "$1" ]; then
|
||||||
|
"$php" scripts/build-huefner-assets.php "$1" "$assets"
|
||||||
|
fi
|
||||||
|
if [ ! -f "$assets/favicon.png" ]; then
|
||||||
|
echo "Bilder fehlen: erst mit dem Ordner des Entwurfs als Argument aufrufen." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
rm -f "$root/var/huefner.db"
|
||||||
|
rm -rf "$root/var/huefner-media"
|
||||||
|
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
|
||||||
|
"$php" -d memory_limit=512M scripts/seed-huefner.php "$assets"
|
||||||
|
# Barlow und Barlow Condensed 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
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -518,6 +518,274 @@ $startseite = $einblenden($startseite, [
|
|||||||
|
|
||||||
$anlegen('home', $startseite, 'Kantarci Markt – Neueröffnung in Baden-Baden', $medien['markt']['id']);
|
$anlegen('home', $startseite, 'Kantarci Markt – Neueröffnung in Baden-Baden', $medien['markt']['id']);
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
// Eröffnungsseite (Demo, 24.09.2026)
|
||||||
|
// ---------------------------------------------------------------------------------------------
|
||||||
|
//
|
||||||
|
// So sieht die Startseite aus, wenn der Markt geöffnet hat. Sie liegt vorerst unter
|
||||||
|
// /eroeffnung, ist nicht verlinkt und steht auf noindex: Umut soll sie ansehen können, ohne dass
|
||||||
|
// die Vorab-Seite verschwindet. Zur Eröffnung wandert ihr Inhalt als Entwurf auf die Startseite
|
||||||
|
// (geplante Veröffentlichung), /eroeffnung wird danach gelöscht.
|
||||||
|
//
|
||||||
|
// Die Angebote kommen aus der Web-Ansicht der eigenen App (app.kantarci-markt.de). Die erlaubt
|
||||||
|
// das Einbetten selbst (frame-ancestors) und meldet ihre Höhe per postMessage. Das Skript am
|
||||||
|
// Baustein ist Umuts Gegenstück dazu (24.09.2026). Eine Einwilligung braucht es nicht, solange die
|
||||||
|
// Website keine Einwilligungsleiste hat: Consent::granted() lässt dann jede Gruppe durch.
|
||||||
|
//
|
||||||
|
// Platzhalter, bis Umut die echten Werte schickt: Öffnungszeiten, Telefon, Store-Adressen (die
|
||||||
|
// führen vorerst auf die Suche im jeweiligen Store) und die Store-Badges (eigene Knöpfe statt der
|
||||||
|
// offiziellen Grafiken von Apple und Google).
|
||||||
|
|
||||||
|
// Ohne eigene Überschrift und ohne Knopf zur App: Die Seite hat ihre Überschrift, und Umut will keine Links nach
|
||||||
|
// außen (24.09.2026). Ein Klick auf ein Angebot öffnet ein Popup in der Ansicht. Heller Ton, weil der Abschnitt Sand ist.
|
||||||
|
$angeboteUrl = 'https://app.kantarci-markt.de/embed/angebote.php?titel=0&app=0';
|
||||||
|
$appStoreUrl = 'https://apps.apple.com/de/search?term=Kantarci%20Markt';
|
||||||
|
$playStoreUrl = 'https://play.google.com/store/search?q=Kantarci%20Markt&c=apps';
|
||||||
|
$oeffnungszeiten = [['Montag bis Freitag', '8:00 – 20:00 Uhr'], ['Samstag', '8:00 – 20:00 Uhr'], ['Sonntag und Feiertage', 'geschlossen']];
|
||||||
|
$routeUrl = 'https://www.google.com/maps/dir/?api=1&destination='.rawurlencode($strasse.', '.$plz.' '.$ort);
|
||||||
|
|
||||||
|
// Umuts Gegenstück aus der App, um zwei Dinge ergänzt. Erstens läuft es als eigene Datei (defer)
|
||||||
|
// und damit oft erst, nachdem die App ihre Höhe schon einmal gemeldet hat - danach meldet sie nur
|
||||||
|
// noch Änderungen. Deshalb fragt es nach dem Start nach ('km-angebote-height-request'); antwortet
|
||||||
|
// die App nicht, lädt es den Rahmen als Rückfall einmal neu. Zweitens meldet die App beim Öffnen
|
||||||
|
// eines Angebots, wo ihr Popup steht ('km-angebote-focus'); liegt das außerhalb des Sichtbaren,
|
||||||
|
// scrollt die Seite dorthin.
|
||||||
|
$hoehenSkript = <<<'JS'
|
||||||
|
(function () {
|
||||||
|
var gemeldet = false;
|
||||||
|
function passend(f) {
|
||||||
|
try {
|
||||||
|
var src = new URL(f.src, location.href);
|
||||||
|
return src.pathname.indexOf('/embed/angebote') !== -1 ? src : null;
|
||||||
|
} catch (e) { return null; }
|
||||||
|
}
|
||||||
|
window.addEventListener('message', function (ev) {
|
||||||
|
if (!ev.data || (ev.data.type !== 'km-angebote-height' && ev.data.type !== 'km-angebote-focus')) return;
|
||||||
|
var frames = document.querySelectorAll('iframe');
|
||||||
|
for (var i = 0; i < frames.length; i++) {
|
||||||
|
var f = frames[i];
|
||||||
|
if (f.contentWindow !== ev.source) continue;
|
||||||
|
var src = passend(f);
|
||||||
|
if (!src || src.origin !== ev.origin) return;
|
||||||
|
if (ev.data.type === 'km-angebote-focus') {
|
||||||
|
var oben = f.getBoundingClientRect().top + Math.max(0, Math.min(20000, Number(ev.data.top) || 0));
|
||||||
|
var hoehe = Math.max(0, Math.min(20000, Number(ev.data.height) || 0));
|
||||||
|
if (oben < 16 || oben + Math.min(hoehe, window.innerHeight - 32) > window.innerHeight - 16) {
|
||||||
|
var still = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||||
|
window.scrollTo({ top: window.scrollY + oben - 24, behavior: still ? 'auto' : 'smooth' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var h = Math.max(150, Math.min(20000, Number(ev.data.height) || 0));
|
||||||
|
f.style.height = (h + 2) + 'px';
|
||||||
|
f.style.minHeight = '0';
|
||||||
|
f.style.background = 'transparent';
|
||||||
|
f.setAttribute('scrolling', 'no');
|
||||||
|
gemeldet = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
var frames = document.querySelectorAll('iframe');
|
||||||
|
for (var i = 0; i < frames.length; i++) {
|
||||||
|
(function (f) {
|
||||||
|
var src = passend(f);
|
||||||
|
if (!src) return;
|
||||||
|
f.contentWindow.postMessage({ type: 'km-angebote-height-request' }, src.origin);
|
||||||
|
setTimeout(function () { if (!gemeldet) f.src = f.src; }, 600);
|
||||||
|
})(frames[i]);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
JS;
|
||||||
|
|
||||||
|
/** Ändert Bausteine eines Baums nach ID, ohne ihn neu zu schreiben. */
|
||||||
|
$aendern = static function (array $block, array $plan) use (&$aendern): array {
|
||||||
|
if (isset($plan[$block['id'] ?? ''])) {
|
||||||
|
$block = $plan[$block['id']]($block);
|
||||||
|
}
|
||||||
|
foreach ($block['children'] ?? [] as $i => $kind) {
|
||||||
|
$block['children'][$i] = $aendern($kind, $plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $block;
|
||||||
|
};
|
||||||
|
$setzeText = static fn (string $inhalt): Closure => static function (array $b) use ($inhalt): array {
|
||||||
|
$b['props']['text'] = $inhalt;
|
||||||
|
|
||||||
|
return $b;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Ein Store-Knopf: schwarz, zweizeilig, wie die Badges der Stores, aber ohne deren Logos. */
|
||||||
|
$storeKnopf = static fn (string $id, string $oben, string $unten, string $href): array => $text($id,
|
||||||
|
'<a href="'.htmlspecialchars($href, ENT_QUOTES).'">'.$oben.'<br><strong>'.$unten.'</strong></a>',
|
||||||
|
['desktop' => ['margin' => ['bottom' => '0px']]],
|
||||||
|
"& a{display:inline-block;min-width:176px;padding:9px 20px 10px;border-radius:9px;background:#000;border:1px solid #a6a6a6;color:#fff;font-size:12px;line-height:1.25;text-decoration:none}\n"
|
||||||
|
."& a:hover{background:#123b26;border-color:#d2b978}\n"
|
||||||
|
."& a:focus-visible{outline:3px solid #ad893b;outline-offset:3px}\n"
|
||||||
|
.'& strong{display:block;font-size:21px;font-weight:500;letter-spacing:-0.01em}');
|
||||||
|
|
||||||
|
$abschnittTitel = static fn (string $id, string $klein, string $titel, string $farbeKlein, string $farbeTitel, string $strich = '@gold', string $farbeBetont = '#8c6b27') => [
|
||||||
|
$kleinzeile($id.'-label', $klein, $farbeKlein, $strich),
|
||||||
|
$heading($id.'-h2', $titel, 2, [
|
||||||
|
'desktop' => ['fontSize' => '64px', 'fontWeight' => '400', 'lineHeight' => 1.06, 'letterSpacing' => '-0.035em', 'color' => $farbeTitel, 'margin' => ['bottom' => '22px']],
|
||||||
|
'tablet' => ['fontSize' => '52px'],
|
||||||
|
'mobile' => ['fontSize' => '40px'],
|
||||||
|
], '& em{font-style:italic;color:'.$farbeBetont.'}'),
|
||||||
|
];
|
||||||
|
|
||||||
|
$eroeffnung = [
|
||||||
|
'schemaVersion' => $SCHEMA,
|
||||||
|
'title' => 'Jetzt geöffnet',
|
||||||
|
'description' => 'Kantarci Markt in der Wilhelm-Drapp-Straße in Baden-Baden: frische Produkte, echte Vielfalt, faire Preise und jede Woche neue Angebote.',
|
||||||
|
'blocks' => [
|
||||||
|
// 1 – Hero: wie die Vorab-Seite, aber mit Öffnungszeiten und zwei Wegen weiter.
|
||||||
|
$aendern($startseite['blocks'][0], [
|
||||||
|
'hero-label-text' => $setzeText('Jetzt geöffnet'),
|
||||||
|
'hero-titel' => $setzeText('Frische hat jetzt<br><em>eine Adresse.</em>'),
|
||||||
|
'hero-intro' => $setzeText('Sorgfältig ausgewählte Produkte, echte Vielfalt und faire Preise – ab sofort in der Wilhelm-Drapp-Straße in Baden-Baden.'),
|
||||||
|
'hero-termin-icon' => static function (array $b): array {
|
||||||
|
$b['props']['icon'] = 'clock';
|
||||||
|
|
||||||
|
return $b;
|
||||||
|
},
|
||||||
|
'hero-termin-klein' => $setzeText('Heute geöffnet'),
|
||||||
|
'hero-termin-satz' => $setzeText($oeffnungszeiten[0][1]),
|
||||||
|
'hero-adresse' => $setzeText('So finden Sie uns<br><strong>'.$strasse.'</strong><br>'.$plz.' '.$ort),
|
||||||
|
'hero-markt' => static function (array $b): array {
|
||||||
|
$b['props']['caption'] = 'Frische Vielfalt, jeden Tag.';
|
||||||
|
|
||||||
|
return $b;
|
||||||
|
},
|
||||||
|
'hero-text' => static function (array $b) use ($zeile, $button): array {
|
||||||
|
// Die beiden Wege stehen zwischen Einleitung und Öffnungszeiten.
|
||||||
|
array_splice($b['children'], 3, 0, [$zeile('hero-wege', [
|
||||||
|
$button('hero-angebote', 'Aktuelle Angebote', '#angebote'),
|
||||||
|
['id' => 'hero-besuch', 'type' => 'button', 'props' => ['text' => 'Anfahrt & Öffnungszeiten', 'href' => '#besuch'],
|
||||||
|
'css' => "background:transparent;color:#123b26;box-shadow:inset 0 0 0 1px #123b26\n& span{display:none}\n&:hover{background:#123b26;color:#fff}"],
|
||||||
|
], '14px', ['flexWrap' => 'wrap', 'margin' => ['top' => '0px', 'bottom' => '30px']])]);
|
||||||
|
|
||||||
|
return $b;
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
|
||||||
|
// 2 – Angebote aus der App, auf Sand: Die Web-Ansicht ist für hellen Grund gestaltet
|
||||||
|
// (dunkle Überschrift, weiße Karten); `ton` ändert daran derzeit nichts (24.09.2026).
|
||||||
|
$section('angebote-bereich', [
|
||||||
|
$row('angebote-kopf', [
|
||||||
|
$column('angebote-kopf-titel', $abschnittTitel('angebote', 'Diese Woche', 'Aktuelle<br><em>Angebote.</em>', '@gold-text', '@forst')),
|
||||||
|
$column('angebote-kopf-text', [
|
||||||
|
$text('angebote-intro', 'Direkt aus unserer App – jede Woche neu. Die Angebote gelten im Markt, solange der Vorrat reicht.', [
|
||||||
|
'desktop' => ['maxWidth' => '420px', 'fontSize' => '17px', 'lineHeight' => 1.65, 'color' => '@text', 'margin' => ['bottom' => '0px']],
|
||||||
|
]),
|
||||||
|
], ['desktop' => ['alignSelf' => 'flex-end']]),
|
||||||
|
], ['desktop' => ['columns' => 'two'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']], [
|
||||||
|
'desktop' => ['gap' => '40px', 'alignItems' => 'flex-end', 'margin' => ['top' => '0px', 'bottom' => '44px']],
|
||||||
|
'mobile' => ['gap' => '8px', 'margin' => ['bottom' => '32px']],
|
||||||
|
]),
|
||||||
|
['id' => 'angebote-app', 'type' => 'embed',
|
||||||
|
'props' => ['group' => 'external', 'title' => 'Aktuelle Angebote von Kantarci Markt', 'provider' => 'Kantarci-Markt-App', 'url' => $angeboteUrl, 'hint' => '', 'height' => 900],
|
||||||
|
// Die Höhe setzt das Skript, sobald die App sie meldet; bis dahin gelten die 900 px.
|
||||||
|
'css' => '& iframe{border-radius:0;background:transparent}',
|
||||||
|
'script' => $hoehenSkript],
|
||||||
|
], [
|
||||||
|
'desktop' => ['backgroundColor' => '@sand', 'padding' => ['top' => '110px', 'bottom' => '110px'], 'borderWidth' => ['top' => '1px', 'right' => '0px', 'bottom' => '1px', 'left' => '0px'], 'borderStyle' => 'solid', 'borderColor' => '@gold'],
|
||||||
|
'tablet' => ['padding' => ['top' => '84px', 'bottom' => '84px']],
|
||||||
|
'mobile' => ['padding' => ['top' => '64px', 'bottom' => '64px']],
|
||||||
|
], 'angebote'),
|
||||||
|
|
||||||
|
// 3 – Die App: Text und Store-Knöpfe links, die Bildmarke als App-Symbol rechts.
|
||||||
|
$section('app-bereich', [
|
||||||
|
$row('app-zeile', [
|
||||||
|
$column('app-text', [
|
||||||
|
...$abschnittTitel('app', 'Die Kantarci-Markt-App', 'Angebote immer<br>in der Tasche.', '@gold-text', '@forst'),
|
||||||
|
$text('app-intro', 'Mit unserer App haben Sie die aktuellen Angebote jederzeit dabei – auch unterwegs und vor dem Einkauf.', [
|
||||||
|
'desktop' => ['maxWidth' => '520px', 'fontSize' => '18px', 'lineHeight' => 1.65, 'color' => '@text', 'margin' => ['bottom' => '30px']],
|
||||||
|
'mobile' => ['fontSize' => '17px'],
|
||||||
|
]),
|
||||||
|
$zeile('app-stores', [
|
||||||
|
$storeKnopf('app-ios', 'Laden im', 'App Store', $appStoreUrl),
|
||||||
|
$storeKnopf('app-android', 'Jetzt bei', 'Google Play', $playStoreUrl),
|
||||||
|
], '14px', ['flexWrap' => 'wrap']),
|
||||||
|
]),
|
||||||
|
$column('app-symbol', [
|
||||||
|
$bild('app-symbol-bild', $medien['symbol'], 'App-Symbol von Kantarci Markt', [
|
||||||
|
'desktop' => ['maxWidth' => '200px', 'width' => '200px', 'margin' => $seiten('0px'), 'rotate' => -6,
|
||||||
|
'shadowY' => '24px', 'shadowBlur' => '50px', 'shadowColor' => '#062f2440', 'borderRadius' => $rund('44px'), 'overflow' => 'hidden'],
|
||||||
|
'mobile' => ['maxWidth' => '140px', 'width' => '140px', 'borderRadius' => $rund('31px')],
|
||||||
|
], null, '& img{display:block;width:100%;height:auto}'),
|
||||||
|
], ['desktop' => ['display' => 'flex', 'justifyContent' => 'center', 'alignItems' => 'center']]),
|
||||||
|
], ['desktop' => ['columns' => 'two'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']], [
|
||||||
|
'desktop' => ['gap' => '60px', 'alignItems' => 'center', 'margin' => ['top' => '0px'], 'columnWidths' => '3 2'],
|
||||||
|
'mobile' => ['gap' => '44px'],
|
||||||
|
]),
|
||||||
|
], [
|
||||||
|
'desktop' => ['backgroundColor' => '@creme', 'padding' => ['top' => '110px', 'bottom' => '110px']],
|
||||||
|
'tablet' => ['padding' => ['top' => '84px', 'bottom' => '84px']],
|
||||||
|
'mobile' => ['padding' => ['top' => '64px', 'bottom' => '72px']],
|
||||||
|
], 'app'),
|
||||||
|
|
||||||
|
// 4 – Das Versprechen, wie auf der Vorab-Seite.
|
||||||
|
$startseite['blocks'][1],
|
||||||
|
|
||||||
|
// 5 – Besuch: Adresse mit Route, Öffnungszeiten, Kontakt.
|
||||||
|
$section('besuch-bereich', [
|
||||||
|
$row('besuch-kopf', [
|
||||||
|
$column('besuch-titel', $abschnittTitel('besuch', 'Besuchen Sie uns', 'Wir freuen uns<br>auf Sie.', '@gold-text', '@forst')),
|
||||||
|
], [], ['desktop' => ['margin' => ['top' => '0px', 'bottom' => '40px']]]),
|
||||||
|
$row('besuch-spalten', [
|
||||||
|
$column('besuch-adresse', [
|
||||||
|
$heading('besuch-adresse-h3', 'Adresse', 3, ['desktop' => ['fontFamily' => 'body', 'fontSize' => '14px', 'fontWeight' => '700', 'letterSpacing' => '0.18em', 'textTransform' => 'uppercase', 'color' => '@gold-text', 'margin' => ['bottom' => '14px']]]),
|
||||||
|
$text('besuch-adresse-text', $name.'<br>'.$strasse.'<br>'.$plz.' '.$ort, ['desktop' => ['fontSize' => '18px', 'lineHeight' => 1.6, 'color' => '@forst', 'margin' => ['bottom' => '22px']]]),
|
||||||
|
['id' => 'besuch-route', 'type' => 'button', 'props' => ['text' => 'Route planen', 'href' => $routeUrl, 'newTab' => true], 'css' => '& span{display:none}'],
|
||||||
|
]),
|
||||||
|
$column('besuch-zeiten', [
|
||||||
|
$heading('besuch-zeiten-h3', 'Öffnungszeiten', 3, ['desktop' => ['fontFamily' => 'body', 'fontSize' => '14px', 'fontWeight' => '700', 'letterSpacing' => '0.18em', 'textTransform' => 'uppercase', 'color' => '@gold-text', 'margin' => ['bottom' => '14px']]]),
|
||||||
|
$text('besuch-zeiten-text', implode('<br>', array_map(static fn (array $z): string => '<strong>'.$z[0].'</strong>'.$z[1], $oeffnungszeiten)), [
|
||||||
|
'desktop' => ['fontSize' => '17px', 'lineHeight' => 1.6, 'color' => '@text', 'margin' => ['bottom' => '0px']],
|
||||||
|
], "& strong{display:block;margin-top:12px;color:#123b26;font-weight:700}\n& strong:first-child{margin-top:0}"),
|
||||||
|
]),
|
||||||
|
$column('besuch-kontakt', [
|
||||||
|
$heading('besuch-kontakt-h3', 'Kontakt', 3, ['desktop' => ['fontFamily' => 'body', 'fontSize' => '14px', 'fontWeight' => '700', 'letterSpacing' => '0.18em', 'textTransform' => 'uppercase', 'color' => '@gold-text', 'margin' => ['bottom' => '14px']]]),
|
||||||
|
$text('besuch-kontakt-text', 'Telefon folgt<br>E-Mail: <a href="mailto:'.$mail.'">'.$mail.'</a>', [
|
||||||
|
'desktop' => ['fontSize' => '17px', 'lineHeight' => 1.9, 'color' => '@text', 'margin' => ['bottom' => '0px']],
|
||||||
|
], $linkCss),
|
||||||
|
]),
|
||||||
|
], ['desktop' => ['columns' => 'three'], 'tablet' => ['columns' => 'one'], 'mobile' => ['columns' => 'one']], [
|
||||||
|
'desktop' => ['gap' => '48px', 'alignItems' => 'flex-start', 'margin' => ['top' => '0px'], 'borderWidth' => $nurOben('1px'), 'borderStyle' => 'solid', 'borderColor' => '#e4dcc8', 'padding' => ['top' => '36px']],
|
||||||
|
'mobile' => ['gap' => '36px'],
|
||||||
|
]),
|
||||||
|
], [
|
||||||
|
'desktop' => ['backgroundColor' => '@creme', 'padding' => ['top' => '110px', 'bottom' => '110px']],
|
||||||
|
'tablet' => ['padding' => ['top' => '84px', 'bottom' => '84px']],
|
||||||
|
'mobile' => ['padding' => ['top' => '64px', 'bottom' => '72px']],
|
||||||
|
], 'besuch'),
|
||||||
|
|
||||||
|
// 6 – Abschluss wie auf der Vorab-Seite, mit neuem Gruß.
|
||||||
|
$aendern($startseite['blocks'][2], [
|
||||||
|
'abschluss-gruss-text' => $setzeText('Danke, dass Sie da sind.'),
|
||||||
|
'abschluss-gruss-satz' => $setzeText('Bis gleich im Kantarci Markt.'),
|
||||||
|
]),
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
$eroeffnung = $einblenden($eroeffnung, [
|
||||||
|
'hero-label' => ['fade'],
|
||||||
|
'hero-titel' => ['rise', 'short'],
|
||||||
|
'hero-intro' => ['rise', 'medium'],
|
||||||
|
'hero-wege' => ['rise', 'long'],
|
||||||
|
'hero-termin' => ['rise', 'long'],
|
||||||
|
'hero-adresse' => ['fade', 'long'],
|
||||||
|
'app-text' => ['rise'],
|
||||||
|
'app-symbol' => ['rise', 'short'],
|
||||||
|
'versprechen-intro' => ['rise'],
|
||||||
|
'versprechen-liste' => ['rise', 'short'],
|
||||||
|
'abschluss-spruch' => ['rise'],
|
||||||
|
'abschluss-gruss' => ['rise', 'short'],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$eroeffnungId = $anlegen('eroeffnung', $eroeffnung, 'Kantarci Markt – frisch, vielfältig, fair in Baden-Baden', $medien['markt']['id']);
|
||||||
|
// Nur zum Ansehen: nicht in Suchmaschinen, nicht in der Sitemap, nirgends verlinkt.
|
||||||
|
$db->executeStatement('UPDATE page SET robots = ? WHERE id = ?', ['noindex', $eroeffnungId]);
|
||||||
|
|
||||||
/** Eine Rechtsseite: Kleinzeile, Titel und Vorspann auf Waldgrün, dann Absätze mit Zwischenüberschriften. */
|
/** Eine Rechtsseite: Kleinzeile, Titel und Vorspann auf Waldgrün, dann Absätze mit Zwischenüberschriften. */
|
||||||
$rechtsseite = static function (string $praefix, string $label, string $titel, string $vorspann, array $absaetze) use ($section, $row, $column, $kleinzeile, $heading, $text, $nurOben, $linkCss): array {
|
$rechtsseite = static function (string $praefix, string $label, string $titel, string $vorspann, array $absaetze) use ($section, $row, $column, $kleinzeile, $heading, $text, $nurOben, $linkCss): array {
|
||||||
$kopf = $section($praefix.'-kopf', [
|
$kopf = $section($praefix.'-kopf', [
|
||||||
|
|||||||
@@ -236,14 +236,14 @@ final class DocumentValidator
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ('button' === $type) {
|
if ('button' === $type) {
|
||||||
$this->url($props['href'] ?? null);
|
$this->link($props['href'] ?? null);
|
||||||
}
|
}
|
||||||
if ('iconcard' === $type) {
|
if ('iconcard' === $type) {
|
||||||
$this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.');
|
$this->require(in_array($props['icon'] ?? null, self::ICONS, true), 'Unknown icon.');
|
||||||
$this->text($props['title'] ?? null, 120);
|
$this->text($props['title'] ?? null, 120);
|
||||||
$this->text($props['text'] ?? null, 600, true);
|
$this->text($props['text'] ?? null, 600, true);
|
||||||
if ('' !== ($props['href'] ?? '')) {
|
if ('' !== ($props['href'] ?? '')) {
|
||||||
$this->url($props['href']);
|
$this->link($props['href']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ('stat' === $type) {
|
if ('stat' === $type) {
|
||||||
@@ -261,7 +261,7 @@ final class DocumentValidator
|
|||||||
$this->text($props['title'] ?? null, 160);
|
$this->text($props['title'] ?? null, 160);
|
||||||
$this->text($props['text'] ?? null, 600, true);
|
$this->text($props['text'] ?? null, 600, true);
|
||||||
$this->text($props['buttonText'] ?? null, 80);
|
$this->text($props['buttonText'] ?? null, 80);
|
||||||
$this->url($props['href'] ?? null);
|
$this->link($props['href'] ?? null);
|
||||||
}
|
}
|
||||||
if ('contact' === $type) {
|
if ('contact' === $type) {
|
||||||
foreach (['phone', 'email', 'address'] as $field) {
|
foreach (['phone', 'email', 'address'] as $field) {
|
||||||
@@ -329,7 +329,7 @@ final class DocumentValidator
|
|||||||
$this->text($props['label'] ?? null, 120, $props['decorative']);
|
$this->text($props['label'] ?? null, 120, $props['decorative']);
|
||||||
$this->require(!$props['decorative'] || '' === $props['label'], 'Ein dekoratives Symbol hat keine Beschriftung.');
|
$this->require(!$props['decorative'] || '' === $props['label'], 'Ein dekoratives Symbol hat keine Beschriftung.');
|
||||||
if ('' !== ($props['href'] ?? '')) {
|
if ('' !== ($props['href'] ?? '')) {
|
||||||
$this->url($props['href']);
|
$this->link($props['href']);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ('social' === $type) {
|
if ('social' === $type) {
|
||||||
@@ -414,7 +414,7 @@ final class DocumentValidator
|
|||||||
// Kopfbereich ohne Verweis auf die Startseite ist der erste Klick, den jeder sucht.
|
// Kopfbereich ohne Verweis auf die Startseite ist der erste Klick, den jeder sucht.
|
||||||
if ('' !== ($props['href'] ?? '')) {
|
if ('' !== ($props['href'] ?? '')) {
|
||||||
$this->require($schemaVersion >= 17, 'Ein verlinktes Bild braucht Schema v17.');
|
$this->require($schemaVersion >= 17, 'Ein verlinktes Bild braucht Schema v17.');
|
||||||
$this->url($props['href']);
|
$this->link($props['href']);
|
||||||
}
|
}
|
||||||
// Was ein Klick auf das Bild tut. Ohne Angabe: nichts - wie bisher.
|
// Was ein Klick auf das Bild tut. Ohne Angabe: nichts - wie bisher.
|
||||||
if (isset($props['click'])) {
|
if (isset($props['click'])) {
|
||||||
@@ -498,6 +498,38 @@ final class DocumentValidator
|
|||||||
$this->require($local || $external || 1 === preg_match('/^#[a-zA-Z][a-zA-Z0-9_-]*$/D', $url), 'Only local paths, fragments and HTTPS URLs are allowed.');
|
$this->require($local || $external || 1 === preg_match('/^#[a-zA-Z][a-zA-Z0-9_-]*$/D', $url), 'Only local paths, fragments and HTTPS URLs are allowed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Linkziel: alles, was url() erlaubt, dazu tel: und mailto: - dieselben Ziele, die Menü
|
||||||
|
* (MenuRepository::url) und formatierter Text (RichText::safeHref) schon kennen. Ein Knopf
|
||||||
|
* „Jetzt anrufen“ war sonst nur als Textlink im Knopf-Aussehen zu bauen (Hüfner, 06.10.2026).
|
||||||
|
* Bildquellen und eingebettete Inhalte bleiben bei url(): dort ist tel: kein Ziel.
|
||||||
|
*/
|
||||||
|
private function link(mixed $url): void
|
||||||
|
{
|
||||||
|
if (is_string($url) && strlen($url) <= 2048 && !preg_match('/[\x00-\x20\\\\]/', $url)) {
|
||||||
|
if (1 === preg_match('/^tel:\+?[0-9()-]{3,32}$/D', $url)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Adresse vor dem ersten ?, danach höchstens Betreff und Text, prozentkodiert.
|
||||||
|
if (1 === preg_match('/^mailto:([^?]+)(\?[A-Za-z0-9%._~=&+-]{0,500})?$/D', $url, $teile)
|
||||||
|
&& false !== filter_var(rawurldecode($teile[1]), FILTER_VALIDATE_EMAIL)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
$this->require(true === $this->isUrl($url), 'Erlaubt sind lokale Pfade, Sprungmarken, HTTPS-Adressen sowie tel: und mailto:.');
|
||||||
|
}
|
||||||
|
|
||||||
|
private function isUrl(mixed $url): bool
|
||||||
|
{
|
||||||
|
try {
|
||||||
|
$this->url($url);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
} catch (InvalidDocument) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private function require(bool $condition, string $message): void
|
private function require(bool $condition, string $message): void
|
||||||
{
|
{
|
||||||
if (!$condition) {
|
if (!$condition) {
|
||||||
|
|||||||
+34
-1
@@ -187,7 +187,10 @@ final class CmsTest extends TestCase
|
|||||||
$bad = $base;
|
$bad = $base;
|
||||||
$bad['blocks'][] = $base['blocks'][0];
|
$bad['blocks'][] = $base['blocks'][0];
|
||||||
yield 'duplicate IDs' => [$bad];
|
yield 'duplicate IDs' => [$bad];
|
||||||
foreach (['javascript:alert(1)', 'data:text/html,hi', '//evil.test', '/\\evil.test', "https://example.test/\nfoo"] as $url) {
|
// tel: und mailto: sind Linkziele, aber nur in sauberer Form: keine Buchstaben in der
|
||||||
|
// Nummer, eine echte Adresse, kein Weg aus dem Attribut.
|
||||||
|
foreach (['javascript:alert(1)', 'data:text/html,hi', '//evil.test', '/\\evil.test', "https://example.test/\nfoo",
|
||||||
|
'tel:ruf-mich-an', 'tel:+49 7229 6992826', 'mailto:keine-adresse', 'mailto:a@b.de?subject="x"', 'mailto:a@b.de?subject=<script>'] as $url) {
|
||||||
$bad = $base;
|
$bad = $base;
|
||||||
$bad['blocks'][0]['children'][3]['props']['href'] = $url;
|
$bad['blocks'][0]['children'][3]['props']['href'] = $url;
|
||||||
yield $url => [$bad];
|
yield $url => [$bad];
|
||||||
@@ -243,6 +246,36 @@ final class CmsTest extends TestCase
|
|||||||
yield 'missing meaningful alt' => [$bad];
|
yield 'missing meaningful alt' => [$bad];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ein Knopf darf anrufen oder eine E-Mail öffnen - wie ein Menüeintrag oder ein Link im Text.
|
||||||
|
* Vorher war „Jetzt anrufen“ nur als Textlink im Knopf-Aussehen zu bauen. Eine Bildquelle
|
||||||
|
* bleibt dagegen bei Pfad und HTTPS.
|
||||||
|
*/
|
||||||
|
public function testButtonsAndLinkedBlocksMayCallOrWrite(): void
|
||||||
|
{
|
||||||
|
$document = DemoContent::pages()['home'];
|
||||||
|
$ziele = ['tel:+4972296992826', 'mailto:info@huefner-gartenbau.de', 'mailto:info@huefner-gartenbau.de?subject=Bewerbung%3A%20Helfer'];
|
||||||
|
foreach ($ziele as $i => $ziel) {
|
||||||
|
$document['blocks'][0]['children'][3]['props']['href'] = $ziel;
|
||||||
|
$this->pages->saveDraft('page-1', $document, $i);
|
||||||
|
}
|
||||||
|
$id = $this->pages->saveDraft('page-1', $document, 3);
|
||||||
|
$this->pages->publish('page-1', $id, 4);
|
||||||
|
$html = $this->request('/')->getContent();
|
||||||
|
self::assertStringContainsString('href="mailto:info@huefner-gartenbau.de?subject=Bewerbung%3A%20Helfer"', (string) $html);
|
||||||
|
|
||||||
|
$bild = DemoContent::pages()['home'];
|
||||||
|
$bild['blocks'][0]['children'][] = ['id' => 'bild-tel', 'type' => 'image', 'props' => ['src' => 'tel:+4972296992826', 'alt' => 'x', 'decorative' => false, 'width' => 10, 'height' => 10]];
|
||||||
|
$this->expectException(InvalidDocument::class);
|
||||||
|
$this->pages->saveDraft('page-1', $bild, 5);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testTheButtonArrowTakesTheColourOfTheButtonText(): void
|
||||||
|
{
|
||||||
|
// Der Pfeil ist ein Symbol (block-icon) und bekam die Akzentfarbe: grün auf einem roten Knopf.
|
||||||
|
self::assertStringContainsString('.button-arrow .block-icon{color:inherit}', (string) file_get_contents(__DIR__.'/../public/assets/site.css'));
|
||||||
|
}
|
||||||
|
|
||||||
#[DataProvider('invalidDocuments')]
|
#[DataProvider('invalidDocuments')]
|
||||||
public function testRejectsInvalidDocumentsBeforeWriting(array $document): void
|
public function testRejectsInvalidDocumentsBeforeWriting(array $document): void
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in new issue
Block a user