Compare commits

...
12 Commits
Author SHA1 Message Date
kantarciandClaude Fable 5.2 3386cbb767 Note the analysis and the 1.40.1 release in the status
CMS quality / php (8.2) (push) Waiting to run
CMS quality / php (8.4) (push) Waiting to run
CMS quality / browser (push) Waiting to run
CMS quality / mysql (push) Waiting to run
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:15:49 +01:00
kantarciandClaude Fable 5.2 f3798cc102 Release 1.40.1
Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:15:49 +01:00
kantarciandClaude Fable 5.2 d464c82ac8 Fix lost form submissions on archives, the unlocked update finish and six more
Update Symfony to 7.4.23 (12 advisories in 8 packages).

- A form can be sent from /beitraege, /kategorie/... and /schlagwort/...;
  it answered 404 before anything was stored or mailed.
- Booleans reach the database as integers, so MySQL in strict mode
  accepts form submissions, task switches and plugin states.
- A stepped form with a required multiple choice shows its message
  instead of stopping on a TypeError.
- The second update step runs under the update lock; a concurrent
  finish no longer rolls a successful update back.
- Accordion entries and gallery images can be selected from the
  editor panel again.
- Category and tag names are escaped in the archive heading.
- Restoring a published page from the bin needs the publisher role.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-10 11:15:49 +01:00
kantarciandClaude Opus 5.5 441e629e80 Note the 1.40.0 release in the status
CMS quality / php (8.2) (push) Waiting to run
CMS quality / php (8.4) (push) Waiting to run
CMS quality / browser (push) Waiting to run
CMS quality / mysql (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:10:07 +01:00
kantarciandClaude Opus 5.5 492bb96fff Release 1.40.0
CMS quality / php (8.2) (push) Waiting to run
CMS quality / php (8.4) (push) Waiting to run
CMS quality / browser (push) Waiting to run
CMS quality / mysql (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:08:32 +01:00
kantarciandClaude Opus 5.5 9e3862093e Add the Becker Just In Time site build
CMS quality / php (8.2) (push) Waiting to run
CMS quality / php (8.4) (push) Waiting to run
CMS quality / browser (push) Waiting to run
CMS quality / mysql (push) Waiting to run
Seed, asset and reset scripts for Becker - Just In Time e. Kfm.
(Wiernsheim): logo cut from the PDF raster, logo-free photos generated
with ChatGPT, before/after slider, service pages, contact form, legal
pages. Launch configs on ports 8116/8117, Manrope 800 and Montserrat
700/800 fetched locally.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:03:16 +01:00
kantarciandClaude Opus 5.5 c90c03084b Add a filled-outline house icon to the icon set
"house" is a closed house shape with the door cut out, next to the
existing line-drawn "home". Written through scripts/build-icons.mjs
into the Twig template and the validator allowlist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-10 10:03:15 +01:00
kantarciandClaude Opus 5.5 76e179b077 Note the 1.39.1 release in the status
CMS quality / php (8.2) (push) Waiting to run
CMS quality / php (8.4) (push) Waiting to run
CMS quality / browser (push) Waiting to run
CMS quality / mysql (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:02:58 +01:00
kantarciandClaude Opus 5.5 b60e599dda Release 1.39.1
CMS quality / php (8.2) (push) Waiting to run
CMS quality / php (8.4) (push) Waiting to run
CMS quality / browser (push) Waiting to run
CMS quality / mysql (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:02:24 +01:00
kantarciandClaude Opus 5.5 791c265f42 Add the listings page to the Luximmo site build
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:02:24 +01:00
kantarciandClaude Opus 5.5 78083fac6a Use own SVG icons instead of text glyphs on the website
Phones turn glyphs such as arrows and crosses into emoji. The viewer, scroll button, video toggle, menus, breadcrumbs, pagers and quote stars now draw SVG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 20:02:24 +01:00
kantarciandClaude Opus 5.5 b9740e6c1b Note the 1.39.0 release in the status
CMS quality / php (8.2) (push) Waiting to run
CMS quality / php (8.4) (push) Waiting to run
CMS quality / browser (push) Waiting to run
CMS quality / mysql (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-09 19:19:02 +01:00
41 changed files with 1759 additions and 204 deletions

No files matched your search

+20
View File
@@ -229,6 +229,26 @@
],
"port": 8107
},
{
"name": "becker",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/becker.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/becker-media'; $env:SITE_ORIGIN='http://127.0.0.1:8116'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8116 -t public bin/dev-router.php"
],
"port": 8116
},
{
"name": "becker-prod",
"runtimeExecutable": "powershell",
"runtimeArgs": [
"-NoProfile",
"-Command",
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/becker.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/becker-media'; $env:SITE_ORIGIN='http://127.0.0.1:8117'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8117 -t public bin/dev-router.php"
],
"port": 8117
},
{
"name": "luximmo",
"runtimeExecutable": "powershell",
+1 -1
View File
@@ -1 +1 @@
1.39.0
1.40.1
Generated
+151 -151
View File
@@ -748,16 +748,16 @@
},
{
"name": "symfony/cache",
"version": "v7.4.22",
"version": "v7.4.23",
"source": {
"type": "git",
"url": "https://github.com/symfony/cache.git",
"reference": "f0024b7dd1e6d4f2f34b08c1a61c6c7d33febad7"
"reference": "bbaa2ca4a93a94532285bdd5a0caf225d34ff578"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/cache/zipball/f0024b7dd1e6d4f2f34b08c1a61c6c7d33febad7",
"reference": "f0024b7dd1e6d4f2f34b08c1a61c6c7d33febad7",
"url": "https://api.github.com/repos/symfony/cache/zipball/bbaa2ca4a93a94532285bdd5a0caf225d34ff578",
"reference": "bbaa2ca4a93a94532285bdd5a0caf225d34ff578",
"shasum": ""
},
"require": {
@@ -827,7 +827,7 @@
"psr6"
],
"support": {
"source": "https://github.com/symfony/cache/tree/v7.4.22"
"source": "https://github.com/symfony/cache/tree/v7.4.23"
},
"funding": [
{
@@ -847,20 +847,20 @@
"type": "tidelift"
}
],
"time": "2026-10-28T10:54:34+00:00"
"time": "2026-11-01T22:26:51+00:00"
},
{
"name": "symfony/cache-contracts",
"version": "v3.7.1",
"version": "v3.8.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/cache-contracts.git",
"reference": "9789738bc19af1106dc54d6afba9a0b467516cf2"
"reference": "af26a91232085b7edd4e04be62f75e7c25159ece"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/cache-contracts/zipball/9789738bc19af1106dc54d6afba9a0b467516cf2",
"reference": "9789738bc19af1106dc54d6afba9a0b467516cf2",
"url": "https://api.github.com/repos/symfony/cache-contracts/zipball/af26a91232085b7edd4e04be62f75e7c25159ece",
"reference": "af26a91232085b7edd4e04be62f75e7c25159ece",
"shasum": ""
},
"require": {
@@ -874,7 +874,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.7-dev"
"dev-main": "3.8-dev"
}
},
"autoload": {
@@ -907,7 +907,7 @@
"standards"
],
"support": {
"source": "https://github.com/symfony/cache-contracts/tree/v3.7.1"
"source": "https://github.com/symfony/cache-contracts/tree/v3.8.1"
},
"funding": [
{
@@ -927,7 +927,7 @@
"type": "tidelift"
}
],
"time": "2026-06-05T06:23:12+00:00"
"time": "2026-09-26T07:05:14+00:00"
},
{
"name": "symfony/clock",
@@ -1009,16 +1009,16 @@
},
{
"name": "symfony/config",
"version": "v7.4.22",
"version": "v7.4.23",
"source": {
"type": "git",
"url": "https://github.com/symfony/config.git",
"reference": "d7f9d7495992429d916aea42d1a4d76a12dfa29f"
"reference": "8ef666abf731e85e59cf478929dda6c7cb2fa9c4"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/config/zipball/d7f9d7495992429d916aea42d1a4d76a12dfa29f",
"reference": "d7f9d7495992429d916aea42d1a4d76a12dfa29f",
"url": "https://api.github.com/repos/symfony/config/zipball/8ef666abf731e85e59cf478929dda6c7cb2fa9c4",
"reference": "8ef666abf731e85e59cf478929dda6c7cb2fa9c4",
"shasum": ""
},
"require": {
@@ -1064,7 +1064,7 @@
"description": "Helps you find, load, combine, autofill and validate configuration values of any kind",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/config/tree/v7.4.22"
"source": "https://github.com/symfony/config/tree/v7.4.23"
},
"funding": [
{
@@ -1084,20 +1084,20 @@
"type": "tidelift"
}
],
"time": "2026-10-25T14:40:16+00:00"
"time": "2026-11-04T08:27:22+00:00"
},
{
"name": "symfony/console",
"version": "v7.4.22",
"version": "v7.4.23",
"source": {
"type": "git",
"url": "https://github.com/symfony/console.git",
"reference": "8012f842110b414fd1cc641adb0ed8423353b0bb"
"reference": "81957c2440c05c962fe8291f318de6e85f8db330"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/console/zipball/8012f842110b414fd1cc641adb0ed8423353b0bb",
"reference": "8012f842110b414fd1cc641adb0ed8423353b0bb",
"url": "https://api.github.com/repos/symfony/console/zipball/81957c2440c05c962fe8291f318de6e85f8db330",
"reference": "81957c2440c05c962fe8291f318de6e85f8db330",
"shasum": ""
},
"require": {
@@ -1162,7 +1162,7 @@
"terminal"
],
"support": {
"source": "https://github.com/symfony/console/tree/v7.4.22"
"source": "https://github.com/symfony/console/tree/v7.4.23"
},
"funding": [
{
@@ -1182,20 +1182,20 @@
"type": "tidelift"
}
],
"time": "2026-10-28T11:08:27+00:00"
"time": "2026-11-03T21:47:07+00:00"
},
{
"name": "symfony/dependency-injection",
"version": "v7.4.22",
"version": "v7.4.23",
"source": {
"type": "git",
"url": "https://github.com/symfony/dependency-injection.git",
"reference": "f98a1b0625d5608a723481fa104b5473e6b18ebd"
"reference": "4df0d83a8253d95b222c2f136b9dca3a1a9451a7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/dependency-injection/zipball/f98a1b0625d5608a723481fa104b5473e6b18ebd",
"reference": "f98a1b0625d5608a723481fa104b5473e6b18ebd",
"url": "https://api.github.com/repos/symfony/dependency-injection/zipball/4df0d83a8253d95b222c2f136b9dca3a1a9451a7",
"reference": "4df0d83a8253d95b222c2f136b9dca3a1a9451a7",
"shasum": ""
},
"require": {
@@ -1246,7 +1246,7 @@
"description": "Allows you to standardize and centralize the way objects are constructed in your application",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/dependency-injection/tree/v7.4.22"
"source": "https://github.com/symfony/dependency-injection/tree/v7.4.23"
},
"funding": [
{
@@ -1266,20 +1266,20 @@
"type": "tidelift"
}
],
"time": "2026-10-25T14:40:16+00:00"
"time": "2026-11-01T17:38:00+00:00"
},
{
"name": "symfony/deprecation-contracts",
"version": "v3.7.1",
"version": "v3.8.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/deprecation-contracts.git",
"reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d"
"reference": "feb1edf4e546af3b85c5caebe8359a20db230ca3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/f3202fa1b5097b0af062dc978b32ecf63404e31d",
"reference": "f3202fa1b5097b0af062dc978b32ecf63404e31d",
"url": "https://api.github.com/repos/symfony/deprecation-contracts/zipball/feb1edf4e546af3b85c5caebe8359a20db230ca3",
"reference": "feb1edf4e546af3b85c5caebe8359a20db230ca3",
"shasum": ""
},
"require": {
@@ -1292,7 +1292,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.7-dev"
"dev-main": "3.8-dev"
}
},
"autoload": {
@@ -1317,7 +1317,7 @@
"description": "A generic function and convention to trigger deprecation notices",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.7.1"
"source": "https://github.com/symfony/deprecation-contracts/tree/v3.8.1"
},
"funding": [
{
@@ -1337,7 +1337,7 @@
"type": "tidelift"
}
],
"time": "2026-06-05T06:23:12+00:00"
"time": "2026-09-16T14:15:10+00:00"
},
{
"name": "symfony/dotenv",
@@ -1586,16 +1586,16 @@
},
{
"name": "symfony/event-dispatcher-contracts",
"version": "v3.7.1",
"version": "v3.8.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/event-dispatcher-contracts.git",
"reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e"
"reference": "658e29751424da3ef4758c60f769e59d1b2981eb"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/c7de7a00ffb67842132da02ea92988a39ccd9f4e",
"reference": "c7de7a00ffb67842132da02ea92988a39ccd9f4e",
"url": "https://api.github.com/repos/symfony/event-dispatcher-contracts/zipball/658e29751424da3ef4758c60f769e59d1b2981eb",
"reference": "658e29751424da3ef4758c60f769e59d1b2981eb",
"shasum": ""
},
"require": {
@@ -1609,7 +1609,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.7-dev"
"dev-main": "3.8-dev"
}
},
"autoload": {
@@ -1642,7 +1642,7 @@
"standards"
],
"support": {
"source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.7.1"
"source": "https://github.com/symfony/event-dispatcher-contracts/tree/v3.8.1"
},
"funding": [
{
@@ -1662,20 +1662,20 @@
"type": "tidelift"
}
],
"time": "2026-06-05T06:23:12+00:00"
"time": "2026-09-16T14:15:10+00:00"
},
{
"name": "symfony/filesystem",
"version": "v7.4.21",
"version": "v7.4.23",
"source": {
"type": "git",
"url": "https://github.com/symfony/filesystem.git",
"reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8"
"reference": "53d609da66578dad5a39fe290e8f924ec8f05e93"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/fd0e3462be6d32a0d130e8423e4940a7688162d8",
"reference": "fd0e3462be6d32a0d130e8423e4940a7688162d8",
"url": "https://api.github.com/repos/symfony/filesystem/zipball/53d609da66578dad5a39fe290e8f924ec8f05e93",
"reference": "53d609da66578dad5a39fe290e8f924ec8f05e93",
"shasum": ""
},
"require": {
@@ -1712,7 +1712,7 @@
"description": "Provides basic utilities for the filesystem",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/filesystem/tree/v7.4.21"
"source": "https://github.com/symfony/filesystem/tree/v7.4.23"
},
"funding": [
{
@@ -1732,20 +1732,20 @@
"type": "tidelift"
}
],
"time": "2026-10-06T20:05:19+00:00"
"time": "2026-11-04T08:27:22+00:00"
},
{
"name": "symfony/finder",
"version": "v7.4.22",
"version": "v7.4.23",
"source": {
"type": "git",
"url": "https://github.com/symfony/finder.git",
"reference": "e13420a8e0a76bdde1e7be09f923e62ad13739f2"
"reference": "c723f990aefe8c1053b18374faf4231eba84f518"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/finder/zipball/e13420a8e0a76bdde1e7be09f923e62ad13739f2",
"reference": "e13420a8e0a76bdde1e7be09f923e62ad13739f2",
"url": "https://api.github.com/repos/symfony/finder/zipball/c723f990aefe8c1053b18374faf4231eba84f518",
"reference": "c723f990aefe8c1053b18374faf4231eba84f518",
"shasum": ""
},
"require": {
@@ -1780,7 +1780,7 @@
"description": "Finds files and directories via an intuitive fluent interface",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/finder/tree/v7.4.22"
"source": "https://github.com/symfony/finder/tree/v7.4.23"
},
"funding": [
{
@@ -1800,20 +1800,20 @@
"type": "tidelift"
}
],
"time": "2026-10-19T16:56:54+00:00"
"time": "2026-11-02T10:56:49+00:00"
},
{
"name": "symfony/framework-bundle",
"version": "v7.4.22",
"version": "v7.4.23",
"source": {
"type": "git",
"url": "https://github.com/symfony/framework-bundle.git",
"reference": "17d23a62561b493b85486c9ae8c2752f36303b2c"
"reference": "6a749f2c37cb9def53793633ec72958aaad4b359"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/framework-bundle/zipball/17d23a62561b493b85486c9ae8c2752f36303b2c",
"reference": "17d23a62561b493b85486c9ae8c2752f36303b2c",
"url": "https://api.github.com/repos/symfony/framework-bundle/zipball/6a749f2c37cb9def53793633ec72958aaad4b359",
"reference": "6a749f2c37cb9def53793633ec72958aaad4b359",
"shasum": ""
},
"require": {
@@ -1828,7 +1828,7 @@
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
"symfony/filesystem": "^7.1|^8.0",
"symfony/finder": "^6.4|^7.0|^8.0",
"symfony/http-foundation": "^7.4|^8.0",
"symfony/http-foundation": "^7.4.23|^8.1.11",
"symfony/http-kernel": "^7.4|^8.0",
"symfony/polyfill-mbstring": "~1.0",
"symfony/polyfill-php85": "^1.32",
@@ -1937,7 +1937,7 @@
"description": "Provides a tight integration between Symfony components and the Symfony full-stack framework",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/framework-bundle/tree/v7.4.22"
"source": "https://github.com/symfony/framework-bundle/tree/v7.4.23"
},
"funding": [
{
@@ -1957,20 +1957,20 @@
"type": "tidelift"
}
],
"time": "2026-10-26T21:52:50+00:00"
"time": "2026-11-04T07:46:24+00:00"
},
{
"name": "symfony/http-foundation",
"version": "v7.4.22",
"version": "v7.4.23",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-foundation.git",
"reference": "63eda1d0c4de229d6b3e825709e49dd5c149699a"
"reference": "7fb73cffa06e2bcc2cb523fcccb1e8174106b206"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/63eda1d0c4de229d6b3e825709e49dd5c149699a",
"reference": "63eda1d0c4de229d6b3e825709e49dd5c149699a",
"url": "https://api.github.com/repos/symfony/http-foundation/zipball/7fb73cffa06e2bcc2cb523fcccb1e8174106b206",
"reference": "7fb73cffa06e2bcc2cb523fcccb1e8174106b206",
"shasum": ""
},
"require": {
@@ -2019,7 +2019,7 @@
"description": "Defines an object-oriented layer for the HTTP specification",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-foundation/tree/v7.4.22"
"source": "https://github.com/symfony/http-foundation/tree/v7.4.23"
},
"funding": [
{
@@ -2039,20 +2039,20 @@
"type": "tidelift"
}
],
"time": "2026-10-27T13:26:03+00:00"
"time": "2026-11-04T08:27:22+00:00"
},
{
"name": "symfony/http-kernel",
"version": "v7.4.22",
"version": "v7.4.23",
"source": {
"type": "git",
"url": "https://github.com/symfony/http-kernel.git",
"reference": "87e727b33983238d3f2ea50f9f62265499ca1a6d"
"reference": "94c57f4c5c45a311d244a0aeadb72b14ee41d25d"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/87e727b33983238d3f2ea50f9f62265499ca1a6d",
"reference": "87e727b33983238d3f2ea50f9f62265499ca1a6d",
"url": "https://api.github.com/repos/symfony/http-kernel/zipball/94c57f4c5c45a311d244a0aeadb72b14ee41d25d",
"reference": "94c57f4c5c45a311d244a0aeadb72b14ee41d25d",
"shasum": ""
},
"require": {
@@ -2138,7 +2138,7 @@
"description": "Provides a structured process for converting a Request into a Response",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/http-kernel/tree/v7.4.22"
"source": "https://github.com/symfony/http-kernel/tree/v7.4.23"
},
"funding": [
{
@@ -2158,7 +2158,7 @@
"type": "tidelift"
}
],
"time": "2026-10-28T18:18:28+00:00"
"time": "2026-11-04T11:26:18+00:00"
},
{
"name": "symfony/mailer",
@@ -3064,16 +3064,16 @@
},
{
"name": "symfony/property-access",
"version": "v7.4.22",
"version": "v7.4.23",
"source": {
"type": "git",
"url": "https://github.com/symfony/property-access.git",
"reference": "b45cfcfe0abdbba37350645dae148be29b94f25a"
"reference": "4dce45112d7a31d530d2e0e69143fc88eaa1521f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/property-access/zipball/b45cfcfe0abdbba37350645dae148be29b94f25a",
"reference": "b45cfcfe0abdbba37350645dae148be29b94f25a",
"url": "https://api.github.com/repos/symfony/property-access/zipball/4dce45112d7a31d530d2e0e69143fc88eaa1521f",
"reference": "4dce45112d7a31d530d2e0e69143fc88eaa1521f",
"shasum": ""
},
"require": {
@@ -3121,7 +3121,7 @@
"reflection"
],
"support": {
"source": "https://github.com/symfony/property-access/tree/v7.4.22"
"source": "https://github.com/symfony/property-access/tree/v7.4.23"
},
"funding": [
{
@@ -3141,20 +3141,20 @@
"type": "tidelift"
}
],
"time": "2026-10-26T21:52:50+00:00"
"time": "2026-11-01T19:17:22+00:00"
},
{
"name": "symfony/property-info",
"version": "v7.4.22",
"version": "v7.4.23",
"source": {
"type": "git",
"url": "https://github.com/symfony/property-info.git",
"reference": "25c1b823ee4e0995e57e49bc0160c43ea199a7b5"
"reference": "1eeaa666bcd1864b883b98cad9e5188064ddac26"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/property-info/zipball/25c1b823ee4e0995e57e49bc0160c43ea199a7b5",
"reference": "25c1b823ee4e0995e57e49bc0160c43ea199a7b5",
"url": "https://api.github.com/repos/symfony/property-info/zipball/1eeaa666bcd1864b883b98cad9e5188064ddac26",
"reference": "1eeaa666bcd1864b883b98cad9e5188064ddac26",
"shasum": ""
},
"require": {
@@ -3211,7 +3211,7 @@
"validator"
],
"support": {
"source": "https://github.com/symfony/property-info/tree/v7.4.22"
"source": "https://github.com/symfony/property-info/tree/v7.4.23"
},
"funding": [
{
@@ -3231,20 +3231,20 @@
"type": "tidelift"
}
],
"time": "2026-10-25T14:40:16+00:00"
"time": "2026-11-02T10:56:49+00:00"
},
{
"name": "symfony/rate-limiter",
"version": "v7.4.22",
"version": "v7.4.23",
"source": {
"type": "git",
"url": "https://github.com/symfony/rate-limiter.git",
"reference": "97a013c93ce39c70ace5316499c0d8aaec43a7f6"
"reference": "a65b8280000598b80b4456004280a56fdad011b3"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/rate-limiter/zipball/97a013c93ce39c70ace5316499c0d8aaec43a7f6",
"reference": "97a013c93ce39c70ace5316499c0d8aaec43a7f6",
"url": "https://api.github.com/repos/symfony/rate-limiter/zipball/a65b8280000598b80b4456004280a56fdad011b3",
"reference": "a65b8280000598b80b4456004280a56fdad011b3",
"shasum": ""
},
"require": {
@@ -3285,7 +3285,7 @@
"rate-limiter"
],
"support": {
"source": "https://github.com/symfony/rate-limiter/tree/v7.4.22"
"source": "https://github.com/symfony/rate-limiter/tree/v7.4.23"
},
"funding": [
{
@@ -3305,20 +3305,20 @@
"type": "tidelift"
}
],
"time": "2026-10-20T19:25:08+00:00"
"time": "2026-11-01T19:17:22+00:00"
},
{
"name": "symfony/routing",
"version": "v7.4.22",
"version": "v7.4.23",
"source": {
"type": "git",
"url": "https://github.com/symfony/routing.git",
"reference": "f79ed6fbf60d9e32d358054f1915295f3b8a6f7a"
"reference": "0000ade604b7bc798cb1e75edf6f17df48185f73"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/routing/zipball/f79ed6fbf60d9e32d358054f1915295f3b8a6f7a",
"reference": "f79ed6fbf60d9e32d358054f1915295f3b8a6f7a",
"url": "https://api.github.com/repos/symfony/routing/zipball/0000ade604b7bc798cb1e75edf6f17df48185f73",
"reference": "0000ade604b7bc798cb1e75edf6f17df48185f73",
"shasum": ""
},
"require": {
@@ -3370,7 +3370,7 @@
"url"
],
"support": {
"source": "https://github.com/symfony/routing/tree/v7.4.22"
"source": "https://github.com/symfony/routing/tree/v7.4.23"
},
"funding": [
{
@@ -3390,20 +3390,20 @@
"type": "tidelift"
}
],
"time": "2026-10-26T21:52:50+00:00"
"time": "2026-11-04T08:27:22+00:00"
},
{
"name": "symfony/security-bundle",
"version": "v7.4.22",
"version": "v7.4.23",
"source": {
"type": "git",
"url": "https://github.com/symfony/security-bundle.git",
"reference": "0323d198b93b3ec4fd0e31a5e84516c2b28261d7"
"reference": "1cb338633172a5ce2fb92b31f5d39315f4aa4c00"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/security-bundle/zipball/0323d198b93b3ec4fd0e31a5e84516c2b28261d7",
"reference": "0323d198b93b3ec4fd0e31a5e84516c2b28261d7",
"url": "https://api.github.com/repos/symfony/security-bundle/zipball/1cb338633172a5ce2fb92b31f5d39315f4aa4c00",
"reference": "1cb338633172a5ce2fb92b31f5d39315f4aa4c00",
"shasum": ""
},
"require": {
@@ -3415,7 +3415,7 @@
"symfony/dependency-injection": "^6.4.11|^7.1.4|^8.0",
"symfony/deprecation-contracts": "^2.5|^3",
"symfony/event-dispatcher": "^6.4|^7.0|^8.0",
"symfony/http-foundation": "^6.4|^7.0|^8.0",
"symfony/http-foundation": "^6.4.50|^7.4.23|^8.1.11",
"symfony/http-kernel": "^6.4.13|^7.1.6|^8.0",
"symfony/password-hasher": "^6.4|^7.0|^8.0",
"symfony/security-core": "^7.4.22|^8.1.10",
@@ -3483,7 +3483,7 @@
"description": "Provides a tight integration of the Security component into the Symfony full-stack framework",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/security-bundle/tree/v7.4.22"
"source": "https://github.com/symfony/security-bundle/tree/v7.4.23"
},
"funding": [
{
@@ -3503,20 +3503,20 @@
"type": "tidelift"
}
],
"time": "2026-10-27T13:52:57+00:00"
"time": "2026-11-04T08:27:22+00:00"
},
{
"name": "symfony/security-core",
"version": "v7.4.22",
"version": "v7.4.23",
"source": {
"type": "git",
"url": "https://github.com/symfony/security-core.git",
"reference": "4efce2a1e6c3d686bf0ec9a098e425d53dc3e674"
"reference": "c93b2194c0255496c2c0ac999821fa7f5296c7cc"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/security-core/zipball/4efce2a1e6c3d686bf0ec9a098e425d53dc3e674",
"reference": "4efce2a1e6c3d686bf0ec9a098e425d53dc3e674",
"url": "https://api.github.com/repos/symfony/security-core/zipball/c93b2194c0255496c2c0ac999821fa7f5296c7cc",
"reference": "c93b2194c0255496c2c0ac999821fa7f5296c7cc",
"shasum": ""
},
"require": {
@@ -3575,7 +3575,7 @@
"description": "Symfony Security Component - Core Library",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/security-core/tree/v7.4.22"
"source": "https://github.com/symfony/security-core/tree/v7.4.23"
},
"funding": [
{
@@ -3595,7 +3595,7 @@
"type": "tidelift"
}
],
"time": "2026-10-25T14:40:16+00:00"
"time": "2026-11-01T19:17:22+00:00"
},
{
"name": "symfony/security-csrf",
@@ -3673,16 +3673,16 @@
},
{
"name": "symfony/security-http",
"version": "v7.4.22",
"version": "v7.4.23",
"source": {
"type": "git",
"url": "https://github.com/symfony/security-http.git",
"reference": "0020c7563c188bade070cf4bc83be3ee75ceab29"
"reference": "ea0623a02764025268bd928810f012767ea1965a"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/security-http/zipball/0020c7563c188bade070cf4bc83be3ee75ceab29",
"reference": "0020c7563c188bade070cf4bc83be3ee75ceab29",
"url": "https://api.github.com/repos/symfony/security-http/zipball/ea0623a02764025268bd928810f012767ea1965a",
"reference": "ea0623a02764025268bd928810f012767ea1965a",
"shasum": ""
},
"require": {
@@ -3742,7 +3742,7 @@
"description": "Symfony Security Component - HTTP Integration",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/security-http/tree/v7.4.22"
"source": "https://github.com/symfony/security-http/tree/v7.4.23"
},
"funding": [
{
@@ -3762,20 +3762,20 @@
"type": "tidelift"
}
],
"time": "2026-10-28T10:54:34+00:00"
"time": "2026-11-04T08:27:22+00:00"
},
{
"name": "symfony/service-contracts",
"version": "v3.7.3",
"version": "v3.8.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/service-contracts.git",
"reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257"
"reference": "052852a96fd5e5ebdb617dba22031e41a3300fb5"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/service-contracts/zipball/15e6a07ec2a2c75ceb1b21dd98105ee8456d2257",
"reference": "15e6a07ec2a2c75ceb1b21dd98105ee8456d2257",
"url": "https://api.github.com/repos/symfony/service-contracts/zipball/052852a96fd5e5ebdb617dba22031e41a3300fb5",
"reference": "052852a96fd5e5ebdb617dba22031e41a3300fb5",
"shasum": ""
},
"require": {
@@ -3793,7 +3793,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.7-dev"
"dev-main": "3.8-dev"
}
},
"autoload": {
@@ -3829,7 +3829,7 @@
"standards"
],
"support": {
"source": "https://github.com/symfony/service-contracts/tree/v3.7.3"
"source": "https://github.com/symfony/service-contracts/tree/v3.8.1"
},
"funding": [
{
@@ -3849,7 +3849,7 @@
"type": "tidelift"
}
],
"time": "2026-07-27T15:39:01+00:00"
"time": "2026-10-20T17:38:25+00:00"
},
{
"name": "symfony/stopwatch",
@@ -4010,16 +4010,16 @@
},
{
"name": "symfony/translation-contracts",
"version": "v3.7.1",
"version": "v3.8.1",
"source": {
"type": "git",
"url": "https://github.com/symfony/translation-contracts.git",
"reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621"
"reference": "0a3ae917402632fcba2aeddae9c98332f3d7dd05"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/translation-contracts/zipball/ccb206b98faccc511ebae8e5fad50f2dc0b30621",
"reference": "ccb206b98faccc511ebae8e5fad50f2dc0b30621",
"url": "https://api.github.com/repos/symfony/translation-contracts/zipball/0a3ae917402632fcba2aeddae9c98332f3d7dd05",
"reference": "0a3ae917402632fcba2aeddae9c98332f3d7dd05",
"shasum": ""
},
"require": {
@@ -4032,7 +4032,7 @@
"name": "symfony/contracts"
},
"branch-alias": {
"dev-main": "3.7-dev"
"dev-main": "3.8-dev"
}
},
"autoload": {
@@ -4068,7 +4068,7 @@
"standards"
],
"support": {
"source": "https://github.com/symfony/translation-contracts/tree/v3.7.1"
"source": "https://github.com/symfony/translation-contracts/tree/v3.8.1"
},
"funding": [
{
@@ -4088,7 +4088,7 @@
"type": "tidelift"
}
],
"time": "2026-06-05T06:23:12+00:00"
"time": "2026-10-03T08:17:19+00:00"
},
{
"name": "symfony/twig-bridge",
@@ -4297,16 +4297,16 @@
},
{
"name": "symfony/type-info",
"version": "v7.4.22",
"version": "v7.4.23",
"source": {
"type": "git",
"url": "https://github.com/symfony/type-info.git",
"reference": "f69ab198af73e1aac485c0ff5aa066d20c14adf0"
"reference": "eca5fdb294539268ad3245b2214f32f24b337e09"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/type-info/zipball/f69ab198af73e1aac485c0ff5aa066d20c14adf0",
"reference": "f69ab198af73e1aac485c0ff5aa066d20c14adf0",
"url": "https://api.github.com/repos/symfony/type-info/zipball/eca5fdb294539268ad3245b2214f32f24b337e09",
"reference": "eca5fdb294539268ad3245b2214f32f24b337e09",
"shasum": ""
},
"require": {
@@ -4356,7 +4356,7 @@
"type"
],
"support": {
"source": "https://github.com/symfony/type-info/tree/v7.4.22"
"source": "https://github.com/symfony/type-info/tree/v7.4.23"
},
"funding": [
{
@@ -4376,7 +4376,7 @@
"type": "tidelift"
}
],
"time": "2026-10-24T08:22:23+00:00"
"time": "2026-11-02T09:31:16+00:00"
},
{
"name": "symfony/var-dumper",
@@ -4548,16 +4548,16 @@
},
{
"name": "symfony/yaml",
"version": "v7.4.22",
"version": "v7.4.23",
"source": {
"type": "git",
"url": "https://github.com/symfony/yaml.git",
"reference": "1ed6afa13d48c579c51cbf32e38f50686313497b"
"reference": "1e601207ffe627a2e621faa9d661f1d32dcf1cec"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/yaml/zipball/1ed6afa13d48c579c51cbf32e38f50686313497b",
"reference": "1ed6afa13d48c579c51cbf32e38f50686313497b",
"url": "https://api.github.com/repos/symfony/yaml/zipball/1e601207ffe627a2e621faa9d661f1d32dcf1cec",
"reference": "1e601207ffe627a2e621faa9d661f1d32dcf1cec",
"shasum": ""
},
"require": {
@@ -4600,7 +4600,7 @@
"description": "Loads and dumps YAML files",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/yaml/tree/v7.4.22"
"source": "https://github.com/symfony/yaml/tree/v7.4.23"
},
"funding": [
{
@@ -4620,7 +4620,7 @@
"type": "tidelift"
}
],
"time": "2026-10-18T14:39:27+00:00"
"time": "2026-11-04T08:27:22+00:00"
},
{
"name": "twig/twig",
@@ -7578,16 +7578,16 @@
},
{
"name": "symfony/browser-kit",
"version": "v7.4.22",
"version": "v7.4.23",
"source": {
"type": "git",
"url": "https://github.com/symfony/browser-kit.git",
"reference": "018a7c9b63d9ed402762a2d9f59238207928a2ed"
"reference": "a0d19ca90c1fcce29d3337decb761b5adb808965"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/symfony/browser-kit/zipball/018a7c9b63d9ed402762a2d9f59238207928a2ed",
"reference": "018a7c9b63d9ed402762a2d9f59238207928a2ed",
"url": "https://api.github.com/repos/symfony/browser-kit/zipball/a0d19ca90c1fcce29d3337decb761b5adb808965",
"reference": "a0d19ca90c1fcce29d3337decb761b5adb808965",
"shasum": ""
},
"require": {
@@ -7628,7 +7628,7 @@
"description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically",
"homepage": "https://symfony.com",
"support": {
"source": "https://github.com/symfony/browser-kit/tree/v7.4.22"
"source": "https://github.com/symfony/browser-kit/tree/v7.4.23"
},
"funding": [
{
@@ -7648,7 +7648,7 @@
"type": "tidelift"
}
],
"time": "2026-10-20T19:25:08+00:00"
"time": "2026-11-01T07:00:22+00:00"
},
{
"name": "symfony/css-selector",
+63 -1
View File
@@ -1,13 +1,75 @@
# Projektstatus
## Fassung 1.40.1: Symfony-Sicherheitsupdate und acht Korrekturen aus der Analyse (10.11.2026)
**Anlass:** Umut nach der Komplettanalyse: „Ja, 1.40.1 bauen“. Alle Installationen laufen auf SQLite (Umut), der MySQL-Punkt ist also Vorsorge.
**Erledigt (keine Migration, kein Schemawechsel):**
- Symfony 7.4.22 → 7.4.23 (`composer update "symfony/*" --with-all-dependencies`, 24 Pakete); `composer audit` danach ohne Befund. `composer.json` unverändert.
- F1: Ein Formular lässt sich von `/beitraege`, `/kategorie/…`, `/schlagwort/…` absenden (auch unter `/en/…`). Vorher 404, die Anfrage war weg. `PageController::ARCHIVE_PATH`, `FormController::submit`.
- F2: Wahrheitswerte gehen als Zahl an die Datenbank (`form_submission.delivered`, `scheduled_task.enabled`, `plugin.active`).
- F3: Schrittformular mit Pflicht-Mehrfachauswahl meldet „Bitte wähle mindestens eine Option.“ statt mit einem TypeError stehen zu bleiben (`form-steps.js`).
- F5: `ArchiveUpdate::finish()` läuft unter `Updater::lock()`. Ein zweiter Abschluss zur selben Zeit meldet „läuft bereits“ und schaltet nichts zurück.
- E1: Im Editor wählen die Einträge im Akkordeon-Panel und „Bearbeiten“ in der Galerie wieder aus (lokale Funktion heißt jetzt `setProp`).
- S2: Kategorie- und Schlagwortnamen werden in der Archivüberschrift maskiert.
- S3: Eine veröffentlichte Seite holt nur aus dem Papierkorb zurück, wer veröffentlichen darf; die Meldung sagt, ob die Seite wieder öffentlich ist.
**Geprüft:** PHPUnit 695 grün (PHP 8.4, drei neue Tests und ein erweiterter), `npm test` 41, php-cs-fixer, Twig-Lint, `test:editor` grün. Im Browser: Schrittformular (Luximmo, Pflichtgruppe per Skript gesetzt) zeigt die Meldung und bleibt im Schritt; Editor-Auswahl mit und ohne Korrektur verglichen. Nicht geprüft: PHP 8.2, `test:browser`, MySQL, das Update aus der Verwaltung auf einem Hosting.
**Bekannte Fehler:** `test:accordion` scheitert schon vor dieser Fassung an der Auswahl im Strukturbaum (Skript kennt die Seitenleiste seit 1.11 nicht). `tests/pilot-fixture.php` migriert die Kopie nicht. Alle übrigen Punkte der Analyse sind offen.
**Nächster Schritt:** Zweig `release-1.40.1` nach `main`, Tag `v1.40.1`, Releaseeintrag – nach Umuts Freigabe. Danach 1.41 (Stabilität) laut Bericht.
## Komplettanalyse 1.40.0 (10.11.2026)
**Anlass:** Umut: „Komplettanalyse – Speedcheck, Bugfix, Oberflächenverbesserung, Optimierung“. Nur Bestandsaufnahme, kein Code geändert. Bericht: `reports/komplettanalyse-2026-11.md` (nicht in Git).
**Geprüft:** PHPUnit 692 grün (PHP 8.4), `npm test` 41, php-cs-fixer, Twig-Lint, `npm audit` ohne Befund. `composer audit`: 12 Meldungen in 8 Symfony-Paketen (7.4.22, behoben in 7.4.23). Lighthouse 13.4.1 live gegen kant-it.de (noch 1.37.0), vier Seiten, je drei Läufe: Leistung 99–100, Barrierefreiheit 95 auf `/beitraege` und 96 auf der Beitragsseite, sonst 100. Rundgang durch 16 Verwaltungsseiten in 1440 und 390 px mit axe. Code-Durchsicht in vier Bereichen. Nicht geprüft: MySQL, `test:editor`, `test:browser`, Safari, PHP 8.2, Felddaten.
**Bekannte Fehler (Auswahl, Nummern wie im Bericht):** F1 Formular auf Archivseiten endet in 404; F2 `false` wird als `''` gebunden, auf MySQL scheitert vermutlich jede Formularabsendung (MySQL nicht ausgeführt); F3 `form-steps.js:162` wirft bei Pflicht-Mehrfachauswahl; F4 alte Seiten mit eigenem CSS können mit 500 antworten; F5 zweiter Updateschritt ohne Sperre; E1 lokale `choose` in `editor.js:360` verdeckt die Auswahlfunktion; E2 fünf endgültige Löschaktionen ohne Rückfrage; P1 Bilder laufen durch PHP (live rund 250 ms je Bild); P2 Bildstufen ohne 720 px.
**Nächster Schritt:** 1.40.1 mit Symfony-Update und den Korrekturen S1, F1, F2, F3, F5, E1, S2, S3 – nach Umuts Freigabe. Vorher klären, ob eine Kundeninstallation auf MySQL läuft.
## Fassung 1.40.0: Symbol „Haus (Fläche)“, Becker-Paket (10.11.2026)
**Anlass:** Umut wählte für das Startseiten-Symbol im Becker-Menü einen eigenen Entwurf; der Symbolsatz hat dafür `house` bekommen. Danach: „Release bauen und mir becker als zip paket ablegen“.
**Release:** Tag `v1.40.0` auf `main`, Releaseeintrag mit Zip (3700 Dateien, 6,1 MB), `.sha256` und `install.php`, gebaut aus sauberem Checkout des Tags (ohne ImmoScout-Plugin); anonymer Download stimmt mit der Prüfsumme überein.
**Geprüft:** PHPUnit 692 grün (PHP 8.4), `npm test` 41, Twig-Lint, php-cs-fixer. `test:editor` und `test:browser` nicht gelaufen.
**Becker-Paket:** `var/becker-paket.zip` mit Fassung 1.40.0 neu exportiert, Kopie als `Downloads/becker-paket-1.40.0.zip`. Lässt sich nur auf 1.40.0 oder neuer importieren. Der Plugin-Zustand gehört nicht zum Paket: live nach dem Import unter Plugins „Bildvergleich“ einschalten, sonst bleibt der Vorher/Nachher-Regler leer.
## Neue Installation „Becker – Just In Time e. Kfm.“, Wiernsheim (10.11.2026)
**Anlass:** Umut lieferte `Becker-Just-In-Time-Version-5.zip` (ChatGPT-Sites-Entwurf), `Logo_Becker_original.pdf` und sieben Werbemotive mit Logos: „nachbauen, an die CI halten, eigenen Senf dazugeben, Bilder entlogolisieren und von ChatGPT ohne Logos nachbauen lassen“.
**Umgesetzt:** Die Fotos hat ChatGPT in Umuts Konto neu erzeugt (11 Motive, `var/becker-quelle/ki`, Originale in `var/becker-quelle/original`); die Asbestmotive zeigen dabei Schutzanzug und Atemschutz. `scripts/build-becker-assets.php` stellt das Logo aus dem PDF-Rasterbild frei (Kopf quer, Fuß gestapelt, drei Leistungssymbole, Favicon). `sh scripts/reset-becker.sh [var/becker-quelle]` baut alles neu: Navy #172234 + Logo-Gold #F4B43A, Montserrat/Manrope, Startseite mit Hero, Leistungskarten, Vorher/Nachher-Regler (Plugin „vergleich“), Ablauf, Einblicke-Galerie, Team, Seiten `/gartenbau`, `/zaunbau`, `/asbestsanierung`, `/kontakt` (Formular `anfrage` in zwei Schritten), Impressum, Datenschutz, 404, Paket `var/becker-paket.zip`. Port 8116 (dev) / 8117 (prod), Passwort in `var/becker-zugang.txt`.
**Geprüft:** Lighthouse im Produktionsmodus für `/`, `/gartenbau`, `/asbestsanierung`, `/kontakt`: Leistung mobil 97–98, Desktop 99–100; Barrierefreiheit, Best Practices, SEO, Agentic Browsing je 100. Ganzseitige Aufnahmen 1440 und 390 px angesehen. Gold-Streifen als Inline-SVG (vorher CSS-Verlauf, sah treppig aus). Nicht geprüft: `test:browser`, Formularversand, Bedienung des Reglers per Tastatur von Hand.
**Kern:** neues Symbol `house` (geschlossene Hausform, Tür ausgespart) in `public/assets/icons.js`, per `node scripts/build-icons.mjs` in Twig und Validator; Becker nutzt es als Startseiten-Punkt im Menü. `tests/icons.test.mjs` und PHPUnit-Filter „Icon“ (26) grün. Das Becker-Paket braucht deshalb einen Kern mit diesem Symbol (nächstes Release).
**Offen:** Impressum – „e. Kfm.“ verlangt Registergericht und HRA-Nummer (fehlen); die im Entwurf als USt-IdNr. genannte „48116/36926“ ist eine Steuernummer und steht deshalb nicht drin. E-Mail: Entwurf `info@becker-justintime.de`, Logo-PDF `Becker-Justintime@web.de` – verwendet ist die aus dem Entwurf. Fotos sind KI-Symbolbilder (so im Bildnachweis). Domain/Hosting unbekannt.
## Fassung 1.39.1: SVG statt Schriftzeichen auf der Website, Luximmo-Immobilien (09.11.2026)
**Anlass:** Umut: „nur eigene SVGs nutzen“. Bildbetrachter, PDF-Betrachter, Nach-oben-Knopf, Video-Knopf, Menüpfeile, Brotkrumen, Weiterlesen, Blättern und Zitatsterne sind SVG (Masken in `site.css`, sonst eingebettet).
**Geprüft:** PHPUnit 692 grün (PHP 8.4), `npm test` 41, `test:editor`, `test:browser`, Twig-Lint; Symbole im Browser nachgemessen. Tag `v1.39.1`, Download stimmt mit der Prüfsumme überein.
**Offen:** Die Verwaltung enthält weiter Schriftzeichen (Editor-Symbole samt Plugin-Bausteinen, Pfeile auf Knöpfen). `var/luximmo-paket.zip` trägt Fassung 1.39.0 und hat die Immobilien-Seite. Im Menü von Luximmo bricht der fünfte Punkt zwischen etwa 1025 und 1130 px um. Immoscout-Plugin aktuell 1.5.2: Paket-Import ersetzt die Mediathek, der Abgleich holt die Bilder danach neu.
## Kern: Plugins mit PHP-Code; Plugin „ImmoScout-Abgleich“ für DEEG (09.11.2026)
**Anlass:** Umut: ImmoScout-Objekte im Takt abgleichen, kostenlos per URL, „100 % ein Plugin“, „wie WordPress“.
**Kern (ADR 127):** `plugin.php` mit `PluginApi`/`PluginRuntime`: Hooks, Aufgaben, Seiten, eigene Tabellen, Verwaltungsbildschirme, Fehler je Plugin eingegrenzt. `VERSION` steht im Arbeitsstand auf 1.39.0, nicht committet, kein Release.
**Kern (ADR 127):** `plugin.php` mit `PluginApi`/`PluginRuntime`: Hooks, Aufgaben, Seiten, eigene Tabellen, Verwaltungsbildschirme, Fehler je Plugin eingegrenzt. **Release:** Tag `v1.39.0` auf `main`, Releaseeintrag mit Zip (3694 Dateien, 5,8 MB), `.sha256` und `install.php`, gebaut aus sauberem Stand ohne das ImmoScout-Plugin; anonymer Download stimmt mit der Prüfsumme überein.
**Geprüft:** PHPUnit 692 grün (PHP 8.4, neu `PluginServerTest`), `npm test` 41, `test:editor`, Twig-Lint, php-cs-fixer. Auf einer Kopie der DEEG-Installation in der Verwaltung: einschalten, einstellen, echter Abgleich (5 Objekte, Bilder, Exposés), Liste, Objektseite 1440 und 390 px ohne waagrechtes Scrollen, Sitemap, Anfrage mit Objektangabe gespeichert, verschwundenes Objekt beim zweiten Lauf inaktiv mit 410. Nicht geprüft: Hosting, MySQL, Mailversand, Lighthouse der Objektseiten; die Abweichung Angebotszahl/gelesene Objekte wurde nicht einzeln simuliert.
**Nächster Schritt:** DEEG auf 1.39.0 aktualisieren, `var/immoscout-plugin-1.4.0.zip` nach `plugins/` entpacken, einschalten, Portal-Adresse eintragen, abgleichen, Baustein auf `/angebote` setzen. Vorher Zustimmung von DEEG einholen.
**Plugin:** liegt in `plugins/immoscout` (lokal von Git ausgeschlossen, Auslieferung als ZIP), nicht Teil des Kerns. ImmoScouts `robots.txt` verbietet automatisches Abrufen; das hat Umut entschieden, der Kunde sollte zustimmen. Eine Sperre wird nicht umgangen.
## Kern: Unternehmensart, Standort und Logo in den strukturierten Daten (09.11.2026)
+3 -3
View File
@@ -357,7 +357,7 @@ function renderContent(block, panel) {
};
// Eine Eigenschaft setzen, die kein Textfeld ist (Symbol, Stellung, Bewegung). Danach wird das
// Panel neu gezeichnet, und der Fokus kehrt zu dem Bedienelement zurück, das sie gesetzt hat.
const choose = (key, next, version, focus) => {
const setProp = (key, next, version, focus) => {
history.change(doc => {
const current = locate(doc.blocks, selected)?.block;
if (!current) return;
@@ -370,7 +370,7 @@ function renderContent(block, panel) {
document.querySelector(focus)?.focus({ preventScroll: true });
};
const iconField = (label, value, options = {}) => panel.append(renderIconField({
label, value, none: options.none, onChoose: name => choose('icon', name, options.version, '.icon-field-change'),
label, value, none: options.none, onChoose: name => setProp('icon', name, options.version, '.icon-field-change'),
}));
// Zwei bis drei Möglichkeiten nebeneinander statt einer Auswahlliste.
const segmented = (label, key, current, options, version) => {
@@ -381,7 +381,7 @@ function renderContent(block, panel) {
for (const [value, text] of options) {
const option = document.createElement('button'); option.type = 'button'; option.dataset[key] = value; option.textContent = text;
option.setAttribute('aria-pressed', String(value === current));
option.addEventListener('click', () => { if (value !== current) choose(key, value, version, `[data-${key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}="${value}"]`); });
option.addEventListener('click', () => { if (value !== current) setProp(key, value, version, `[data-${key.replace(/[A-Z]/g, c => `-${c.toLowerCase()}`)}="${value}"]`); });
group.append(option);
}
wrap.append(caption, group); panel.append(wrap);
+8 -4
View File
@@ -39,10 +39,6 @@
/* 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}
/* latin-ext */
@font-face{font-family:'Manrope';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/manrope-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:'Manrope';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/manrope-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:'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 */
@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}
@@ -50,3 +46,11 @@
@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}
/* latin-ext */
@font-face{font-family:'Manrope';font-style:normal;font-weight:400 800;font-display:swap;src:url(/assets/fonts/manrope-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:'Manrope';font-style:normal;font-weight:400 800;font-display:swap;src:url(/assets/fonts/manrope-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:'Montserrat';font-style:normal;font-weight:700 800;font-display:swap;src:url(/assets/fonts/montserrat-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:'Montserrat';font-style:normal;font-weight:700 800;font-display:swap;src:url(/assets/fonts/montserrat-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}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+8 -2
View File
@@ -159,8 +159,14 @@
if (group.closest('[data-inactive]')) continue;
const note = group.querySelector('.field-error[data-live]');
if (!group.querySelector('input:checked')) {
if (!note) group.append(Object.assign(el('p', 'field-error', t('chooseOne', 'Bitte wähle mindestens eine Option.')), { dataset: { live: '' } }));
else note.hidden = false;
if (!note) {
// dataset lässt sich nicht als Ganzes zuweisen: Object.assign warf hier, und „Weiter“
// tat ohne Meldung nichts.
const hint = el('p', 'field-error', t('chooseOne', 'Bitte wähle mindestens eine Option.'));
hint.dataset.live = '';
hint.setAttribute('role', 'alert');
group.append(hint);
} else note.hidden = false;
group.classList.add('has-error');
group.querySelector('input')?.focus();
return false;
+3 -2
View File
@@ -74,6 +74,7 @@ export const ICON_PATHS = {
star: 'm12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z',
// Haus, Garten und Handwerk
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
house: 'M4 10.5 12 4l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5.5h-5V20H5a1 1 0 0 1-1-1Z',
building: 'M5 21V4h10v17m0-12h4v12M3 21h18M8.5 7.5h3m-3 3.5h3m-3 3.5h3',
wrench: 'M14.5 3.5a5 5 0 0 0-4.6 6.9L3.5 16.8a1.9 1.9 0 0 0 2.7 2.7l6.4-6.4a5 5 0 0 0 6.9-4.6l-3 3-3-1-1-3Z',
hammer: 'M14 8.5 4.5 18a1.5 1.5 0 0 0 2.1 2.1L16 10.5M11 6l3-3 3.5 1L21 7.5 18.5 10l-2-2-2.5 2.5Z',
@@ -128,7 +129,7 @@ export const ICON_LABELS = {
mic: 'Mikrofon', book: 'Buch',
user: 'Person', people: 'Personen', login: 'Anmelden', lock: 'Schloss', key: 'Schlüssel', shield: 'Schutz',
eye: 'Ansehen', 'thumbs-up': 'Daumen hoch', award: 'Auszeichnung', graduation: 'Ausbildung', heart: 'Herz', star: 'Stern',
home: 'Haus', building: 'Gebäude', wrench: 'Werkzeug', hammer: 'Hammer', brush: 'Malerrolle', ruler: 'Lineal',
home: 'Haus', house: 'Haus (Fläche)', building: 'Gebäude', wrench: 'Werkzeug', hammer: 'Hammer', brush: 'Malerrolle', ruler: 'Lineal',
leaf: 'Blatt', tree: 'Baum', sun: 'Sonne', droplet: 'Wasser', flame: 'Flamme', bolt: 'Strom', solar: 'Solar',
thermometer: 'Temperatur', snowflake: 'Kälte', car: 'Auto',
check: 'Häkchen', 'check-circle': 'Häkchen im Kreis', info: 'Info', help: 'Frage', alert: 'Warnung',
@@ -144,7 +145,7 @@ export const ICON_GROUPS = [
['Handel & Geld', ['shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase']],
['Dateien & Medien', ['document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book']],
['Personen & Sicherheit', ['user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star']],
['Haus & Handwerk', ['home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car']],
['Haus & Handwerk', ['home', 'house', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car']],
['Allgemein', ['check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design']],
['Soziale Netzwerke', ['facebook', 'instagram', 'whatsapp']],
];
+7 -4
View File
@@ -1,3 +1,6 @@
/* Symbole als SVG: Schriftzeichen macht ein Telefon zu Emoji. */
const symbol = (p) => `<svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="${p}"/></svg>`;
/*
* Der Bildbetrachter.
*
@@ -23,9 +26,9 @@
<img class="zoom-image" alt="">
<figcaption class="zoom-caption"></figcaption>
</figure>
<button class="zoom-close" type="button" aria-label="Schließen">×</button>
<button class="zoom-prev" type="button" aria-label="Vorheriges Bild">‹</button>
<button class="zoom-next" type="button" aria-label="Nächstes Bild">›</button>
<button class="zoom-close" type="button" aria-label="Schließen">${symbol('M6 6l12 12M18 6 6 18')}</button>
<button class="zoom-prev" type="button" aria-label="Vorheriges Bild">${symbol('M15 5.5 8.5 12l6.5 6.5')}</button>
<button class="zoom-next" type="button" aria-label="Nächstes Bild">${symbol('m9 5.5 6.5 6.5L9 18.5')}</button>
<p class="zoom-counter" aria-live="polite"></p>
`;
document.body.append(dialog);
@@ -138,7 +141,7 @@
<p class="pdf-title" id="pdf-dialog-title"></p>
<a class="pdf-action pdf-download" download>Herunterladen</a>
<a class="pdf-action pdf-open" target="_blank" rel="noopener">Im neuen Tab</a>
<button class="pdf-close" type="button" aria-label="Schließen">×</button>
<button class="pdf-close" type="button" aria-label="Schließen">${symbol('M6 6l12 12M18 6 6 18')}</button>
</div>
<iframe class="pdf-frame" title="PDF-Dokument"></iframe>
`;
+17 -7
View File
@@ -1,3 +1,5 @@
/* Symbole als eingebettete SVG-Masken: Schriftzeichen macht ein Telefon zu Emoji. */
:root{--kant-svg-unten:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");--kant-svg-oben:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m6 15 6-6 6 6'/%3E%3C/svg%3E");--kant-svg-rechts:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m9 5.5 6.5 6.5L9 18.5'/%3E%3C/svg%3E");--kant-svg-pfeil:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M4 12h15.5m-6.5-6.5 6.5 6.5-6.5 6.5'/%3E%3C/svg%3E");--kant-svg-pfeil-links:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M20 12H4.5M11 5.5 4.5 12 11 18.5'/%3E%3C/svg%3E");--kant-svg-pause:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 5v14m8-14v14'/%3E%3C/svg%3E");--kant-svg-play:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%23000' stroke='%23000' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M8 5v14l11-7z'/%3E%3C/svg%3E")}
@charset "UTF-8";
/* Lange Wörter dürfen umbrechen, damit nichts aus der Spalte läuft. Getrennt wird nur im
Fließtext: eine Überschrift mit Trennstrich („di-gitale") sieht nach Fehler aus. */
@@ -19,14 +21,14 @@ p{hyphens:auto}
.menu-list a:hover,.menu-list summary:hover{text-decoration:underline}
.menu-list [aria-current=page]{font-weight:700;text-decoration:none}
.menu-branch>summary{cursor:pointer;padding:6px 2px;list-style:none}
.menu-branch>summary::after{content:' ▾'}
.menu-branch[open]>summary::after{content:' ▴'}
.menu-branch>summary::after{content:'';display:inline-block;width:.75em;height:.75em;background:currentColor;-webkit-mask:var(--kant-svg-unten) center/contain no-repeat;mask:var(--kant-svg-unten) center/contain no-repeat;margin-left:.3em;vertical-align:-.05em}
.menu-branch[open]>summary::after{-webkit-mask-image:var(--kant-svg-oben);mask-image:var(--kant-svg-oben)}
.menu-branch-body{display:grid;gap:2px;padding:6px 0 6px 14px}
.menu-level-2{flex-direction:column;align-items:flex-start;gap:2px}
/* Posts */
.post-list{list-style:none;margin:0;padding:0;display:grid;gap:26px}.post-list-karten{grid-template-columns:repeat(auto-fill,minmax(260px,1fr))}.post-list-liste{gap:18px}.post-list-liste .post-card{flex-direction:row;align-items:stretch}.post-list-liste .post-image{flex:0 0 34%;max-width:300px}.post-list-liste .post-image img{height:100%;aspect-ratio:auto;min-height:170px}.post-list-liste .post-body{flex:1 1 auto;align-content:center;padding:22px 26px}.post-list-wechsel{gap:0;counter-reset:beitrag}.post-list-wechsel .post-card{position:relative;counter-increment:beitrag;display:grid;grid-template-columns:1fr 1fr;align-items:center;gap:0 56px;padding:38px 0;background:transparent;border:0;border-radius:0;overflow:visible}.post-list-wechsel .post-card + .post-card{border-top:1px solid var(--line)}.post-list-wechsel .post-image{border-radius:20px;overflow:hidden;box-shadow:0 18px 46px -22px color-mix(in srgb,var(--ink) 55%,transparent)}.post-list-wechsel .post-image img{aspect-ratio:16/10;border-radius:20px}.post-list-wechsel .post-body{padding:0;display:grid;gap:12px;align-content:center}.post-list-wechsel .post-body::before{content:counter(beitrag,decimal-leading-zero);font-size:13px;font-weight:800;letter-spacing:2px;color:var(--accent)}.post-list-wechsel .post-card h3{font-size:28px;line-height:1.18;letter-spacing:-.5px}.post-list-wechsel .post-excerpt{margin:0;max-width:46ch}.post-list-wechsel .post-meta,.post-list-wechsel .post-terms{margin:0}.post-list-wechsel .post-more{display:inline-flex;align-items:center;gap:8px;margin-top:4px;font-size:15px;font-weight:700;color:var(--accent);text-decoration:none;width:fit-content}
.post-more::after{content:'→';transition:transform .2s}
.post-more::after{content:'';display:inline-block;width:1em;height:1em;background:currentColor;-webkit-mask:var(--kant-svg-pfeil) center/contain no-repeat;mask:var(--kant-svg-pfeil) center/contain no-repeat;vertical-align:-.12em;transition:transform .2s}
.post-more:hover::after{transform:translateX(4px)}.post-list-wechsel .post-more::after{content:'\2192'}.post-list-wechsel .post-card:nth-child(even) .post-image{order:2}.post-list-wechsel .post-card:nth-child(even) .post-body{order:1}@media(max-width:767px){.post-list-wechsel .post-card{grid-template-columns:1fr;gap:18px;padding:26px 0}.post-list-wechsel .post-card:nth-child(even) .post-image{order:0}.post-list-wechsel .post-card:nth-child(even) .post-body{order:0}.post-list-wechsel .post-card h3{font-size:22px}}.post-list-kompakt{gap:0}.post-list-kompakt .post-card{flex-direction:row;align-items:baseline;justify-content:space-between;gap:18px;background:transparent;border:0;border-bottom:1px solid var(--line);border-radius:0;overflow:visible}.post-list-kompakt .post-body{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 18px;padding:16px 2px}.post-list-kompakt .post-meta,.post-list-kompakt .post-terms{margin:0}.post-list-kompakt .post-excerpt{flex:1 1 100%;margin:0}
.post-card{display:flex;flex-direction:column;background:var(--paper);border:1px solid var(--line);border-radius:18px;overflow:hidden}.post-card>.post-body{display:grid;gap:10px;padding:20px 22px 22px}
.post-image{display:block;position:relative;overflow:hidden;text-decoration:none}.post-view{position:absolute;inset:0;display:grid;place-items:center;background:color-mix(in srgb,var(--ink) 55%,transparent);color:#fff;font-weight:700;font-size:15px;letter-spacing:.4px;opacity:0}.post-card:hover .post-view,.post-card:focus-within .post-view{opacity:1}.post-image img{width:100%;height:auto;aspect-ratio:16/10;object-fit:cover;border-radius:0;display:block;background:var(--ink)}
@@ -66,7 +68,9 @@ p{hyphens:auto}
.block-quote blockquote p{margin:0;font-size:inherit;line-height:1.65}
.block-quote figcaption{font-size:14px;font-weight:600}
.block-quote figcaption span{font-weight:400;opacity:.85}
.quote-rating{margin:0;letter-spacing:3px}
.quote-rating{margin:0}
.quote-rating svg{display:inline-block;width:1.1em;height:1.1em;margin-right:2px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linejoin:round}
.quote-rating .quote-star-voll{fill:currentColor}
.block-logos{list-style:none;margin:0;padding:0;display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));align-items:center;gap:24px}
.block-logos img{max-height:52px;width:auto;margin-inline:auto;display:block}
.kant .block-cta .button{background:var(--accent);color:var(--paper);border-color:var(--accent)}.kant .block-cta .button:hover{background:color-mix(in srgb,var(--accent) 86%,var(--ink))}.block-cta{display:flex;flex-wrap:wrap;gap:20px 34px;align-items:center;justify-content:space-between;padding:32px;border:1px solid currentColor;border-radius:14px}
@@ -170,7 +174,7 @@ p{hyphens:auto}
@media(max-width:767px){.form-summary-list{grid-template-columns:1fr;gap:2px 0}.form-summary-list dd{margin-bottom:8px}}
.block-breadcrumb ol{list-style:none;display:flex;flex-wrap:wrap;gap:6px 10px;margin:0;padding:0;font-size:13px}
.block-breadcrumb li+li::before{content:'›';margin-right:10px;opacity:.6}
.block-breadcrumb li+li::before{content:'';display:inline-block;width:.85em;height:.85em;background:currentColor;-webkit-mask:var(--kant-svg-rechts) center/contain no-repeat;mask:var(--kant-svg-rechts) center/contain no-repeat;margin-right:10px;opacity:.6;vertical-align:-.08em}
.block-breadcrumb a{text-decoration:none}
.block-breadcrumb a:hover{text-decoration:underline}
@@ -247,8 +251,8 @@ body.consent-open{padding-bottom:320px}
.section-video{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;pointer-events:none}
.has-video>.section-inner{position:relative;z-index:1}
.video-toggle{position:absolute;right:16px;bottom:16px;z-index:2;width:44px;height:44px;padding:0;border:0;border-radius:50%;background:rgba(10,20,35,.72);color:#fff;font:14px/1 system-ui,sans-serif;cursor:pointer}
.video-toggle::before{content:"❚❚"}
.video-toggle.is-paused::before{content:"▶"}
.video-toggle::before{content:'';display:inline-block;width:20px;height:20px;background:currentColor;-webkit-mask:var(--kant-svg-pause) center/contain no-repeat;mask:var(--kant-svg-pause) center/contain no-repeat;display:block;margin:auto}
.video-toggle.is-paused::before{-webkit-mask-image:var(--kant-svg-play);mask-image:var(--kant-svg-play)}
.video-toggle:focus-visible{outline:3px solid #fff;outline-offset:2px}
.video-toggle[hidden]{display:none}
.block-html{max-width:100%}
@@ -603,6 +607,10 @@ html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;tran
.zoom-close,.zoom-prev,.zoom-next{position:fixed;display:grid;place-items:center;width:52px;height:52px;border-radius:999px;border:0;background:rgba(255,255,255,.14);color:#fff;font-size:26px;line-height:1;cursor:pointer}
.zoom-close:hover,.zoom-prev:hover,.zoom-next:hover{background:rgba(255,255,255,.26)}
.zoom-close:focus-visible,.zoom-prev:focus-visible,.zoom-next:focus-visible{outline:3px solid #fff;outline-offset:3px}
.zoom-close svg,.zoom-prev svg,.zoom-next svg,.pdf-close svg,.scroll-top svg{display:block;fill:none;stroke:currentColor;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
.zoom-close svg,.zoom-prev svg,.zoom-next svg{width:26px;height:26px}
.pdf-close svg{width:20px;height:20px}
.scroll-top svg{width:46%;height:46%}
.zoom-close{top:18px;right:18px}
.zoom-prev{left:18px;top:50%;transform:translateY(-50%)}
.zoom-next{right:18px;top:50%;transform:translateY(-50%)}
@@ -644,3 +652,5 @@ html.kant-top-armed.kant-top-shown .scroll-top{opacity:1;visibility:visible;tran
.tone-ink,.tone-mint{background:transparent;color:#000}
[data-step][hidden],.block-form [hidden]{display:block!important}
}
.post-pager a[rel="prev"]::before{content:'';display:inline-block;width:1em;height:1em;background:currentColor;-webkit-mask:var(--kant-svg-pfeil-links) center/contain no-repeat;mask:var(--kant-svg-pfeil-links) center/contain no-repeat;margin-right:.35em;vertical-align:-.12em}
.post-pager a[rel="next"]::after{content:'';display:inline-block;width:1em;height:1em;background:currentColor;-webkit-mask:var(--kant-svg-pfeil) center/contain no-repeat;mask:var(--kant-svg-pfeil) center/contain no-repeat;margin-left:.35em;vertical-align:-.12em}
+3 -3
View File
@@ -56,14 +56,14 @@ export function initSliders(doc = document) {
go(direction > 0 ? (last ? visible[0] : visible[Math.min(visible.length - 1, position + 1)]) : (first ? visible.at(-1) : visible[Math.max(0, position - 1)]));
};
const manual = action => { playing = false; clearTimeout(timer); update(); action(); };
const previous = button('←', 'Vorherige Folie', () => manual(() => step(-1))); previous.className = 'slider-prev';
const previous = button('', 'Vorherige Folie', () => manual(() => step(-1))); previous.className = 'slider-prev';
const count = doc.createElement('span'); count.className = 'slider-position'; controls.append(count);
const next = button('→', 'Nächste Folie', () => manual(() => step(1))); next.className = 'slider-next';
const next = button('', 'Nächste Folie', () => manual(() => step(1))); next.className = 'slider-next';
const dots = [];
if (slider.classList.contains('slider-dots')) {
const group = doc.createElement('div'); group.className = 'slider-control-dots'; controls.append(group);
slides.forEach((slide, i) => {
const dot = button('●', `Folie ${i + 1} anzeigen`, () => manual(() => go(i))); group.append(dot); dots.push(dot);
const dot = button('', `Folie ${i + 1} anzeigen`, () => manual(() => go(i))); group.append(dot); dots.push(dot);
});
}
const play = seconds >= 3 ? button('', 'Automatische Wiedergabe pausieren', () => { playing = !playing; update(); schedule(); }) : null;
+129
View File
@@ -0,0 +1,129 @@
<?php
/**
* Bereitet Logo und Fotos von „Becker – Just In Time e. Kfm.“ für die Mediathek des Nachbaus vor.
*
* Quellordner (var/becker-quelle):
* - logo-original.png: das Rasterbild aus Logo_Becker_original.pdf (1536 × 1024, Navy-Grund #172234),
* - ki/*.png: die von ChatGPT ohne Logos neu erzeugten Fotos (10.11.2026, nach Umuts Vorlagen).
*
* Daraus entstehen im Zielordner:
* - logo.png: Bagger-Signet links, „BECKER / JUST IN TIME e. Kfm.“ rechts – für den Kopf,
* - logo-gross.png: das Logo gestapelt mit den drei Leistungssymbolen – für den Fuß,
* - favicon.png: das Bagger-Signet auf Navy, 192 × 192,
* - je Foto eine WebP-Datei (Startbild 1920 px, sonst 1600 px breit).
*
* Der Navy-Grund des Logos wird weich freigestellt: Deckkraft aus dem Farbabstand zum Grund,
* die Kantenfarbe zurückgerechnet. So passt das Logo auf jede dunkle Fläche.
*
* Aufruf: php scripts/build-becker-assets.php <Quellordner> <Zielordner>
*/
$quelle = rtrim($argv[1] ?? '', '/\\');
$ziel = rtrim($argv[2] ?? '', '/\\');
if ('' === $quelle || '' === $ziel || !is_file($quelle.'/logo-original.png')) {
fwrite(STDERR, "Aufruf: php scripts/build-becker-assets.php <Ordner mit logo-original.png und ki/> <Zielordner>\n");
exit(1);
}
if (!is_dir($ziel) && !mkdir($ziel, 0775, true)) {
fwrite(STDERR, "Zielordner lässt sich nicht anlegen: $ziel\n");
exit(1);
}
$logo = imagecreatefrompng($quelle.'/logo-original.png');
$grund = [23, 34, 52];
/** Ein Ausschnitt des Logos mit freigestelltem Navy-Grund. */
$freistellen = static function (int $x, int $y, int $b, int $h) use ($logo, $grund): GdImage {
$bild = imagecreatetruecolor($b, $h);
imagealphablending($bild, false);
imagesavealpha($bild, true);
for ($j = 0; $j < $h; ++$j) {
for ($i = 0; $i < $b; ++$i) {
$c = imagecolorat($logo, $x + $i, $y + $j);
$p = [$c >> 16 & 0xFF, $c >> 8 & 0xFF, $c & 0xFF];
$abstand = max(abs($p[0] - $grund[0]), abs($p[1] - $grund[1]), abs($p[2] - $grund[2]));
$a = min(1.0, max(0.0, ($abstand - 6) / 90));
if ($a <= 0.0) {
imagesetpixel($bild, $i, $j, imagecolorallocatealpha($bild, 0, 0, 0, 127));
continue;
}
$f = array_map(static fn (int $k): int => (int) max(0, min(255, round($grund[$k] + ($p[$k] - $grund[$k]) / $a))), [0, 1, 2]);
imagesetpixel($bild, $i, $j, imagecolorallocatealpha($bild, $f[0], $f[1], $f[2], (int) round(127 * (1 - $a))));
}
}
return $bild;
};
/** Verkleinert mit Durchsichtigkeit. */
$verkleinern = static function (GdImage $bild, int $breite): GdImage {
$hoehe = (int) round(imagesy($bild) * $breite / imagesx($bild));
$neu = imagecreatetruecolor($breite, $hoehe);
imagealphablending($neu, false);
imagesavealpha($neu, true);
imagefill($neu, 0, 0, imagecolorallocatealpha($neu, 0, 0, 0, 127));
imagecopyresampled($neu, $bild, 0, 0, 0, 0, $breite, $hoehe, imagesx($bild), imagesy($bild));
return $neu;
};
// Bänder im Original (gemessen): Signet y 15–455 / x 360–1196, „BECKER“ y 466–636,
// „JUST IN TIME e. Kfm.“ mit Linien y 647–701 / x 162–1382, Leistungssymbole y 722–897.
$signet = $freistellen(352, 8, 852, 456);
$schrift = $freistellen(154, 458, 1236, 252);
// Querformat für den Kopf: Signet links (so hoch wie die Schrift plus etwas), Schrift rechts.
$sh = 330;
$sb = (int) round(852 * $sh / 456);
$quer = imagecreatetruecolor($sb + 36 + 1236, $sh);
imagealphablending($quer, false);
imagesavealpha($quer, true);
imagefill($quer, 0, 0, imagecolorallocatealpha($quer, 0, 0, 0, 127));
imagealphablending($quer, true);
imagecopyresampled($quer, $signet, 0, 0, 0, 0, $sb, $sh, 852, 456);
imagecopy($quer, $schrift, $sb + 36, (int) round(($sh - 252) / 2) + 10, 0, 0, 1236, 252);
imagealphablending($quer, false);
imagepng($verkleinern($quer, 760), $ziel.'/logo.png', 9);
echo 'logo.png: 760 × '.(int) round($sh * 760 / imagesx($quer))."\n";
$gross = $freistellen(100, 8, 1336, 898);
imagepng($verkleinern($gross, 560), $ziel.'/logo-gross.png', 9);
echo "logo-gross.png: 560 px breit\n";
// Die drei Leistungssymbole des Logos (Blätter, Zaun, Dach) einzeln, quadratisch, 128 px.
foreach (['symbol-garten' => [250, 716, 186, 120], 'symbol-zaun' => [679, 716, 176, 124], 'symbol-asbest' => [1071, 716, 226, 120]] as $name => [$x, $y, $b, $h]) {
$teil = $freistellen($x, $y, $b, $h);
$q = max($b, $h);
$quadrat = imagecreatetruecolor(128, 128);
imagealphablending($quadrat, false);
imagesavealpha($quadrat, true);
imagefill($quadrat, 0, 0, imagecolorallocatealpha($quadrat, 0, 0, 0, 127));
$f = 128 / $q;
imagecopyresampled($quadrat, $teil, (int) round((128 - $b * $f) / 2), (int) round((128 - $h * $f) / 2), 0, 0, (int) round($b * $f), (int) round($h * $f), $b, $h);
imagepng($quadrat, $ziel.'/'.$name.'.png', 9);
echo "$name.png: 128 × 128\n";
}
// Seitensymbol: Signet mittig auf Navy.
$fav = imagecreatetruecolor(192, 192);
imagefill($fav, 0, 0, imagecolorallocate($fav, ...$grund));
imagecopyresampled($fav, $logo, 14, 22 + (int) round((148 - 164 * 440 / 836) / 2), 360, 15, 164, (int) round(164 * 440 / 836), 836, 440);
imagepng($fav, $ziel.'/favicon.png', 9);
echo "favicon.png: 192 × 192\n";
// Fotos: ChatGPT liefert PNG; als WebP auf Zielbreite.
foreach (['haus' => 1920, 'team' => 1600, 'zaun' => 1600, 'zauntor' => 1600, 'asbest' => 1600, 'dach' => 1600,
'bagger' => 1600, 'terrasse' => 1600, 'zisterne' => 1600, 'vorher' => 1600, 'nachher' => 1600] as $name => $breite) {
$pfad = $quelle.'/ki/'.$name.'.png';
if (!is_file($pfad)) {
fwrite(STDERR, "Foto fehlt: $pfad\n");
exit(1);
}
$bild = imagecreatefrompng($pfad);
$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.'.webp', 80);
echo "$name.webp: $breite × $hoehe\n";
}
+31
View File
@@ -0,0 +1,31 @@
#!/bin/sh
# Wirft die Becker-Installation (Becker – Just In Time e. Kfm., Wiernsheim) weg und baut sie neu auf.
# Nur fuer den Aufbau gedacht.
# Erstes Argument (optional): Quellordner mit logo-original.png (Rasterbild aus Logo_Becker_original.pdf)
# und ki/*.png (die ohne Logos neu erzeugten Fotos). Dann entstehen Logo, Symbole und WebP-Fotos neu.
set -e
root="C:/Django Project/KantSites"
php="${PHP_BINARY:-C:/php84/php.exe}"
export DATABASE_URL="sqlite:///$root/var/becker.db"
export MEDIA_DIRECTORY="$root/var/becker-media"
assets="$root/var/becker-assets"
if [ -n "$1" ]; then
"$php" scripts/build-becker-assets.php "$1" "$assets"
fi
if [ ! -f "$assets/haus.webp" ] || [ ! -f "$assets/logo.png" ]; then
echo "Bilder fehlen: erst mit dem Quellordner (z. B. var/becker-quelle) als Argument aufrufen." >&2
exit 1
fi
rm -f "$root/var/becker.db"
rm -rf "$root/var/becker-media"
"$php" vendor/bin/doctrine-migrations migrate --no-interaction -q
"$php" -d memory_limit=512M scripts/seed-becker.php "$assets"
# Manrope und Montserrat 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/becker-paket.zip"
"$php" bin/console cms:paket:export --ziel="$root/var/becker-paket.zip"
File diff suppressed because it is too large. Load diff
+28 -5
View File
@@ -452,8 +452,8 @@ $kopf = [
]],
// Goldene Linie, die beim Überfahren wächst; das Klappmenü liegt als Fläche
// unter dem ganzen Kopf.
'css' => "& .menu-list{gap:4px 28px;align-items:center}\n"
."& .menu-list a{position:relative;padding:12px 0;text-decoration:none}\n"
'css' => "& .menu-list{gap:4px clamp(10px,calc(4.2vw - 32px),28px);align-items:center}\n"
."& .menu-list a{position:relative;padding:12px 0;text-decoration:none;font-size:clamp(13px,1.05vw,15px)}\n"
."& .menu-list a::after{content:'';position:absolute;left:0;right:100%;bottom:6px;height:1.5px;background:#BDA076;transition:right .25s ease}\n"
."& .menu-list a:hover::after{right:0}\n"
."& .menu-burger summary{width:46px;height:46px;display:flex;align-items:center;justify-content:center;padding:0}\n"
@@ -501,7 +501,7 @@ $fuss = [
]),
$column('fuss-seiten', [
$kleinzeile('fuss-seiten-titel', 'Leistungen', '@gold-dunkel', '8px'),
$fussText('fuss-seiten-text', '<a href="/#leistungen">Immobilienverkauf</a><br><a href="/#leistungen">WEG-Verwaltung</a><br><a href="/#leistungen">Mietverwaltung</a><br><a href="/#leistungen">Sondereigentum</a><br><a href="/verwaltungsanfrage">Verwaltungsanfrage</a><br><a href="/immobilienbewertung">Immobilienbewertung</a>'),
$fussText('fuss-seiten-text', '<a href="/#leistungen">Immobilienverkauf</a><br><a href="/#leistungen">WEG-Verwaltung</a><br><a href="/#leistungen">Mietverwaltung</a><br><a href="/#leistungen">Sondereigentum</a><br><a href="/verwaltungsanfrage">Verwaltungsanfrage</a><br><a href="/immobilienbewertung">Immobilienbewertung</a><br><a href="/immobilien">Immobilien</a>'),
]),
], [
'desktop' => ['display' => 'grid', 'gridColumns' => 3, 'gap' => '40px', 'padding' => ['top' => '56px', 'bottom' => '40px']],
@@ -649,7 +649,10 @@ $startseite = [
$section('leistungen-bereich', [
$zeile('leistungen-kopf', [
$heading('leistungen-titel', 'Leistungen', 2, $h2Stil),
$text('leistungen-link', '<a href="/verwaltungsanfrage">Unverbindlich anfragen →</a>', ['desktop' => ['fontSize' => '15px', 'fontWeight' => '500', 'margin' => ['bottom' => '0px']]], $linkCss),
$zeile('leistungen-link-zeile', [
$text('leistungen-link', '<a href="/verwaltungsanfrage">Unverbindlich anfragen</a>', ['desktop' => ['fontSize' => '15px', 'fontWeight' => '500', 'margin' => ['bottom' => '0px']]], $linkCss),
$symbol('leistungen-link-pfeil', 'arrow-right', 16, ['desktop' => ['color' => '@tinte']]),
], '6px'),
], '16px', ['justifyContent' => 'space-between', 'alignItems' => 'baseline', 'flexWrap' => 'wrap', 'margin' => ['bottom' => '56px']], ['margin' => ['bottom' => '36px']]),
$column('leistungen-raster', [
$leistung('leistung-verkauf', $medien['poster'], 'Offener Wohn- und Essbereich mit Treppe, Holzlamellen und großer Glasfront', 'Immobilienverkauf', 'Professionelle Vermarktung, marktgerechte Bewertung und persönliche Begleitung bis zum erfolgreichen Verkauf.', [
@@ -816,7 +819,11 @@ $startseite = [
]),
$column('kontakt-mehr', [
$kleinzeile('kontakt-mehr-titel', 'Direkt', '@gold-dunkel', '8px'),
$text('kontakt-mehr-text', '<a href="'.$tel.'">'.$telefon.'</a><br><a href="mailto:'.$mail.'">'.$mail.'</a><br>Für ein Angebot:<br><a href="/verwaltungsanfrage">Verwaltungsanfrage →</a>', ['desktop' => ['fontSize' => '17px', 'lineHeight' => 1.6, 'margin' => ['bottom' => '0px']]], $linkCss),
$text('kontakt-mehr-text', '<a href="'.$tel.'">'.$telefon.'</a><br><a href="mailto:'.$mail.'">'.$mail.'</a><br>Für ein Angebot:', ['desktop' => ['fontSize' => '17px', 'lineHeight' => 1.6, 'margin' => ['bottom' => '0px']]], $linkCss),
$zeile('kontakt-mehr-zeile', [
$text('kontakt-mehr-link', '<a href="/verwaltungsanfrage">Verwaltungsanfrage</a>', ['desktop' => ['fontSize' => '17px', 'lineHeight' => 1.6, 'margin' => ['bottom' => '0px']]], $linkCss),
$symbol('kontakt-mehr-pfeil', 'arrow-right', 16, ['desktop' => ['color' => '@tinte']]),
], '6px'),
]),
], '56px', ['alignItems' => 'flex-start', 'flexWrap' => 'wrap', 'rowGap' => '32px']),
]),
@@ -959,6 +966,21 @@ $hinweis = static fn (string $id, string $titel, array $punkte) => $column($id,
// Die Bildkarten einer Auswahl: Bild oben, gerundet, dunkler Rand bei Wahl.
$kartenCss = "\n& .form-choice-grid{grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:14px}\n& .form-choice-card label{border:1.5px solid #E6DED2;border-radius:20px;background:#fff;padding:10px 10px 14px;font-weight:500;font-size:15px;color:#141414}\n& .form-choice-card label:hover{border-color:#BDA076}\n& .form-choice-card input:checked+label{border-color:#141414;box-shadow:0 0 0 1.5px #141414}\n& .form-choice-card input{accent-color:#141414;top:20px;left:20px;width:20px;height:20px;min-height:0;padding:0;margin:0}\n& .form-choice-card input:focus{outline:none}\n& .form-choice-picture{border-radius:12px}";
// Die Objekte kommen aus dem Plugin „ImmoScout-Abgleich“; der Baustein trägt das Luximmo-Aussehen.
// Ohne eingeschaltetes Plugin bleibt die Seite erreichbar, nur die Liste fehlt.
$anlegen('immobilien', $einblenden([
'schemaVersion' => $SCHEMA, 'title' => 'Immobilien',
'description' => 'Aktuelle Immobilienangebote von Luximmo in Gaggenau und Umgebung, Baden-Baden und im Schwarzwald.',
'blocks' => [
$seitenkopf('immobilien', 'Aktuelle Angebote', 'Unsere Immobilien.', 'Wohnungen und Häuser, die wir aktuell in Gaggenau und Umgebung, Baden-Baden und im Schwarzwald anbieten.'),
$section('immobilien', [
['id' => 'immobilien-liste', 'type' => 'immoscout.liste',
'props' => ['art' => 'alle', 'darstellung' => 'kacheln', 'spalten' => '3', 'spaltenTablet' => '2', 'spaltenMobil' => '1', 'reihen' => 0, 'anzahl' => 0, 'bild' => 'breit', 'abstand' => 'normal', 'ausrichtung' => 'links', 'zeigeArt' => true, 'zeigeOrt' => true, 'zeigeFakten' => true],
'css' => "& .immoscout-karte{border:0;border-radius:24px;overflow:hidden;background:#F7F4EF}\n& .immoscout-karte-text{padding:24px 26px 28px}\n& .immoscout-karte h2{font-size:22px;font-weight:500;letter-spacing:-0.015em}\n& .immoscout-art{font-size:13px;font-weight:500;letter-spacing:0.04em;color:#6E5830}\n& .immoscout-ort{color:#5A554D;opacity:1}\n& .immoscout-karte dt{color:#5A554D;opacity:1}\n& .immoscout-karte dd{font-weight:500}\n& .immoscout-leer{border:0;border-radius:24px;background:#F7F4EF}\n& .immoscout-leer h2{font-weight:500;letter-spacing:-0.02em}\n& .immoscout-leer p{color:#5A554D}"],
], $abschnitt('0px', '160px'), null, '', 'wide'),
],
], ['immobilien-liste' => ['rise']]), 'Immobilien in Gaggenau, Baden-Baden und im Schwarzwald | LUXIMMO', $medien['miet']['id']);
$anlegen('immobilienbewertung', $einblenden([
'schemaVersion' => $SCHEMA, 'title' => 'Immobilienbewertung',
'description' => 'Was ist Ihre Immobilie wert? Luximmo schätzt Wohnungen, Häuser, Mehrfamilienhäuser und Grundstücke in Gaggenau und Umgebung – kostenlos und unverbindlich.',
@@ -1093,6 +1115,7 @@ $anker = static fn (string $label, string $anker): array => ['label' => $label,
$menus->save('header', 'Hauptnavigation', [
$anker('Leistungen', 'leistungen'),
$anker('Über uns', 'ueber-uns'),
['label' => 'Immobilien', 'type' => 'page', 'pageId' => $seiteId('immobilien'), 'children' => []],
['label' => 'Bewertung', 'type' => 'page', 'pageId' => $seiteId('immobilienbewertung'), 'children' => []],
['label' => 'Anfrage', 'type' => 'page', 'pageId' => $seiteId('verwaltungsanfrage'), 'children' => []],
], 0);
+1 -1
View File
@@ -16,7 +16,7 @@ final class DocumentValidator
/** Blocks introduced with schema v6 for the sections a marketing page actually needs. */
public const V6_TYPES = ['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'];
/** Icons ship as inline SVG from this fixed list; no icon font and no external request. */
public const ICONS = ['arrow-up-right', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'chevron-right', 'chevron-left', 'chevron-up', 'chevron-down', 'arrow-circle', 'external', 'refresh', 'menu', 'close', 'plus', 'minus', 'phone', 'app', 'mail', 'chat', 'send', 'calendar', 'clock', 'pin', 'map', 'navigation', 'web', 'wifi', 'support', 'shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase', 'document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book', 'user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star', 'home', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car', 'check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design', 'facebook', 'instagram', 'whatsapp'];
public const ICONS = ['arrow-up-right', 'arrow-right', 'arrow-left', 'arrow-up', 'arrow-down', 'chevron-right', 'chevron-left', 'chevron-up', 'chevron-down', 'arrow-circle', 'external', 'refresh', 'menu', 'close', 'plus', 'minus', 'phone', 'app', 'mail', 'chat', 'send', 'calendar', 'clock', 'pin', 'map', 'navigation', 'web', 'wifi', 'support', 'shop', 'cart', 'tag', 'euro', 'credit-card', 'gift', 'percent', 'truck', 'package', 'briefcase', 'document', 'download', 'upload', 'print', 'link', 'share', 'image', 'camera', 'video', 'play', 'pause', 'music', 'mic', 'book', 'user', 'people', 'login', 'lock', 'key', 'shield', 'eye', 'thumbs-up', 'award', 'graduation', 'heart', 'star', 'home', 'house', 'building', 'wrench', 'hammer', 'brush', 'ruler', 'leaf', 'tree', 'sun', 'droplet', 'flame', 'bolt', 'solar', 'thermometer', 'snowflake', 'car', 'check', 'check-circle', 'info', 'help', 'alert', 'search', 'settings', 'filter', 'grid', 'list', 'sparkle', 'coffee', 'utensils', 'laptop', 'hardware', 'network', 'design', 'facebook', 'instagram', 'whatsapp'];
/** Das Symbol eines Buttons, wenn keins gewählt ist: der Pfeil, den jeder Button vor v25 hatte. */
public const BUTTON_DEFAULT_ICON = 'arrow-up-right';
public const SPACER_SIZES = ['small', 'medium', 'large', 'huge'];
+5 -2
View File
@@ -707,7 +707,10 @@ final class FormRepository
'id' => $id, 'form_id' => $formId,
'data' => json_encode($values, JSON_THROW_ON_ERROR | JSON_UNESCAPED_UNICODE),
'sender_hash' => $senderHash, 'created_at' => $now,
'consent_at' => $consent ? $now : null, 'delivered' => false,
'consent_at' => $consent ? $now : null,
// Als Zahl: ohne Typangabe wird false als leere Zeichenkette gebunden, und MySQL im
// Strict-Modus lehnt die für eine Zahlenspalte ab. SQLite nimmt beides.
'delivered' => 0,
]);
return $id;
@@ -715,7 +718,7 @@ final class FormRepository
public function markDelivered(string $submissionId): void
{
$this->db->executeStatement('UPDATE form_submission SET delivered = ? WHERE id = ?', [true, $submissionId]);
$this->db->executeStatement('UPDATE form_submission SET delivered = ? WHERE id = ?', [1, $submissionId]);
}
/**
+6
View File
@@ -190,6 +190,12 @@ final class PageRepository
);
}
/** Ob eine Seite eine veröffentlichte Fassung trägt - auch wenn sie im Papierkorb liegt. */
public function hasPublication(string $pageId): bool
{
return false !== $this->db->fetchOne('SELECT id FROM page WHERE id = ? AND website_id = ? AND published_revision_id IS NOT NULL', [$pageId, 'default']);
}
public function trashPage(string $pageId, int $expectedVersion): int
{
return $this->db->transactional(function () use ($pageId, $expectedVersion): int {
+8 -1
View File
@@ -332,13 +332,20 @@ final class EditorController extends AbstractController
if (!$this->isCsrfTokenValid('pages', $request->request->getString('_csrf_token'))) {
return $this->back('Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.');
}
// Der Papierkorb lässt die veröffentlichte Fassung stehen: eine Seite, die online war, ist
// nach dem Wiederherstellen sofort wieder online. Das entscheidet, wer veröffentlichen
// darf (ADR 116) - wie beim Weg in den Papierkorb.
$live = $this->pages->hasPublication($id);
if ($live && !$this->isGranted('ROLE_PUBLISHER')) {
return $this->back('Diese Seite war veröffentlicht und wäre nach dem Wiederherstellen sofort wieder öffentlich. Das macht, wer veröffentlichen darf.');
}
try {
$this->pages->restorePage($id);
} catch (InvalidPage $e) {
return $this->back($e->getMessage());
}
return $this->back('Die Seite ist zurück. Sie ist erst nach dem Veröffentlichen wieder öffentlich.', 'success');
return $this->back($live ? 'Die Seite ist zurück und wieder öffentlich.' : 'Die Seite ist zurück. Sie ist ein Entwurf, bis sie veröffentlicht wird.', 'success');
}
#[Route('/pages/{id}/revisions/{revision}', name: 'admin_restore_revision', requirements: ['revision' => '[a-f0-9]{32}'], methods: ['POST'])]
+7 -2
View File
@@ -43,8 +43,12 @@ final class FormController extends AbstractController
{
$form = $this->forms->findBySlug($slug);
$path = trim($request->request->getString('page'), '/') ?: $this->settings->homePath();
// Ein Archiv (Beiträge, Kategorie, Schlagwort) ist keine gespeicherte Seite, trägt aber Kopf
// und Fuß wie jede andere - und damit oft das Kontaktformular. Ohne diese Ausnahme endete
// das Absenden dort in einer 404, und die Anfrage war weg.
$archive = 1 === preg_match(PageController::ARCHIVE_PATH, $path);
// Ein Formular kann auch auf der Seite eines Plugins stehen (ADR 127).
$page = $this->pages->findPublished($path) ?? $this->plugins->page('/'.$path);
$page = $archive ? ['id' => null, 'path' => $path] : ($this->pages->findPublished($path) ?? $this->plugins->page('/'.$path));
if (null === $form || null === $page) {
throw $this->createNotFoundException('Formular oder Seite nicht gefunden.');
}
@@ -65,7 +69,8 @@ final class FormController extends AbstractController
$language = $request->request->getString('language');
$translatedPath = null;
if ($this->languages->isExtra($language)) {
$translatedPath = $this->pages->translatedPaths($page['id'])[$language] ?? null;
// Archive heißen in jeder Sprache gleich und stehen nur hinter der Sprache.
$translatedPath = $archive ? $path : ($this->pages->translatedPaths($page['id'])[$language] ?? null);
}
$language = null === $translatedPath ? null : $language;
+12 -2
View File
@@ -49,6 +49,9 @@ final class PageController extends AbstractController
// the CMS routes (/admin, /media, /assets) out of the catch-all, which matches before them.
private const PATH = '(?!admin(?:/|$)|media/|assets/|kategorie/|schlagwort/|beitraege(?:/|$)|einwilligung$|feed\.xml$|sitemap\.xml$|robots\.txt$|llms\.txt$)[a-z0-9]+(?:-[a-z0-9]+)*(?:/[a-z0-9]+(?:-[a-z0-9]+)*){0,4}';
/** Die Adressen der Archive, ohne Sprache davor: Beiträge, eine Kategorie, ein Schlagwort. */
public const ARCHIVE_PATH = '#^(beitraege|kategorie/([a-z0-9-]{1,80})|schlagwort/([a-z0-9-]{1,80}))$#D';
#[Route('/', name: 'home', methods: ['GET'])]
#[Route('/{path}', name: 'page', requirements: ['path' => self::PATH], methods: ['GET'])]
public function page(Request $request, string $path = ''): Response
@@ -60,6 +63,11 @@ final class PageController extends AbstractController
if ('' !== $path && $this->languages->isExtra($segments[0])) {
return $this->translatedPage($request, $segments[0], $segments[1] ?? '');
}
// Nur der Formularversand kommt mit einer Archivadresse hier an (die Route selbst schließt
// sie aus): er zeigt das Archiv mit den Meldungen des Formulars noch einmal.
if (1 === preg_match(self::ARCHIVE_PATH, $path, $archiv)) {
return $this->renderArchive($request, str_starts_with($path, 'kategorie/') ? 'category' : 'tag', '' !== ($archiv[2] ?? '') ? $archiv[2] : (($archiv[3] ?? '') ?: null));
}
// Mehr als vier Ebenen gibt es nur mit einer Sprache davor.
if (substr_count($path, '/') >= PageRepository::MAX_DEPTH) {
return $this->notFound($request);
@@ -111,7 +119,7 @@ final class PageController extends AbstractController
return $this->shell($this->redirect('/'.$locale, 301));
}
// Die Archive gibt es in jeder Sprache unter derselben Adresse mit vorangestellter Sprache.
if (1 === preg_match('#^(beitraege|kategorie/([a-z0-9-]{1,80})|schlagwort/([a-z0-9-]{1,80}))$#D', $rest, $archiv)) {
if (1 === preg_match(self::ARCHIVE_PATH, $rest, $archiv)) {
$this->translation = ['locale' => $locale, 'map' => $map];
return $this->renderArchive($request, str_starts_with($rest, 'kategorie/') ? 'category' : 'tag', '' !== ($archiv[2] ?? '') ? $archiv[2] : (($archiv[3] ?? '') ?: null));
@@ -933,7 +941,9 @@ final class PageController extends AbstractController
// Stylesheet-Bündel, Nach-oben-Knopf, Einwilligung, Blätterverweise. Vorher hatte es eine
// eigene Vorlage, die von alledem nur die Palette kannte.
$kopf = [
['id' => 'archiv-titel', 'type' => 'heading', 'props' => ['text' => $heading, 'level' => 1]],
// Ab Schemaversion 12 wird der Text einer Überschrift als Markup gedruckt. Der Name
// einer Kategorie ist aber freier Text, den niemand gefiltert hat - also maskieren.
['id' => 'archiv-titel', 'type' => 'heading', 'props' => ['text' => htmlspecialchars($heading, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8'), 'level' => 1]],
['id' => 'archiv-zahl', 'type' => 'text', 'props' => ['text' => sprintf('%d %s', $result['total'], 1 === $result['total'] ? $t('Beitrag') : $t('Beiträge'))]],
];
if (null !== $term) {
+10
View File
@@ -177,6 +177,9 @@ final class SystemController extends AbstractController
@set_time_limit(0);
if ($request->request->getBoolean('finish')) {
$ergebnis = $this->archive->finish($this->installation);
if ($ergebnis['laeuft'] ?? false) {
return $this->back(ArchiveUpdate::LAEUFT_BEREITS);
}
$request->getSession()->set('update_steps', $ergebnis['steps']);
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
? 'Das Update ist fertig. Die Website ist wieder erreichbar.'
@@ -271,6 +274,10 @@ final class SystemController extends AbstractController
}
@set_time_limit(0);
$ergebnis = $this->archive->finish($this->installation, $this->pruefung);
if ($ergebnis['laeuft'] ?? false) {
// Der andere Abschluss schreibt den Stand fort; die Systemseite fragt ihn weiter ab.
return new JsonResponse(['ok' => true, 'meldung' => ArchiveUpdate::LAEUFT_BEREITS, 'status' => $this->archive->status()]);
}
$this->sendJsonAndStop(['ok' => $ergebnis['ok'], 'status' => $this->archive->status()]);
}
@@ -311,6 +318,9 @@ final class SystemController extends AbstractController
}
@set_time_limit(0);
$ergebnis = $this->archive->finish($this->installation);
if ($ergebnis['laeuft'] ?? false) {
return $this->back(ArchiveUpdate::LAEUFT_BEREITS);
}
$request->getSession()->set('update_steps', $ergebnis['steps']);
$this->addFlash($ergebnis['ok'] ? 'success' : 'error', $ergebnis['ok']
? sprintf('Update auf %s abgeschlossen. Die Website ist wieder erreichbar.', $offen['fassung'])
@@ -62,7 +62,7 @@ final class RemoteUpdateFinishSubscriber implements EventSubscriberInterface
}
@ignore_user_abort(true);
@set_time_limit(0);
$this->archive->finish($this->installation, $this->pruefung);
$this->archive->finish($this->installation, $this->pruefung, true);
} catch (\Throwable) {
// Was schiefging, steht im Updateprotokoll; die Wartungsseite bleibt dann an.
} finally {
+24 -1
View File
@@ -23,6 +23,8 @@ namespace App\Ops;
*/
final class ArchiveUpdate
{
public const LAEUFT_BEREITS = 'Der Abschluss läuft bereits in einer anderen Anfrage. Diese Seite in einem Moment neu laden.';
/** Was aus dem Archiv übernommen wird. Alles andere im Projektstamm bleibt unberührt. */
private const ERSETZEN = ['bin', 'config', 'migrations', 'src', 'templates', 'vendor', 'composer.json', 'composer.lock', 'migrations.php', 'cli-config.php', 'VERSION'];
@@ -363,7 +365,28 @@ final class ArchiveUpdate
* gibt null zurück oder den Grund, warum sie nicht
* läuft - dann wird zurückgeschaltet
*/
public function finish(Installation $installation, ?callable $pruefen = null): array
public function finish(Installation $installation, ?callable $pruefen = null, bool $gesperrt = false): array
{
// Nur ein Abschluss zur selben Zeit. Die Zwischenseite ruft ihn von selbst auf und bietet
// daneben einen Link an: Zwei Abschlüsse nebeneinander ließen den zweiten an der bereits
// laufenden Migration scheitern - und der schaltete dann zurück, Code und Datenbank,
// obwohl der erste gelungen war. $gesperrt: der Aufrufer hält die Sperre schon.
$sperre = $gesperrt ? null : $this->updater->lock();
if (!$gesperrt && null === $sperre) {
return ['ok' => false, 'laeuft' => true, 'steps' => [['step' => 'Abschluss', 'ok' => true, 'detail' => self::LAEUFT_BEREITS]]];
}
try {
return $this->abschliessen($installation, $pruefen);
} finally {
if (null !== $sperre) {
flock($sperre, LOCK_UN);
fclose($sperre);
}
}
}
/** @param (callable(): ?string)|null $pruefen */
private function abschliessen(Installation $installation, ?callable $pruefen): array
{
$schritte = [];
$offen = $this->pending();
+2 -2
View File
@@ -291,7 +291,7 @@ final class Scheduler
$this->db->insert('scheduled_task', [
'name' => $name,
'interval_seconds' => $task->interval(),
'enabled' => true,
'enabled' => 1,
'next_run_at' => $now->modify('+120 seconds')->format('Y-m-d H:i:s'),
'last_message' => '',
'last_duration_ms' => 0,
@@ -357,7 +357,7 @@ final class Scheduler
public function enable(string $name, bool $on): void
{
$this->db->executeStatement('UPDATE scheduled_task SET enabled = ? WHERE name = ?', [$on, $name]);
$this->db->executeStatement('UPDATE scheduled_task SET enabled = ? WHERE name = ?', [(int) $on, $name]);
$this->refreshState();
}
+3 -1
View File
@@ -160,8 +160,10 @@ final class PluginRegistry
private function write(string $name, array $fields): void
{
$existing = $this->rows()[$name] ?? null;
// Wahrheitswerte als Zahl: false ginge sonst als leere Zeichenkette an die Datenbank.
$fields = array_map(static fn (mixed $value): mixed => \is_bool($value) ? (int) $value : $value, $fields);
if (null === $existing) {
$this->db->insert('plugin', ['name' => $name, 'active' => false, 'version' => '', 'activated_at' => null, 'settings' => '{}', ...$fields]);
$this->db->insert('plugin', ['name' => $name, 'active' => 0, 'version' => '', 'activated_at' => null, 'settings' => '{}', ...$fields]);
} else {
$this->db->update('plugin', $fields, ['name' => $name]);
}
+4 -4
View File
@@ -81,8 +81,8 @@ final class LanguageExtension extends AbstractExtension
'Beiträge in der Kategorie „%s“.' => 'Posts in the category “%s”.',
'Beiträge zum Schlagwort „%s“.' => 'Posts tagged “%s”.',
'von' => 'by',
'← Neuere' => '← Newer',
'Ältere →' => 'Older →',
'Neuere' => 'Newer',
'Ältere' => 'Older',
'Seite %d von %d' => 'Page %d of %d',
'Externe Inhalte und Einwilligung' => 'External content and consent',
'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.' => 'This website only loads certain content once you agree. Without consent the page is shown in full; only embedded content from other providers stays empty.',
@@ -173,8 +173,8 @@ final class LanguageExtension extends AbstractExtension
'Beiträge in der Kategorie „%s“.' => '„%s“ kategorisindeki yazılar.',
'Beiträge zum Schlagwort „%s“.' => '„%s“ etiketli yazılar.',
'von' => 'yazan:',
'← Neuere' => '← Daha yeni',
'Ältere →' => 'Daha eski →',
'Neuere' => 'Daha yeni',
'Ältere' => 'Daha eski',
'Seite %d von %d' => 'Sayfa %d / %d',
'Externe Inhalte und Einwilligung' => 'Harici içerikler ve onay',
'Diese Website lädt bestimmte Inhalte erst, wenn du zustimmst. Ohne Zustimmung wird die Seite vollständig angezeigt, nur eingebettete Inhalte anderer Anbieter bleiben leer.' => 'Bu web sitesi bazı içerikleri ancak onayınızdan sonra yükler. Onay vermeseniz de sayfa eksiksiz görüntülenir; yalnızca diğer sağlayıcıların yerleştirilmiş içerikleri boş kalır.',
+1 -1
View File
@@ -78,7 +78,7 @@
{% elseif type == 'quote' %}
<figure id="{{ block.id }}" class="block-quote{{ designClasses }}">
{% if props.rating|default(0) > 0 %}
<p class="quote-rating"><span class="visually-hidden">Bewertung: {{ props.rating }} von 5</span><span aria-hidden="true">{% for i in 1..5 %}{{ i <= props.rating ? '★' : '☆' }}{% endfor %}</span></p>
<p class="quote-rating"><span class="visually-hidden">Bewertung: {{ props.rating }} von 5</span><span aria-hidden="true">{% for i in 1..5 %}<svg class="{{ i <= props.rating ? 'quote-star-voll' : 'quote-star' }}" viewBox="0 0 24 24" focusable="false"><path d="m12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z"/></svg>{% endfor %}</span></p>
{% endif %}
<blockquote><p>{{ props.text }}</p></blockquote>
<figcaption>{{ props.author }}{% if props.role|default('') %} <span>{{ props.role }}</span>{% endif %}</figcaption>
+1
View File
@@ -68,6 +68,7 @@
heart: 'M12 20s-7.5-4.6-7.5-10A4.3 4.3 0 0 1 12 7.4 4.3 4.3 0 0 1 19.5 10c0 5.4-7.5 10-7.5 10Z',
star: 'm12 3 2.7 5.9 6.3.7-4.7 4.3 1.3 6.1L12 17l-5.6 3 1.3-6.1L3 9.6l6.3-.7Z',
home: 'm3 11.5 9-7.5 9 7.5M5.5 10v10h13V10M10 20v-6.5h4V20',
house: 'M4 10.5 12 4l8 6.5V19a1 1 0 0 1-1 1h-4.5v-5.5h-5V20H5a1 1 0 0 1-1-1Z',
building: 'M5 21V4h10v17m0-12h4v12M3 21h18M8.5 7.5h3m-3 3.5h3m-3 3.5h3',
wrench: 'M14.5 3.5a5 5 0 0 0-4.6 6.9L3.5 16.8a1.9 1.9 0 0 0 2.7 2.7l6.4-6.4a5 5 0 0 0 6.9-4.6l-3 3-3-1-1-3Z',
hammer: 'M14 8.5 4.5 18a1.5 1.5 0 0 0 2.1 2.1L16 10.5M11 6l3-3 3.5 1L21 7.5 18.5 10l-2-2-2.5 2.5Z',
+2 -2
View File
@@ -41,8 +41,8 @@
{% if pages|default(1) > 1 %}
{# Dieselbe Blätterleiste wie im Archiv: eine Liste, die mehr hat als sie zeigt, sagt es. #}
<nav class="post-pager" aria-label="{{ 'Weitere Beiträge'|kant_t }}">
{% if page > 1 %}<a rel="prev" href="?seite={{ page - 1 }}">{{ '← Neuere'|kant_t }}</a>{% endif %}
{% if page > 1 %}<a rel="prev" href="?seite={{ page - 1 }}">{{ 'Neuere'|kant_t }}</a>{% endif %}
<span>{{ 'Seite %d von %d'|kant_t|format(page, pages) }}{% if total %} · {{ total }} {{ 'Beiträge'|kant_t }}{% endif %}</span>
{% if page < pages %}<a rel="next" href="?seite={{ page + 1 }}">{{ 'Ältere →'|kant_t }}</a>{% endif %}
{% if page < pages %}<a rel="next" href="?seite={{ page + 1 }}">{{ 'Ältere'|kant_t }}</a>{% endif %}
</nav>
{% endif %}
+1 -1
View File
@@ -125,7 +125,7 @@
{# Ein Anker, kein Skript. Er springt an den Anfang - das kann ein Browser allein. Der nav-Rahmen
gibt ihm eine Landmarke: ein Verweis außerhalb jeder Landmarke ist für Vorlesesoftware
Inhalt ohne Ort, und axe meldet ihn als Verstoß gegen „region“. #}
<nav aria-label="{{ oben.scrollTopLabel|default('Nach oben')|kant_t }}"><a class="scroll-top{{ oben.scrollTopPosition|default('rechts') == 'links' ? ' scroll-top-links' : '' }}" href="#seitenanfang" aria-label="{{ oben.scrollTopLabel|default('Nach oben')|kant_t }}"><span aria-hidden="true">↑</span></a></nav>
<nav aria-label="{{ oben.scrollTopLabel|default('Nach oben')|kant_t }}"><a class="scroll-top{{ oben.scrollTopPosition|default('rechts') == 'links' ? ' scroll-top-links' : '' }}" href="#seitenanfang" aria-label="{{ oben.scrollTopLabel|default('Nach oben')|kant_t }}"><svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M12 20V4.5m-6.5 6.5L12 4.5l6.5 6.5"/></svg></a></nav>
{% endif %}
</body>
</html>
+25
View File
@@ -311,6 +311,31 @@ final class ArchiveUpdateTest extends TestCase
self::assertFileDoesNotExist($this->root.'/'.Updater::MAINTENANCE_FILE);
}
public function testASecondFinishWhileOneRunsDoesNotSwitchBack(): void
{
// Meta-Refresh und der Link „Weiter, falls nichts passiert“ können den Abschluss zweimal
// anstoßen. Der zweite darf nichts tun - vorher scheiterte er an der laufenden Migration
// und schaltete ein gelungenes Update zurück.
$this->releases->release(self::BASE, self::API, '1.1.0', $this->archive('1.1.0'));
self::assertTrue($this->updater()->run()['ok']);
$sperre = (new Updater($this->root, 'sqlite:///'.$this->root.'/var/cms.db', new CommandRunner(), $this->db))->lock();
self::assertNotNull($sperre);
$zweiter = $this->updater()->finish($this->installation(), static fn (): string => 'würde zurückschalten');
self::assertTrue($zweiter['laeuft'] ?? false);
self::assertSame('1.1.0', trim((string) file_get_contents($this->root.'/VERSION')), 'Der Code der neuen Fassung bleibt.');
self::assertNotNull($this->updater()->pending(), 'Der offene Abschluss gehört weiter dem ersten Lauf.');
self::assertSame('migrieren', $this->updater()->status()['phase']);
flock($sperre, LOCK_UN);
fclose($sperre);
// Wer die Sperre schon hält (der Abschluss aus der Ferne), läuft durch.
$fertig = $this->updater()->finish($this->installation());
self::assertTrue($fertig['ok'], $this->explain($fertig));
self::assertNull($this->updater()->pending());
}
public function testANewVersionThatDoesNotRunIsSwitchedBackCompletely(): void
{
// Wie bei reiseSoft: Läuft die neue Fassung nach den Migrationen nicht, kommt die alte
+33
View File
@@ -163,6 +163,39 @@ final class BacklogTest extends TestCase
self::assertStringContainsString('Dieses Feld wird benötigt.', $antwort->getContent());
}
public function testAFormInTheFooterCanBeSentFromAnArchive(): void
{
(new FormRepository($this->db))->save('kontakt', 'Kontakt', [
['name' => 'email', 'type' => 'email', 'label' => 'E-Mail', 'required' => true],
['name' => 'datenschutz', 'type' => 'consent', 'label' => 'Einverstanden'],
], ['recipient' => 'info@example.org'], 0);
$footer = $this->partials->findByKind('footer');
$doc = $footer['document'];
$doc['schemaVersion'] = 23;
$doc['blocks'][] = ['id' => 'fuss-formular', 'type' => 'form', 'props' => ['form' => 'kontakt']];
$this->partials->save($footer['id'], $doc, $footer['version']);
$this->publishHome(DemoContent::starter('Start'));
$this->post('erster', 'Erster Beitrag', '2026-01-01 10:00', ['Neues']);
self::assertStringContainsString('name="page" value="beitraege"', $this->request('/beitraege')->getContent());
// Ein Archiv ist keine gespeicherte Seite. Vorher endete das Absenden hier in einer 404,
// bevor irgendetwas gespeichert oder verschickt war.
$body = fn (string $page, string $email): array => ['page' => $page, 'block' => 'fuss-formular', 'issued' => (string) (time() - 10), 'issuedSignature' => FormRepository::stamp('kontakt', time() - 10, 'test-only'), 'email' => $email, 'datenschutz' => 'ja'];
$fehler = $this->request('/formular/kontakt', 'POST', $body('beitraege', ''));
self::assertSame(422, $fehler->getStatusCode());
self::assertStringContainsString('Dieses Feld wird benötigt.', $fehler->getContent());
self::assertStringContainsString('Erster Beitrag', $fehler->getContent(), 'Die Meldung steht im Archiv, nicht auf einer Fehlerseite.');
foreach (['beitraege', 'kategorie/neues'] as $n => $archiv) {
$antwort = $this->request('/formular/kontakt', 'POST', $body($archiv, 'kunde'.$n.'@example.org'));
self::assertSame(303, $antwort->getStatusCode());
self::assertSame('/'.$archiv.'?gesendet=kontakt#fuss-formular', $antwort->headers->get('Location'));
self::assertSame($n + 1, (int) $this->db->fetchOne('SELECT COUNT(*) FROM form_submission'), 'Die Anfrage ist gespeichert.');
}
self::assertSame(200, $this->request('/beitraege?gesendet=kontakt')->getStatusCode());
self::assertSame(404, $this->request('/formular/kontakt', 'POST', $body('gibt-es-nicht', 'x@example.org'))->getStatusCode(), 'Eine unbekannte Seite bleibt eine unbekannte Seite.');
}
public function testASectionLinkedTwiceRendersWithoutDuplicateIds(): void
{
$sektion = DemoContent::starter('Band');
+12
View File
@@ -931,6 +931,18 @@ final class CmsTest extends TestCase
self::assertStringContainsString('href="https://cms.test/beitraege"', $html);
}
public function testACategoryNameIsPrintedAsTextInItsArchiveHeading(): void
{
// Die Überschrift des Archivs läuft durch den Baustein „Überschrift“, der seinen Text als
// Markup druckt. Ein Kategoriename ist aber ungefilterter Text eines Redakteurs.
$beitrag = $this->publishedPost('markup', 'Markup', '2026-02-01', ['Bau & <img src="https://example.org/x.png"> Technik']);
$href = $this->posts->termsFor($beitrag)['category'][0]['href'];
$html = $this->request($href)->getContent();
self::assertStringNotContainsString('<img src="https://example.org/x.png">', $html);
self::assertStringContainsString('Bau &amp; &lt;img src=&quot;https://example.org/x.png&quot;&gt; Technik</h1>', $html);
}
public function testCategoriesAndTagsGroupPostsAndGetTheirOwnArchives(): void
{
$projekt = $this->publishedPost('kanttresor', 'KantTresor', '2026-02-01', ['Projekte'], ['WordPress', 'DSGVO']);
+14
View File
@@ -382,9 +382,23 @@ final class EditorSecurityTest extends WebTestCase
$client->request('POST', '/admin/pages/'.$entwurf.'/move', ['_csrf_token' => $token, 'expectedVersion' => $version($entwurf), 'slug' => 'neuer-name']);
self::assertSame('neuer-name', $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$entwurf]));
// Liegt die veröffentlichte Seite im Papierkorb, holt er sie nicht zurück: sie wäre sofort
// wieder online. Seinen Entwurf dagegen schon.
$pages->trashPage($live, $version($live));
$pages->trashPage($entwurf, $version($entwurf));
foreach ([$live, $entwurf] as $id) {
$client->request('POST', '/admin/pages/'.$id.'/restore', ['_csrf_token' => $token]);
self::assertResponseRedirects();
}
self::assertNotNull($this->db->fetchOne('SELECT deleted_at FROM page WHERE id = ?', [$live]), 'Ein Redakteur stellt keine veröffentlichte Seite wieder online.');
self::assertNull($this->db->fetchOne('SELECT deleted_at FROM page WHERE id = ?', [$entwurf]));
// Wer veröffentlichen darf, darf auch das.
$client = $this->login('publisher', $client);
$token = $client->request('GET', '/admin')->filter('input[name="_csrf_token"]')->last()->attr('value');
$client->request('POST', '/admin/pages/'.$live.'/restore', ['_csrf_token' => $token]);
self::assertNull($this->db->fetchOne('SELECT deleted_at FROM page WHERE id = ?', [$live]));
self::assertStringContainsString('wieder öffentlich', $client->followRedirect()->text());
$client->request('POST', '/admin/pages/'.$live.'/move', ['_csrf_token' => $token, 'expectedVersion' => $version($live), 'slug' => 'umbenannt']);
self::assertSame('umbenannt', $this->db->fetchOne('SELECT path FROM page WHERE id = ?', [$live]));
}