Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
090436233d | ||
|
|
8b212d5cc9 | ||
|
|
daad5a1740 | ||
|
|
dd5bc16413 | ||
|
|
bb9d7c6b45 | ||
|
|
be4a4f23d8 | ||
|
|
32e42f973b | ||
|
|
a57b829a15 | ||
|
|
e465ec7437 | ||
|
|
9e82918a5e | ||
|
|
3c5c50f499 | ||
|
|
c9c797e592 | ||
|
|
ad7c333215 | ||
|
|
14e7a3b310 | ||
|
|
69f7977ecf | ||
|
|
6f6e5d876c | ||
|
|
6fa303f901 | ||
|
|
6390c00876 | ||
|
|
6ee05f4c70 | ||
|
|
1b6cbb2247 | ||
|
|
b298b7469f | ||
|
|
760efc3297 | ||
|
|
28070298aa | ||
|
|
436c66ecd3 | ||
|
|
a6a334fe68 | ||
|
|
339a48c810 | ||
|
|
f2b29afe22 | ||
|
|
47292c7266 | ||
|
|
8aee38bde8 | ||
|
|
91ebfde829 | ||
|
|
ad86e4874b | ||
|
|
e15b2517b1 | ||
|
|
c7705b7083 | ||
|
|
28b2442188 | ||
|
|
8e628b9417 | ||
|
|
13f6759df8 | ||
|
|
48ae2443cd | ||
|
|
f00fbfef09 | ||
|
|
08998de533 | ||
|
|
6af2e2aead | ||
|
|
88737a95a9 | ||
|
|
e1753fa7e4 | ||
|
|
5bfe1f3c6e | ||
|
|
0ba82b7d33 | ||
|
|
668287f640 | ||
|
|
5e5d380fd3 | ||
|
|
6fb6d30e8d | ||
|
|
ace6c50743 | ||
|
|
82a74277cb | ||
|
|
5d55a65c22 | ||
|
|
24c1d9c871 | ||
|
|
a976c146de | ||
|
|
3ea28a03fc | ||
|
|
711af312af | ||
|
|
31fb53c1fc | ||
|
|
24f98633d0 | ||
|
|
e47f278a13 | ||
|
|
e8a9dcdf5c | ||
|
|
04927b9563 | ||
|
|
3b1dfc0948 | ||
|
|
e21e800f2c | ||
|
|
d8c8cbe7bd | ||
|
|
45d1141951 | ||
|
|
42e74af052 | ||
|
|
106f84dfd5 | ||
|
|
6632836580 | ||
|
|
d0019c80af | ||
|
|
ab9e6a7ea0 | ||
|
|
8328bf0964 | ||
|
|
245f189d57 | ||
|
|
99b3aba39a | ||
|
|
c91f996524 | ||
|
|
0ed4c83e41 | ||
|
|
9e0365f841 | ||
|
|
ff2b57db58 | ||
|
|
741eaa80f7 | ||
|
|
23fdca4edb | ||
|
|
a8368068aa | ||
|
|
f4ec0d9877 | ||
|
|
d08d6f648a | ||
|
|
4539f357c1 | ||
|
|
d885b3ddb5 | ||
|
|
59a40157a4 | ||
|
|
6b1aa50d02 | ||
|
|
30aac4389d | ||
|
|
53fb713f74 | ||
|
|
c8a8fa7460 | ||
|
|
66ff68abf8 | ||
|
|
d07154eec1 | ||
|
|
7eb462ac7b | ||
|
|
45040b2ca6 | ||
|
|
937344e642 | ||
|
|
232e6fe6dc | ||
|
|
533e181590 | ||
|
|
5c2dabdd5d | ||
|
|
32ee28888e | ||
|
|
f6350de636 | ||
|
|
d05dba3042 | ||
|
|
4d1f51801c | ||
|
|
160c394c3f | ||
|
|
a4ee04533c | ||
|
|
fb7cfa37d1 | ||
|
|
0aa1ff6d3e | ||
|
|
65b11563a8 | ||
|
|
0bdcc7f90e | ||
|
|
860bed9199 | ||
|
|
1973bd10ef | ||
|
|
a434e0cefc | ||
|
|
858b8c6de4 | ||
|
|
5f7ea63b76 | ||
|
|
3cb5b03dd9 | ||
|
|
73719bae4a | ||
|
|
215ab064ae | ||
|
|
042aef9a32 | ||
|
|
a19b6cc1c9 | ||
|
|
fd4cb75548 | ||
|
|
30e4b3eb2e | ||
|
|
cef710d5db | ||
|
|
434e89f779 | ||
|
|
6dc30a43fe | ||
|
|
9095aa5d2c | ||
|
|
769b0fae2d | ||
|
|
9c8462d46d | ||
|
|
3785d3dfef | ||
|
|
b634f486e5 | ||
|
|
2299184401 | ||
|
|
a1f8b905cd | ||
|
|
ebc16777d6 | ||
|
|
7c1c86b864 | ||
|
|
c82a37643b | ||
|
|
cff468167b | ||
|
|
f182f9e125 | ||
|
|
37c2942d1d | ||
|
|
eea6747d9e | ||
|
|
3391fea58b | ||
|
|
bb4e422af8 | ||
|
|
81276ddd1a | ||
|
|
d300eae241 | ||
|
|
a3029966cb | ||
|
|
a108e7d143 | ||
|
|
55dab4bc1e | ||
|
|
c1b152b376 | ||
|
|
8f475d1374 | ||
|
|
ab86312e87 | ||
|
|
16da844934 | ||
|
|
a3c89bb69d | ||
|
|
25fd388f80 | ||
|
|
e18c881db8 | ||
|
|
6d21c452bd | ||
|
|
b8991ddd5c | ||
|
|
0920e3cb83 | ||
|
|
d78a58f714 | ||
|
|
41d87f62a4 | ||
|
|
11b60fc5d8 | ||
|
|
495b26896e | ||
|
|
b164c4edfe | ||
|
|
6a044e59e9 | ||
|
|
149e1fe520 | ||
|
|
b783d9d1e5 | ||
|
|
05ea703655 | ||
|
|
d1ac9d760b | ||
|
|
7c3c8671a7 | ||
|
|
9726af9254 | ||
|
|
7225ae052e | ||
|
|
0e2334d30f | ||
|
|
22e4ada830 | ||
|
|
af5a2cef86 | ||
|
|
d295757ce0 | ||
|
|
4153ba0f65 | ||
|
|
d6d476177a | ||
|
|
ea3dd4ddf2 | ||
|
|
c517173579 | ||
|
|
c5e85e4db0 | ||
|
|
43c20b14c7 | ||
|
|
b218dc79ff | ||
|
|
aaea385422 | ||
|
|
9f1ac39251 | ||
|
|
de1e66350b | ||
|
|
74766f6684 | ||
|
|
6c477a52be | ||
|
|
e0d06ac44c | ||
|
|
a2dfd0f796 | ||
|
|
7c6b2a9ec8 | ||
|
|
074ef0e7e5 | ||
|
|
35d8270afd | ||
|
|
f2ef2a4901 | ||
|
|
f0b634f0c1 | ||
|
|
0d6c1a17af | ||
|
|
84e88c6636 | ||
|
|
1b22265a54 | ||
|
|
5a5933f8cd | ||
|
|
b2c4f45474 | ||
|
|
2715d329ef | ||
|
|
1b164f4c86 | ||
|
|
5f23194760 | ||
|
|
e6dcc8e823 | ||
|
|
e21dff91fe | ||
|
|
70283db20a | ||
|
|
efa285c0d0 | ||
|
|
e34b4dca67 | ||
|
|
c3bff7d936 | ||
|
|
c046681302 | ||
|
|
269fb223af | ||
|
|
8c49f6a67a | ||
|
|
af3589c95c | ||
|
|
67b85751cf | ||
|
|
464f52b147 | ||
|
|
806df4b5df | ||
|
|
7251b82f09 | ||
|
|
30d8d58bbf |
No files matched your search
+191
-1
@@ -18,6 +18,196 @@
|
||||
"bin/dev-router.php"
|
||||
],
|
||||
"port": 8080
|
||||
},
|
||||
{
|
||||
"name": "kantsites-pilot",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/pilot-a1c96276da5c/pilot.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; & C:/php84/php.exe -d upload_max_filesize=24M -d post_max_size=26M -d memory_limit=256M -S 127.0.0.1:8092 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8092
|
||||
},
|
||||
{
|
||||
"name": "elektro-bernhard",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/elektro-bernhard.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/elektro-bernhard-media'; $env:SITE_ORIGIN='http://127.0.0.1:8088'; & php -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8088 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8088
|
||||
},
|
||||
{
|
||||
"name": "tagespflege-moldovan",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/tagespflege-moldovan.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/tagespflege-moldovan-media'; $env:SITE_ORIGIN='http://127.0.0.1:8089'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8089 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8089
|
||||
},
|
||||
{
|
||||
"name": "kantit",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantit.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantit-media'; $env:SITE_ORIGIN='http://127.0.0.1:8130'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8130 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8130
|
||||
},
|
||||
{
|
||||
"name": "maler-drews",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/maler-drews.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/maler-drews-media'; $env:SITE_ORIGIN='http://127.0.0.1:8093'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8093 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8093
|
||||
},
|
||||
{
|
||||
"name": "immobilien-deeg",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/immobilien-deeg.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/immobilien-deeg-media'; $env:SITE_ORIGIN='http://127.0.0.1:8094'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8094 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8094
|
||||
},
|
||||
{
|
||||
"name": "ebsolar",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebsolar.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebsolar-media'; $env:SITE_ORIGIN='http://127.0.0.1:8096'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8096 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8096
|
||||
},
|
||||
{
|
||||
"name": "ebsolar-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/ebsolar.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/ebsolar-media'; $env:SITE_ORIGIN='http://127.0.0.1:8097'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8097 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8097
|
||||
},
|
||||
{
|
||||
"name": "immobilien-deeg-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/immobilien-deeg.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/immobilien-deeg-media'; $env:SITE_ORIGIN='http://127.0.0.1:8095'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8095 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8095
|
||||
},
|
||||
{
|
||||
"name": "kantarci-immobilien",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8098'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8098 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8098
|
||||
},
|
||||
{
|
||||
"name": "kantarci-immobilien-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-immobilien.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-immobilien-media'; $env:SITE_ORIGIN='http://127.0.0.1:8099'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8099 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8099
|
||||
},
|
||||
{
|
||||
"name": "kantarci-markt",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-markt.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-markt-media'; $env:SITE_ORIGIN='http://127.0.0.1:8100'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8100 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8100
|
||||
},
|
||||
{
|
||||
"name": "kantarci-markt-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-markt.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-markt-media'; $env:SITE_ORIGIN='http://127.0.0.1:8101'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8101 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8101
|
||||
},
|
||||
{
|
||||
"name": "huefner",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/huefner.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8102'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8102 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8102
|
||||
},
|
||||
{
|
||||
"name": "huefner-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/huefner.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8103'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8103 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8103
|
||||
},
|
||||
{
|
||||
"name": "kantarci-reisen",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-reisen.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-reisen-media'; $env:SITE_ORIGIN='http://127.0.0.1:8104'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8104 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8104
|
||||
},
|
||||
{
|
||||
"name": "kantarci-reisen-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/kantarci-reisen.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/kantarci-reisen-media'; $env:SITE_ORIGIN='http://127.0.0.1:8105'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8105 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8105
|
||||
},
|
||||
{
|
||||
"name": "sprachen-test",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='dev'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/lang/huefner-lang.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8111'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8111 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8111
|
||||
},
|
||||
{
|
||||
"name": "sprachen-test-prod",
|
||||
"runtimeExecutable": "powershell",
|
||||
"runtimeArgs": [
|
||||
"-NoProfile",
|
||||
"-Command",
|
||||
"$env:APP_ENV='prod'; $env:APP_DEBUG='0'; $env:DATABASE_URL='sqlite:///C:/Django Project/KantSites/var/browser-tests/lang/huefner-lang.db'; $env:MEDIA_DIRECTORY='C:/Django Project/KantSites/var/huefner-media'; $env:SITE_ORIGIN='http://127.0.0.1:8112'; & C:/php84/php.exe -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0.0.1:8112 -t public bin/dev-router.php"
|
||||
],
|
||||
"port": 8112
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +0,0 @@
|
||||
APP_ENV=dev
|
||||
APP_DEBUG=1
|
||||
DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"
|
||||
SITE_ORIGIN=http://127.0.0.1:8080
|
||||
MEDIA_DIRECTORY="%kernel.project_dir%/var/media"
|
||||
MAILER_DSN=sendmail://default
|
||||
@@ -0,0 +1,17 @@
|
||||
# Vorlage: Der Installer erstellt daraus die Konfiguration .env.
|
||||
# .env gehört der Installation und wird bei Updates erhalten, nicht in Git gespeichert.
|
||||
# Installer, Setup und Admin-Pfad-Befehl schreiben ausschließlich in .env.
|
||||
# Ältere .env.local-Einstellungen werden einmalig mit Sicherung zusammengeführt.
|
||||
APP_ENV=prod
|
||||
APP_DEBUG=0
|
||||
DATABASE_URL="sqlite:///%kernel.project_dir%/var/cms.db"
|
||||
# Für MySQL stattdessen, mit den Zugangsdaten des Hostings:
|
||||
# DATABASE_URL="mysql://benutzer:passwort@127.0.0.1:3306/datenbank?charset=utf8mb4"
|
||||
# Die oeffentliche Adresse der Website, mit oder ohne www - so, wie sie in Sitemap, Feed und
|
||||
# Canonical stehen soll. Die andere Schreibweise leitet die Anwendung dorthin um.
|
||||
SITE_ORIGIN=https://example.de
|
||||
MEDIA_DIRECTORY="%kernel.project_dir%/var/media"
|
||||
MAILER_DSN=sendmail://default
|
||||
# Die Adresse der Verwaltung. Ohne Eintrag: /admin. Aenderbar mit
|
||||
# `php bin/console cms:admin:path <neu>` - der Befehl schreibt sie nach .env.
|
||||
#ADMIN_PATH=admin
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
- run: php vendor/bin/doctrine-migrations migrate --no-interaction
|
||||
- run: php bin/console cms:seed-demo
|
||||
- run: php bin/console cms:seed-demo
|
||||
- run: php bin/console lint:twig templates && php bin/console lint:yaml config && php bin/console lint:container
|
||||
- run: php bin/console lint:twig templates && php bin/console lint:yaml config --parse-tags && php bin/console lint:container
|
||||
- run: composer build
|
||||
browser:
|
||||
runs-on: ubuntu-latest
|
||||
@@ -83,3 +83,8 @@ jobs:
|
||||
- run: php vendor/bin/doctrine-migrations migrate --no-interaction
|
||||
- run: php bin/console cms:seed-demo
|
||||
- run: php bin/console cms:seed-demo
|
||||
# Vorher hoerte der Job hier auf und sagte nichts ueber Sicherung und Betrieb auf MySQL.
|
||||
- run: php bin/console cms:health
|
||||
- run: php bin/console cms:backup --keep=2
|
||||
- run: php bin/console cms:backup --list
|
||||
- run: php vendor/bin/phpunit
|
||||
@@ -3,9 +3,18 @@
|
||||
/var/
|
||||
/node_modules/
|
||||
/.env.local
|
||||
/.env
|
||||
/.env.lock
|
||||
/.env.*.tmp
|
||||
/.env.*.local
|
||||
/phpunit.cache/
|
||||
/reports/
|
||||
/.php-cs-fixer.cache
|
||||
*.log
|
||||
/config/reference.php
|
||||
/var/releases/
|
||||
|
||||
# Die Schriften der Pilotwebsite liegen im Repository (siehe public/assets/fonts/LIZENZ.md),
|
||||
# damit auf einem frischen Server nichts fehlt und nichts von Google geladen wird. Andere
|
||||
# Installationen holen ihre eigenen mit `cms:fonts:fetch` - deren Dateien gehoeren nicht hierher.
|
||||
/public/assets/build/
|
||||
@@ -0,0 +1,24 @@
|
||||
# Schutz fuer den Fall, dass die Domain auf den Projektordner zeigt statt auf public/ (ADR 116).
|
||||
#
|
||||
# Bewusst kein "alles sperren": Apache wendet diese Datei auch auf public/ an, und dort soll alles
|
||||
# wie gewohnt laufen. Gesperrt werden nur Dateien, die es in public/ nicht gibt oder die
|
||||
# public/.htaccess ohnehin sperrt. Die Ordner var/, vendor/, config/, src/ usw. tragen eine eigene
|
||||
# Sperrdatei.
|
||||
<FilesMatch "^\.|\.(env|db|sqlite|sqlite3|lock|md|yaml|yml|dist|log|key)$">
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
<FilesMatch "^(composer\.json|VERSION|cli-config\.php|migrations\.php)$">
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
@@ -1,9 +1,175 @@
|
||||
# KantSites
|
||||
|
||||
Lokaler CMS-Prototyp für PHP-Webhosting mit erstem visuellen Editor. Symfony 7.4, Twig, Doctrine DBAL/Migrations; SQLite lokal, MySQL/MariaDB als Hostingziel. Drei gespeicherte Beispielseiten werden ohne JavaScript als HTML ausgeliefert. Anmeldung, geschützte Bearbeitung und getrennte Veröffentlichung sind implementiert; weitere Layoutwerkzeuge folgen.
|
||||
Selbst betreibbares Block-CMS für PHP-Webhosting mit visuellem Editor nach dem Bedienprinzip des Flatsome UX Builders. Symfony 7.4, Twig, Doctrine DBAL/Migrations; SQLite oder MySQL/MariaDB. Öffentliche Seiten kommen als fertiges HTML ohne Pflicht-JavaScript, mit Sitemap, Feed, strukturierten Daten und `llms.txt`. Webinstaller, Releasearchive mit Update aus der Verwaltung, Sicherung, Aufgabenplan, Formulare, Statistik und Einwilligung sind eingebaut. Fünf Websites laufen lokal als eigene Installationen (Kant-IT, Elektro Bernhard, Tagespflege Moldovan, Malerservice Drews, DEEG Immobilien), `web.kant-it.de` und das Drews-Hosting mit Releasearchiven.
|
||||
|
||||
Die übernommenen Originaldokumente sind `AGENTS.md` und `ENTWICKLUNGSPLAN.md`. Den tatsächlichen Stand enthält `docs/STATUS.md`, Entscheidungen und Datenmodell stehen in `docs/ARCHITECTURE.md`.
|
||||
|
||||
## Buttons im Menü (ab 1.26.0)
|
||||
|
||||
Unter **Menüs** einen Eintrag aufklappen und **Als Button darstellen** anhaken. Dann stehen Form, Darstellung und Größe (leer = Website-Standard unter Design → Buttons), Fläche und Schrift – auch beim Überfahren – sowie die Symbolfarbe mit der Farbpalette zur Wahl; das Symbol wird wie im Editor im Fenster gewählt, dazu Bewegung und „Nur das Symbol zeigen“. **Auf dem Telefon**: im aufgeklappten Menü (volle Breite), neben dem Menü-Knopf oder dort nur als Symbol. Eine Vorschau zeigt den Button normal und beim Überfahren. Einträge mit Unterpunkten können kein Button sein. Prüfung: `npm run test:menu-buttons`, Screenshots unter `reports/menu-buttons/`. Details: ADR 110.
|
||||
|
||||
## Bildecken unter Design (ab 1.21.0)
|
||||
|
||||
Wie stark die Ecken der Bilder gerundet sind, steht jetzt unter **Design → Bildecken** und gilt für die ganze Website – Bilder, Galerien, Logoleisten und Beitragslisten. Vorgabe ist `12px` wie bisher, `0px` macht sie eckig. Ein einzelnes Bild lässt sich davon abweichend einstellen: im Editor beim ausgewählten Bild unter **Design → Bild → Bildecken**, dort auch je Gerät und je Ecke. Vorher stand die Rundung fest im Stylesheet und war in keinem Feld zu finden.
|
||||
|
||||
## Kaufmännisches Und und Zeilenumbrüche bleiben, wie sie sind (ab 1.20.1)
|
||||
|
||||
**Behoben.** Ein Text ohne Auszeichnung wurde beim Speichern doppelt kodiert: aus `&` wurde `&`, beim nächsten Speichern `&amp;` – irgendwann stand die Entity sichtbar auf der Seite. Ebenso wuchsen Zeilenumbrüche: jedes zweite Speichern machte aus einem `<br>` zwei. Beides ist behoben, und wiederholtes Speichern ändert einen Text jetzt nicht mehr. Was bereits falsch gespeichert ist, räumt `php bin/console cms:repair:entities` auf: ohne Option zeigt er nur, was sich ändern würde, mit `--schreiben` wendet er es an. Angefasst werden die veröffentlichte Fassung und der Entwurf jeder Seite sowie die Bereiche; ältere Fassungen bleiben Historie.
|
||||
|
||||
## Eigenes HTML und eingebettete Seiten (ab 1.20.0)
|
||||
|
||||
Der Baustein **Eigenes HTML** (Werkzeugkasten, Gruppe „Seite und Daten“) nimmt Markup auf, das unverändert auf der Seite landet – eigene Tabellen, fremde Widgets, Auszeichnungen, die es als Baustein nicht gibt. Ändern darf ihn nur ein **Administrator**, genau wie das Feld „Eigenes Skript“; für alle anderen ist er sichtbar und schreibgeschützt, und der Server weist eine Änderung ohnehin ab. Zwei Tags nimmt er nicht an: `<script>` gehört unter „Erweitert“ in „Eigenes Skript“, und ein `<iframe>` in den Baustein **Externer Inhalt** – nur der fragt vorher um Einwilligung und trägt den Anbieter in die Sicherheitsrichtlinie ein (ADR 106).
|
||||
|
||||
Am eingebetteten Rahmen lässt sich alles gestalten, was dieser Website gehört: Höhe als Eigenschaft, Abstände, Breite, Rand und Ecken über Design-Tokens und freie Stile, dazu eigenes CSS am Block (`& iframe{…}`) und für den Platzhalter vor der Einwilligung (`&.embed-blocked`, `& .embed-title`, `& .consent-button`). **Was im Rahmen steht, gehört dem Anbieter** – daran kommt kein CSS dieser Website heran, das trennt der Browser zwischen zwei Herkünften. Den Einwilligungsbalken selbst gestaltet man unter **Einstellungen** (Form und vier Farben).
|
||||
|
||||
## Adresse mit oder ohne www, Rollen wie versprochen, Gesamtprüfung (ab 1.17.0)
|
||||
|
||||
**Eine Adresse.** `SITE_ORIGIN` in `.env` entscheidet, ob die Website mit oder ohne `www` heißt; die andere Schreibweise leitet die Anwendung selbst mit 301 dorthin um (auch die Verwaltung), ein fremder Host bleibt erreichbar. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt die Einstellung, der Bildschirm „System“ zeigt sie und warnt, wenn die Verwaltung gerade unter einer anderen Adresse läuft. Der www-Block in `public/.htaccess` ist weg.
|
||||
|
||||
**Rollen.** Redakteure bearbeiten Entwürfe, laden Medien hoch und sehen die Statistik. Veröffentlicher dürfen zusätzlich veröffentlichen, Bereiche, Menüs, Formulare, Weiterleitungen und die 404-Seite pflegen. Administratoren allein ändern Design, Unternehmensdaten (samt Einwilligung und Spamschutz), Benutzer, System und eigene Skripte. Vorher genügte für Design, Einstellungen, Menüs, Bereiche und Formulare die Rolle Redakteur – entgegen der Rollenhilfe. Die Navigation zeigt nur, was die Rolle öffnen darf.
|
||||
|
||||
**Behoben.** Nach `cms:admin:path` war keine Anmeldung möglich (Sitzungscookie blieb auf `/admin`). Der Installer öffnete sich wieder, sobald die Datenbank nicht antwortete. Ein MySQL-Passwort mit Sonderzeichen ließ den Container abbrechen (`resolve:`), ebenso ein SMTP-Passwort. Die Einwilligung nahm Entscheidungen von fremden Websites an und leitete auf `/\fremd.example` weiter. `</script>` in Titel oder FAQ zerriss die strukturierten Daten. Eigenes CSS konnte mit einem Komma den Block verlassen. Eine alte Fassung konnte ein Skript an der Administratorregel vorbei zurückholen. Im Editor ließen sich Menü, Beitragsliste und Slider in älteren Dokumenten nicht speichern (Schemafassung), „Gestaltung einfügen“ und „Als Vorlage speichern“ waren fälschlich aktiv, „Ausrücken“ und Baum-Drop umgingen die Elternregeln, ein Bild auf ein Akkordeon warf einen Fehler, abgelehnte Einfügungen hinterließen leere Verlaufsschritte. Dazu: Schatten bei Überfahren/Scrollen, Tabs und Bildbetrachter in Kopf, Fuß und Sektionen, Sitemap-`lastmod`, Feed-Zeitzone, Vollbild in eingebetteten Videos, Update-Downloads nur über HTTPS-Weiterleitungen, Composer- und PHP-Aufruf aus dem Web-Update unter php-fpm, unerwartete Fehler im Updater nehmen den Rückweg, eine geplante Veröffentlichung blockiert die anderen nicht mehr, keine Unterseite unter einer Seite im Papierkorb.
|
||||
|
||||
## Spaltenbreiten einer Zeile durch Ziehen (ab 1.16.0)
|
||||
|
||||
Wähle eine Zeile oder eine ihrer Spalten: auf jeder Grenze zwischen zwei Spalten liegt ein Griff. Ziehen macht die eine Spalte breiter und die andere schmaler, Loslassen ist ein Schritt für „Rückgängig“; die Pfeiltasten verschieben um fünf Prozentpunkte. Der Wert steht auch im Design-Bedienfeld der Zeile unter **Layout → Spaltenbreiten**, etwa `2 1` oder `25 50 25`, und gilt je Gerät. Wo die Spalten gestapelt sind, etwa auf dem Handy, gibt es keinen Griff und die Spalten bleiben untereinander.
|
||||
|
||||
## Rückstände der Gesamtprüfung (unveröffentlicht)
|
||||
|
||||
Archivseiten (`/beitraege`, Kategorien, Schlagwörter) sind jetzt gewöhnliche Seiten mit Kopf, Fuß, Sektionen, eigenem CSS und Nach-oben-Knopf; blätternde Beitragslisten tragen Canonical mit Seitenzahl und `rel=prev/next`. Formulare, Beitragslisten und Brotkrumen funktionieren auch in Kopf, Fuß und verknüpften Sektionen, eigene Skripte von Bereichen laufen. Eine zweimal verknüpfte Sektion bekommt beim zweiten Mal eigene Kennungen. Tablet und Telefon trennen sich überall bei 767 px. Dunkle Paletten färben Formularfelder, Fehlermeldungen, Skip-Link und Aufruf-Buttons mit; es gibt Druckstile. **Betrieb:** Sicherung, Wiederherstellung, Archiv-Update und Rückweg laufen auch auf MySQL/MariaDB (`mysqldump` und `mysql` müssen aufrufbar sein), Updates sperren sich gegenseitig (`var/update.lock`), das Archiv wird direkt in eine Datei geladen, `public/.htaccess` behält beim Archiv-Update eingeschaltete HTTPS- und HSTS-Zeilen, und nach dem Update werden `var/update/` (bis auf zwei) und alte Caches aufgeräumt. Der Editor zeigt in der Vorschau eigenes CSS, Bereichsstile und Formulare; frisch eingefügte Kontaktdaten, Formulare, verknüpfte Sektionen und externe Inhalte sind gültig, bis man sie füllt.
|
||||
|
||||
## Plugins (unveröffentlicht)
|
||||
|
||||
Erweiterungen liegen als Ordner unter `plugins/` mit einer `plugin.json`, Twig-Vorlagen und optional Stylesheet und Skript unter `assets/`. Unter **Plugins** (Administrator) werden sie ein- und ausgeschaltet; ihre Bausteine erscheinen dann im Editor unter „Erweiterungen“, mit einem Eigenschaftenpanel aus dem Schema des Manifests. Der Kern prüft die Eigenschaften beim Speichern und beim Rendern, liefert Stylesheet und Skript nur auf Seiten mit dem Baustein aus und lässt die Bausteine eines ausgeschalteten Plugins unangetastet in den Seiten stehen. Ein Plugin kann außerdem Container-Bausteine (mit Liste erlaubter Kinder), Einstellungen für den Betreiber (Formular auf dem Bildschirm „Plugins“), eigene Adressen wie `/vergleich/daten` (Twig-Vorlage, Antwort als html, json, text oder xml), und Seitenvorlagen für den Editor mitbringen. Farben, Schriften und Buttons fasst ein Plugin nicht an – die ändert man unter **Design** (ADR 105). PHP-Code bringt ein Plugin nicht mit – deshalb gibt es keine Hooks und keine eigenen Tabellen (ADR 103). Mitgeliefert ist **Bildvergleich** (`plugins/vergleich`: Bildvergleich, Vergleichsgalerie, Vorlage, Adresse, Einstellungen). Wie man ein Plugin schreibt, steht in `docs/PLUGINS.md`. Migrationen `20260915000100` und `20260916000100` legen die Tabelle `plugin` mit Einstellungen an.
|
||||
|
||||
## Formulare in Schritten, mit Bedingungen und Zusammenfassung (unveröffentlicht)
|
||||
|
||||
Unter **Formulare** gibt es drei neue Feldtypen – **Zahl** (mit Unter-, Obergrenze und Einheit wie m²), **Auswahl (Optionsfelder)** und **Mehrfachauswahl** – und den Typ **Neuer Schritt**: seine Beschriftung ist der Titel des Abschnitts, sein Hilfetext die Einleitung, alle Felder bis zum nächsten Schritt gehören dazu. Jedes Feld und jeder Schritt kann von einer früheren Auswahl abhängen („Nur zeigen, wenn das Feld `immobilienart` einen dieser Werte hat: Wohnung, Haus“); der Server verlangt und speichert es dann nur, wenn die Bedingung zutrifft. Bei Optionsfeldern und Mehrfachauswahl lässt sich je Option ein **Bild aus der Mediathek** wählen; sobald eine Option eines hat, zeigt die Website die Auswahl als anklickbare Bildkarten statt als Kästchen. Unter Zustellung schaltet „Zusammenfassung vor dem Absenden zeigen“ einen letzten Schritt mit allen Angaben und „Ändern“ je Schritt ein. Auf der Website zeigt ein kleines Skript einen Schritt nach dem anderen mit Fortschritt, „Zurück“ und „Weiter“; ohne JavaScript steht das Formular als Ganzes mit Abschnitten auf der Seite. Das Skript kommt nur auf Seiten, deren Formular Schritte, Bedingungen oder die Zusammenfassung hat. Beispiel: der Preisfinder der DEEG-Website (`scripts/seed-immobilien-deeg.php`, Formular `bewertung`).
|
||||
|
||||
## Eigene Absenderadresse je Formular (ab 1.15.1)
|
||||
|
||||
Unter **Formulare → Zustellung** gibt es neben dem Empfänger ein Feld **Absender**. Bisher ging jede Nachricht von der Empfängeradresse an sich selbst; das scheitert, sobald der Empfänger ein Postfach bei einem anderen Anbieter ist, weil viele Hoster nur zustellen, was von einer Adresse der eigenen Domain kommt. Leer heißt weiterhin: Absender ist der Empfänger. Die Antwortadresse aus dem Formularfeld bleibt davon unberührt.
|
||||
|
||||
## Formulare, Statistik, Design und Seitenleiste überarbeitet (ab 1.15.0)
|
||||
|
||||
**Formulare** sind ein Editor: jedes Feld eine Karte mit Nummer, Beschriftung, Typ, technischem Namen, Hilfetext und Pflicht-Schalter, dazu „Feld hinzufügen“ und ein Entfernen-Knopf je Karte. Der technische Name folgt der Beschriftung, bis du ihn selbst schreibst. Unter **Aussehen** bekommt jedes Formular eine Buttonbeschriftung sowie Rahmen- und Hintergrundfarbe der Felder, Button- und Buttonschriftfarbe – aus der Palette oder als eigener Wert, leer heißt „wie die Website“. Die Vorschau daneben folgt jeder Eingabe, zeichnet den Absendeknopf mit dem Standardbutton des Designs und nennt den Kontrast der Buttonschrift.
|
||||
|
||||
**Die Statistik** beginnt mit Kennzahlen: Aufrufe mit Vergleich zum gleich langen Zeitraum davor, Schnitt je Tag, stärkster Tag, aufgerufene Seiten, Aufrufe über Verweise, nicht gefundene Adressen. Der Verlauf hat Rasterlinien, jeder Balken nennt beim Überfahren Tag und Zahl, der stärkste Tag ist hervorgehoben; neu ist ein Wochentagsdiagramm im Schnitt. Die Ranglisten tragen Rang und Anteilsbalken.
|
||||
|
||||
**Im Design** ist die Palette eine Reihe von Farbkarten, deren Fläche der Farbwähler selbst ist. Sieben fertige Paletten (Wald, Ozean, Sand, Koralle, Schiefer, Olive, Nacht) lassen sich per Klick übernehmen; eigene Farben bleiben stehen. Text-, Seiten- und Linkfarbe wählst du aus der Palette oder trägst einen eigenen Wert ein. Jede Schrift zeigt eine Probe in sich selbst, die Schnitte sind Chips, und eine Typografie-Vorschau zeigt Schrift, Größe, Zeilenhöhe, Skala, Farben und Unterstreichung live.
|
||||
|
||||
**Die Seitenleiste** endet in einer Kontokarte mit Avatar, Name, Rolle und Abmeldesymbol, darunter die Fassung mit dem Update-Hinweis. Auf Tablets bleiben Konto und Abmelden rechts im Bild, nur die Liste wischt.
|
||||
|
||||
## Fußzeile des Einwilligungshinweises (ab 1.14.2)
|
||||
|
||||
„Einzeln wählen“ steht links, Datenschutz und Impressum rechts auf einer Linie; aufgeklappt nimmt die Einzelwahl die volle Breite, die Links rutschen rechtsbündig darunter.
|
||||
|
||||
## Einwilligungshinweis gestalten, Nach-oben-Knopf auch bei offener Frage (ab 1.14.1)
|
||||
|
||||
**Der Einwilligungshinweis lässt sich gestalten**, unter **Einstellungen → Einwilligung → Aussehen des Hinweises**: Anordnung (Karte unten in der Mitte, Leiste über die volle Breite, kleine Karte rechts unten), Form (abgerundet, rund, eckig – gilt für Fläche und Knöpfe zusammen), eigene Überschrift und vier Farben (Fläche, Schrift, Knopf „Alle akzeptieren“, Schrift auf dem Knopf), jeweils als `#hex` oder `@schlüssel` aus der Palette. Leer heißt Papier, Tinte und Akzent aus dem Design. Die Fläche trägt einen schmalen Streifen und ein Symbol in der Knopffarbe; „Nur notwendige“ bleibt ein gleich großer Rahmenknopf daneben. Dieselben Farben gelten auf `/einwilligung` und in den Beitragsarchiven.
|
||||
|
||||
**Der Nach-oben-Knopf** war ausgeblendet, solange die Einwilligungsfrage offen stand – auf dem Telefon bleibt sie das oft, und der Knopf kam dort nie. Jetzt setzt er sich auf die Oberkante der Leiste, wo sie die Ecke abdeckt; Browser ohne Ankerpositionierung blenden ihn dort weiter aus. Der reservierte Abstand unten reicht auf schmalen Bildschirmen wieder für die ganze Leiste.
|
||||
|
||||
**Im Designbereich** sind belegte Eigenschaften jetzt in Orange beschriftet, nicht nur mit Punkt und Fettdruck; die Gerätechips D/T/M stehen weiter auseinander, und der Fokusring bleibt in der Lücke.
|
||||
|
||||
## Schriften vom eigenen Server, Hinweise auf jedem Bildschirm (ab 1.14.0)
|
||||
|
||||
**Schriften kommen ausschließlich von deinem Server.** Die Ausgabe verlinkt kein Stylesheet mehr von `fonts.googleapis.com` – weder die öffentliche Seite noch die Editorvorschau, die es bisher bei jedem Aufruf tat. Fehlen die Dateien, zeigt die Website die eingestellte Ersatzschrift und sagt es dir. Geholt werden sie wie bisher einmalig unter **Design → Schriften ausliefern**. Zusätzlich wird jetzt **jeder** Schnitt des Designs vorab geladen: bisher waren es die ersten zwei je Familie, weshalb die Überschriften sichtbar von der Ersatzschrift auf die richtige Schrift sprangen.
|
||||
|
||||
**Offene Hinweise stehen auf jedem Verwaltungsbildschirm**, mit dem Knopf daneben: „Fassung X ist verfügbar“ → *Jetzt aktualisieren*, „Schriftdateien fehlen“ → *Schriften holen*. Keiner der beiden fragt beim Öffnen im Netz nach.
|
||||
|
||||
**System und Update** ist auf dem Telefon benutzbar: Die Seite lief seitlich aus dem Bild, die Sicherungstabelle wird zu gestapelten Blöcken, und die Texte sind knapper – das Kleingedruckte steht zugeklappt.
|
||||
|
||||
**Im Designbereich** zeigen die Gerätechips **D/T/M** jetzt in Orange, welches Gerät einen eigenen Wert trägt; das gerade bearbeitete Gerät hat einen Rahmen statt des bisherigen roten Rings, der am Rand der Spalte abgeschnitten wurde.
|
||||
|
||||
## Öffnungszeiten, Einsatzgebiet und FAQ in den strukturierten Daten (ab 1.14.0)
|
||||
|
||||
Unter **Einstellungen → Unternehmensdaten** gibt es **Öffnungszeiten** (je Wochentag Öffnen und Schließen; leer heißt geschlossen) und das **Einsatzgebiet** (ein Ort je Zeile). Beides erscheint in den strukturierten Daten der Startseite (`openingHoursSpecification`, `areaServed`); gleiche Zeiten werden zu einer Angabe zusammengefasst. Der sichtbare Text auf der Website bleibt unabhängig davon.
|
||||
|
||||
Ein Akkordeon wird per Kästchen **Als FAQ auszeichnen** zur `FAQPage`: die Titel sind die Fragen, die Textbausteine im Eintrag die Antwort. Einträge ohne Text erscheinen nicht. Das gilt nur für Akkordeons mit gesetztem Kästchen – ein Akkordeon mit Leistungen bleibt ein Akkordeon.
|
||||
|
||||
## Mehr Symbole, Menü-Knopf gestalten, Nach-oben-Knopf zuverlässig (ab 1.13.0)
|
||||
|
||||
Der Symbolsatz hat drei Zeichnungen mehr: **Menschen**, **Musik** und **Haus**. Sie stehen überall zur Wahl, wo es Symbole gibt – Symbolbaustein, Symbolkarte und Menüeinträge. Eine am Symbolbaustein eingestellte **Farbe** wirkt jetzt auch wirklich auf die Zeichnung; bisher blieb sie in der Akzentfarbe der Website stecken.
|
||||
|
||||
Der **Menü-Knopf** (der aufklappbare „Menü“-Knopf auf Tablet und Telefon) lässt sich gestalten: Navigation auswählen → **Inhalt → Menü-Knopf** wählt zwischen *Symbol und Text*, *Nur Text* und *Nur Symbol*; „Nur Symbol“ behält den Namen für Vorlesesoftware. Unter **Design → Menü-Knopf** stehen Schriftfarbe, Fläche, Rahmenfarbe, Rahmenstärke, Ecken und Schriftgröße, je Gerät und mit Palettenfarben. Das aufgeklappte Menü legt sich im Kopfbereich als Fläche über die volle Breite unter den Kopf, statt die Kopfzeile auseinanderzuschieben.
|
||||
|
||||
Der **Nach-oben-Knopf** erscheint wieder überall. Er blendete sich über eine Scroll-Animation ein; Browser, die diese Eigenschaft zwar kennen, aber nicht ausführen, ließen ihn dauerhaft unsichtbar. Jetzt entscheidet eine Klasse, die das kleine Scroll-Skript setzt – und ohne JavaScript bleibt der Knopf einfach sichtbar.
|
||||
|
||||
## Soziale Profile (ab 1.12.0)
|
||||
|
||||
Unter **Einstellungen → Unternehmensdaten → Soziale Profile** je Netzwerk eine Adresse eintragen (Facebook, Instagram, LinkedIn, YouTube, TikTok, X, Threads, Pinterest, Xing, WhatsApp, Google-Unternehmensprofil). Das Element **Soziale Profile** (Werkzeugkasten → Bausteine) zeigt sie dann an jeder Stelle – im Kopf- oder Fußbereich, in einer Kontaktsektion – und bestimmt dort Größe, Form (nur Symbol, runde oder eckige Fläche), Anordnung, Beschriftung und ob nur bestimmte Netzwerke erscheinen. Farbe, Abstand und Ausrichtung wie bei jedem Element unter **Design**. Eine Adresse ändern heißt: überall geändert. Die Profile stehen automatisch in den strukturierten Daten (`sameAs`). Ohne Eintrag zeigt das Element auf der Website nichts.
|
||||
|
||||
## Slider-Steuerung auf dem Bild (ab 1.11.4)
|
||||
|
||||
Die Steuerung liegt jetzt auf dem Slider, wie beim Hero-Slider von kant-it.de: runde weiße Pfeile links und rechts in der Mitte, Punkte unten rechts mit dem aktiven Punkt als Strich in der Akzentfarbe, ein kleiner Pause-Knopf daneben, kein Zähler mehr (der bleibt für Vorlesesoftware). Auf Bildschirmen bis 600 px verschwinden die Pfeile, gewischt wird mit dem Finger. Unter **Design → Slider-Steuerung** gibt es zusätzlich „Aktiver Punkt“.
|
||||
|
||||
## Farben der Slider-Steuerung (ab 1.11.3)
|
||||
|
||||
Slider auswählen → **Design → Slider-Steuerung**: Farbe der Punkte, Farbe von Pfeilen und Zähler sowie Hintergrund der Knöpfe, je Gerät und mit Palettenfarben. Ohne eigene Werte sind die Punkte halbtransparent weiß und die Pfeile weiß mit Akzentfarbe. Die Farben gelten auch für die skriptfreien Punkte, die der Browser selbst zeichnet.
|
||||
|
||||
## Slider auf dem iPhone (ab 1.11.2)
|
||||
|
||||
Safari bricht sanftes Weiterblättern in einem einrastenden Slider ab und springt zur vorigen Folie zurück; die Automatik und die Pfeile stotterten deshalb auf dem iPhone. Während der Fahrt ist das Einrasten jetzt ausgeschaltet und wird bei Ankunft auf der Folie wieder eingeschaltet. Die Punkte unter dem Slider sind echte Punkte, der aktive gefüllt, bei unveränderter Trefffläche von 44 px.
|
||||
|
||||
## Kopfbereich beim Scrollen und gleich hohe Karten (ab 1.11.1)
|
||||
|
||||
Der Kopfbereich kann für die gescrollte Seite eigene Farben tragen, wie der transparente Header in Flatsome: Kopfbereich im Editor öffnen, die Sektion wählen, **Design → Hintergrund**. Dort steht unter „Hintergrundfarbe“ die Zeile **„Hintergrundfarbe beim Scrollen“**; dasselbe gibt es für Textfarbe, Rahmenfarbe, Schattenfarbe und Deckkraft. Der normale Wert gilt am Seitenanfang, der Scroll-Wert sobald die Seite mehr als 40 px bewegt wurde, mit weichem Übergang. Für einen durchsichtigen Kopf am Anfang `#ffffff00` als normale Hintergrundfarbe eintragen. Damit der Kopf über der ersten Sektion liegt statt darüber zu stehen, unter **Einstellungen** „Kopfbereich über dem Inhalt schweben lassen“ einschalten; zusammen mit „oben halten“ bleibt er beim Scrollen sichtbar. Ohne JavaScript behält der Kopf seine normalen Farben.
|
||||
|
||||
Karten, Kundenstimmen und Aufruf-Bänder nebeneinander in einer Zeile sind wieder gleich hoch. Der Abstand, den gestapelte Karten in einer Spalte untereinander brauchen, hatte in der Zeile die zweite bis letzte Karte um 18 px niedriger gemacht.
|
||||
|
||||
## Editor mit einer Seitenleiste und Bewegung wie Flatsome (ab 1.11.0)
|
||||
|
||||
Der Editor folgt jetzt dem Aufbau des Flatsome UX Builders: eine dunkle Kopfzeile mit Gerätewahl in der Mitte, Rückgängig/Wiederholen, Bewegung abspielen, Speichern und Veröffentlichen; links **eine** Seitenleiste mit den Reitern **Elemente**, **Struktur** und **Seite**; rechts die Vorschau. Ein Klick auf ein Element in der Vorschau öffnet seine Optionen in der Seitenleiste: Name, Pfad (Seite › Sektion › Zeile), die Reiter Inhalt/Design/Erweitert, unten Verschieben, Duplizieren, Löschen. **‹** wählt das übergeordnete Element, **+** fügt in einen Container ein oder direkt hinter das Element, **✕** oder Escape hebt die Auswahl auf. Rechtsklick in der Vorschau öffnet ein Kontextmenü mit denselben Aktionen. Entf löscht das gewählte Element, sofern kein Feld den Fokus hat.
|
||||
|
||||
Die Elemente stehen als Kartenraster mit Symbol und Namen; was an der gewählten Stelle nicht passt, ist ausgegraut und sagt beim Überfahren warum. In der Struktur wählt ein Klick nur aus, **Bearbeiten** oder Doppelklick öffnet das Element. Die Sichtbarkeit je Gerät steht jetzt am Ende von Inhalt und Erweitert. Die verstellbaren Panelbreiten entfallen.
|
||||
|
||||
**Bewegung:** Einblendungen, Hover-Bewegungen und Slider-Automatik spielen standardmäßig bei jedem Besucher, wie bei Flatsome und den meisten Websites. Manche Browser melden „Bewegung reduzieren“, ohne dass jemand es eingestellt hat; bisher blieb dann jede Einblendung aus. Unter **Einstellungen → Einblendungen und Hover-Bewegungen** lässt sich „Browservorgabe beachten“ wählen; dann trägt `<html>` die Klasse `motion-respect`, und Skript wie CSS halten sich wieder an die Vorgabe. Einblendungen verstecken einen Block, bis er ins Bild scrollt, und blenden ihn dann von unsichtbar ein; wer per Sprungmarke oder schnellem Scrollen an einem Block vorbeikommt, sieht ihn sofort. Ohne JavaScript ist alles sichtbar.
|
||||
|
||||
Gezielt prüfen (isolierte Kopien; PHP mit SQLite3-Erweiterung):
|
||||
|
||||
```powershell
|
||||
$env:PHP_BINARY = 'C:\php84\php.exe'
|
||||
node scripts/motion-check.mjs
|
||||
node scripts/editor-check.mjs
|
||||
npm.cmd test
|
||||
```
|
||||
|
||||
## Formularbutton und Bewegungseffekte (ab 1.10.1)
|
||||
|
||||
„Absenden“ übernimmt den Website-Standardbutton aus Design/Typografie: Form, Darstellung, Größe, Schrift und normale/Hover-Farben. Bestehende Formulare aktualisieren sich ohne erneutes Speichern; der Button hat seine natürliche Breite statt automatisch die gesamte Formularbreite. Formularfelder, Versand und Validierung bleiben unverändert.
|
||||
|
||||
Einblendungen starten nach dem Wechsel von reduzierter zu erlaubter Bewegung wieder ohne Neuladen. Bereits gezeigte Elemente werden nicht erneut animiert. Im Editor zeigt eine Meldung, wenn der Browser Bewegung reduziert. Eine weiterhin aktive Browservorgabe wird beachtet; dieses Update überschreibt keine Besucherpräferenzen.
|
||||
|
||||
## Sichtbarkeit und Hamburger-Menüs (ab 1.10.0)
|
||||
|
||||
Element auswählen → **Sichtbarkeit**: auf allen Geräten, nur Desktop/Tablet/Mobil, auf einem Gerät ausblenden oder überall ausblenden. Die Auswahl steht in Inhalt, Design und Erweitert. Ausgeblendete Elemente bleiben links in der Struktur auswählbar; dort steht auch ihre Sichtbarkeit. „Auf allen Geräten“ setzt die Gerätebeschränkung zurück. Eigene Layoutwerte bleiben erhalten. Ausgeblendete Elternbereiche verbergen auch ihre Inhalte.
|
||||
|
||||
Waagerechte Navigation wird automatisch auf Tablet und Mobil zum Hamburger, auch auf großen iPads mit Touchbedienung ohne Maus. Unter **Inhalt → Automatisch als Hamburger** lässt sich stattdessen „Nur Mobil“ oder „Nie“ wählen. Vertikale Menüs bleiben vertikal und die Menüart „Burger“ bleibt immer aufklappbar. Bereits getrennt angelegte Desktop- und Mobilmenüs lassen sich über **Sichtbarkeit** passend zuordnen. Das Menü funktioniert ohne JavaScript und lässt sich mit Enter oder Leertaste öffnen und schließen.
|
||||
|
||||
Sichtbarkeitsgrenzen: Desktop ab 1025 px, Tablet 768–1024 px, Mobil bis 767 px. Undo/Redo, Speichern und Veröffentlichung gelten wie gewohnt. Neue Optionen verwenden Schema 22; Rückweg siehe ADR 088.
|
||||
|
||||
Gezielt prüfen (isolierte Kopie der lokalen Kant-IT-Daten; PHP mit SQLite3-Erweiterung):
|
||||
|
||||
```powershell
|
||||
$env:PHP_BINARY = 'C:\php84\php.exe'
|
||||
npm.cmd run test:responsive-navigation
|
||||
php vendor/bin/phpunit tests/ResponsiveVisibilityTest.php
|
||||
npm.cmd test
|
||||
```
|
||||
|
||||
Bericht und Screenshots: `reports/responsive-navigation/`. Produktionsbuild: `composer build`.
|
||||
|
||||
## Buttons, Sprungziele und Menüs (ab 1.9.0)
|
||||
|
||||
Unter Design/Typografie konfiguriert „Standardbutton“ Form, Darstellung, Größe, Schrift und Farben für die Website. Acht Vorlagen zeigen das Ergebnis direkt, einschließlich Hover-Vorschau. Einzelne Buttons können im Editor davon abweichen, auch je Gerät: eckig, abgerundet, Pille, Rahmen, dezent oder Textlink. Eigene freie Designwerte können diese Vorgaben übersteuern.
|
||||
|
||||
Sektionen, Zeilen und Spalten haben im Inhaltsbereich das Feld „Sprungziel“. Dort beispielsweise `kontakt` eintragen und einen Link auf `#kontakt` setzen. Ziele sind eindeutig; die internen Block-IDs bleiben erhalten. Duplikate erhalten kein kopiertes Sprungziel.
|
||||
|
||||
Menüeinträge am Punktgriff ziehen oder per Pfeiltasten verschieben und einrücken. Optional ein Symbol vor oder nach dem Text auswählen. Änderungen anschließend mit „Menü speichern“ übernehmen. Neben der Fassung links erscheint für Administratoren ein Link, sobald die bestehende tägliche Updateprüfung eine neuere Version gefunden hat. Der Aufgaben-Worker muss dafür laufen; die Sidebar selbst führt keine Netzwerkanfrage aus.
|
||||
|
||||
Gezielte Entwicklungsprüfungen: `npm run test:buttons` und `npm run test:navigation-ux` verwenden eine isolierte Kant-IT-Kopie. Responsive Button-CSS nach Änderungen mit `npm run build:buttons` erzeugen.
|
||||
|
||||
## Lokal starten
|
||||
|
||||
Voraussetzungen: PHP ab 8.2 mit Composer 2, PDO SQLite, Ctype, Iconv, Session, XML und GD mit WebP-Unterstützung. EXIF wird für die automatische Ausrichtung von JPEG-Fotos empfohlen. Für Tests zusätzlich die von `composer install` geprüften Erweiterungen, insbesondere DOM, XMLWriter und Mbstring. Für Hosting eine aktuell gepflegte PHP-Patchversion verwenden. Node 24 wird für Browserprüfungen und das Generieren der Design-CSS gebraucht, nicht im Hostingbetrieb.
|
||||
@@ -18,10 +184,30 @@ php -d upload_max_filesize=8M -d post_max_size=10M -d memory_limit=256M -S 127.0
|
||||
|
||||
Danach `http://127.0.0.1:8080/` öffnen. Der Routerskript-Parameter ist nicht optional: der eingebaute PHP-Server liefert vorhandene Dateien selbst aus und antwortet für jeden fehlenden Pfad mit Dateiendung mit 404, sodass `/sitemap.xml`, `/robots.txt`, `/llms.txt` und `/feed.xml` den Frontcontroller nie erreichen. Auf echtem Hosting erledigt das `public/.htaccess`; `bin/dev-router.php` bildet dasselbe lokal nach. Unter Windows hier vorhanden: `C:\xampp\php\php.exe` (8.2.12) und `C:\php84\php.exe` (8.4.22). Die Befehle verwenden die PHP-Version im PATH; alternativ den vollständigen Pfad einsetzen. Diese vorhandenen Patchstände sind kein empfohlenes Produktionsimage.
|
||||
|
||||
`bin/setup.php` erzeugt ein zufälliges lokales Secret und `var/`; bestehende `.env.local` bleibt erhalten. Die Datenbank liegt in `var/cms.db`. Wiederholtes Seeding erhält existierende Seiten einschließlich ihrer Bearbeitungen. Es gibt keinen öffentlichen Installer und keine Standardzugangsdaten. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token.
|
||||
`bin/setup.php` erzeugt ein zufälliges Secret und `var/`. Die Konfiguration steht ausschließlich in der nicht versionierten `.env`; vorhandene Einstellungen und Secrets bleiben erhalten. Die Datenbank liegt in `var/cms.db`. Wiederholtes Seeding erhält existierende Seiten einschließlich ihrer Bearbeitungen. Standardzugangsdaten gibt es nicht.
|
||||
|
||||
**Installation ohne Konsole** läuft über `/install`: Systemprüfung, Datenbank, Migration, Website, erstes Konto. Der Installer antwortet nur, solange keine Installation existiert – sobald ein Konto angelegt ist, liefert jede Adresse darunter 404, und die Sperre hängt am Zustand der Datenbank, nicht an einer Datei. Solange nichts installiert ist, führt jede Adresse dorthin. Davor genügt eine einzelne hochgeladene Datei: `install.php` aus einem Release lädt das Archiv von einer fest eingetragenen Adresse, vergleicht die SHA-256, entpackt es und löscht sich selbst. Ohne passende Prüfsumme wird nichts entpackt. **Seit 1.30.0 öffnet sich der Installer nur mit dem Installationsschlüssel** (ADR 116): `install.php` legt ihn an und zeigt ihn mit einem fertigen Verweis auf seiner Abschlussseite; wer per FTP entpackt, findet ihn in `var/install.key`. Mit dem ersten Konto wird er gelöscht. Das Paket bringt Sperrdateien mit (`.htaccess` im Projektstamm und in `bin/`, `config/`, `migrations/`, `plugins/`, `src/`, `templates/`, `vendor/`; `var/.htaccess` legt die Website selbst an), damit auch bei einer Domain, die versehentlich auf den Projektordner zeigt, weder `.env` noch Datenbank oder Protokolle abrufbar sind. Editor-Endpunkte benötigen Anmeldung, Berechtigung und bei POST einen CSRF-Token.
|
||||
|
||||
**Inhalte zwischen Installationen übertragen** (seit 1.32.0, ADR 118): System → „Inhalte übertragen“ exportiert ein Inhaltspaket (Zip) und importiert eines – per Upload oder aus `var/pakete/`. Ersetzt werden Seiten, Bereiche, Menüs, Medien, Formulare, Weiterleitungen, Kategorien, Übersetzungen, Gestaltung und Einstellungen; Anfragen, Statistik und Turnstile-Zugangsdaten bleiben, Konten nur mit eigenem Häkchen. Vorher wird gesichert. Auf der Konsole: `php bin/console cms:paket:export` und `php bin/console cms:paket:import <datei>`.
|
||||
|
||||
## Eigene 404-Seite (ab 1.8.0)
|
||||
|
||||
Unter **Einstellungen → 404-Seite** eine vorhandene Seite per Livesuche auswählen und **404-Zuordnung speichern**. Oder **404-Vorlage anlegen** anklicken, den angelegten Entwurf im Editor gestalten und veröffentlichen. Die Ausgabe übernimmt den Standard-Header, Standard-Footer, Schriften und Farben der Website. **404-Vorschau** öffnet die tatsächlich sichtbare Fehlerseite.
|
||||
|
||||
Ohne veröffentlichte Auswahl erscheint automatisch eine Standard-404 im Website-Design. Entwürfe bleiben privat; Umbenennen erhält die Zuordnung, nach dem Löschen greift wieder der Standard. Das Entfernen der Auswahl setzt die Zuordnung zurück und erhält den Seiteninhalt. Die Zuordnung benötigt Veröffentlichungsrechte. Unbekannte URLs bleiben unverändert und antworten mit HTTP 404; die Fehlerseite wird nicht in Sitemap/llms.txt aufgenommen. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:404`, Berichte und Screenshots unter `reports/not-found/`. Datenvertrag und Rückweg: ADR 082.
|
||||
|
||||
## Anmelden und bearbeiten
|
||||
|
||||
**Scroll-Einblendungen prüfen:** Block auswählen → Design → Bewegung und Effekte → Einblendung beim Erscheinen → „Von links“ oder „Von rechts“. Anschließend speichern und veröffentlichen. Im Editor startet „Bewegung testen“ die Vorschauwiedergabe. Öffentliche Seiten animieren beim ersten Erscheinen; die Systemoption „Bewegung reduzieren“ unterdrückt Animationen. `node scripts/motion-check.mjs` prüft Scroll-Auslösung, beide Richtungen, Bewegungsreduktion und Ausgabe ohne JavaScript mit einer isolierten Kant-IT-Kopie. PHP mit SQLite3-Unterstützung über `PHP_BINARY` wählen, etwa in PowerShell `$env:PHP_BINARY='C:\php84\php.exe'`. Ergebnisse stehen unter `reports/motion/`.
|
||||
|
||||
**Bildausrichtung (ab 1.8.4):** Bild auswählen → Design → Layout → **Eigene Ausrichtung quer: Mitte**. Zentriert den Bildblock und auch kleinere Bilder innerhalb des Rahmens. Funktioniert mit Prozentbreite, maximaler Breite und Bildlinks; Anfang/Ende und Gerätewerte werden ebenfalls berücksichtigt. In nebeneinander angeordneten Flex-Gruppen bleibt die Querachse vertikal. Prüfen: `npm run test:image-alignment` auf einer isolierten Kant-IT-Kopie; Screenshots unter `reports/image-alignment/`.
|
||||
|
||||
**Akkordeon-Inhalte (ab 1.8.3):** Einträge bleiben im Editor geöffnet. Im Eintrag auf **Inhalt hinzufügen** klicken und links Text, Bild oder ein anderes Element auswählen; alternativ direkt in die sichtbare Inhaltsfläche ziehen. **Eintrag hinzufügen** steht unter dem Akkordeon und in dessen Inhaltsoptionen. Die Eintragsübersicht rechts und die Struktur links zeigen die Titel. **Beim Laden geöffnet** bestimmt ausschließlich den Zustand der veröffentlichten Seite. Prüfen: `npm run test:accordion`, Screenshots unter `reports/accordion/`.
|
||||
|
||||
**Farbauswahl im Editor (ab 1.8.2):** Unter Design zeigt jedes Farbfeld eine Vorschau mit Palettenname und Hexwert. Anklicken öffnet die Website-Farben als Farbkacheln. Eigene Farben über den Farbwähler oder als Hexwert eingeben und mit Enter/✓ übernehmen. Transparente Hexfarben werden auf einem Schachbrett dargestellt. „Geerbten Wert verwenden“ entfernt die eigene Überschreibung für das aktive Gerät. Prüfen: `npm run test:colors` mit isolierten Kant-IT-Daten; Screenshots unter `reports/color-picker/`.
|
||||
|
||||
**Footer-Links (ab 1.8.1):** Datenschutz, Impressum und weitere Links werden selbst im Footer oder Seiteninhalt platziert. Das CMS hängt keine zusätzliche Rechtszeile an. Der optionale Baustein „Rechtliches“ verwendet die Adressen aus den Einstellungen und erscheint nur an seiner eingefügten Position.
|
||||
|
||||
Für eine neue Installation im interaktiven Terminal ein Konto anlegen:
|
||||
|
||||
```powershell
|
||||
@@ -32,15 +218,31 @@ Das Passwort wird verdeckt zweimal abgefragt (14–72 Bytes). Bestehende Benutze
|
||||
|
||||
Unter `http://127.0.0.1:8080/admin` eine Seite auswählen. Links Blöcke hinzufügen/auswählen, in der Mitte die Vorschau, rechts Eigenschaften. Die Auswahl ist zwischen Strukturbaum und Vorschau synchron. Sektion, Zeile, Spalte, Überschrift, Text, Bild, Button und Galerie sind vorhanden. Bilder können aus der Mediathek oder über vorhandene lokale/HTTPS-Adressen eingebunden werden.
|
||||
|
||||
**Bild prozentual verkleinern (ab 1.7.2):** Bild auswählen → Inhalt → **Bildbreite (%)**. Regler oder Zahlenfeld setzen 1–100 % der verfügbaren Breite in Sektion/Spalte. Desktop, Tablet und Mobil können eigene Werte erhalten; kleinere Geräte übernehmen zunächst den Wert vom größeren Gerät. „Breite zurücksetzen“ entfernt den eigenen Wert und stellt die Vererbung beziehungsweise automatische Breite wieder her. Originalbreite/-höhe beschreiben weiterhin die Datei. Der Regler bearbeitet denselben Wert wie Design → Größe → Breite; eine dort gesetzte maximale Breite bleibt wirksam. Prüfung mit kopierten Kant-IT-Daten: `npm run test:image-size`, Bericht und Screenshots unter `reports/image-size/`.
|
||||
|
||||
**Links und Bildvorschauen (ab 1.7.3):** Unter Linkziel steht **In neuem Tab öffnen** – für Buttons, verlinkte Bilder, Symbole, Symbolkarten und Aufruf-Bänder. Im Text zuerst den Link über die Werkzeugleiste öffnen; bei Menüeinträgen steht das Häkchen in den aufgeklappten Einstellungen. Ausgewählte Bilder und Galerie-/Logobilder zeigen eine Vorschau mit Entfernen-Funktion. „Bild entfernen“ entfernt das Bild aus dem Inhalt; die Datei bleibt in der Mediathek. Rückgängig stellt die Verwendung wieder her. Design → Hintergrund zeigt auch geerbte Bilder: Entfernen blendet das Bild auf dem aktiven und kleineren Geräten aus, Zurücksetzen stellt die Vererbung wieder her. Bildaustausch erhält Link, Bildunterschrift und eingestellte Größe. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:link-media`, Bericht und Screenshots unter `reports/link-media/`. Datenvertrag und Rückweg: ADR 081 in `docs/ARCHITECTURE.md`.
|
||||
|
||||
**Symbole (ab 1.25.0):** Button, Symbol oder Symbolkarte auswählen → Inhalt → **Symbol** → **Ändern …**. Das Fenster zeigt 98 Symbole in sieben Kategorien, mit Namen; Pfeiltasten und Escape funktionieren, beim Button gibt es „Kein Symbol“. Am Button außerdem **Stellung** (vor/nach dem Text), **Beim Überfahren** (Ruhig, Rückt, Erscheint) und **Nur das Symbol zeigen** (quadratischer Knopf, die Beschriftung bleibt für Vorlesesoftware). Farbe, Fläche, Größe, Ecken, Drehung und Abstand stehen unter **Design → Symbol**, je Gerät und beim Überfahren. Ohne eigene Wahl trägt ein Button wie bisher den schrägen Pfeil. Neue Symbole kommen nach `public/assets/icons.js`, danach `npm run build:icons`. Prüfung mit isolierter Kant-IT-Kopie: `npm run test:button-icons`, Screenshots unter `reports/button-icons/`. Datenvertrag: ADR 108 und 109.
|
||||
|
||||
Blöcke per Drag-and-drop verschieben: auf einen Container ziehen, um hineinzuverschieben; auf einen Text-/Bild-/Linkblock ziehen, um davor einzufügen. Alternativ unten am Strukturbaum die Schaltflächen für Auf/Ab, Verschachteln und Herausheben verwenden. Duplizieren vergibt neue Block-IDs und wandelt eine kopierte H1 in H2 um. Löschen lässt sich rückgängig machen. Jede speicherbare Seite benötigt genau eine H1.
|
||||
|
||||
Undo/Redo gilt für die lokale Bearbeitung, `Strg+S` speichert den Entwurf. `Strg+Z`/`Strg+Umschalt+Z` gelten außerhalb der Texteingaben für das Dokument; Texteingaben behalten ihr natives Undo. Erst speichern, dann separat veröffentlichen. Die Bestätigungsdialoge unterstützen Escape und Fokus-Rückkehr. Reine Redakteure können nicht veröffentlichen.
|
||||
Undo/Redo gilt für die lokale Bearbeitung, `Strg+S` speichert den Entwurf. `Strg+Z`/`Strg+Umschalt+Z` gelten außerhalb der Texteingaben für das Dokument; Texteingaben behalten ihr natives Undo. Erst speichern, dann separat veröffentlichen. Die Bestätigungsdialoge unterstützen Escape und Fokus-Rückkehr. Reine Redakteure können nicht veröffentlichen; was Redakteur, Veröffentlicher und Administrator dürfen, steht unter Benutzer bei jeder Rolle und im Abschnitt „Rollen“ oben.
|
||||
|
||||
Ein Speicherkonflikt überschreibt nichts: lokale Änderungen als JSON sichern und den Serverstand bewusst neu laden. Auch Netzwerk-/Sitzungsfehler lassen den lokalen Stand erhalten. Die Vorschau behält bei ungültigen Eingaben die letzte gültige Ausgabe.
|
||||
|
||||
Rechts zwischen **Inhalt** und **Design** wechseln. Unter Design je nach Block Abstände, Schriftgröße/-stärke, Zeilenhöhe, Ausrichtung, Spalten und Ecken wählen. Oben **Desktop/Tablet/Mobil** bestimmt das bearbeitete Gerät: Desktop ist die Basis, Tablet überschreibt bis 1024 px, Mobil bis 650 px. „Übernehmen“ entfernt einen eigenen Wert und stellt die Vererbung wieder her. Gerätewerte lassen sich gemeinsam zurücksetzen und rückgängig machen. Farben und Inhaltsbreite einer Sektion stehen unter Inhalt.
|
||||
Rechts zwischen **Inhalt** und **Design** wechseln. Unter Design je nach Block Abstände, Schriftgröße/-stärke, Zeilenhöhe, Ausrichtung, Spalten und Ecken wählen. Oben **Desktop/Tablet/Mobil** bestimmt das bearbeitete Gerät: Desktop ist die Basis, Tablet überschreibt bis 1024 px, Mobil bis 767 px. „Übernehmen“ entfernt einen eigenen Wert und stellt die Vererbung wieder her. Gerätewerte lassen sich gemeinsam zurücksetzen und rückgängig machen. Farben und Inhaltsbreite einer Sektion stehen unter Inhalt.
|
||||
|
||||
Die Vorschau rendert mit echten 1240/768/390 px Layoutbreite und passt sich durch Skalierung an den verfügbaren Platz an. Links lassen sich fertige Intro-, Leistungs- und Kontaktabschnitte einfügen. Der Werkzeugkasten kann für mehr Platz geschlossen werden. Die Struktursuche findet Text oder Elementtypen und zeigt den Pfad zum Treffer; Dreiecke klappen Teilbäume ein. Oberhalb der Vorschau führen die Pfadschaltflächen direkt zum übergeordneten Element.
|
||||
Die Vorschau rendert auf Desktop mit mindestens 1240 px Layoutbreite und wächst bei mehr Platz mit. Tablet/Mobil verwenden 768/390 px; bei weniger Platz wird die Vorschau passend skaliert. Links lassen sich fertige Intro-, Leistungs- und Kontaktabschnitte einfügen. Der Werkzeugkasten kann für mehr Platz geschlossen werden. Die Struktursuche findet Text oder Elementtypen und zeigt den Pfad zum Treffer; Dreiecke klappen Teilbäume ein. Oberhalb der Vorschau führen die Pfadschaltflächen direkt zum übergeordneten Element.
|
||||
|
||||
### Platz und Blockgestaltung im Editor (1.6.1)
|
||||
|
||||
**Leere Layouts ab 1.6.2:** Sektionen und Spalten zeigen eine umrahmte Einfügefläche. In einer leeren Sektion direkt **1–4 Spalten** wählen; in einer Spalte **Inhalt hinzufügen** drücken und links das gewünschte Element auswählen. Alternativ ein Element auf die Fläche ziehen: die Markierung nennt das genaue Ziel, am Vorschaurand wird mitgescrollt. Neue Container werden in den sichtbaren Ausschnitt gebracht. Diese Hilfen erscheinen nur im Editor, verschwinden nach dem Befüllen und kehren bei Undo zurück.
|
||||
|
||||
**Neue Sektion auf Seitenebene** steht dauerhaft oben links und fügt nach dem aktuellen Seitenabschnitt ein, unabhängig von der ausgewählten Verschachtelung oder einem gesetzten Plus-Einfügepunkt. Auch „Sektion“ im Werkzeugkasten verwendet ohne expliziten Einfügepunkt die Seitenebene. Verschachtelte Sektionen sind weiterhin über die Plus-Einfügepunkte oder Drag-and-drop möglich.
|
||||
|
||||
Die Trennlinien links und rechts der Vorschau lassen sich ziehen. Mit Tab fokussieren und mit den Pfeiltasten ändern; Umschalt vergrößert den Schritt, Home/End wählen die Grenzen, Doppelklick setzt die jeweilige Breite zurück. Die Breiten werden im Browser behalten und bei schmalen Fenstern an den verfügbaren Platz angepasst. Auf dem Telefon stehen die Panels unter der Vorschau.
|
||||
|
||||
Text und Überschriften nutzen die Containerbreite. Die Sektion wählt unter **Inhalt → Inhaltsbreite** zwischen Websitebreite und randloser Ausgabe. **Design → Größe → Maximale Breite** begrenzt ein einzelnes Element ausdrücklich, etwa auf `60ch`; „Ohne Begrenzung“ hebt einen eigenen Grenzwert auf. Der Feinschliff zeigt passende Optionen für Text, Bild oder Container zuerst. **Alle Eigenschaften anzeigen** macht die übrigen Optionen zugänglich. Bereits gespeicherte Werte bleiben sichtbar.
|
||||
|
||||
## Produktionsmodus lokal prüfen
|
||||
|
||||
@@ -55,11 +257,11 @@ Unter Linux dieselben PHP-Argumente mit vorangestelltem `APP_ENV=prod APP_DEBUG=
|
||||
|
||||
## Medien, Galerien und Effekte
|
||||
|
||||
Links im Werkzeugkasten **Medienverwaltung öffnen** oder auf der Seitenübersicht **Medienverwaltung** wählen. JPEG/PNG/WebP hochladen, Titel und Alternativtext bearbeiten und speichern. Bilder lassen sich suchen, archivieren und wiederherstellen. Die Dateien werden als optimierte WebP-Varianten gespeichert; Originaldateien werden nicht aufgehoben. Uploadgrenzen: 8 MB, 12 Megapixel und 8000 px Kantenlänge; Hostinglimits können niedriger sein. Bild-URLs sind öffentlich erreichbar, auch wenn sie noch in keinem veröffentlichten Inhalt verwendet werden.
|
||||
Links im Werkzeugkasten **Medienverwaltung öffnen** oder auf der Seitenübersicht **Medienverwaltung** wählen. JPEG/PNG/WebP hochladen, Titel und Alternativtext bearbeiten und speichern. Bilder lassen sich suchen, archivieren und wiederherstellen. Die Dateien werden als optimierte WebP-Varianten in vier Breiten gespeichert (480, 960, 1600, 2400 px; ein schmaleres Bild bekommt weniger Stufen und wird nie hochskaliert). Originaldateien werden nicht aufgehoben. Bildbausteine liefern `srcset`, Hintergrundbilder wählen die Variante je Gerät. Uploadgrenzen: 8 MB, 12 Megapixel und 8000 px Kantenlänge; Hostinglimits können niedriger sein. Bild-URLs sind öffentlich erreichbar, auch wenn sie noch in keinem veröffentlichten Inhalt verwendet werden.
|
||||
|
||||
Für ein einzelnes Bild **Bild aus Mediathek wählen** anklicken. Für eine Galerie den Block **Galerie** einfügen und **Galeriebilder auswählen** öffnen. Mehrere Bilder markieren, Alt-Texte speichern oder die dekorative Verwendung wählen und **Auswahl übernehmen**. Im Strukturbaum einzelne Bilder auswählen, beschriften und sortieren; Reihenfolge und Einfügen lassen sich rückgängig machen. Unter **Design** Spaltenzahl und Abstände pro Gerät einstellen.
|
||||
|
||||
Unter **Design → Effekte** Schatten, Hover-Anheben/Bildzoom und Einblendung beim Laden wählen. Effekte gelten für alle Geräte; bei aktivierter Bewegungsreduktion werden Animationen abgeschaltet. Die öffentliche Seite benötigt dafür kein JavaScript. Lightbox, Slideshow, Video/PDF-Upload und Bildzuschnitt sind noch nicht enthalten.
|
||||
Unter **Design → Bewegung und Effekte** Schatten, Hover-Effekte und **Einblendung beim Erscheinen** wählen. Die gewählte Richtung, Zoom, Unschärfe oder Überblendung startet beim Eintreten in den Bildschirm. Das kleine Bewegungsmodul lädt nur auf Seiten mit Einblendungen; ohne JavaScript bleiben die Inhalte sichtbar. Slider haben Pfeile, optionale Punkte sowie bei aktivierter Automatik Pause/Wiedergabe. Manuelle Bedienung pausiert die Automatik, Wiedergabe setzt sie fort. Bewegungsreduktion schaltet Einblendungen und Automatik aus; manuelles Blättern bleibt möglich. Im Editor startet **Bewegung testen** diese Vorschau, **Bewegung stoppen** beendet sie. Die Vorschau-Sandbox bleibt ohne Skriptfreigabe.
|
||||
|
||||
Medien liegen standardmäßig in `var/media`; mit `MEDIA_DIRECTORY` lässt sich ein absoluter Ordner außerhalb von `public` konfigurieren. Er muss für PHP beschreibbar sein. Auf dem Hosting `upload_max_filesize=8M`, `post_max_size=10M` und ausreichend Arbeitsspeicher (lokal 256M) einstellen, soweit der Tarif dies erlaubt. Datenbank und Medienordner gemeinsam sichern. Archivieren erhält Bild-URLs; es ist keine Dateilöschung.
|
||||
|
||||
@@ -77,6 +279,7 @@ composer build
|
||||
npm.cmd ci
|
||||
npx.cmd playwright install chromium
|
||||
npm.cmd run test:browser
|
||||
npm.cmd run test:links
|
||||
npm.cmd test
|
||||
npm.cmd run test:editor
|
||||
npm.cmd run test:editor -- --audit
|
||||
@@ -85,9 +288,17 @@ npm.cmd run audit
|
||||
|
||||
Unter Linux/macOS `npm` und `npx` ohne `.cmd`; in CI `npx playwright install --with-deps chromium`. `test:browser` und `audit` benötigen den laufenden lokalen Produktionsmodus auf Port 8080. Ein anderes Ziel lässt sich über `CMS_URL` konfigurieren. `test:editor` startet einen eigenen PHP-Prozess auf einem freien Port mit isolierter Datenbank und zufälligem Testkonto; es ändert keine echten Seiten oder Benutzer. `PHP_BINARY` kann dafür einen alternativen PHP-Pfad angeben. Testdaten bleiben unter `var/browser-tests`, Berichte/Screenshots unter `reports/`. Das Skript erneuert zuvor den Produktionscache.
|
||||
|
||||
**Datenbank:** SQLite ab Fassung 3.16 (Doctrine liest das Schema mit Tabellenfunktionen, die es erst seit dieser Fassung gibt) oder MySQL/MariaDB. Auf älteren Systemen – manche Shared Hoster liefern noch SQLite 3.7 – bleibt MySQL; die Systemprüfung des Installers nennt die gefundene Fassung und bietet SQLite gar nicht erst an, wenn sie zu alt ist.
|
||||
|
||||
Die Verwaltung liegt unter `/admin`. `php bin/console cms:admin:path meine-verwaltung` legt sie auf eine andere Adresse: der Wert landet in `.env`, gilt für Routen, Firewall und Schutzkopfzeilen, und wird in der `robots.txt` bewusst nicht genannt. Ohne Argument zeigt der Befehl die aktuelle Adresse.
|
||||
|
||||
Die öffentliche Adresse der Website steht als `SITE_ORIGIN` in `.env` – mit oder ohne `www`, so wie sie in Canonical, Sitemap, Feed und strukturierten Daten stehen soll. `php bin/console cms:site:origin https://www.meine-firma.de` prüft und schreibt sie; ohne Argument zeigt der Befehl die aktuelle. Dasselbe geht in der Verwaltung unter „System“, mit einem Link auf die andere Schreibweise. Dort wird vorher geprüft, ob die Website unter der neuen Adresse antwortet. Die jeweils andere Schreibweise (`meine-firma.de` ↔ `www.meine-firma.de`) leitet die Anwendung selbst mit 301 auf die eingetragene um, mit Pfad und Abfrage, auch für die Verwaltung. Ein ganz anderer Host – die Vorschauadresse des Hosters, eine IP-Adresse, `localhost` – bleibt erreichbar, damit eine falsch eingetragene Adresse niemanden aussperrt; der Bildschirm „System“ zeigt dann, unter welcher Adresse die Verwaltung gerade läuft und welche eingetragen ist. HTTPS erzwingt die Anwendung nicht selbst; unter „System“ → „HTTPS“ schaltet „HTTPS erzwingen“ die drei Zeilen in `public/.htaccess` ein – nur wenn die Adresse vorher über HTTPS hier ankommt, mit Probe danach und Rücknahme bei einer Schleife. HSTS ist ein eigener Schalter. Konsole: `php bin/console cms:https` (Stand), `cms:https an|aus|hsts-an|hsts-aus` (ADR 120).
|
||||
|
||||
`composer format` formatiert PHP. `composer build` wärmt den Symfony-Produktionscache; öffentliche CSS-Dateien sind direkt auslieferbar, es ist kein Frontend-Bundler nötig. Bei Änderungen an `public/assets/design-tokens.json` einmal `npm.cmd run build:design` ausführen und die erzeugte CSS-Datei mitliefern; Node ist auf dem Hosting nicht erforderlich. Nach Änderungen an Produktionskonfiguration/Templates `php bin/console cache:clear --env=prod --no-debug` ausführen. Falls lokale Tests alte Templates verwenden, vor PHPUnit auch `php bin/console cache:clear --env=test --no-debug` ausführen.
|
||||
|
||||
Das Lighthouse-Skript misst die Startseite dreimal je Profil und prüft die Mediane gegen die Projektziele. `test:editor -- --audit` prüft die im isolierten Editortest gestaltete v3-Seite mit Abschnittsvorlage, Galerie, tatsächlichen Bildvarianten und Effekten; Berichte stehen unter `reports/lighthouse-editor`. Uploads und Datenbank des Tests liegen getrennt unter `var/browser-tests`. Diese kleinen Prototypseiten ersetzen nicht die spätere Referenzsuite mit Kundeninhalten, Fotos, Beitrag und Formular. Messgrenzen und Ergebnisse: `docs/QUALITY.md`.
|
||||
`test:links` folgt der Sitemap, sammelt jeden internen Verweis samt Bildern, Stylesheets und Hintergrundbildern und meldet jede Antwort ab 400 – gebaut, nachdem im Nachbau von kant-it.de vier Verweise ins Leere zeigten, die kein Test finden konnte.
|
||||
|
||||
Das Lighthouse-Skript misst dreimal je Profil und prüft die Mediane gegen die Projektziele. Welche Adressen geprüft werden, sagt `CMS_PATHS` (mit Komma getrennt, Standard `/`); in der Git-Bash davor `MSYS_NO_PATHCONV=1` setzen, sonst wird aus `/` ein Windows-Pfad. `test:editor -- --audit` prüft die im isolierten Editortest gestaltete v3-Seite mit Abschnittsvorlage, Galerie, tatsächlichen Bildvarianten und Effekten; Berichte stehen unter `reports/lighthouse-editor`. Uploads und Datenbank des Tests liegen getrennt unter `var/browser-tests`. Diese kleinen Prototypseiten ersetzen nicht die spätere Referenzsuite mit Kundeninhalten, Fotos, Beitrag und Formular. Messgrenzen und Ergebnisse: `docs/QUALITY.md`.
|
||||
|
||||
## Inhalte und Entwicklung
|
||||
|
||||
@@ -95,8 +306,31 @@ Die Formate v1/v2/v3 und erlaubten Blöcke stehen in `src/Content/DocumentValida
|
||||
|
||||
Ein JSON-Dokument lässt sich zusätzlich lokal über `php bin/console cms:save-draft PAGE_ID PFAD_ZUR_DATEI ERWARTETE_VERSION` speichern. Der Befehl gibt die neue Revisions-ID aus. Die redaktionelle Bearbeitung und Veröffentlichung erfolgen jetzt über den geschützten Editor.
|
||||
|
||||
## Wiederkehrende Aufgaben
|
||||
|
||||
Sicherung, geplante Veröffentlichung, Statistik verdichten, Updateprüfung und Aufräumen laufen über einen eingebauten Plan. Auf geteiltem Hosting gibt es oft keinen Cronjob – deshalb stößt hier **die Anfrage** die Arbeit an, nach der ausgelieferten Antwort (`kernel.terminate`), sodass kein Besucher darauf wartet. Der Bildschirm **Verwaltung → Aufgaben** zeigt Plan, letztes Ergebnis und Protokoll und schaltet jede Aufgabe einzeln.
|
||||
|
||||
Vier Auslöser, in aufsteigender Verlässlichkeit:
|
||||
|
||||
1. **Besuch** – jeder Seitenaufruf prüft, ob etwas fällig ist. Der Normalfall kostet eine gelesene Datei (`var/scheduler.json`) und keine Datenbankabfrage. Standardmäßig an.
|
||||
2. **Geöffnete Verwaltung** – das Backend fragt jede Minute nach, solange der Reiter sichtbar ist. Damit läuft der Plan auch auf einer Website ohne Besucher. Standardmäßig an.
|
||||
3. **URL-Cronjob** – wenn der Hoster nur das Aufrufen einer Adresse anbietet: die Adresse steht auf dem Bildschirm „Aufgaben“ (`/cms-cron/<Schlüssel>`, aus `APP_SECRET` abgeleitet), zum Beispiel alle fünf Minuten.
|
||||
4. **Konsole** – `php bin/console cms:tasks:run`, für einen echten Cronjob jede Minute:
|
||||
|
||||
```
|
||||
* * * * * cd /pfad/zur/installation && php bin/console cms:tasks:run
|
||||
```
|
||||
|
||||
`cms:tasks` zeigt denselben Plan wie die Verwaltung (`--log` zusätzlich das Protokoll), `cms:tasks:run --task=backup` startet eine bestimmte Aufgabe sofort, `cms:tasks:run --loop=60 --max-time=3600` läuft als Dauerläufer unter einem Prozessverwalter. Die alten Einzelbefehle `cms:backup`, `cms:stats:prune` und `cms:publish-due` gibt es weiterhin; wer sie in einen Cronjob einträgt, schaltet die entsprechende Aufgabe im Plan besser ab, damit nicht beides läuft.
|
||||
|
||||
**Die Grenze gehört dazu:** ohne Besuch und ohne geöffnetes Backend passiert nichts. Eine Aufgabe für 03:00 Uhr läuft auf einer stillen Website erst beim nächsten Aufruf, vielleicht um 07:15 Uhr. Wer Minutengenauigkeit braucht, richtet Weg 3 oder 4 ein und schaltet den Besuchs-Auslöser ab.
|
||||
|
||||
## PHP-Webhosting
|
||||
|
||||
**Archiv-Updates prüfen:** `node scripts/archive-update-check.mjs <altes-release.zip> <neues-release.zip>` testet im Produktionsmodus den vollständigen HTTP-Updateablauf mit isolierter Kopie von `var/kantit.db`. PHP mit SQLite3 über `PHP_BINARY` wählen. Downloadtransport lokal, Originaldatenbank unverändert; Protokolle unter `var/archive-check-*`. Ein drittes Argument `500` prüft ausdrücklich den bekannten Altfehler samt Wiederaufnahme. Um den nächsten Updateaufruf des korrigierten 1.10.2-Pakets ohne Folgerelease zu testen, dasselbe Paket zweimal angeben und ausschließlich für diesen Test `UPDATE_SOURCE_VERSION=1.10.1` setzen; geändert wird nur die Versionsdatei der Wegwerfinstallation.
|
||||
|
||||
**500 direkt nach einem Archiv-Update aus 1.10.1 oder älter:** `/admin/system` direkt öffnen und angebotene Migrationen abschließen. Der alte Updater kann nach erfolgreichem Dateitausch seinen eigenen Cache löschen und beim Antworten abbrechen. Ab 1.10.2 behalten laufende Anfragen ihren Cache; neue Releases erhalten ein eigenes Cacheverzeichnis. Details und Grenzen stehen in ADR 090. Nicht erneut installieren oder die Datenbank ersetzen.
|
||||
|
||||
Anforderungen vor dem Pilotbetrieb beim Anbieter bestätigen:
|
||||
|
||||
- Gepflegte PHP-Version ab 8.2, PDO MySQL und die Composer-Plattformanforderungen.
|
||||
@@ -105,8 +339,68 @@ Anforderungen vor dem Pilotbetrieb beim Anbieter bestätigen:
|
||||
- Apache mit Rewrite-Unterstützung für `public/.htaccess` oder entsprechende FastCGI/Nginx-Konfiguration.
|
||||
- Schreibzugriff für PHP auf `var/`, HTTPS und eine Möglichkeit, Migrationen/Cache-Kommandos kontrolliert auszuführen.
|
||||
|
||||
Serverkonfiguration über echte Umgebungsvariablen oder eine nicht öffentliche `.env.local`: `APP_ENV=prod`, `APP_DEBUG=0`, eigenes zufälliges `APP_SECRET`, `SITE_ORIGIN=https://eigene-domain.example` und `DATABASE_URL=mysql://USER:URL_ENCODED_PASSWORD@HOST:3306/DATABASE?charset=utf8mb4`. Das ist eine Vorlage, keine funktionierende Zugangskonfiguration.
|
||||
Serverkonfiguration über die nicht öffentliche `.env`; echte Server-Umgebungsvariablen dürfen sie gezielt überschreiben: `APP_ENV=prod`, `APP_DEBUG=0`, eigenes zufälliges `APP_SECRET`, `SITE_ORIGIN=https://eigene-domain.example` und `DATABASE_URL=mysql://USER:URL_ENCODED_PASSWORD@HOST:3306/DATABASE?charset=utf8mb4`. Das ist eine Vorlage, keine funktionierende Zugangskonfiguration.
|
||||
|
||||
Deployment später mit `composer install --no-dev --optimize-autoloader`, Backup, Migration und Cacheaufbau. Composer darf auf einem geeigneten Build-Rechner laufen, sodass das Hosting weder Node noch Composer benötigt; CLI-Zugriff oder ein verlässlich dokumentierter Deploymentweg für Datenbankmigrationen bleibt zu klären. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
|
||||
**Composer wird auf dem Hosting nicht vorausgesetzt.** Ein Release wird auf einem Rechner gebaut, der Composer hat: `php scripts/build-release.php` legt `var/releases/kantsites-<Fassung>.zip` samt `.sha256` an. Das Archiv enthält die Anwendung mit fertigem `vendor/` ohne Entwicklungspakete, aber keine Tests, keine Entwicklungsskripte und kein `var/`. Eine `.env` aus der unvertraulichen `.env.example` liegt für die Erstinstallation bei. Der Webinstaller schreibt die eigenen Werte in `.env`, migriert und legt das erste Benutzerkonto an. Beim Update die eigene `.env` erhalten und niemals mit der Vorlage aus dem Archiv überschreiben. Node wird dort ebenfalls nicht gebraucht. Keine Installation ins öffentlich erreichbare Projektstammverzeichnis.
|
||||
|
||||
Ein Livegang, Hostzugriff, Restore-Test und Produktionsfreigabe sind nicht erfolgt. Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` gehört danach in einen täglichen Cronjob. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt. Wie ein Update später über einen Git-Tag laufen soll, steht als Plan in `docs/UPDATE.md` – gebaut ist davon noch nichts.
|
||||
`web.kant-it.de` und das Drews-Hosting laufen mit Releasearchiven dieses Codes; die Domainumschaltung von kant-it.de, ein Restore-Test auf dem Hosting und der Formularversand über einen echten Mailserver stehen aus (`docs/LIVEGANG.md`). Bei bestehenden Installationen vor dem Update Datenbank und Medien sichern und `php vendor/bin/doctrine-migrations migrate --no-interaction` ausführen. Migration `20260912000200` ergänzt Benutzer, `20260912000300` die Medientabelle; beide verändern keine Seitendokumente. Migration `20260912001100` ergänzt drei Datumsspalten an `cms_user` (drei ADD COLUMN, kein Tabellenneubau). Benutzerverwaltung und Passwortänderung gibt es jetzt in der Oberfläche unter „Benutzer“ und „Mein Konto“; `php bin/console cms:user:create` bleibt der Weg, wenn kein Konto mehr funktioniert. Migration `20260912001300` ergänzt zwei Spalten an `page` für Kopf- und Fußbereich je Seite (zwei ADD COLUMN). Migration `20260914000100` legt die Tabelle des Aufgabenplans an (neue Tabelle, kein Eingriff in Bestehendes); ohne sie läuft der Plan nicht, meldet das aber, statt eine Seite zu zerstören. Migration `20261009000100` legt die Tabelle `page_translation` für die Mehrsprachigkeit an (neue Tabelle, kein Eingriff in Bestehendes; Sprachen unter Einstellungen → Sprachen, ADR 111). Migration `20260912001200` legt die Zählertabelle der Statistik an (neue Tabelle, kein Eingriff in Bestehendes); `php bin/console cms:stats:prune` und `php bin/console cms:backup` erledigt jetzt der eingebaute Aufgabenplan (Abschnitt „Wiederkehrende Aufgaben“); ohne Cronjob genügen Besuche und das geöffnete Backend – die Sicherung schreibt nach `var/backups/`, rotiert selbst und enthält Datenbank und Medien, aber weder `.env` noch `vendor/`. Zurückspielen: `php bin/console cms:restore <name>`. Der Code liegt auf `https://git.kant-it.de/kantarci/KantSites.git`; Fassungen sind Tags der Form `v1.0.1`, und die laufende Fassung steht in `VERSION`. Ein Update zieht `php bin/console cms:update` (mit `--check`, `--dry-run`, `--to=v1.2.0`) oder der Bildschirm „System“ in der Verwaltung; `--maintenance=an|aus` schaltet den Wartungszustand von Hand. Ändert ein Update die Abhängigkeiten (`composer.lock`) und ist Composer auf dem Server nicht aufrufbar, bricht der Updater ab und verweist auf das Releasearchiv; Updates ohne Abhängigkeitsänderung laufen auch ohne Composer durch. Ablauf und Grenzen stehen in `docs/UPDATE.md`. Der Abgleich mit der Pilotwebsite bleibt der nächste Schritt.
|
||||
|
||||
## Verwaltung ab 1.7.0
|
||||
|
||||
**Seiten** und **Beiträge** sind getrennte Menüpunkte. Suche, Status einschließlich Papierkorb und 20/50 Einträge begrenzen jede Ergebnisliste bereits in der Datenbank. Vor/Zurück erhält die Filter; übergeordnete Seiten lassen sich unabhängig von der aktuellen Ergebnisseite suchen.
|
||||
|
||||
Ab **1.7.1** aktualisieren sich Seiten, Beiträge und Mediathek beim Tippen per AJAX, ohne Enter oder Neuladen. Status, Anzahl und Blättern reagieren ebenfalls direkt. Der Cursor bleibt im Suchfeld; Zurück/Vor im Browser stellt die Filter wieder her. Die Mediathek sucht in Titel und Alternativtext über alle Ergebnisseiten. Menüs, Seitenauswahl und Editor-Suchfelder reagieren ebenfalls beim Tippen. Bei Ladefehlern bleiben die bisherigen Treffer sichtbar und „Erneut suchen“ wiederholt die Anfrage. Ohne JavaScript bleibt das Suchformular mit „Anwenden“ benutzbar.
|
||||
|
||||
Unter **Menüs** zuerst den Menüplatz wählen, links Inhalte suchen und zum Menü hinzufügen. Rechts lassen sich Beschriftung, Ziel, Reihenfolge und Unterpunkte bearbeiten. Pfeilbuttons sind per Tastatur erreichbar; Rückgängig stellt entfernte oder umgeordnete Einträge wieder her. Erst **Menü speichern** ändert die Website. Validierungs-, Verbindungs- und Versionsfehler erhalten die lokalen Eingaben. Ein paralleler Speicherstand muss bewusst neu geladen werden.
|
||||
|
||||
**Bereiche** verwenden denselben Editor; Speichern wirkt sofort auf allen verknüpften Seiten. Unter **Formulare** ist die Meldung nach dem Absenden ein mehrzeiliges Feld über die verfügbare Formularbreite.
|
||||
|
||||
### Konfigurationswechsel von älteren Fassungen
|
||||
|
||||
Beim ersten Start von 1.7.0 wird eine vorhandene `.env.local` einmalig mit `.env` zusammengeführt. Die bisher wirksamen Werte aus `.env.local` bleiben dabei erhalten, insbesondere Datenbank, Medienordner und Secret. Beide Originale liegen anschließend unter `var/backups/environment-<Zeitpunkt>-<Kennung>/`; erst nach erfolgreichem Schreiben wird `.env.local` entfernt. Danach gelten Änderungen direkt in `.env`. System zeigt die tatsächlich genutzte Datenbank und die Quelle `.env` oder Server-Umgebung an.
|
||||
|
||||
Weitere Symfony-Overlays (`.env.prod`, `.env.prod.local`, `.env.local.php`) werden nicht mehr automatisch geladen. Falls ein Hosting sie bewusst verwendet hat, deren gewünschte Werte vor dem Wechsel in `.env` oder echte Servervariablen übernehmen. Der Installer, Setup und der Admin-Pfad-Befehl erzeugen keine `.env.local` mehr. Rückweg: alten Code und die beiden gesicherten Konfigurationsdateien gemeinsam wiederherstellen.
|
||||
|
||||
### Zweite lokale Referenzwebsite: Elektro Bernhard
|
||||
|
||||
Neben dem Kant-IT-Piloten liegt eine vollständige Kundenwebsite in einer eigenen Installation: Datenbank `var/elektro-bernhard.db`, Medien in `var/elektro-bernhard-media`. Beide werden über Umgebungsvariablen gewählt, die Anwendung selbst bleibt unverändert:
|
||||
|
||||
```
|
||||
DATABASE_URL="sqlite:///<Projektpfad>/var/elektro-bernhard.db" MEDIA_DIRECTORY="<Projektpfad>/var/elektro-bernhard-media" php -S 127.0.0.1:8088 -t public bin/dev-router.php
|
||||
```
|
||||
|
||||
In der App-Vorschau erledigt das die Startkonfiguration `elektro-bernhard` in `.claude/launch.json` (Port 8088).
|
||||
|
||||
Der Bestand wird nicht von Hand gepflegt, sondern aufgebaut: `scripts/seed-elektro-bernhard.php <Ordner mit den Originalbildern>` legt Gestaltung, Medien, Formular, Kopf- und Fußbereich, elf Seiten, vier Menüs, Einstellungen, Weiterleitungen und ein Administratorkonto an. Das Skript weigert sich, wenn die Datenbank schon Seiten enthält oder `DATABASE_URL` nicht auf diese Installation zeigt. `sh scripts/reset-elektro-bernhard.sh <Bilderordner>` wirft die Installation weg, migriert neu und baut sie wieder auf; das Passwort des Kontos steht am Ende der Ausgabe. Vorlage ist das statische Paket `Elektro-Bernhard-Website`; die drei Stellen, an denen der Nachbau bewusst abweicht, stehen in `docs/STATUS.md`.
|
||||
|
||||
### Dritte lokale Referenzwebsite: Tagespflege Moldovan
|
||||
|
||||
Der Onepager „Tagespflege Jacqueline Moldovan“ liegt in `var/tagespflege-moldovan.db` mit Medien in `var/tagespflege-moldovan-media`; die Startkonfiguration `tagespflege-moldovan` in `.claude/launch.json` nutzt Port 8089. Der Inhalt entsteht ausschließlich über `scripts/seed-tagespflege-moldovan.php <Ordner mit gemeinsam.webp, jacqueline-moldovan.png und marke.png>`; `sh scripts/reset-tagespflege-moldovan.sh [Ordner]` (Standard `var/tagespflege-moldovan-assets`) wirft die Installation weg, migriert neu und baut sie wieder auf. Vorlage war das gelieferte Paket `Tagespflege_Moldovan_Website.zip` (09.09.2026); die bewussten Abweichungen stehen im Kopf des Skripts.
|
||||
|
||||
### Vierte lokale Referenzwebsite: Ihr Malerservice Drews
|
||||
|
||||
Der Nachbau von maler-drews.com (Malermeister Axel Drews, Gernsbach) liegt in `var/maler-drews.db` mit Medien in `var/maler-drews-media`; die Startkonfiguration `maler-drews` in `.claude/launch.json` nutzt Port 8093. `scripts/seed-maler-drews.php <Bilderordner>` baut Gestaltung, Kopf- und Fußbereich, acht Seiten, 31 Beiträge in zwei Kategorien, Menüs, Formular, Einstellungen und Weiterleitungen auf; der Wortlaut der Beiträge, des Impressums und der Datenschutzerklärung steht in `scripts/seed-maler-drews-inhalte.php`. `sh scripts/reset-maler-drews.sh [Bilderordner]` (Standard `var/maler-drews-assets`, Zuordnung in `manifest.json`) wirft die Installation weg und baut sie neu auf; die Schriften Lato 400/700 und Cormorant Garamond 700 holt es nur, wenn die Cormorant-Dateien in `public/assets/fonts` fehlen, weil `cms:fonts:fetch` die geteilte `fonts.css` sonst nur für dieses Design neu schreibt (Kant-IT und Elektro Bernhard brauchen Lato 900). Für den Abruf liegt `var/cacert.pem` (curl.se) bereit, weil das PHP 8.4 auf diesem Rechner keine Stammzertifikate kennt. Die bewussten Abweichungen vom Original stehen im Kopf des Skripts.
|
||||
|
||||
### Fünfte lokale Referenzwebsite: DEEG Immobilien & Hausverwaltung
|
||||
|
||||
Der Onepager „DEEG – Immobilien & Hausverwaltung in Freudenstadt“ (Deeg Hausverwaltung und Immobilien GmbH) liegt in `var/immobilien-deeg.db` mit Medien in `var/immobilien-deeg-media`; die Startkonfiguration `immobilien-deeg` in `.claude/launch.json` nutzt Port 8094, `immobilien-deeg-prod` denselben Bestand im Produktionsmodus auf Port 8095 zum Messen. `scripts/seed-immobilien-deeg.php <Bilderordner>` baut Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Menü, Einstellungen und zwei Formulare auf – `kontakt` und `bewertung` (Bewertungsanfrage mit Auswahl Wohnung, Haus, Mehrfamilienhaus, Grundstück); `sh scripts/reset-immobilien-deeg.sh [Bilderordner]` (Standard `var/immobilien-deeg-assets`: Logo und zwei Architekturfotos aus dem gelieferten Paket) wirft die Installation weg und baut sie neu auf. Die Vorlage nutzt Arial, es gibt keine Schrift zu holen. Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts.
|
||||
|
||||
### Sechste lokale Referenzwebsite: EB Solar
|
||||
|
||||
Der Redesign-Entwurf von ebsolar.de (Elektro Bernhard Regenerative Energie GmbH, Baden-Baden) liegt in `var/ebsolar.db` mit Medien in `var/ebsolar-media`; die Startkonfiguration `ebsolar` in `.claude/launch.json` nutzt Port 8096, `ebsolar-prod` denselben Bestand im Produktionsmodus auf Port 8097 zum Messen. `scripts/seed-ebsolar.php <Bilderordner>` baut Gestaltung, Kopf- und Fußbereich, den Kontaktbereich als wiederverwendbare Sektion, Startseite, Projektübersicht, Solarcheck, Impressum, Datenschutz, 404, acht Projektbeiträge mit Galerie und Bildbetrachter, Menü, Einstellungen und zwei Formulare auf – `kontakt` und `solarcheck` (acht Schritte wie das Original auf ebsolar.de, die fünf Auswahlfragen als Bildkarten – gezeichnet und gerastert von `node scripts/build-ebsolar-options.mjs`); `sh scripts/reset-ebsolar.sh [Bilderordner]` (Standard `var/ebsolar-assets`: Logo, Favicon, Startbild, sechs Herstellerlogos, 31 Projektfotos und `projects.json` aus dem gelieferten Paket) wirft die Installation weg und baut sie neu auf. Die Vorlage nutzt Arial, es gibt keine Schrift zu holen. Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts.
|
||||
|
||||
### Siebte lokale Referenzwebsite: KANTARCI Immobilien (Landingpage)
|
||||
|
||||
Die Landingpage „KANTARCI Immobilien – Mietanfrage“ (KANTARCI Immobilien GmbH, Baden-Baden) liegt in `var/kantarci-immobilien.db` mit Medien in `var/kantarci-immobilien-media`; die Startkonfiguration `kantarci-immobilien` in `.claude/launch.json` nutzt Port 8098, `kantarci-immobilien-prod` denselben Bestand im Produktionsmodus auf Port 8099 zum Messen. `sh scripts/reset-kantarci-immobilien.sh [Bilderordner]` (Standard `var/kantarci-immobilien-assets` mit `kantarci-banner.jpeg` aus dem gelieferten Paket) schneidet Logo, Hausmotiv und Seitensymbol aus dem Banner (`scripts/build-kantarci-assets.php`), zeichnet die Bildkarten der Mietanfrage (`node scripts/build-kantarci-options.mjs`), wirft die Installation weg und baut mit `scripts/seed-kantarci-immobilien.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404, Einstellungen und das Formular `mietanfrage` (drei Schritte mit Bildkarten und Zusammenfassung) auf. Schriften sind Arial und Georgia, es gibt nichts zu holen. Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts.
|
||||
|
||||
### Achte lokale Referenzwebsite: Kantarci Markt (Vorab-Seite zur Neueröffnung)
|
||||
|
||||
Die Seite „Kantarci Markt – Neueröffnung“ (Wilhelm-Drapp-Straße, Baden-Baden) liegt in `var/kantarci-markt.db` mit Medien in `var/kantarci-markt-media`; die Startkonfiguration `kantarci-markt` in `.claude/launch.json` nutzt Port 8100, `kantarci-markt-prod` denselben Bestand im Produktionsmodus auf Port 8101 zum Messen. `sh scripts/reset-kantarci-markt.sh [Bilderordner]` (Standard `var/kantarci-markt-assets` mit `kantarci-markt-logo.png`, `market.jpg` und `vegetables.jpg` aus dem gelieferten Paket) schneidet das Logo zu und rastert Seitensymbol und Stempel (`node scripts/build-kantarci-markt-assets.mjs`), wirft die Installation weg, baut mit `scripts/seed-kantarci-markt.php` Gestaltung, Kopf- und Fußbereich, Startseite, Impressum, Datenschutz, 404 und Einstellungen auf und holt DM Sans auf den eigenen Server (`cms:fonts:fetch`, ersetzt in `fonts.css` nur die Regeln für DM Sans). Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8101 CMS_PATHS=/,/impressum,/datenschutz CMS_AUDIT_DIR=reports/lighthouse-kantarci-markt node scripts/audit.mjs` (in Git Bash verhindert `MSYS_NO_PATHCONV`, dass `/impressum` zu einem Windows-Pfad wird). Die bewussten Abweichungen vom Paket stehen im Kopf des Skripts. Die Demo der Eröffnungsseite liegt unter `/eroeffnung` (noindex) und bettet die Angebote aus `app.kantarci-markt.de` ein; die App erlaubt das nur von `localhost`, deshalb misst man sie mit `CMS_HOST_RULES="MAP localhost 127.0.0.1" CMS_URL=http://localhost:8101 CMS_PATHS=/eroeffnung`.
|
||||
|
||||
### Neunte lokale Referenzwebsite: Hüfner Gartenbau
|
||||
|
||||
Die Website der Garten- und Landschaftsbau Hüfner GmbH (Iffezheim) liegt in `var/huefner.db` mit Medien in `var/huefner-media`; die Startkonfiguration `huefner` in `.claude/launch.json` nutzt Port 8102, `huefner-prod` denselben Bestand im Produktionsmodus auf Port 8103 zum Messen. `sh scripts/reset-huefner.sh [Entwurfsordner Unterlagenordner]` kopiert die Fotos aus dem gelieferten Entwurf und Umuts Unterlagen (Alpaka-Fotos, PQ-Logo, Galabau-Signet, PQ-Bescheinigung und Freistellungsbescheinigungen als PDF, unter festen Namen) nach `var/huefner-assets` und schneidet den Baum aus dem Logo als Seitensymbol (`scripts/build-huefner-assets.php`; ohne Argumente nimmt es die schon vorbereiteten Dateien), wirft die Installation weg, baut mit `scripts/seed-huefner.php` Gestaltung, Kopf- und Fußbereich, Startseite, Alpakaseite, Impressum, Datenschutz, 404, Menü, Einstellungen und das Formular `anfrage` auf und holt Barlow und Barlow Condensed auf den eigenen Server und schreibt zuletzt das Inhaltspaket `var/huefner-paket.zip` für die Live-Seite (dort unter System importieren, Konten bleiben). Gemessen mit `MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8103 CMS_PATHS=/,/alpakas CMS_AUDIT_DIR=reports/lighthouse-huefner-termin node scripts/audit.mjs`. Die Startseite liegt knapp unter dem HTML-Budget von 150 KB aus `test:browser`: wiederkehrende Bausteine tragen ihre Gestaltung deshalb am Behälter. Die bewussten Abweichungen vom Entwurf stehen im Kopf des Skripts.
|
||||
|
||||
Zusätzliche Prüfung mit den lokalen Kant-IT-Inhalten: `node scripts/admin-quality-check.mjs` (PHP über `PHP_BINARY` wählbar). Das Skript benötigt `var/kantit.db` und `var/kantit-media`, erstellt eine eigene Datenbankkopie und ein zufälliges Testkonto darin; es ändert die Pilotdatenbank nicht. Screenshots und Bericht landen in `reports/admin-quality/`.
|
||||
|
||||
Die Livesuche prüft `npm run test:search` mit derselben isolierten Pilotkopie: Eingabe ohne Enter, langsame Antworten, Fehler/Wiederholen, Filter, Browserhistorie, Mediathek, Tastaturfokus, mobile Ansicht und axe. Ergebnisse stehen in `reports/live-search/`. Die allgemeine Verwaltungsprüfung ohne erneuten öffentlichen Bewegungstest startet `npm run test:admin -- --admin-only`.
|
||||
@@ -0,0 +1,9 @@
|
||||
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
|
||||
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
@@ -11,6 +11,38 @@
|
||||
$path = parse_url($_SERVER['REQUEST_URI'] ?? '/', PHP_URL_PATH) ?: '/';
|
||||
$file = __DIR__.'/../public'.$path;
|
||||
if ('/' !== $path && !str_contains($path, '..') && is_file($file)) {
|
||||
// Dieselben Antwortkoepfe wie public/.htaccess sie auf einem Webserver setzt: Stylesheets,
|
||||
// Skripte, Schriften und Bilder tragen ihre Fassung oder ihre Kennung in der Adresse und
|
||||
// duerfen deshalb lange im Browser bleiben. Ohne das misst jede Messung hier etwas anderes
|
||||
// als das, was spaeter ausgeliefert wird - und man optimiert an der falschen Stelle.
|
||||
if (1 === preg_match('/\.(css|js|woff2|webp|png|jpe?g|gif|svg)$/', $path, $treffer) && !str_starts_with($path, '/assets/seite/')) {
|
||||
header('Content-Type: '.match ($treffer[1]) {
|
||||
'css' => 'text/css; charset=UTF-8',
|
||||
'js' => 'text/javascript; charset=UTF-8',
|
||||
'woff2' => 'font/woff2',
|
||||
'svg' => 'image/svg+xml; charset=UTF-8',
|
||||
'jpg', 'jpeg' => 'image/jpeg',
|
||||
default => 'image/'.$treffer[1],
|
||||
});
|
||||
header('Cache-Control: public, max-age=31536000, immutable');
|
||||
// Und ebenso die Komprimierung, die mod_deflate auf einem Webserver uebernimmt: ohne sie
|
||||
// misst man hier eine Stylesheetgroesse, die es im Betrieb nie gibt.
|
||||
$inhalt = (string) file_get_contents($file);
|
||||
if (in_array($treffer[1], ['css', 'js', 'svg'], true) && str_contains($_SERVER['HTTP_ACCEPT_ENCODING'] ?? '', 'gzip')) {
|
||||
$inhalt = (string) gzencode($inhalt, 6);
|
||||
header('Content-Encoding: gzip');
|
||||
header('Vary: Accept-Encoding');
|
||||
}
|
||||
header('Content-Length: '.strlen($inhalt));
|
||||
echo $inhalt;
|
||||
|
||||
exit;
|
||||
}
|
||||
|
||||
return false; // Let the built-in server deliver the static file.
|
||||
}
|
||||
// Auch die Seiten selbst gehen komprimiert raus, wie auf einem Webserver mit mod_deflate.
|
||||
ini_set('zlib.output_compression', '1');
|
||||
ini_set('zlib.output_compression_level', '6');
|
||||
|
||||
require __DIR__.'/../public/index.php';
|
||||
+8
-8
@@ -1,13 +1,13 @@
|
||||
<?php
|
||||
|
||||
// Only creates local state; never replaces an existing environment file.
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
|
||||
use App\Ops\Environment;
|
||||
|
||||
$root = dirname(__DIR__);
|
||||
if (!is_dir($root.'/var')) {
|
||||
mkdir($root.'/var', 0775, true);
|
||||
}
|
||||
if (!file_exists($root.'/.env.local')) {
|
||||
file_put_contents($root.'/.env.local', 'APP_SECRET='.bin2hex(random_bytes(32)).PHP_EOL);
|
||||
echo "Created .env.local with a local secret.\n";
|
||||
} else {
|
||||
echo "Existing .env.local preserved.\n";
|
||||
mkdir($root.'/var', 0o775, true);
|
||||
}
|
||||
Environment::prepare($root);
|
||||
Environment::ensureSecret($root);
|
||||
echo ".env ist eingerichtet; bestehende Einstellungen und das Secret bleiben erhalten.\n";
|
||||
@@ -0,0 +1,9 @@
|
||||
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
|
||||
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
+18
-3
@@ -1,13 +1,28 @@
|
||||
<?php
|
||||
|
||||
use App\Ops\Environment;
|
||||
use Symfony\Component\Dotenv\Dotenv;
|
||||
|
||||
require dirname(__DIR__).'/vendor/autoload.php';
|
||||
// PHP web hosts may omit E from variables_order. Honor real process configuration.
|
||||
foreach (['APP_ENV', 'APP_DEBUG', 'APP_SECRET', 'DATABASE_URL', 'SITE_ORIGIN', 'MEDIA_DIRECTORY'] as $name) {
|
||||
$root = dirname(__DIR__);
|
||||
Environment::prepare($root);
|
||||
// Server variables are explicit deployment overrides; other .env.* files are never loaded.
|
||||
$keys = array_keys((new Dotenv())->parse((string) file_get_contents($root.'/.env')));
|
||||
foreach (array_unique([...$keys, 'APP_ENV', 'APP_DEBUG', 'APP_SECRET', 'DATABASE_URL', 'SITE_ORIGIN', 'MEDIA_DIRECTORY', 'ADMIN_PATH', 'FONT_DIRECTORY', 'ASSET_DIRECTORY']) as $name) {
|
||||
$value = getenv($name, true);
|
||||
if (false !== $value && !isset($_SERVER[$name]) && !isset($_ENV[$name])) {
|
||||
$_SERVER[$name] = $_ENV[$name] = $value;
|
||||
}
|
||||
}
|
||||
(new Dotenv())->bootEnv(dirname(__DIR__).'/.env');
|
||||
(new Dotenv())->load($root.'/.env');
|
||||
$_SERVER['APP_ENV'] = $_ENV['APP_ENV'] = $_SERVER['APP_ENV'] ?? 'prod';
|
||||
$_SERVER['APP_DEBUG'] = $_ENV['APP_DEBUG'] = $_SERVER['APP_DEBUG'] ?? '0';
|
||||
if ('' === ($_SERVER['APP_SECRET'] ?? '')) {
|
||||
try {
|
||||
$secret = Environment::ensureSecret($root);
|
||||
} catch (RuntimeException) {
|
||||
// Keep the installer reachable: its system check names the missing permission.
|
||||
$secret = bin2hex(random_bytes(32)).'-nicht-gespeichert';
|
||||
}
|
||||
$_SERVER['APP_SECRET'] = $_ENV['APP_SECRET'] = $secret;
|
||||
}
|
||||
@@ -8,7 +8,9 @@ framework:
|
||||
cookie_httponly: true
|
||||
cookie_samesite: lax
|
||||
cookie_secure: auto
|
||||
cookie_path: /admin
|
||||
# Der Pfad folgt der Adresse der Verwaltung: hiesse sie anders und das Cookie weiter
|
||||
# /admin, schickte der Browser es nie mit, und keine Anmeldung kaeme je an.
|
||||
cookie_path: '/%kant.admin_path%'
|
||||
name: KANTSESSION
|
||||
router:
|
||||
utf8: true
|
||||
@@ -19,6 +21,7 @@ framework:
|
||||
log: true
|
||||
mailer:
|
||||
# Default to the host's sendmail, which is what ordinary PHP hosting offers. Override with
|
||||
# MAILER_DSN in .env.local for SMTP. Delivery failures never lose a submission: it is
|
||||
# MAILER_DSN in .env for SMTP. Delivery failures never lose a submission: it is
|
||||
# stored first and stays visible as undelivered in the administration.
|
||||
dsn: '%env(resolve:MAILER_DSN)%'
|
||||
# Ohne `resolve:`: ein SMTP-Passwort mit Prozentzeichen ist kein Parametername.
|
||||
dsn: '%env(MAILER_DSN)%'
|
||||
@@ -9,7 +9,7 @@ security:
|
||||
ROLE_PUBLISHER: ROLE_EDITOR
|
||||
firewalls:
|
||||
admin:
|
||||
pattern: ^/admin(?:/|$)
|
||||
pattern: ^/%kant.admin_path%(?:/|$)
|
||||
lazy: true
|
||||
provider: cms_users
|
||||
form_login:
|
||||
@@ -25,5 +25,5 @@ security:
|
||||
target: admin_login
|
||||
enable_csrf: true
|
||||
access_control:
|
||||
- { path: ^/admin/login$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/admin, roles: ROLE_EDITOR }
|
||||
- { path: ^/%kant.admin_path%/login$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/%kant.admin_path%, roles: ROLE_EDITOR }
|
||||
@@ -1,7 +1,13 @@
|
||||
twig:
|
||||
default_path: '%kernel.project_dir%/templates'
|
||||
# Die Vorlagen der Plugins (@plugins/<name>/...) registriert App\Twig\PluginExtension zur
|
||||
# Laufzeit - nur wenn der Ordner plugins/ existiert; ein fester Pfad hier liesse Twig auf einer
|
||||
# Installation ohne Plugins abbrechen.
|
||||
strict_variables: true
|
||||
globals:
|
||||
# Reading the decision needs the current request, so the templates ask the gate directly
|
||||
# instead of every controller passing the same three values along.
|
||||
consentGate: '@App\Content\Consent'
|
||||
# Dasselbe für die Einstellungen der Website: der Kopfbereich fragt, ob er beim
|
||||
# Scrollen kleben soll, und das steht in keinem Dokument.
|
||||
siteSettings: '@App\Content\SiteSettings'
|
||||
+9
-1
@@ -1,3 +1,11 @@
|
||||
controllers:
|
||||
# Erst alle benannten Routen, dann die Auffangroute fuer oeffentliche Seiten. Ohne diese
|
||||
# Reihenfolge wuerde `/verwaltung/partials` als Seitenadresse gelesen, sobald die Verwaltung nicht
|
||||
# mehr `/admin` heisst - die Auffangroute schliesst nur den Standardnamen aus.
|
||||
verwaltung:
|
||||
resource: ../src/Controller/
|
||||
type: attribute
|
||||
exclude: '../src/Controller/PageController.php'
|
||||
|
||||
seiten:
|
||||
resource: ../src/Controller/PageController.php
|
||||
type: attribute
|
||||
@@ -0,0 +1,9 @@
|
||||
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
|
||||
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
@@ -0,0 +1,24 @@
|
||||
# Schutz fuer den Fall, dass die Domain auf den Projektordner zeigt statt auf public/ (ADR 116).
|
||||
#
|
||||
# Bewusst kein "alles sperren": Apache wendet diese Datei auch auf public/ an, und dort soll alles
|
||||
# wie gewohnt laufen. Gesperrt werden nur Dateien, die es in public/ nicht gibt oder die
|
||||
# public/.htaccess ohnehin sperrt. Die Ordner var/, vendor/, config/, src/ usw. tragen eine eigene
|
||||
# Sperrdatei.
|
||||
<FilesMatch "^\.|\.(env|db|sqlite|sqlite3|lock|md|yaml|yml|dist|log|key)$">
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
<FilesMatch "^(composer\.json|VERSION|cli-config\.php|migrations\.php)$">
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
</FilesMatch>
|
||||
+137
-3
@@ -1,34 +1,168 @@
|
||||
parameters:
|
||||
# Die Adresse der Verwaltung. Standard bleibt /admin; wer sie aendert, nimmt Scannern und
|
||||
# Anmeldeversuchen das Ziel - erraten laesst sie sich nicht mehr. Sie steht in der Umgebung und
|
||||
# nicht in der Datenbank, weil die Routen beim Start feststehen muessen. Geaendert wird sie
|
||||
# ueber `php bin/console cms:admin:path <neu>`; der Befehl prueft und raeumt den Cache auf.
|
||||
kant.admin_path: '%env(string:default:kant.admin_path_default:ADMIN_PATH)%'
|
||||
kant.admin_path_default: 'admin'
|
||||
# Wo Schriften und Stylesheets liegen. Im Normalfall im oeffentlichen Ordner; ueber
|
||||
# FONT_DIRECTORY und ASSET_DIRECTORY umstellbar - die Tests nutzen das, damit sie nicht in
|
||||
# das Projekt schreiben, und eine Installation mit schreibgeschuetztem public/ ebenfalls.
|
||||
kant.fonts_dir: '%kernel.project_dir%/public/assets/fonts'
|
||||
kant.asset_dir: '%kernel.project_dir%/public/assets'
|
||||
|
||||
services:
|
||||
_defaults:
|
||||
autowire: true
|
||||
autoconfigure: true
|
||||
# Jede Aufgabe traegt sich selbst in den Plan ein: eine Klasse, die App\Ops\Tasks\Task
|
||||
# implementiert, bekommt das Etikett und liegt beim naechsten Abgleich in der Tabelle. Eine
|
||||
# neue Aufgabe ist damit eine Datei und keine Liste, die man an drei Stellen nachfuehrt.
|
||||
_instanceof:
|
||||
App\Ops\Tasks\Task:
|
||||
tags: ['kant.task']
|
||||
App\:
|
||||
resource: '../src/'
|
||||
exclude: '../src/{Kernel.php,Domain/}'
|
||||
Doctrine\DBAL\Connection:
|
||||
factory: ['App\Infrastructure\ConnectionFactory', 'create']
|
||||
arguments: ['%env(resolve:DATABASE_URL)%']
|
||||
arguments: ['%env(projectdir:DATABASE_URL)%', '@App\Infrastructure\QueryCounter']
|
||||
App\Controller\PageController:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
# Mit oder ohne www entscheidet SITE_ORIGIN; die andere Schreibweise leitet dorthin um.
|
||||
App\EventSubscriber\CanonicalHostSubscriber:
|
||||
arguments:
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
App\Content\MediaLibrary:
|
||||
arguments:
|
||||
$directory: '%env(resolve:MEDIA_DIRECTORY)%'
|
||||
$directory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
App\Content\DocumentLibrary:
|
||||
arguments:
|
||||
$directory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
# Die Schriften liegen im oeffentlichen Ordner: sie sollen vom Webserver direkt ausgeliefert
|
||||
# werden, ohne PHP dazwischen - das ist der Punkt der Uebung.
|
||||
# Die Stellen, die wissen muessen, wie die Verwaltung heisst.
|
||||
App\EventSubscriber\AdminHeadersSubscriber:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
App\EventSubscriber\PublicNotFoundSubscriber:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
App\Content\Statistics:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
App\Content\FontLibrary:
|
||||
arguments:
|
||||
$directory: '%env(default:kant.fonts_dir:FONT_DIRECTORY)%'
|
||||
App\Content\StyleBundle:
|
||||
arguments:
|
||||
$assetDirectory: '%env(default:kant.asset_dir:ASSET_DIRECTORY)%'
|
||||
App\Controller\FormController:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
App\EventSubscriber\ErrorLogSubscriber:
|
||||
arguments:
|
||||
$debug: '%kernel.debug%'
|
||||
App\Controller\SeoController:
|
||||
arguments:
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
$adminPath: '%kant.admin_path%'
|
||||
App\Command\HealthCommand:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\Controller\SystemController:
|
||||
arguments:
|
||||
$adminPath: '%kant.admin_path%'
|
||||
$databaseUrl: '%env(DATABASE_URL)%'
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\Command\AdminPathCommand:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$adminPath: '%kant.admin_path%'
|
||||
$environment: '%kernel.environment%'
|
||||
# Eigene kurze Zeitgrenzen statt des Download-Dienstes mit denen fuer Releasearchive.
|
||||
App\Ops\OriginCheck:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
$download: null
|
||||
# HTTPS erzwingen und HSTS (ADR 120); die Probe ueber http kommt im Test als Closure herein.
|
||||
App\Ops\HttpsRedirect:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$httpProbe: null
|
||||
App\Command\HttpsCommand:
|
||||
arguments:
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
App\Command\SiteOriginCommand:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$siteOrigin: '%env(SITE_ORIGIN)%'
|
||||
App\Ops\ArchiveUpdate:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
App\Ops\Updater:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
$databaseUrl: '%env(resolve:DATABASE_URL)%'
|
||||
$databaseUrl: '%env(projectdir:DATABASE_URL)%'
|
||||
App\EventSubscriber\InstallSubscriber:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\EventSubscriber\MaintenanceSubscriber:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$adminPath: '%kant.admin_path%'
|
||||
App\Ops\Installation:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
$secret: '%kernel.secret%'
|
||||
App\Ops\Backup:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
$databaseUrl: '%env(projectdir:DATABASE_URL)%'
|
||||
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
App\Ops\SitePackage:
|
||||
arguments:
|
||||
$root: '%kernel.project_dir%'
|
||||
$mediaDirectory: '%env(projectdir:MEDIA_DIRECTORY)%'
|
||||
# Der Aufgabenplan. Die Aufgaben kommen ueber ihr Etikett herein, damit diese Stelle nicht
|
||||
# jedes Mal mitwaechst.
|
||||
App\Ops\Scheduler:
|
||||
arguments:
|
||||
$tasks: !tagged_iterator kant.task
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\Ops\Tasks\CleanupTask:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\EventSubscriber\TaskWorkerSubscriber:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
$adminPath: '%kant.admin_path%'
|
||||
App\Controller\TaskController:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
App\Controller\CronController:
|
||||
arguments:
|
||||
$secret: '%kernel.secret%'
|
||||
App\Infrastructure\QueryCounter: ~
|
||||
# `%env(projectdir:...)%`: ersetzt nur %kernel.project_dir%. Symfonys `resolve:` laese ein
|
||||
# prozentkodiertes MySQL-Passwort (`p%40ss`) als Parameter und brauche den Container.
|
||||
App\Infrastructure\ProjectDirEnvProcessor:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
App\EventSubscriber\QueryHeaderSubscriber:
|
||||
arguments:
|
||||
$environment: '%kernel.environment%'
|
||||
|
||||
App\Plugin\PluginRegistry:
|
||||
arguments:
|
||||
$projectDir: '%kernel.project_dir%'
|
||||
|
||||
App\Twig\PluginExtension:
|
||||
arguments:
|
||||
$loader: '@twig.loader.native_filesystem'
|
||||
@@ -0,0 +1,62 @@
|
||||
# Arbeitsplan: Installation, Verwaltung und Bewegung
|
||||
|
||||
Auftrag vom 14.09.2026. Ausgangspunkt: Release 1.6.2 plus bereits vorhandene lokale Änderungen an der Systemdiagnose. Diese bleiben erhalten und werden an den neuen Konfigurationsvertrag angepasst. Vorhandener Patch liegt lokal unter `var/work-before-usability/`.
|
||||
|
||||
## 1. Fehler reproduzieren und Daten schützen
|
||||
|
||||
- [x] Bereichsfehler auf den Seitenzugriff in der gemeinsamen Vorlage zurückführen und Öffnen/Vorschau/Speichern im Browser prüfen.
|
||||
- [x] Kant-IT-Datenbank für die Prüfung isoliert kopieren; echte Inhalte und Zugangsdaten nicht verändern.
|
||||
- [x] Slider und Einblendungen auf der Kant-IT-Startseite mit Positionen und Zeitverlauf prüfen.
|
||||
|
||||
## 2. Eine eindeutige Installationskonfiguration
|
||||
|
||||
- [x] `.env` ist die Konfigurationsdatei der Installation. Installer, Setup und Admin-Pfad-Befehl schreiben dort; keine neue `.env.local`.
|
||||
- [x] Bestehende wirksame Einstellungen aus älteren Installationen einmalig und mit Sicherung in `.env` zusammenführen. Datenbankverbindung und Secret erhalten; danach keine versteckte Überschreibung.
|
||||
- [x] Updates erhalten `.env`; echte Server-Umgebungsvariablen bleiben für Hosting/Testbetrieb möglich. Anzeige im Systembereich muss die tatsächliche Quelle nennen, ohne Passwörter auszugeben.
|
||||
- [x] Neuinstallation, bestehende Installation, wiederholtes Booten, Updateerhalt und Fehler beim Schreiben prüfen.
|
||||
|
||||
## 3. Bereiche zuverlässig bearbeiten
|
||||
|
||||
- [x] Gemeinsamen Editor ohne Zugriff auf nicht vorhandene Seitenvariablen rendern; benannte Routen auch bei geändertem Admin-Pfad verwenden.
|
||||
- [x] Kopf, Fuß und verknüpfte Sektion öffnen, Inhalt ändern, Vorschau prüfen, speichern und neu laden; Versionskonflikte erhalten.
|
||||
- [x] Sofort wirksames Speichern bei Bereichen in der Oberfläche korrekt benennen.
|
||||
|
||||
## 4. Seiten und Beiträge übersichtlich verwalten
|
||||
|
||||
- [x] Eigene Navigation und Ansichten für Seiten und Beiträge; passende Anlageformulare.
|
||||
- [x] Datenbankseitige Pagination, Suche, Statusfilter und Ergebniszahl; keine vollständigen Dokumentlisten für jede Bildschirmseite laden.
|
||||
- [x] Filter und Seitenzahl beim Blättern erhalten; leere Suche und ungültige Seitenzahlen behandeln. Papierkorb ebenfalls begrenzen.
|
||||
- [x] Übergeordnete Seiten bleiben unabhängig von der aktuellen Ergebnisseite wählbar.
|
||||
|
||||
## 5. Menüeditor mit verständlichem Aufbau
|
||||
|
||||
- [x] Seiten/Beiträge nach Titel oder Adresse suchen und mit eindeutiger Vorschau auswählen.
|
||||
- [x] Inhalt auswählen → zum Menü hinzufügen → Reihenfolge/Unterpunkte ändern → speichern. Eigene Links getrennt anbieten.
|
||||
- [x] Bestehende Ziele und Hierarchien erhalten, Umordnen auch mit Tastatur/Buttons; Speicherfehler und ungespeicherte Änderungen sichtbar behandeln.
|
||||
|
||||
## 6. Formulare, Animationen und Slider
|
||||
|
||||
- [x] Meldung nach dem Absenden als ausreichend breites mehrzeiliges Feld anbieten.
|
||||
- [x] Einblendungen sichtbar beim Eintreten in den Bildschirm starten; Effektauswahl tatsächlich unterscheiden. Inhalte ohne JavaScript lesbar lassen und Bewegungsreduktion respektieren.
|
||||
- [x] Slider mit sichtbarer Bedienung, funktionierendem Wechsel und nachvollziehbarer Pause/Automatik. Auf der realen Kant-IT-Seite über mehrere Wechsel prüfen.
|
||||
- [x] Bewegung in der Editorvorschau gezielt prüfbar machen, ohne die Sandbox für Inhaltsskripte zu öffnen.
|
||||
|
||||
## 7. Abnahme und Übergabe
|
||||
|
||||
- [x] Relevante PHP-/JavaScript-Tests und vollständige Browserabläufe, einschließlich Tastatur, Speicherung, Fehlerzuständen und unterschiedlichen Bildschirmbreiten.
|
||||
- [x] Änderungen der öffentlichen Ausgabe auf Kant-IT-Inhalten visuell und mit den passenden Qualitätsprüfungen kontrollieren; keine ungeprüften Scores behaupten.
|
||||
- [x] Tatsächliche Ergebnisse, Grenzen und Migration dokumentieren; Versionsnummer vor Veröffentlichung mit dem Repository abgleichen.
|
||||
|
||||
Jeder Abschnitt wird erst als erledigt markiert, wenn seine Prüfung bestanden ist. Der Plan ersetzt keine Umsetzung; Befunde und Änderungen werden darin nachgeführt.
|
||||
|
||||
## Ergebnis der Abnahme
|
||||
|
||||
Umgesetzt als 1.7.0, ohne neues Datenbank- oder Dokumentformat. Begründungen und Konfigurationsmigration: ADR 076–078. Die ursprünglichen lokalen Änderungen an Systemdiagnose/STATUS wurden integriert, die veraltete lokale Versionsnummer 1.4.7 mit der bestehenden Releasefolge abgeglichen.
|
||||
|
||||
Geprüft: PHP-Vollsuite 461/461, JavaScript 30/30, alle 43 Twig-Dateien und Produktionscontainer. Komplette Editorprüfung einschließlich unterschiedlicher Einblendungen und Bewegungsreduktion. Kant-IT-Browserabläufe mit 4 Seiten/29 Beiträgen, Menüfehlern und Versionskonflikt, mehrzeiliger Formularmeldung, Kopf/Fuß und neu angelegter Sektion samt echtem Textinhalt. Desktop, 1024 und 390 px; axe ohne Verstöße. Öffentliche Inhalte und Slider bleiben ohne JavaScript erreichbar.
|
||||
|
||||
Kant-IT-Startseite, Produktionsmodus, Lighthouse 13.4.1: jeweils drei Läufe mobil/desktop, Leistungsmedian jeweils 99; Barrierefreiheit, Best Practices, SEO und Agentic Browsing jeweils 100. Lokale Labormessung, keine Felddaten/CWV-Abnahme für Kundenseiten. Messungen: `reports/admin-quality/lighthouse/`; Browserbericht und Bilder im übergeordneten Ordner. Der zusätzliche Updater-Test verändert die Datenbank vor einem Migrationsfehler, prüft echte Wiederherstellung und einen erhaltenen Wartungszustand, falls die Sicherung fehlt.
|
||||
|
||||
Veröffentlichungsnachweis und bekannte Grenzen stehen im aktuellen Abschnitt von STATUS.md.
|
||||
|
||||
**Veröffentlicht:** `495b268`, Tag `v1.7.0`, Release mit ZIP/Prüfsumme/Installer. Quellabgleich und anonyme Downloads geprüft; frischer Release-Installer antwortet mit HTTP 200 und erzeugt ausschließlich `.env`.
|
||||
+1123
-6
File diff suppressed because it is too large.
Load diff
+58
-8
@@ -86,6 +86,8 @@ Abnahme: Die Startseite des Piloten ist ohne Code nachbaubar. Keine neue öffent
|
||||
|
||||
### D – Formulare — **erledigt am 12.09.2026, ohne Dateiuploads**
|
||||
|
||||
**Ergänzt am 15.09.2026 (ADR 101):** Feldtypen Zahl, Optionsfelder und Mehrfachauswahl, Schritte, Bedingungen auf frühere Auswahlfelder und eine Zusammenfassung vor dem Absenden – für den Preisfinder der DEEG-Website, als Ausbau des Kerns statt als Plugin. Dateiuploads bleiben offen.
|
||||
|
||||
*Braucht: nichts; das größte eigenständige Paket.*
|
||||
|
||||
Umgesetzt: Feldtypen Text, E-Mail, Telefon, Mehrzeiliges, Auswahl und Einwilligung, serverseitige Validierung mit Meldung je Feld, Einwilligungszeitpunkt, E-Mail-Versand mit Antwortadresse, gespeicherte Einsendungen in der Verwaltung und Spamschutz aus Honeypot, signiertem Zeitstempel und Rate-Limit. **Offen geblieben:** Dateiuploads (eigener nicht öffentlicher Ablageort und Typprüfung, Begründung in ADR 017), Löschen und Umbenennen von Formularen, Export und Aufbewahrungsfrist für Einsendungen. Der tatsächliche Versand ist ungeprüft und gehört zu Paket J.
|
||||
@@ -114,6 +116,12 @@ Seiten umbenennen und verschieben, mit Aktualisierung aller Kindpfade in einer T
|
||||
|
||||
Abnahme: Eine Seite mit Unterseiten wird verschoben; alle alten Adressen leiten weiter, Menüs bleiben intakt.
|
||||
|
||||
### G3 – Designer auf Flatsome-Niveau (Nutzerauftrag vom 12.09.2026) — **weitgehend erledigt am 12.09.2026** (ADR 036)
|
||||
|
||||
Umuts Auftrag: „Prüfe es und baue alles wirklich alles genauso ein styles, effekte, und mehr.“ Der Vergleich steht in `docs/DESIGNER-VERGLEICH.md`. Gebaut: 93 statt 50 Eigenschaften, neun Einblendungen mit Verzögerung, sieben Hover-Effekte, Bild-Eigenschaften mit eigenem Ziel, Positionierung, Filter, Symbol, Fortschritt, Slider, Zeilenvorlagen.
|
||||
|
||||
Offen und dort begründet: Sichtbarkeitsschalter je Gerät, Parallax, Video-Hintergrund, selbst gehostetes Video, Countdown, Popup, Einblendung beim Scrollen, soziale Symbole, Preistabelle, Teammitglied, Suche, Vorlagenbibliothek. Die vier mit Skriptbedarf (Countdown, Popup, Scroll-Einblendung, Autoplay) sind eine Entscheidung, keine Lücke – sie weichen `script-src 'none'` auf und werden nur auf Zuruf gebaut.
|
||||
|
||||
### G – Editor für die tägliche Arbeit
|
||||
|
||||
*Braucht: C.*
|
||||
@@ -133,7 +141,7 @@ Umut hat den Umfang von G erweitert. Vier Punkte, in dieser Reihenfolge:
|
||||
|
||||
Abnahme: Kopf- und Fußbereich sind im Editor gestaltbar und öffentlich sichtbar. Eine verknüpfte Sektion auf zwei Seiten ändert sich an beiden Stellen gleichzeitig. Ein Textelement lässt sich formatiert und als HTML bearbeiten, und keine Auszeichnung außerhalb der Positivliste übersteht das Speichern. Eigenes CSS wirkt, eigenes Skript wirkt nur für Administratoren gesetzt und nur auf der veröffentlichten Seite. Die Verwaltung ist ohne Anleitung bedienbar.
|
||||
|
||||
### H – Plugins und Templates
|
||||
### H – Plugins und Templates — **erledigt am 16.09.2026** (ADR 102 und 103: Manifest, Schalter, geprüfte Bausteine, Dateien je Seite, Editoranbindung, Container, Einstellungen, eigene Adressen, Seitenvorlagen; Referenzplugin „Bildvergleich“. Designpakete in Plugins am selben Tag wieder entfernt – das Design gehört der Website, ADR 105. Bewusst nicht: PHP-Hooks und eigene Tabellen – Plugins bleiben Manifest plus Twig, ADR 103)
|
||||
|
||||
*Braucht: den Erhaltungsvertrag (erledigt).*
|
||||
|
||||
@@ -145,7 +153,7 @@ Abnahme: Templatewechsel erhält die Inhalte. Das Referenzplugin lässt sich akt
|
||||
|
||||
*Braucht: nichts; teils vorziehbar.*
|
||||
|
||||
~~Benutzerverwaltung und Passwortänderung in der Oberfläche.~~ — **erledigt am 12.09.2026** (ADR 030). Offen bleibt der Rest des Pakets: Inhaltscache mit gezielter Erneuerung beim Veröffentlichen. Bildformate und Ressourcenpakete prüfen. Sicherung und Wiederherstellung erproben. Updateverfahren mit Datenbankmigration und Rückfallstrategie dokumentieren — **geplant am 12.09.2026 in `docs/UPDATE.md`**: neue Version über einen Git-Tag ziehen, mit Vorprüfung, Sicherung, Wartungszustand, Migration, Rauchtest und Rückweg. Umsetzung offen; zwei Entscheidungen hängen am Hosting (Composer auf dem Server oder `vendor/` im Tag, und ob es Shell-Zugriff gibt).
|
||||
~~Benutzerverwaltung und Passwortänderung in der Oberfläche.~~ — **erledigt am 12.09.2026** (ADR 030). Offen bleibt der Rest des Pakets: Inhaltscache mit gezielter Erneuerung beim Veröffentlichen. ~~Bildformate prüfen.~~ — **erledigt am 12.09.2026** (ADR 040): WebP in vier Breiten, Hintergrundbilder je Gerät. Ressourcenpakete bleiben offen. ~~Sicherung und Wiederherstellung erproben~~ — **erledigt** in Arbeitspaket O: die Wiederherstellung ist im Test und von Hand nachgewiesen. Updateverfahren mit Datenbankmigration und Rückfallstrategie dokumentieren — **geplant am 12.09.2026 in `docs/UPDATE.md`**: neue Version über einen Git-Tag ziehen, mit Vorprüfung, Sicherung, Wartungszustand, Migration, Rauchtest und Rückweg. Umgesetzt am 12.09.2026 (ADR 035). Die Frage „Composer auf dem Server oder `vendor/` im Tag“ ist entschieden (ADR 040): ein Releasearchiv vom Build-Rechner bringt `vendor/` mit, und ein Update, das die Abhängigkeiten ändert, bricht ohne Composer ab statt halb durchzulaufen. Offen bleibt, ob das Hosting Shell-Zugriff hat.
|
||||
|
||||
Abnahme: Veröffentlichen macht die Änderung sofort sichtbar und erneuert nur die betroffenen Einträge. Eine Wiederherstellung aus dem Backup ist nachgewiesen.
|
||||
|
||||
@@ -172,7 +180,7 @@ Umut: „Als todo noch statistik kannst du das einbauen. Wieviel wann wo wie die
|
||||
|
||||
**Umsetzung:** ein Abonnent, der nach der Antwort zählt (nur `text/html`, nur Status 200/404, Verwaltung und Assets ausgenommen, Bots per User-Agent-Muster verworfen), ein Bildschirm „Statistik“ mit Tages- und Wochensicht als serverseitig gezeichnetes SVG-Balkendiagramm ohne JavaScript, Aufbewahrung 14 Monate mit Verdichtung auf Tageswerte nach 60 Tagen, und ein Export als CSV. Abnahme: ein Aufruf erhöht genau einen Zähler; ein Aufruf der Verwaltung erhöht keinen; die Tabelle enthält nach 1000 Aufrufen keine Spalte, aus der eine Person hervorgeht. **Alle drei erfüllt.** Umgesetzt ist zusätzlich der CSV-Export und `cms:stats:prune` für Verdichtung und Löschung. Abweichung vom Plan: die Gerätegrößen-Klasse entfällt, weil sie ohne Skript nicht feststellbar ist – das stand schon im Plan und ist so geblieben.
|
||||
|
||||
### M – Inhalte und Ladeverhalten prüfen (Nutzerauftrag vom 12.09.2026)
|
||||
### M – Inhalte und Ladeverhalten prüfen (Nutzerauftrag vom 12.09.2026) — **weitgehend erledigt am 12.09.2026** (ADR 039)
|
||||
|
||||
*Braucht: nichts.*
|
||||
|
||||
@@ -183,24 +191,66 @@ Umut: „alle inhalte prüfen immer pagination und minimal laden“. Als durchge
|
||||
- **Ein Ladebudget je Seite**, in der Browserprüfung nachgemessen statt geschätzt: HTML, CSS und Bilder je Seite mit einer Obergrenze, die bricht, wenn sie überschritten wird. Heute lädt eine öffentliche Seite kein JavaScript, zwei bis drei Stylesheets und die Bilder – das soll so bleiben und nachweisbar sein.
|
||||
- **Abfragen je Seite zählen.** Eine Seite mit zehn verknüpften Sektionen darf nicht zehn Einzelabfragen auslösen. Messen, dann zusammenfassen.
|
||||
|
||||
Abnahme: keine Liste in der Verwaltung, die mehr Einträge hat als sie zeigt, ohne das zu sagen und ohne Blättern. Ein automatischer Test, der die Größe der Startseite und die Anzahl der Datenbankabfragen prüft.
|
||||
Abnahme: keine Liste in der Verwaltung, die mehr Einträge hat als sie zeigt, ohne das zu sagen und ohne Blättern. Ein automatischer Test, der die Größe der Startseite und die Anzahl der Datenbankabfragen prüft. **Beides erfüllt.** Offen bleibt aus diesem Paket: `srcset` ist für Bilder aus der Mediathek vorhanden, aber eine Seite mit vielen Bildern ist nie gegen das Budget gemessen worden, und die Benutzerliste blättert noch nicht (sie ist naturgemäß kurz).
|
||||
|
||||
### N – Installer (Nutzerauftrag vom 12.09.2026)
|
||||
### N – Installer (Nutzerauftrag vom 12.09.2026) — **erledigt am 12.09.2026** (ADR 041), außer dem Download aus einer veröffentlichten Releaseadresse
|
||||
|
||||
**Teilweise erledigt am 12.09.2026:** der *Updater* ist gebaut (ADR 035), der *Installer* nicht. Beide teilen die Systemprüfung, die jetzt existiert und dort wiederverwendet wird.
|
||||
**Gebaut:** der Browserinstaller unter `/install` mit sechs Bildschirmen und die einzelne `install.php`, die ein Buildskript mit Adresse und Prüfsumme versieht. Der Schritt „Datenbank“ fragt SQLite oder MySQL ab und prüft die Verbindung sofort; damit brauchte er die Hostingentscheidung aus Arbeitspaket J nicht mehr abzuwarten.
|
||||
|
||||
*Braucht: die Hostingentscheidung aus Arbeitspaket J für den Schritt „Datenbank“.*
|
||||
**Offen bleibt:** ein echter Download (dazu muss ein Release unter seiner Adresse liegen), MySQL gegen einen echten Server, und eine Installation auf echtem Hosting.
|
||||
|
||||
Umut: „Einen Installer brauche ich noch wie wordpress schritt für schritt alles einrichten migration ausführen usw.“ Heute braucht eine Installation `bin/setup.php` und Konsolenzugriff – auf geteiltem Hosting ist das genau die Hürde, an der es scheitert.
|
||||
|
||||
**Ablauf, wie bei WordPress, aber ohne dessen Nachlässigkeiten:** eine Seite unter `/install`, die nur antwortet, solange keine Installation existiert, und danach dauerhaft 404 liefert. Schritte: (1) Systemprüfung – PHP-Version, benötigte Erweiterungen, Schreibrechte auf `var/` und den Medienordner, ob `public/` wirklich der Dokumentenstamm ist; jede Prüfung mit dem Satz, was zu tun ist, wenn sie fehlschlägt. (2) Datenbank – SQLite-Datei oder MySQL-Zugangsdaten, Verbindung sofort geprüft statt erst beim Speichern. (3) Migration ausführen, mit Ausgabe der angewandten Versionen. (4) Erstes Administratorkonto – Name und Passwort, dieselben Regeln wie in der Benutzerverwaltung. (5) Website – Name, Adresse, Sprache; dazu Kopf- und Fußbereich, Startseite sowie Datenschutz- und Impressumsgerüst anlegen. (6) Fertig: Anmeldung, und eine Liste dessen, was noch auszufüllen ist.
|
||||
|
||||
**Davor steht ein einzelner Dateidownload** (Nutzerauftrag vom 12.09.2026: „einen installer das eine php ist und alle dateien herunterladet dann muss man nicht alles hochladen. Am besten zip herunterladen dann entpacken“). Eine Datei `install.php` per FTP hochladen, im Browser aufrufen, fertig – statt tausende Dateien einzeln zu übertragen. Das ist der Teil, der über Sicherheit entscheidet, deshalb die Regeln vorweg:
|
||||
|
||||
- **Die Adresse steht in der Datei, nicht im Formular.** Ein Installer, der eine URL abfragt und den Inhalt entpackt, ist eine Einladung, fremden Code auf den Server zu legen. Geladen wird von einer festen Adresse des Releasearchivs.
|
||||
- **Die Prüfsumme steht ebenfalls in der Datei.** SHA-256 des Archivs, in `install.php` eingebacken und vor dem Entpacken verglichen. Stimmt sie nicht, wird abgebrochen – ohne Prüfsumme kein Entpacken, auch nicht mit Häkchen.
|
||||
- **HTTPS mit Zertifikatsprüfung**, kein `verify_peer => false`, kein Rückfall auf HTTP.
|
||||
- **Schutz gegen ZIP-Slip:** jeder Eintrag wird gegen das Zielverzeichnis geprüft, bevor er geschrieben wird. Ein Archiveintrag `../../.env` darf nichts außerhalb des Ziels anfassen.
|
||||
- **Nach dem Entpacken löscht sich die Datei selbst** und verweigert jeden weiteren Aufruf, sobald eine Installation existiert.
|
||||
- **Vorprüfung zuerst:** Schreibrechte, `ZipArchive` oder `PharData`, genug Platz, PHP-Version, und ob der Download überhaupt möglich ist (`allow_url_fopen` oder curl). Fehlt etwas, sagt die Seite welche Zeile in welcher Datei zu ändern ist – statt auf halbem Weg zu scheitern.
|
||||
- **`vendor/` ist im Archiv.** Damit ist die offene Frage aus Arbeitspaket J für diesen Weg entschieden: das Releasearchiv wird auf einem Build-Rechner erzeugt und bringt die Abhängigkeiten mit, weil geteiltes Hosting kein Composer hat.
|
||||
|
||||
Danach übernimmt der eigentliche Installer unten: Systemprüfung, Datenbank, Migration, erstes Konto, Website.
|
||||
|
||||
**Die drei Punkte, an denen WordPress-Installer regelmäßig zum Problem werden, und was hier anders läuft:**
|
||||
- **Der Installer schreibt `.env`,** braucht dafür Schreibrechte im Projektstamm und sagt es, statt die Datei stillschweigend nicht zu schreiben. `APP_SECRET` wird erzeugt, nicht abgefragt.
|
||||
- **Er ist nach dem ersten Durchlauf tot.** Gesperrt wird über den Zustand der Datenbank (existiert ein Konto?), nicht über eine Datei, die jemand löschen kann.
|
||||
- **Er legt keine Zugangsdaten in ein Protokoll und zeigt kein Passwort erneut an.**
|
||||
|
||||
Abnahme: eine leere Installation ist im Browser in sechs Schritten betriebsbereit, ohne Konsole. Ein zweiter Aufruf von `/install` liefert 404. Die Systemprüfung schlägt bei fehlenden Schreibrechten fehl und sagt, welcher Pfad fehlt. Getestet gegen ein leeres Verzeichnis und eine leere Datenbank, nicht gegen die Entwicklungsinstallation.
|
||||
Abnahme: **eine einzelne hochgeladene PHP-Datei genügt**, um von einem leeren Webspace zu einer betriebsbereiten Installation zu kommen; eine leere Installation ist im Browser in sechs Schritten betriebsbereit, ohne Konsole. Ein manipuliertes Archiv (falsche Prüfsumme) wird abgelehnt, und ein Archiveintrag mit `..` im Pfad landet nirgends. Ein zweiter Aufruf von `/install` liefert 404. Die Systemprüfung schlägt bei fehlenden Schreibrechten fehl und sagt, welcher Pfad fehlt. Getestet gegen ein leeres Verzeichnis und eine leere Datenbank, nicht gegen die Entwicklungsinstallation.
|
||||
|
||||
### O – Sicherung und Wiederherstellung als Funktion (Nutzerauftrag vom 12.09.2026) — **erledigt am 12.09.2026** (ADR 038), außer MySQL und dem Download über den Browser
|
||||
|
||||
*Braucht: nichts. Für MySQL zusätzlich die Hostingentscheidung aus Arbeitspaket J.*
|
||||
|
||||
Umut: „Todo aufnehmen. Standard backup funktion.“ Heute gibt es Sicherungen nur als Nebenprodukt: der Updater kopiert die Datenbank vor einem Update, und ich habe vor jeder Migration von Hand eine Kopie in `var/backups/` gelegt. Das ist keine Sicherungsfunktion, das ist Disziplin – und Disziplin fällt aus, sobald jemand anderes arbeitet.
|
||||
|
||||
**Was in eine Sicherung gehört**, und was nicht:
|
||||
|
||||
- **Die Datenbank.** Bei SQLite die Datei, und zwar konsistent: eine Kopie während eines Schreibvorgangs kann unbrauchbar sein, deshalb `VACUUM INTO` statt `copy()`. Bei MySQL `mysqldump`.
|
||||
- **Der Medienordner.** Ohne ihn ist die Sicherung wertlos: die Dokumente verweisen auf Bild-IDs, die Dateien liegen im Medienordner. Das ist auch der Teil, der groß wird.
|
||||
- **Ein Manifest**: Zeitpunkt, Fassung aus `VERSION`, höchste angewandte Migration, Anzahl Seiten, Fassungen und Medien, und eine Prüfsumme je Teil. Ohne das weiß man beim Zurückspielen nicht, ob die Sicherung zum Code passt.
|
||||
- **Nicht `.env`.** Darin steht `APP_SECRET`; eine Sicherung, die man herunterlädt und weitergibt, darf das nicht enthalten. Die Datei wird beim serverseitigen Aufbewahren mitkopiert (der Updater tut das schon), aber nicht in ein Archiv gelegt, das den Server verlässt. Beim Zurückspielen wird sie deshalb auch nicht überschrieben.
|
||||
|
||||
**Ablauf einer Sicherung:** Wartungszustand ist *nicht* nötig – `VACUUM INTO` liefert einen konsistenten Stand, und Medien werden nur hinzugefügt, nie verändert. Geschrieben wird nach `var/backups/sicherung-<zeitstempel>/` mit Manifest; auf Wunsch als ein Archiv. `var/` liegt außerhalb des Dokumentenstamms (`public/`), also ist keine Sicherung über das Netz erreichbar – das muss die Systemprüfung nachweisen, nicht annehmen.
|
||||
|
||||
**Aufbewahrung mit Rotation:** die letzten sieben täglichen, vier wöchentlichen, drei monatlichen. Ohne Rotation läuft die Platte voll und dann scheitert die nächste Sicherung genau dann, wenn man sie braucht.
|
||||
|
||||
**Herunterladen:** in der Verwaltung, als Archiv, gestreamt. Mit zwei ehrlichen Hinweisen: ohne `.env`, und bei einem großen Medienordner bricht ein Browserdownload womöglich ab – dann ist die Konsole oder ein Dateizugriff der Weg. Kein Archiv im Speicher aufbauen.
|
||||
|
||||
**Wiederherstellung** ist der Teil, der zählt, und der Teil, den niemand übt:
|
||||
|
||||
1. `cms:restore <verzeichnis-oder-archiv>`, und in der Verwaltung nur die Anzeige, welche Sicherungen es gibt. Ein Knopf, der die Datenbank überschreibt, gehört nicht neben einen Knopf, der eine Seite speichert.
|
||||
2. Manifest gegen die laufende Installation prüfen: eine Sicherung aus einer **neueren** Fassung wird abgewiesen – deren Migrationen kennt der alte Code nicht. Eine ältere ist erlaubt, dann läuft danach die Migration.
|
||||
3. Wartungszustand an, **Sicherung des aktuellen Stands** anlegen (auch eine Wiederherstellung kann die falsche sein), Datenbank ersetzen, Medien ergänzen statt löschen, Migration, Cache, Rauchtest, Wartungszustand aus.
|
||||
4. Protokoll nach `var/backups.log`.
|
||||
|
||||
**Was gebaut wird:** `src/Ops/Backup.php`, `cms:backup` (mit `--rotate`, `--archive`), `cms:restore` (mit `--force`), eine Übersicht im Bildschirm „System“ mit Größe, Zeitpunkt, Fassung und Prüfsummenstatus je Sicherung, und ein Eintrag im Cronjob-Abschnitt des README.
|
||||
|
||||
**Abnahme – und hier liegt der offene Punkt aus Arbeitspaket I:** eine Wiederherstellung ist *nachgewiesen*. Nicht „die Funktion existiert“, sondern: eine Sicherung wird angelegt, die Datenbank wird nachweislich verändert (eine Seite gelöscht, ein Bild entfernt), die Sicherung wird zurückgespielt, und danach ist beides wieder da – geprüft im Test gegen eine wegwerfbare Installation und einmal von Hand auf der Entwicklungsinstallation. Eine Sicherungsfunktion, deren Wiederherstellung nie gelaufen ist, ist eine Vermutung.
|
||||
|
||||
### K – Migration von kant-it.de
|
||||
|
||||
|
||||
@@ -0,0 +1,102 @@
|
||||
# Der Designer im Vergleich mit Flatsome (UX Builder)
|
||||
|
||||
Stand: 12.09.2026.
|
||||
|
||||
## Woran dieser Vergleich hängt
|
||||
|
||||
Umut hat gefragt: „Ist der Designer vom Funktionsumfang so aufgestellt wie Flatsome?“ Zur Ehrlichkeit gehört, wie ich das geprüft habe – und wie nicht.
|
||||
|
||||
**Nicht geprüft:** Flatsome selbst. Die Erweiterung ist hier nicht installiert, das WordPress-Backend des Piloten ist mir nicht zugänglich, und in der öffentlich ausgelieferten Startseite sieht man die Bausteine, nicht den Baukasten. Ich habe also **keinen Klick in UX Builder gemacht**.
|
||||
|
||||
**Geprüft:** der eigene Designer, Eigenschaft für Eigenschaft, gegen den mir bekannten Funktionsumfang von UX Builder (Elementliste, Optionsgruppen je Element, Animations- und Hover-Sammlung). Das ist Wissen, keine Messung – wo es falsch ist, korrigiere mich, dann ziehe ich nach. Für die konkrete Pilotseite ist ohnehin der Abgleich aus Arbeitspaket K maßgeblich, und der braucht Backendzugang.
|
||||
|
||||
## Gestaltungsoptionen
|
||||
|
||||
Vor heute hatte der Designer 50 Eigenschaften, jetzt 93 – jede je Gerät (Desktop, Tablet, Mobil) und ein Teil zusätzlich für den Hover-Zustand.
|
||||
|
||||
| Bereich | Flatsome (UX Builder) | KantSites |
|
||||
| --- | --- | --- |
|
||||
| Typografie | Schriftart, Größe, Gewicht, Zeilenhöhe, Laufweite, Ausrichtung, Schreibweise, Farbe | **11 Eigenschaften**, dazu Schriftlage, Textlinie, Wortabstand; Schriftart aus der Website-Palette (Systemschrift oder Google Fonts) |
|
||||
| Hintergrund | Farbe, Bild, Position, Größe, Wiederholung, Parallax, Überlagerung, Verlauf, Video | **11**: Farbe, Bild aus der Mediathek, Größe, Ausschnitt, Wiederholung, Scrollverhalten, Überlagerung mit Deckkraft, Verlauf mit Winkel. **Fehlt: Parallax und Video-Hintergrund** |
|
||||
| Abstand | Innen/Außen je Seite | **je Seite**, je Gerät |
|
||||
| Größe | Breite, Höhe, Mindesthöhe | **7**: Breite, Mindest-, Maximalbreite, Höhe, Mindest-, Maximalhöhe, Überlauf |
|
||||
| Rahmen | Stärke je Seite, Farbe, Art, Rundung je Ecke | **8**: alles davon, dazu ein zweiter Rahmen (outline) mit Abstand. **Heute behoben:** die Stärke je Seite erzeugte `border-width-top` statt `border-top-width` – ungültiges CSS, das der Browser stillschweigend verwarf. Rahmenstärken je Seite hatten damit nie eine Wirkung |
|
||||
| Bild | Rundung, Zuschnitt, Seitenverhältnis, Filter | **neu, 8 Eigenschaften**, die auf das Bild *im* Block wirken statt auf seinen Rahmen: Ausschnitt (object-fit), Bildmitte, Seitenverhältnis, Höhe, Ecken je Ecke, Rahmen. Vorher landete eine Rundung auf dem `figure` und das Bild blieb eckig – genau das, was Umut aufgefallen ist |
|
||||
| Layout | Spalten, Abstand, Verteilung, Ausrichtung | **20**: Anzeige, Flexrichtung, Umbruch, Verteilung, Ausrichtung, Spaltenzahl, Abstände (auch getrennt für Zeilen und Spalten), Reihenfolge, eigene Ausrichtung, Wachsen, Schrumpfen, Grundbreite, **Positionierung (auch freigestellt und klebend), Abstände zu allen vier Rändern, Ebene (z-index)** |
|
||||
| Wirkung | Schatten, Deckkraft, Animationen, Hover | **26**: Deckkraft, Schatten (auch **innen**), Textschatten, Verschieben, Skalieren, Drehen, Übergangsdauer, **Weichzeichnen, Helligkeit, Kontrast, Sättigung, Graustufen, Sepia, Farbdrehung, Umkehren, Mischmodus, Mauszeiger** |
|
||||
| Eigener Code | CSS je Element | **CSS je Element für alle, Skript je Element nur für Administratoren** (ADR 028) |
|
||||
| Sichtbarkeit je Gerät | „Hide on mobile/tablet/desktop“ | über `Anzeige: keine` je Gerät möglich, aber **nicht als eigener Schalter** – siehe „Was noch fehlt“ |
|
||||
| Abschnittsbreite | Normal, breit, randlos | **Normal (1180 px), schmal (800 px), breit (1600 px), randlos** – randlos heißt ohne Begrenzung und ohne Rand, für Bild oder Farbfläche von Kante zu Kante |
|
||||
| Globale Inhaltsbreite | im Theme einstellbar | **im Designbereich einstellbar** – und wirkt seit heute auch nach oben; vorher deckelte das Stylesheet bei 1180 px |
|
||||
| Volle Bildschirmhöhe | „Full screen“ je Abschnitt | über `Mindesthöhe: 100dvh` plus `Anzeige: flex` und `Ausrichtung quer: Mitte`; die Vorlage **„Vollbild-Hero“** setzt genau das auf einen Klick. Neu sind dafür die Einheiten **dvh, svh, lvh** – `100vh` ist auf dem Handy höher als der Bildschirm |
|
||||
|
||||
### Bewegung
|
||||
|
||||
| | Flatsome | KantSites |
|
||||
| --- | --- | --- |
|
||||
| Einblendungen | rund fünfzehn, beim Scrollen ausgelöst | **neun** (sanft, von unten, von oben, von links, von rechts, Heranzoomen, Kippen, aus der Unschärfe) mit **Verzögerung** in drei Stufen. **Sie laufen beim Laden der Seite, nicht beim Scrollen** – dafür bräuchte es JavaScript, und eine öffentliche Seite führt keines aus. Das steht so im Editor |
|
||||
| Hover | rund zehn | **sieben** (anheben, absenken, Bild vergrößern, Leuchten, Kippen, Rahmen einfahren) – dazu **frei**: jede Farbe, jeder Schatten, jede Verschiebung, jede Skalierung hat einen eigenen Hover-Wert |
|
||||
| „Bewegung reduzieren“ | teilweise berücksichtigt | **jede** Animation liegt in der Abfrage `prefers-reduced-motion`; wer weniger Bewegung wünscht, bekommt den Endzustand |
|
||||
|
||||
## Bausteine
|
||||
|
||||
| Flatsome-Element | KantSites |
|
||||
| --- | --- |
|
||||
| Section, Row, Column | Sektion, Zeile, Spalte – **neu: „Zeile, 2/3/4 Spalten“ auf einen Klick**, weil eine leere Zeile aussieht wie nichts und deshalb niemand sie gefunden hat |
|
||||
| Text, Headline | Text mit Werkzeugleiste und HTML-Ansicht, Überschrift |
|
||||
| Button | Button |
|
||||
| Image, Gallery | Bild, Galerie |
|
||||
| Icon, Icon Box | **neu: Symbol** als eigenes Element (vorher nur in der Symbolkarte), Symbolkarte |
|
||||
| Slider, Banner | **neu: Slider** als Scrollbereich mit Einrasten – ohne JavaScript, ohne Autoplay, mit Finger, Trackpad, Mausrad und Tastatur bedienbar. Sichtbare Folien je Gerät einstellbar |
|
||||
| Accordion, Tabs | Akkordeon, Tabs |
|
||||
| Progress bar | **neu: Fortschritt** – mit `role="meter"` und dem Wert im Markup, nicht nur in der Balkenbreite |
|
||||
| Testimonial | Kundenstimme mit Sternen |
|
||||
| Counter, Countdown | **fehlt** – braucht JavaScript |
|
||||
| Team member, Price table | **fehlt als eigenes Element**; aus Zeile, Spalte, Bild, Überschrift und Liste zusammensetzbar |
|
||||
| Video | **teils**: ein eingebettetes Video geht über „Externer Inhalt“ und lädt erst nach Einwilligung. Eine selbst gehostete Videodatei fehlt |
|
||||
| Google Map | über „Externer Inhalt“ – **und zwar richtig**: ohne Einwilligung wird keine Verbindung zu Google aufgebaut |
|
||||
| Social icons, Share | **fehlt als Element**; aus Symbolen mit Linkziel zusammensetzbar |
|
||||
| Contact form | Formular, serverseitig validiert, mit Einwilligung und Spamschutz |
|
||||
| Blog posts | Beitragsliste, nach Kategorie oder Schlagwort filterbar |
|
||||
| Shop-Elemente | **bewusst nicht** – kein Shop im Projekt |
|
||||
| Popup, Lightbox | **fehlt** – braucht JavaScript |
|
||||
| HTML, Custom CSS | eigenes CSS je Element für alle, eigenes Skript nur für Administratoren |
|
||||
| Divider, Gap | Trennlinie, Abstand |
|
||||
| Search | **fehlt** – es gibt keine Suche auf der Website (Arbeitspaket M) |
|
||||
| Breadcrumbs, Navigation | Brotkrumen, Navigation aus der Menüverwaltung – **vier Plätze** (Haupt, zweite, Fußzeile, Rechtliches) und **drei Arten**: waagerecht, untereinander, aufklappbar als Burger (ohne JavaScript) |
|
||||
| Kopfbereich je Seite („Hide header“) | **je Seite wählbar**: Standard, ein anderer Kopf- oder Fußbereich, oder keiner |
|
||||
| Verknüpfte Abschnitte | Verknüpfte Sektion – eine Änderung wirkt auf allen Seiten, die sie einbinden |
|
||||
|
||||
## Was absichtlich anders ist
|
||||
|
||||
Das ist kein Funktionsmangel, sondern eine Entscheidung:
|
||||
|
||||
1. **Eine öffentliche Seite führt kein JavaScript aus** (`script-src 'none'`), außer sie enthält Tabs oder ein eigenes Skript. Deshalb gibt es kein Autoplay-Karussell, keinen Countdown und kein Popup. Der Preis: diese drei fehlen. Der Gegenwert: die Seite ist ohne Skript vollständig lesbar und schnell, und eine ganze Klasse von Fehlern und Angriffen entfällt.
|
||||
2. **Keine Inline-Stile.** Jede Gestaltung wird zu einem Stylesheet mit Hash zusammengefasst. Deshalb sind Symbolgrößen und Fortschrittswerte Klassen und keine `style`-Attribute.
|
||||
3. **Eingebettete Inhalte laden erst nach Einwilligung** – vorher steht nicht einmal die Adresse des Anbieters im HTML.
|
||||
4. **Jeder Wert wird serverseitig geprüft.** Was der Editor anbietet, ist genau das, was der Server annimmt; ein Muster oder eine feste Liste je Eigenschaft, keine freie CSS-Eingabe außer im ausdrücklich dafür gedachten Feld.
|
||||
5. **Kein Shop und keine Produktelemente.**
|
||||
|
||||
## Was noch fehlt, nach Nutzen geordnet
|
||||
|
||||
1. **Sichtbarkeit je Gerät als eigener Schalter.** Technisch über `Anzeige: keine` je Gerät schon möglich, aber niemand sucht es dort. Ein Häkchen „Auf diesem Gerät ausblenden“ im Designbereich – klein, hoher Nutzen.
|
||||
2. **Parallax und Video-Hintergrund.** Parallax geht in modernen Browsern rein mit CSS (`background-attachment: fixed` gibt es schon; echtes Parallax bräuchte `animation-timeline: scroll()`, das Safari und Firefox noch nicht können). Video-Hintergrund braucht ein `<video>`-Element und eine Entscheidung über Datenmenge und Einwilligung.
|
||||
3. **Selbst gehostetes Video** als Element, mit Vorschaubild und ohne Autoplay mit Ton.
|
||||
4. **Countdown und Popup.** Beide brauchen ein Skript. Machbar wie die Tabs: eine Seite, die eines enthält, bekommt `script-src 'self'` und lädt eine kleine Datei. Das ist eine bewusste Aufweichung – ich würde sie nur bauen, wenn du sie wirklich brauchst.
|
||||
5. **Einblendung beim Scrollen** statt beim Laden. Ohne Skript nur mit `animation-timeline: view()` möglich, das noch nicht überall funktioniert; mit Skript sofort.
|
||||
6. **Soziale Symbole, Preistabelle, Teammitglied** als eigene Elemente – heute zusammensetzbar, aber jedes Mal Handarbeit.
|
||||
7. **Suche auf der Website.**
|
||||
8. **Vorlagenbibliothek.** Flatsome bringt fertige Seitenvorlagen mit. Hier gibt es drei Abschnittsvorlagen und die Zeilenvorlagen; eine echte Sammlung gehört zu Arbeitspaket H (Templates).
|
||||
|
||||
## Was heute dabei herauskam
|
||||
|
||||
Neben dem Ausbau zwei echte Fehler, beide vorher unbemerkt:
|
||||
|
||||
- **Rahmenstärken je Seite hatten nie eine Wirkung.** `border-width-top` ist kein CSS; der Browser verwirft die Zeile stillschweigend. Die Schreibweise steht jetzt je Eigenschaft im Schema, weil sie sich nicht erraten lässt.
|
||||
- **Eine Rundung auf einem Bildblock rundete den Rahmen, nicht das Bild.** Jetzt erbt das Bild die Rundung seines Rahmens, und die neuen Bild-Eigenschaften schreiben ohnehin direkt auf das Bild.
|
||||
|
||||
## Offen: Menüart je Gerät (13.09.2026)
|
||||
|
||||
Beim Nachbau von kant-it.de aufgefallen: die **Menüart** (`horizontal`, `vertikal`, `Burger`) gilt für alle Bildschirmgrößen. Gebraucht wird fast immer „auf dem Desktop nebeneinander, auf dem Telefon ein Burger". Heute geht das nur über **zwei Menüblöcke**, von denen je Gerät einer über `display: none` ausgeblendet wird – das funktioniert und ist barrierefrei (der ausgeblendete Block steht nicht im Zugänglichkeitsbaum), aber es ist eine Umgehung und keine Einstellung.
|
||||
|
||||
Sauber wäre die Menüart als Wert je Gerät, wie es die Gestaltungseigenschaften ohnehin sind. Dafür müsste die Umschaltung ohne doppeltes Markup auskommen – `<details>` lässt sich per CSS nicht zuverlässig in allen Browsern aufklappen, also läuft es entweder auf ein Aufklappmuster ohne `details` hinaus oder auf zwei gerenderte Varianten, von denen eine verborgen ist. Die Entscheidung steht aus.
|
||||
@@ -0,0 +1,117 @@
|
||||
# Abgleich mit dem Flatsome UX Builder
|
||||
|
||||
## Bedienkorrektur für leere Layouts (14.09.2026, KantSites 1.6.2)
|
||||
|
||||
Erneuter Quelltextabgleich mit dem von Umut genannten Ordner `C:\Users\UmutKantarci\Downloads\flatsome`, Theme-Version **3.20.9** laut `style.css`. Keine laufende WordPress-Installation dieses Pakets gestartet; die folgenden Aussagen stammen aus seinen tatsächlichen Builder-Dateien.
|
||||
|
||||
| Beobachtung im Flatsome-Code | Umsetzung in KantSites |
|
||||
| --- | --- |
|
||||
| `assets/css/builder/core/content.css`: leeres `content` mit mindestens 90 px Höhe, getöntem Hintergrund und gepunktetem Rahmen | Vorschau-only-Flächen für leere Sektionen, Zeilen und Spalten mit realer Mindesthöhe, Rahmen und Typbezeichnung |
|
||||
| `tools-addable.is-empty` und `assets/js/builder/core/content.js`: Plus in der Mitte, eigener Einfüge-/Drop-Zustand für leere Container | „Inhalt hinzufügen“ im Zielbereich; Spalten werden nummeriert; der Einfügepunkt wird konkret markiert und benannt |
|
||||
| `inc/builder/shortcodes/row.php`: fertige Zeilen mit einer, zwei, drei und vier Spalten | Diese vier gleichmäßigen Layouts direkt in der leeren Sektion anbieten, einschließlich passender mobiler Stapelung |
|
||||
|
||||
Zusätzlich: neue Container werden nach dem Rendern in den sichtbaren Ausschnitt gebracht, ein Auswahlwechsel löst alte Einfügepunkte, und beim Ziehen scrollt die Vorschau an den Rändern mit. Die Ablageanzeige nennt den Zielcontainer; beim Verschieben von Spalten entscheidet die horizontale Position. Editorhilfen entstehen ausschließlich im Vorschau-DOM, nicht im gespeicherten Dokument oder in der öffentlichen Ausgabe.
|
||||
|
||||
Der ältere Gesamtabgleich unten bleibt eine historische Bestandsaufnahme. Diese Änderung übernimmt konkrete Bedienprinzipien für den Aufbau, keine vollständige Flatsome-Oberfläche und keine WordPress-/Shortcode-Abhängigkeit.
|
||||
|
||||
Stand: 14.09.2026, KantSites 1.5.0 (Dokumentschema v19).
|
||||
Quelle des Vergleichs: Flatsome-Theme im Download-Ordner, `inc/builder/shortcodes/` (69 Definitionen)
|
||||
und `inc/shortcodes/`. Ziel laut Auftrag: alle Funktionen übernehmen und verbessern, die
|
||||
Bedienbarkeit übernehmen und verbessern, die Oberfläche mobil nutzbar machen — der Builder
|
||||
bleibt dabei Desktop-first.
|
||||
|
||||
## 1. Wo KantSites heute schon besser ist
|
||||
|
||||
Diese Punkte sind bereits gelöst und dürfen beim Nachbau nicht verschlechtert werden.
|
||||
|
||||
| Thema | Flatsome | KantSites heute |
|
||||
| --- | --- | --- |
|
||||
| Datenhaltung | Shortcode-Text im `post_content`, per Regex geparst | Validierter Blockbaum mit stabilen IDs, versioniert (v19), Migrationsweg |
|
||||
| Gestaltung | Feste Optionslisten je Element, wenige Werte je Gerät | 93 freie Eigenschaften, je Block, je Gerät **und** für Hover |
|
||||
| Öffentliche Ausgabe | jQuery, mehrere Bundles, Slider/Tabs brauchen JS | Tabs/Slider/Fortschritt ohne JavaScript, Editorcode nicht im Frontend |
|
||||
| Entwurf/Live | Revisionen von WordPress | Entwurf und Veröffentlichung getrennt, Fassungsverlauf, geplante Veröffentlichung, Papierkorb |
|
||||
| Recht | Plugins nötig | Einwilligung mit echtem Zurückhalten, Statistik ohne Wiedererkennung, Pflichtseiten |
|
||||
| Eigener Code | `ux_html`-Element | CSS je Element für alle, Skript nur für Administratoren, als eigene Datei |
|
||||
|
||||
**Folgerung:** Der Gestaltungsteil muss nicht nachgebaut werden. Zu tun sind der
|
||||
Elementkatalog und die Bedienung.
|
||||
|
||||
## 2. Elementkatalog — Lücke
|
||||
|
||||
Flatsome hat 69 Builder-Elemente, davon 12 reine Shop-Elemente (WooCommerce).
|
||||
KantSites hat 31 Blocktypen.
|
||||
|
||||
### Bereits abgedeckt (21)
|
||||
|
||||
`section` `row` `col` `title` `text` `ux_image` `button` `ux_gallery` `blog_posts`
|
||||
`featured_box` `testimonial` `accordion` `accordion_item` `tabgroup` `tab` `contact`
|
||||
`divider` `gap` `block` `ux_menu` `ux_slider`
|
||||
|
||||
### Offen, ohne Shop (20)
|
||||
|
||||
| Flatsome | Was es kann | Bewertung |
|
||||
| --- | --- | --- |
|
||||
| `ux_banner` | Bild/Video-Hintergrund, Overlay, Höhe, Parallax, Textposition | **hoch** — das meistgenutzte Element überhaupt |
|
||||
| `text_box` | frei positionierter Textkasten über einem Banner | **hoch** — gehört zu `ux_banner` |
|
||||
| `ux_stack` | Elemente nebeneinander, mit Abstand und Ausrichtung | **hoch** — löst viele Zeile/Spalte-Verrenkungen |
|
||||
| `ux_image_box` | Bild über Text als Karte | hoch |
|
||||
| `col_grid` / `ux_banner_grid` | Rasterlayout mit ungleichen Kacheln | mittel |
|
||||
| `message_box` | Hinweiskasten mit Symbol und Farbe | mittel |
|
||||
| `price_table` | Preistabelle | mittel |
|
||||
| `team_member` | Person mit Bild, Rolle, Sozialsymbolen | mittel |
|
||||
| `ux_pages` | Unterseiten dieser Seite auflisten | mittel |
|
||||
| `ux_video` | eingebettetes Video | teilweise über `embed` |
|
||||
| `video_button` | Video im Leuchtkasten öffnen | mittel |
|
||||
| `map` | Karte | teilweise über `embed` |
|
||||
| `follow` | Sozialsymbole zum Folgen | mittel |
|
||||
| `share` | Teilen-Symbole | niedrig |
|
||||
| `search` | Suchfeld | mittel |
|
||||
| `logo` | Logo der Website als Block | hoch (Kopfbereich) |
|
||||
| `scroll_to` | Ankerziel und Sprunglink | mittel |
|
||||
| `ux_countdown` | Countdown | niedrig |
|
||||
| `ux_hotspot` | Punkte auf einem Bild | niedrig |
|
||||
| `ux_lottie` / `ux_instagram_feed` / `ux_portfolio` | Animation, Instagram, Portfolio | niedrig |
|
||||
| `ux_the_title` | dynamischer Seitentitel | mittel (Vorlagen) |
|
||||
|
||||
### Bewusst ausgelassen (12)
|
||||
|
||||
Alle WooCommerce-Elemente (`products`, `ux_products`, `ux_products_list`,
|
||||
`ux_product_categories`, `ux_product_flip`, `ux_bestseller_products`, `ux_payment_icons`,
|
||||
`ux_product_gallery`, `woocommerce_cart`, `woocommerce_checkout`, `ux_badge`,
|
||||
`custom-product`). KantSites hat keinen Shop; ohne Warenkorb, Bestellung und Zahlung
|
||||
wären diese Blöcke leere Hüllen.
|
||||
|
||||
## 3. Bedienung — die eigentliche Lücke
|
||||
|
||||
Der Auftrag nennt die Bedienbarkeit ausdrücklich. Hier ist Flatsome heute voraus.
|
||||
|
||||
| Flatsome | KantSites heute | Verbesserung |
|
||||
| --- | --- | --- |
|
||||
| Element aus der Liste **in die Vorschau ziehen**, mit Einfügemarke | Einfügen „hinter das gewählte Element“ | Ziehen mit sichtbarer Marke, **zusätzlich** die heutige Tastaturbedienung behalten |
|
||||
| **Werkzeugleiste am Element** in der Vorschau (bearbeiten, duplizieren, löschen, Elternelement) | nur über den Strukturbaum links | Leiste am Element, Auswahl bleibt mit dem Baum verbunden |
|
||||
| **Pluszeichen zwischen Elementen** | — | Einfügepunkt genau dort, wo geklickt wird |
|
||||
| Elementwahl als **Bilderraster** mit Suche | Textliste mit Symbolzeichen | Raster mit echten Vorschaubildern, Suche, Kategorien |
|
||||
| **Vorlagen je Element** mit Vorschaubild (Sektion: 20+) | 4 Textvorlagen gesamt | Vorlagen je Element, mit Bild, erweiterbar |
|
||||
| **Je Option ein Geräteschalter** | ein globaler Geräteschalter | Schalter direkt an der Option, zeigt wo ein abweichender Wert gesetzt ist |
|
||||
| **Zahl durch Ziehen ändern** (`scrubfield`) | Eingabefeld | Ziehen plus Eingabe plus Pfeiltasten |
|
||||
| **Innen-/Außenabstand als Rahmenbild** | vier Felder | Rahmenbild mit verbundenen Seiten |
|
||||
| **Spaltenbreite in der Vorschau ziehen** | Zahl eingeben | Ziehgriff zwischen Spalten |
|
||||
| Vorschau **sofort** (clientseitige Vorlagen) | Server-Neuladen, entprellt | Sofortige optimistische Anzeige, Server bleibt die Wahrheit |
|
||||
| Kopieren/Einfügen zwischen Seiten | — | Zwischenablage für Blöcke, seitenübergreifend |
|
||||
| Mobil | Builder nicht bedienbar | Verwaltung vollständig mobil; Builder mobil im Lesemodus mit Inhaltsbearbeitung |
|
||||
|
||||
## 4. Vorgeschlagene Reihenfolge
|
||||
|
||||
1. **Arbeitspaket H — Bedienung des Builders.** Ziehen und Ablegen, Werkzeugleiste am
|
||||
Element, Einfügepunkte, Bilderraster, optimistische Vorschau. Größter Nutzen je Aufwand,
|
||||
betrifft jedes vorhandene Element sofort.
|
||||
2. **Arbeitspaket I — Elementkatalog Stufe 1.** `banner`, `textbox`, `stack`, `imagebox`,
|
||||
`logo`, `messagebox`. Die Blöcke, ohne die eine Startseite Umwege braucht.
|
||||
3. **Arbeitspaket J — Optionsbedienung.** Geräteschalter je Option, Ziehfelder,
|
||||
Abstandsrahmen, Spaltenbreite ziehen, Vorlagen je Element.
|
||||
4. **Arbeitspaket K — Elementkatalog Stufe 2.** Raster, Preistabelle, Team, Unterseiten,
|
||||
Video-Leuchtkasten, Suche, Sozialsymbole, Ankersprung.
|
||||
5. **Arbeitspaket L — Mobile Oberfläche.** Verwaltung und Builder auf dem Telefon.
|
||||
|
||||
Jede Stufe endet mit einem prüfbaren Ergebnis und darf die Qualitätsziele aus AGENTS.md
|
||||
nicht verschlechtern.
|
||||
@@ -0,0 +1,55 @@
|
||||
# Livegang von kant-it.de
|
||||
|
||||
Stand: 13.09.2026. Diese Liste trennt, was **geprüft ist**, von dem, was **nur auf dem echten Hosting geprüft werden kann** – und in welcher Reihenfolge umgeschaltet wird.
|
||||
|
||||
## Geprüft (lokal, gegen den Nachbau)
|
||||
|
||||
| Prüfung | Ergebnis |
|
||||
| --- | --- |
|
||||
| PHPUnit, PHP 8.2 und 8.4 | 409 Tests, 1791 Zusicherungen, grün |
|
||||
| JavaScript-Modelltests | grün |
|
||||
| Editor durchgespielt (Playwright + axe) | grün, isolierte Datenbank |
|
||||
| Öffentliche Seiten (Playwright + axe, 1440 px und 390 px) | keine Beanstandung |
|
||||
| Interne Verweise (`npm run test:links`) | 35 Seiten, 408 Adressen, kein toter Verweis |
|
||||
| Lighthouse, vier Adressen, je drei Läufe | Barrierefreiheit, Best Practices, SEO, agentic browsing **100** überall; Performance 100 außer Startseite (98 mobil / 96 Desktop) |
|
||||
| Sicherung (`cms:backup`) | angelegt: 33 Seiten, 100 Fassungen, 388 Mediendateien |
|
||||
| Weiterleitungen der alten WordPress-Adressen | 14 Stück angelegt, alle beantwortet mit 301 |
|
||||
|
||||
Gemessen wurde im Produktionsmodus auf dem PHP-Entwicklungsserver, der dieselben Kopfzeilen sendet wie `public/.htaccess`. **Das ist kein Ersatz für eine Messung auf dem echten Hosting.**
|
||||
|
||||
## Nicht geprüft – muss vor dem Umschalten passieren
|
||||
|
||||
1. **E-Mail-Versand.** Nie ausgeführt, in keiner Umgebung. `MAILER_DSN=sendmail://default` funktioniert auf den meisten PHP-Hostings; verlangt der Anbieter SMTP, gehört dort eine Zeile der Form `smtp://benutzer:passwort@mailserver:587` hinein. **Nach dem Hochladen eine echte Anfrage über das Kontaktformular abschicken und prüfen, ob sie ankommt** – auch im Spam-Ordner. Die Anfrage wird vor dem Versand gespeichert; kommt keine Mail an, steht sie trotzdem unter „Formulare“ in der Verwaltung.
|
||||
2. **HTTPS.** Zertifikat einrichten, dann unter System → „HTTPS“ auf „HTTPS erzwingen“ (prüft vorher und nachher, ADR 120; von Hand: die drei Zeilen in `public/.htaccess` einschalten). **Erst wenn das eine Weile läuft**, „HSTS einschalten“ – HSTS lässt sich in einem Browser ein Jahr lang nicht zurücknehmen.
|
||||
3. **www oder nicht.** Das alte kant-it.de ist kanonisch mit `www`. Die Entscheidung steht an einer Stelle: `SITE_ORIGIN` in `.env` (`php bin/console cms:site:origin https://www.kant-it.de`). Die jeweils andere Schreibweise leitet die Anwendung selbst mit 301 dorthin um; in `.htaccess` gibt es dafür keine Regel mehr. Der Bildschirm „System“ zeigt die eingetragene Adresse und warnt, wenn die Verwaltung gerade unter einer anderen läuft.
|
||||
4. **Datenbank.** Geprüft ist ausschließlich SQLite. Für MySQL ist alles vorbereitet, aber nichts erprobt – wer MySQL nimmt, sollte danach Seiten anlegen, veröffentlichen, Bilder hochladen und ein Formular absenden.
|
||||
5. **Wiederkehrende Aufgaben.** Sicherung, Statistik und geplante Veröffentlichung laufen nach dem Hochladen von selbst – angestoßen von Seitenaufrufen und vom geöffneten Backend (Verwaltung → Aufgaben). **Das genügt, ist aber nicht minutengenau:** ohne Besuch passiert nichts. Bietet der Hoster einen Cronjob, gehört `php bin/console cms:tasks:run` dorthin (jede Minute); bietet er nur einen URL-Cronjob, die Adresse vom Bildschirm „Aufgaben“ (alle fünf Minuten). Danach dort den Auslöser „Besuche der Website“ abschalten. **Nach dem Umzug einmal nachsehen**, ob in der Spalte „Zuletzt“ bei der Sicherung ein `ok` steht – ohne Sicherung gibt es keinen Rückweg.
|
||||
6. **Schriften.** Auf dem neuen Server einmal `php bin/console cms:fonts:fetch` ausführen oder im Designbereich den Knopf drücken – die Dateien liegen nicht im Repository. Bis dahin lädt die Seite die Schriften von Google, was sie nicht soll.
|
||||
7. **Rechtstexte.** Impressum und Datenschutzerklärung sind vom alten Auftritt übernommen. Der Abschnitt über Google Fonts wurde an die Wirklichkeit angepasst (die Schriften kommen jetzt vom eigenen Server), die Abschnitte über Google Maps und reCAPTCHA entfernt, weil beides nicht eingebunden ist. **Das ersetzt keine anwaltliche Prüfung**, und es ist die Verantwortung des Betreibers, nicht des CMS.
|
||||
8. **Texte.** Acht Projektbeschreibungen stammen nicht vom alten Auftritt, sondern sind neu geschrieben. Sie sollten gelesen werden, bevor sie öffentlich sind.
|
||||
|
||||
## Optional: die Verwaltung umbenennen
|
||||
|
||||
`/admin` ist die erste Adresse, die jeder Scanner probiert. `php bin/console cms:admin:path meine-verwaltung` legt sie woanders hin; der Name steht danach in `.env` und in keiner öffentlichen Datei. **Vorher notieren, wohin** – danach antwortet `/admin` mit 404 wie jede unbekannte Adresse. Zurück geht es mit `php bin/console cms:admin:path admin`.
|
||||
|
||||
## Optional: Spamschutz mit Cloudflare
|
||||
|
||||
Die Formulare haben drei eingebaute Prüfungen ohne Dritte. Kommt trotzdem Spam durch, gibt es unter
|
||||
Einstellungen → „Spamschutz für Formulare" Cloudflare Turnstile: Schlüsselpaar im Cloudflare-Konto
|
||||
anlegen (kostenlos), beide Schlüssel eintragen, Haken setzen. **Vorher wissen:** das Widget braucht
|
||||
JavaScript, und die IP-Adresse des Absenders geht an Cloudflare – beides gehört in die
|
||||
Datenschutzerklärung, bevor es scharf geschaltet wird.
|
||||
|
||||
## Reihenfolge am Umzugstag
|
||||
|
||||
1. Dateien auf das Hosting, `.env` mit `APP_ENV=prod`, `APP_DEBUG=0`, `DATABASE_URL`, `MEDIA_DIRECTORY`, `SITE_ORIGIN`, `MAILER_DSN` und einem neuen `APP_SECRET`.
|
||||
2. Datenbank und Medienordner einspielen (oder `php vendor/bin/doctrine-migrations migrate` auf einer leeren Datenbank und dann der Installer).
|
||||
3. `php bin/console cms:fonts:fetch`, danach eine Seite aufrufen und im Quelltext prüfen, dass nichts von `fonts.googleapis.com` geladen wird.
|
||||
4. Kontaktformular abschicken, Mail prüfen.
|
||||
5. **Jetzt erst** die Domain umstellen.
|
||||
6. Danach: `CMS_URL=https://www.kant-it.de npm run test:links` von einem Rechner mit Node, `robots.txt` und `sitemap.xml` im Browser aufrufen, die Sitemap in der Google Search Console neu einreichen, und drei bis vier alte Adressen von Hand aufrufen (etwa `/productsolutions/`, `/kanttresor-dsgvo-konforme-archivierung-fuer-moderne-unternehmen/`) – sie müssen mit 301 auf die neue Adresse führen.
|
||||
7. Eine Woche lang in der Search Console auf 404-Meldungen schauen; jede weitere alte Adresse bekommt eine Weiterleitung unter „Einstellungen → Weiterleitungen“.
|
||||
|
||||
## Rückweg
|
||||
|
||||
Das alte WordPress bleibt bis auf Weiteres bestehen. Geht etwas schief, wird die Domain dorthin zurückgestellt; die Inhalte des neuen Auftritts liegen unverändert in der Sicherung unter `var/backups/`.
|
||||
@@ -0,0 +1,228 @@
|
||||
# Eine neue Website mit KantSites bauen
|
||||
|
||||
Diese Anleitung richtet sich an den, der die Arbeit macht – Mensch oder Assistent. Sie sagt, in
|
||||
welcher Reihenfolge etwas entsteht, welche Entscheidung wohin gehört und welche Fehler dieses
|
||||
Projekt schon einmal gemacht hat. Wer sie befolgt, kommt ohne Umwege zu einer Website, die die
|
||||
Prüfungen besteht.
|
||||
|
||||
**Lies zuerst:** `AGENTS.md` (Projektregeln), `docs/STATUS.md` (was es gibt und was fehlt),
|
||||
`docs/ARCHITECTURE.md` (warum es so ist). Diese drei sind der Stand der Dinge; diese Datei hier ist
|
||||
das Kochbuch.
|
||||
|
||||
---
|
||||
|
||||
## 0. Die Grundregel
|
||||
|
||||
**Alles, was eine Website ausmacht, steht in der Datenbank – nicht im Code.** Seiten, Kopf- und
|
||||
Fußbereich, Menüs, Formulare, Farben, Schriften, Einstellungen. Wer für eine neue Website eine
|
||||
Twig-Datei anfasst oder CSS in `public/assets/site.css` schreibt, hat sich verlaufen: dann ändert
|
||||
sich das Aussehen *aller* Installationen, und beim nächsten Update ist es weg.
|
||||
|
||||
Ausnahme mit Ansage: eigenes CSS und eigenes Skript je Seite gibt es im Editor (Bereich „Code"),
|
||||
und es landet ebenfalls in der Datenbank.
|
||||
|
||||
---
|
||||
|
||||
## 1. Installation aufsetzen
|
||||
|
||||
```bash
|
||||
# Eigene Datenbank und eigener Medienordner - nie gegen die Entwicklungsinstallation arbeiten.
|
||||
DATABASE_URL="sqlite:///…/var/kunde.db" php vendor/bin/doctrine-migrations migrate --no-interaction
|
||||
DATABASE_URL="sqlite:///…/var/kunde.db" MEDIA_DIRECTORY="…/var/kunde-media" php bin/console cms:user:create
|
||||
```
|
||||
|
||||
Oder im Browser über `/install` – sechs Schritte, legt Startseite, Kopf, Fuß, Menü, Impressum und
|
||||
Datenschutzgerüst gleich mit an.
|
||||
|
||||
**Die Verwaltung muss nicht `/admin` heißen.** `ADMIN_PATH` in `.env` setzt die Adresse; `php
|
||||
bin/console cms:admin:path <neu>` prüft, ändert und sagt, welcher Zwischenspeicher danach zu leeren
|
||||
ist. Bei einer Kundenwebsite lohnt das: `/admin` ist die erste Adresse, die ein Scanner probiert.
|
||||
|
||||
Zum Ansehen ein eigener Server auf einem eigenen Port:
|
||||
|
||||
```bash
|
||||
DATABASE_URL="sqlite:///…/var/kunde.db" MEDIA_DIRECTORY="…/var/kunde-media" \
|
||||
SITE_ORIGIN=http://127.0.0.1:8090 php -S 127.0.0.1:8090 -t public bin/dev-router.php
|
||||
```
|
||||
|
||||
`bin/dev-router.php` ist Pflicht: ohne ihn beantwortet der eingebaute Server `/sitemap.xml` und
|
||||
`/robots.txt` selbst, und die Dateien kommen ohne die Kopfzeilen, die später `public/.htaccess`
|
||||
setzt – jede Messung wäre dann falsch.
|
||||
|
||||
---
|
||||
|
||||
## 2. Inhalte anlegen: als Skript, nicht von Hand
|
||||
|
||||
Eine vollständige Website besteht aus Dutzenden Blöcken mit Hunderten Gestaltungswerten. Das klickt
|
||||
niemand zweimal. **Schreibe ein Aufbauskript**, das Medien hochlädt, Dokumente zusammensetzt und
|
||||
veröffentlicht – Vorlage ist das Skript, mit dem kant-it.de nachgebaut wurde.
|
||||
|
||||
Drei Eigenschaften, die ein solches Skript haben muss (alle drei sind aus Schaden gelernt):
|
||||
|
||||
1. **Wiederholbar.** Ein zweiter Lauf aktualisiert vorhandene Seiten, statt sie zu überspringen –
|
||||
sonst ändert eine Korrektur im Skript nichts an der Website. Bilder werden am Titel
|
||||
wiedererkannt, sonst wächst die Mediathek mit jedem Lauf.
|
||||
2. **Eine Quelle für Adressen.** Verweise nie von Hand schreiben. Die Adresse eines Beitrags
|
||||
entsteht aus derselben Funktion, die den Beitrag anlegt. Im Nachbau standen vier Links im Hero
|
||||
von Hand darin – alle vier tot, weil die Beiträge längere Adressen bekamen.
|
||||
3. **Über die Repository-Klassen, nicht per SQL.** `PageRepository::saveDraft()` und `publish()`
|
||||
prüfen das Dokument. Ein per `INSERT` eingeschmuggeltes Dokument kann Felder enthalten, die der
|
||||
Editor nie wieder öffnen kann.
|
||||
|
||||
Reihenfolge im Skript: Theme → Medien → Kopf- und Fußbereich → Seiten → Beiträge → Menüs →
|
||||
Formulare → Einstellungen.
|
||||
|
||||
---
|
||||
|
||||
## 3. Das Dokumentformat
|
||||
|
||||
Ein Dokument ist `{schemaVersion, title, description, blocks: [...]}`. Ein Block ist
|
||||
`{id, type, props, style, layout, children}`.
|
||||
|
||||
- **`id`** – höchstens 64 Zeichen, eindeutig im Dokument, erscheint als HTML-`id` und als
|
||||
CSS-Selektor. Sprechend wählen (`hero-headline`, nicht `block-7`).
|
||||
- **`type`** – siehe Liste im Editor; `section`, `row`, `column`, `heading`, `text`, `image`,
|
||||
`button`, `postlist`, `slider`, `tabs`, `form`, `contact`, `stat`, `quote`, `iconcard`, …
|
||||
- **`props`** – der Inhalt. Was hier steht, ist Text und Daten, nie Auszeichnung mit Wirkung.
|
||||
- **`style`** – `{desktop: {...}, tablet: {...}, mobile: {...}}`, jeder Wert aus `StyleSchema`.
|
||||
Seiten, Abstände und Ecken sind **Objekte** (`['top' => '10px', …]`), keine Kurzschreibweise.
|
||||
- **`layout.effects`** – `{entrance: 'rise', delay: 'short'}` für das Einblenden beim Scrollen.
|
||||
|
||||
**Setze `schemaVersion` auf die aktuelle Fassung** (siehe `DocumentValidator::SCHEMA_VERSION`),
|
||||
sonst sind neuere Bausteine und Eigenschaften nicht erlaubt – und der Editor kann in einem zu alten
|
||||
Dokument nichts einfügen.
|
||||
|
||||
---
|
||||
|
||||
## 4. Gestaltung
|
||||
|
||||
**Erst das Theme, dann die Blöcke.** Farben und Schriften gehören in die Palette
|
||||
(`/admin/theme`): ein Block, der `@accent` sagt, ändert sich mit dem Theme; einer, der `#c1121f`
|
||||
sagt, nicht. Die Palette färbt auch Knöpfe, Symbole, Fokusringe und den Einwilligungsdialog.
|
||||
|
||||
Jedes Farbfeld hat einen Farbwähler und die Palettenfarben zur Auswahl. **Das Textfeld daneben
|
||||
bleibt maßgeblich**: wer dort `@accent` stehen lässt, behält den Verweis auf die Palette; wer im
|
||||
Farbwähler zieht, schreibt einen festen Wert hinein und kappt ihn.
|
||||
|
||||
Feste Größen nur, wo sie gemeint sind. `maxWidth` ohne `margin: auto` zentriert nichts – das war
|
||||
hier schon zweimal die Ursache eines „geht nicht".
|
||||
|
||||
**Die Vorschau im Editor zeigt dieselbe Ausgabe wie die Website.** Was dort falsch aussieht, ist
|
||||
falsch – nicht „nur die Vorschau".
|
||||
|
||||
---
|
||||
|
||||
## 5. Bilder
|
||||
|
||||
- Hochladen über `MediaLibrary::upload()`; daraus entstehen WebP-Varianten in 480/960/1600/2400 px
|
||||
(nie größer als das Original). Das Original wird nicht behalten.
|
||||
- **Vor dem Hochladen prüfen:** über 12 Megapixel oder 8000 px Kantenlänge werden abgelehnt, und
|
||||
ein Hostingpaket mit 128 MB Speicher schafft große Bilder nicht. Vorher auf 2400 px verkleinern.
|
||||
- Im Dokument immer alle `sources` mitgeben, damit `srcset` entsteht.
|
||||
- Ein Bild mit fester Anzeigebreite bekommt `style.desktop.maxWidth` – daraus entsteht das
|
||||
`sizes`-Attribut, und der Browser lädt die kleine Variante. Ohne das lud ein 190-px-Logo die
|
||||
960er Datei.
|
||||
- Hintergrundbilder werden je Gerät verkleinert, auch wenn sie nur einmal gesetzt wurden.
|
||||
- „Beim Klick" entscheidet je Bild, ob nichts passiert, ob es sich vergrößert oder ob es zu einer
|
||||
Adresse führt; eine Galerie hat dafür einen Schalter. Zugrunde liegt ein gewöhnlicher Verweis
|
||||
auf die große Fassung – ohne JavaScript öffnet ein Klick sie, nichts ist kaputt.
|
||||
|
||||
---
|
||||
|
||||
## 6. Beiträge
|
||||
|
||||
Optional. Gibt es keine, verschwinden Archiv, Feed und Feedverweis von selbst; ein
|
||||
Beitragslisten-Baustein zeigt öffentlich nichts.
|
||||
|
||||
Beim Anlegen wählt man einen von drei Aufbauten (`PostLayouts`: Bildkopf, Klassisch, Magazin), für
|
||||
die Liste eine von vier Darstellungen (Karten, Liste, Kompakt, Wechselseitig). Beides sind
|
||||
Startpunkte, keine Schablonen.
|
||||
|
||||
---
|
||||
|
||||
## 7. SEO
|
||||
|
||||
Meistens fertig, wenn der Inhalt stimmt: Titel, Beschreibung, Canonical, Open Graph, strukturierte
|
||||
Daten, Sitemap, `robots.txt` entstehen aus dem, was dasteht. Zu setzen bleibt:
|
||||
|
||||
- **Titel für Suchergebnisse** je Seite, wenn er sich vom Titel auf der Seite unterscheiden soll.
|
||||
- **Vorschaubild beim Teilen** – sonst nimmt ein Beitrag sein Beitragsbild und eine Seite gar nichts.
|
||||
- **Unternehmensdaten** unter Einstellungen; daraus entsteht der strukturierte Datensatz.
|
||||
- **Weiterleitungen**, wenn die Website eine alte ablöst. **Vor dem Umzug** die Adressliste der
|
||||
alten Website besorgen (deren `sitemap.xml`) und für jede Adresse, die sich ändert, eine
|
||||
Weiterleitung anlegen. Ohne das ist der Rang von Jahren weg.
|
||||
|
||||
---
|
||||
|
||||
## 8. Schriften
|
||||
|
||||
Nach dem Aufsetzen **einmal** `php bin/console cms:fonts:fetch` (oder der Knopf im Designbereich).
|
||||
Ohne das lädt die Website die Schriften von Google – zwei fremde Verbindungen nacheinander und die
|
||||
IP-Adresse jedes Besuchers an einen Dritten. Die Dateien der Pilotwebsite liegen im Repository;
|
||||
wählt eine neue Website andere Schriften, holt dieser Befehl sie.
|
||||
|
||||
---
|
||||
|
||||
## 9. Prüfen
|
||||
|
||||
In dieser Reihenfolge, gegen die neue Website:
|
||||
|
||||
```bash
|
||||
CMS_URL=http://127.0.0.1:8090 npm run test:links # kein toter Verweis
|
||||
CMS_URL=http://127.0.0.1:8090 npm run test:browser # Barrierefreiheit, Tastatur, ohne JavaScript, Ladebudget
|
||||
MSYS_NO_PATHCONV=1 CMS_URL=http://127.0.0.1:8090 CMS_PATHS="/,/leistungen,/kontakt" npm run audit
|
||||
```
|
||||
|
||||
Für `npm run audit` muss die Installation im **Produktionsmodus** laufen (`APP_ENV=prod`,
|
||||
`APP_DEBUG=0`), sonst misst man den Debugmodus.
|
||||
|
||||
Am Code geändert? Dann zusätzlich `php vendor/bin/phpunit` (beide PHP-Fassungen),
|
||||
`npm run test:editor` und `composer format`.
|
||||
|
||||
**Zielwerte:** Barrierefreiheit, Best Practices und SEO je 100. Performance mindestens 95, 100
|
||||
anstreben – eine bilderreiche Startseite mit Slider landet realistisch bei 96–98.
|
||||
|
||||
Findet die Prüfung einen Kontrastmangel aus einer im Editor gesetzten Farbe, wird er **gemeldet und
|
||||
nicht behoben**: das ist eine Entscheidung des Redakteurs, keine des Codes.
|
||||
|
||||
---
|
||||
|
||||
## 10. Livegang
|
||||
|
||||
`docs/LIVEGANG.md` ist die Liste. Die drei Dinge, die dort am häufigsten vergessen werden:
|
||||
E-Mail-Versand einmal wirklich ausprobieren, Schriften holen, und die Weiterleitungen der alten
|
||||
Adressen prüfen.
|
||||
|
||||
---
|
||||
|
||||
## 11. Updates dieser Installation
|
||||
|
||||
Zwei Wege, und welcher gilt, entscheidet das Hosting:
|
||||
|
||||
- **Mit Git** (eigener Server, Shell-Zugriff): Bildschirm „System" oder `php bin/console cms:update`.
|
||||
Vorprüfung, Sicherung, Wartungszustand, Checkout, Migration, Rauchtest, Rückweg.
|
||||
- **Ohne Git** (geteiltes Hosting, über `install.php` installiert): im selben Bildschirm die Karte
|
||||
„Update aus dem Releasearchiv" – zwei Knöpfe, der zweite führt die Migrationen aus. `var/`, `.env`,
|
||||
hochgeladene Schriften und das erzeugte Stylesheet-Bündel bleiben unberührt.
|
||||
|
||||
Beides steht ausführlich in `docs/UPDATE.md`. Wichtig für den, der eine Website baut: **nichts von
|
||||
dem, was diese Website ausmacht, liegt im Code**, also überlebt es beide Wege. Wer dafür doch eine
|
||||
Twig-Datei angefasst hat, verliert die Änderung beim ersten Update – siehe Abschnitt 0.
|
||||
|
||||
---
|
||||
|
||||
## Was in diesem Projekt immer wieder schiefging
|
||||
|
||||
Wer diese fünf kennt, spart sich die Suche:
|
||||
|
||||
1. **Eine Einstellung, die speichert und nichts tut.** Das Feld war da, die Ausgabe nicht – beim
|
||||
Vorschaubild fürs Teilen zwei Monate lang. **Nach jedem neuen Feld nachsehen, ob es vorne
|
||||
ankommt**, und einen Test schreiben, der genau das prüft.
|
||||
2. **`|default(true)` in Twig macht aus einem gespeicherten `false` ein `true`.** Für Schalter immer
|
||||
`??` verwenden.
|
||||
3. **Die Prüfung lief gegen die Demo, nicht gegen die Website.** Feste Pfade und Linktexte in einem
|
||||
Prüfskript prüfen nichts, sobald der Inhalt anders ist.
|
||||
4. **Messen statt vermuten.** Das Vorabladen des Hero-Bildes klang richtig und kostete fünf
|
||||
Punkte. Jede Annahme über Geschwindigkeit wird gemessen, vorher und nachher.
|
||||
5. **Der Entwicklungsserver ist nicht das Hosting.** Ohne Kompression und Cache-Kopfzeilen misst
|
||||
man 90 statt 96 – und optimiert an der falschen Stelle.
|
||||
+183
@@ -0,0 +1,183 @@
|
||||
# Plugins schreiben
|
||||
|
||||
Ein Plugin bringt eigene Bausteine, Vorlagen und Adressen in den Editor und auf die
|
||||
Website. Farben, Schriften und Buttons fasst es nie an – das Design gehört der Website (ADR 105). Es ist ein Ordner unter `plugins/` mit einer `plugin.json`, Twig-Vorlagen und optional
|
||||
Stylesheets und Skripten. Eingeschaltet und eingestellt wird es in der Verwaltung unter **Plugins**
|
||||
(Administrator). Hintergrund und Grenzen: ADR 102 und ADR 103 in `docs/ARCHITECTURE.md`.
|
||||
|
||||
**Ein Plugin bringt keinen PHP-Code mit.** Alles ist Manifest plus Twig. Deshalb gibt es keine
|
||||
Hooks in Speichern oder Veröffentlichen und keine eigenen Tabellen – und deshalb kann ein Update
|
||||
des Kerns ein Plugin nicht brechen, solange die Übergaben unten stehen.
|
||||
|
||||
## Der Ordner
|
||||
|
||||
```
|
||||
plugins/
|
||||
vergleich/ ← Name des Plugins: Kleinbuchstaben und Ziffern, mit Buchstabe beginnend
|
||||
plugin.json ← das Manifest
|
||||
blocks/bildvergleich.html.twig
|
||||
blocks/galerie.html.twig
|
||||
routes/daten.json.twig
|
||||
assets/bildvergleich.css ← nur unter assets/ liegt, was die Website ausliefert
|
||||
assets/bildvergleich.js
|
||||
```
|
||||
|
||||
Das mitgelieferte Plugin ist die Vorlage zum Abschreiben: `plugins/vergleich` (Bausteine,
|
||||
Container, Einstellungen, Adresse, Seitenvorlage).
|
||||
|
||||
## Das Manifest
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "vergleich",
|
||||
"title": "Bildvergleich",
|
||||
"description": "Vorher und nachher in einem Bild.",
|
||||
"version": "1.1.0",
|
||||
"requires": "1.17.0",
|
||||
"settings": {
|
||||
"labelBefore": { "kind": "text", "label": "Standardbeschriftung vorher", "default": "Vorher" }
|
||||
},
|
||||
"blocks": {
|
||||
"bildvergleich": {
|
||||
"label": "Bildvergleich",
|
||||
"icon": "⇔",
|
||||
"template": "blocks/bildvergleich.html.twig",
|
||||
"css": "assets/bildvergleich.css",
|
||||
"script": "assets/bildvergleich.js",
|
||||
"props": {
|
||||
"before": { "kind": "media", "label": "Bild vorher" },
|
||||
"position": { "kind": "number", "label": "Startposition (%)", "min": 5, "max": 95, "default": 50 }
|
||||
}
|
||||
},
|
||||
"galerie": {
|
||||
"label": "Vergleichsgalerie",
|
||||
"template": "blocks/galerie.html.twig",
|
||||
"container": true,
|
||||
"children": ["vergleich.bildvergleich"],
|
||||
"props": { "columns": { "kind": "select", "label": "Spalten", "choices": { "2": "Zwei", "3": "Drei" } } }
|
||||
}
|
||||
},
|
||||
"presets": {
|
||||
"abschnitt": { "label": "Vorher-Nachher-Abschnitt", "blocks": [ { "id": "s", "type": "section", "children": [ ] } ] }
|
||||
},
|
||||
"routes": {
|
||||
"daten": { "path": "/vergleich/daten", "template": "routes/daten.json.twig", "type": "json" }
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `name` muss dem Ordnernamen entsprechen. Der Typ eines Bausteins ist `name.baustein`, hier
|
||||
`vergleich.bildvergleich`; so heißt er im Dokument und in der Ausgabe.
|
||||
- `version` in der Form `1.2.3`. Sie hängt an den Adressen der Dateien (`?v=1.1.0`), damit der
|
||||
Browser nach einem Update die neue Fassung holt.
|
||||
- `requires` (optional): die kleinste KantSites-Fassung. Passt sie nicht, erscheint das Plugin in
|
||||
der Verwaltung mit dem Grund und lässt sich nicht einschalten.
|
||||
- Ein Manifest braucht mindestens einen Baustein, eine Adresse oder eine Vorlage. Fehlt eine
|
||||
genannte Datei, ist das Manifest ungültig.
|
||||
|
||||
### Eigenschaften und Einstellungen (Schema)
|
||||
|
||||
Eigenschaften (`props` eines Bausteins) und Einstellungen (`settings` des Plugins) haben dasselbe
|
||||
Schema: eine Art (`kind`), eine Beschriftung (`label`), optional `help` und `default`.
|
||||
|
||||
| Art | Feld | Prüfung | Weitere Schlüssel |
|
||||
|---|---|---|---|
|
||||
| `text` | einzeilig | Text bis `maxLength` (Standard 200) | `maxLength` |
|
||||
| `textarea` | mehrzeilig | Text bis `maxLength` (Standard 2000) | `maxLength` |
|
||||
| `number` | Zahl | zwischen `min` und `max` | `min`, `max` |
|
||||
| `boolean` | Kästchen | an oder aus | – |
|
||||
| `select` | Liste | einer der Werte | `choices`: `{"wert": "Beschriftung"}` |
|
||||
| `url` | einzeilig | lokaler Pfad, `#sprungmarke`, `https://`, `mailto:`, `tel:` | `maxLength` |
|
||||
| `media` | Bild aus der Mediathek | Kennung eines Bildes oder leer | – |
|
||||
|
||||
Höchstens 30 Eigenschaften je Baustein. Unbekannte Eigenschaften in einem gespeicherten Dokument
|
||||
werden ignoriert (so kann ein Plugin eine Eigenschaft streichen), bekannte müssen passen, sonst
|
||||
lässt sich die Seite nicht speichern. Einstellungen bearbeitet der Administrator auf dem Bildschirm
|
||||
„Plugins“; sie werden genauso geprüft.
|
||||
|
||||
### Container
|
||||
|
||||
`"container": true` macht einen Baustein zum Behälter. `"children"` (optional) zählt auf, was
|
||||
hinein darf – Kernbausteine wie `text` oder Bausteine des Plugins; ohne die Liste darf alles hinein.
|
||||
Der Editor zeigt die Regel im Werkzeugkasten, der Server prüft sie beim Speichern.
|
||||
|
||||
### Seitenvorlagen (`presets`)
|
||||
|
||||
Fertige Bausteine, die der Editor als Ganzes einfügt – hinter den eingebauten Vorlagen mit demselben
|
||||
Knopf. `blocks` ist eine Liste von Bausteinen wie in einem Dokument (höchstens 64 KiB); Kennungen
|
||||
vergibt der Editor bei jedem Einfügen neu, du kannst also feste schreiben.
|
||||
|
||||
### Eigene Adressen (`routes`)
|
||||
|
||||
`path` hat die Form einer Seite (`/name` oder `/name/unter/seite`, Slugs) und darf nichts belegen,
|
||||
was der Kern beantwortet (`/admin`, `/media`, `/assets`, `/formular`, `/beitraege` …). `type` ist
|
||||
`html`, `json`, `text` oder `xml`. Eine veröffentlichte Seite mit derselben Adresse geht vor.
|
||||
Ausgeschaltet antwortet die Adresse mit 404; alles außer `html` trägt `noindex`.
|
||||
|
||||
### Was ein Plugin nicht kann: das Design
|
||||
|
||||
Es gibt kein `theme` im Manifest. Ein Plugin gestaltet seine eigenen Bausteine über deren
|
||||
Stylesheet und die Design-Tokens des Kerns; Palette, Schriften und Buttons der Website ändert
|
||||
nur der Betreiber unter **Design**. So färbt kein Schalter eine Website um, und nichts, was dort
|
||||
eingestellt wurde, geht durch ein Plugin verloren (ADR 105).
|
||||
|
||||
## Die Vorlagen
|
||||
|
||||
### Bausteine
|
||||
|
||||
Die Vorlage eines Bausteins bekommt genau sechs Dinge:
|
||||
|
||||
- `props` – die geprüften Eigenschaften, jede vorhanden, fehlende mit ihrem Vorgabewert
|
||||
- `settings` – die Einstellungen des Plugins, jede vorhanden
|
||||
- `children` – bei einem Container die fertig gerenderten Kinder (leer sonst); ausgeben mit
|
||||
`{{ children }}`
|
||||
- `block` – der Baustein selbst; `block.id` gehört als `id` an das Wurzelelement, sonst greifen
|
||||
eigenes CSS und die Editorauswahl nicht
|
||||
- `designClasses` – Klassen aus Sichtbarkeit, Design-Tokens und Einblendungen, an die `class`
|
||||
des Wurzelelements anhängen
|
||||
- `editing` – `true` in der Editorvorschau, für Hinweise wie „bitte ein Bild wählen“
|
||||
|
||||
Dazu eine Funktion: `kant_plugin_media(kennung)` liefert ein Bild der Mediathek als
|
||||
`{id, title, alt, variants: [{src, width, height}, …]}` (kleinste Fassung zuerst) oder `null`.
|
||||
|
||||
```twig
|
||||
{% set bild = props.before ? kant_plugin_media(props.before) : null %}
|
||||
<div id="{{ block.id }}" class="block-bildvergleich{{ designClasses }}" data-position="{{ props.position }}">
|
||||
{% if bild %}<img src="{{ (bild.variants|last).src }}" alt="{{ props.alt ?: settings.labelBefore }}">{% endif %}
|
||||
{{ children }}
|
||||
</div>
|
||||
```
|
||||
|
||||
### Adressen
|
||||
|
||||
Die Vorlage einer Adresse bekommt `settings`, `query` (die Abfrageparameter mit sauberen Namen,
|
||||
gekürzt) und `route`. Für `json` und `xml` schreibt sie die Antwort selbst, etwa mit `json_encode`.
|
||||
|
||||
Was keine Vorlage bekommt: Datenbank, Einstellungen der Website, Anfrage. Sie läuft mit den
|
||||
Rechten der Anwendung – ein Sandkasten ist das nicht, und so wird es auch nicht genannt.
|
||||
|
||||
## Stylesheet und Skript
|
||||
|
||||
Beide werden nur auf Seiten geladen, auf denen der Baustein vorkommt, über
|
||||
`/plugin-assets/<name>/assets/...`. Die Content-Security-Policy erlaubt Skripte vom eigenen
|
||||
Ursprung, sobald die Seite eines braucht; Inline-Stile und Inline-Skripte bleiben verboten. Ein
|
||||
Skript setzt Werte deshalb über das CSSOM (`element.style.setProperty(...)`), nie über ein
|
||||
`style`-Attribut in der Vorlage. In der Editorvorschau wird das Stylesheet geladen, das Skript
|
||||
nicht – die Vorlage muss auch ohne Skript ein brauchbares Bild abgeben.
|
||||
|
||||
## Was der Kern übernimmt
|
||||
|
||||
- Speichern, Veröffentlichen, Fassungen, Undo/Redo – der Baustein ist ein gewöhnlicher Block.
|
||||
- Eigenes CSS je Block, Design-Tokens (Abstände, Ausrichtung, Ecken), Sichtbarkeit je Gerät,
|
||||
Einblendungen – alles über `designClasses` und die `id`.
|
||||
- Der Editor: Eintrag im Werkzeugkasten unter „Erweiterungen“, Eigenschaftenpanel aus dem
|
||||
Schema, Container-Regeln, Vorlagen, Vorschau über den Server.
|
||||
- Der Erhaltungsvertrag: ist das Plugin aus oder fehlt es, bleibt der Baustein im Dokument, der
|
||||
Editor zeigt einen Platzhalter, die Website einen HTML-Kommentar. Nichts geht verloren.
|
||||
|
||||
## Updates
|
||||
|
||||
Mitgelieferte Plugins liegen im Releasearchiv und werden beim Archiv-Update Datei für Datei
|
||||
aktualisiert. Ein selbst installiertes Plugin bleibt dabei stehen, weil sein Ordner im Archiv
|
||||
nicht vorkommt. Beim Git-Update ist ein fremder Ordner unter `plugins/` nicht von Git verwaltet;
|
||||
`git checkout` lässt ihn in Ruhe. Die Einstellungen liegen in der Datenbank und überleben beides.
|
||||
+138
-2
@@ -1,5 +1,140 @@
|
||||
# Qualitätsbericht – erster ausführbarer Prototyp
|
||||
|
||||
## Ergänzung: DEEG Immobilien & Hausverwaltung gemessen, 15.09.2026 (1.17.0)
|
||||
|
||||
Lighthouse **13.4.1** (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus der fünften Referenzwebsite „DEEG – Immobilien & Hausverwaltung in Freudenstadt“ (`var/immobilien-deeg.db`, Startkonfiguration `immobilien-deeg-prod`, Port 8095), drei Adressen: `/`, `/impressum`, `/datenschutz`. Ergebnis: **Leistung, Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**; Startseite LCP 1,8 s mobil und 0,5 s Desktop (das Startfoto als `img` im Hero), CLS 0, TBT 0 ms. Ladebudget der Startseite 157 KB mit 6 Anfragen und 11 Datenbankabfragen. Berichte unter `reports/lighthouse-immobilien-deeg/`. Voraussetzung für die 100 bei Barrierefreiheit: Blau mit weißer Schrift als `#0076b6` statt Logo-Blau `#0081c7`, kleine blaue Schrift `#006aa3`, Copyright-Zeile mit eigener Farbe. Nach dem Umbau der Bewertungsanfrage zum dreistufigen Preisfinder (ADR 101, zweites Skript `form-steps.js` auf der Startseite) erneut gemessen: `/` mobil und Desktop weiterhin in allen fünf Kategorien 100, Ladebudget 161 KB mit 7 Anfragen.
|
||||
|
||||
## Ergänzung: Ihr Malerservice Drews gemessen, 15.09.2026 (1.16.0 + zwei unveröffentlichte Korrekturen)
|
||||
|
||||
Lighthouse **13.4.1** (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus der vierten Referenzwebsite „Ihr Malerservice Drews“ (`var/maler-drews.db`, Port 8092), sechs Adressen: `/`, `/aktuelles`, `/unsere-projekte`, `/kunstmalerei`, `/kontakt`, `/friedrichsbad-baden-baden`. Ergebnis: **Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**, Leistung 98–100 – Startseite 99 mobil und 98 Desktop, Kunstmalerei 99/99, Projekte und Beitrag 99 mobil, alle übrigen 100. Der Rest zur 100 ist überall das Foto hinter dem Seitenkopf als CSS-Hintergrund (LCP mobil 1,6–2,1 s, Desktop 0,6–1,0 s; CLS ≤ 0,01, TBT ≤ 7 ms). Berichte unter `reports/lighthouse-maler-drews/`.
|
||||
|
||||
Drei Messrunden führten dorthin. Die erste zeigte Barrierefreiheit 91–93: das Gold `#c6a36b` der Vorlage erreicht auf Weiß 2,4:1 (Kleinzeilen, Datum, Links, weiße Buttonschrift auf Gold), die Beitragslisten sprangen von H1 auf H3, der Copyright-Link hob sich nur durch Farbe ab, und der Nach-oben-Knopf lag außerhalb jeder Landmarke (axe „region“, auf jeder Seite). Behoben durch `@gold-text` (`#8a6a2f`, 5,0:1) für Text auf hellem Grund, dunkle Buttonschrift (6:1), eine unsichtbare H2 vor den Listen, Unterstreichung, und im CMS ein `nav` um den Knopf. Die zweite Runde ließ noch den aktiven Menüpunkt (Gold auf Weiß) und graue Formularbeschriftungen (`#999`, 2,8:1) übrig. Für die Leistung: nur das erste Hintergrundbild einer Seite wird vorgeladen – der Slider der Startseite liegt eine Ebene tiefer, deshalb trägt der äußere Abschnitt dasselbe Foto; jede im Design genannte Schriftstärke wird vorgeladen, deshalb Lato nur 400/700 (ohne das 300 der Vorlage, 23 KB weniger vor dem größten Bild); Kacheln und Galeriebilder bekamen feste Breiten, weil ohne sie eine 270 px breite Kachel die 2400er Datei lud (108 KB Ersparnis auf der Startseite laut `image-delivery`).
|
||||
|
||||
axe (WCAG 2.0/2.1 A+AA und Best-Practice-Regeln, Playwright, 1440 und 390 px, nach dem Durchscrollen und zwei Sekunden Wartezeit) auf neun Seiten einschließlich Impressum und Datenschutz: **null Verstöße** (`var/drews-axe.mjs`); die erste Runde hatte 58.
|
||||
|
||||
Maschinenlesbar: `llms.txt` (8 Seiten, 31 Beiträge mit Beschreibung), `robots.txt`, `sitemap.xml` (41 Adressen), `feed.xml`, `LocalBusiness` mit Adresse, Telefon, Öffnungszeiten Mo–Do/Fr, Einsatzgebiet und Instagram als `sameAs`. Nicht gemessen: Felddaten (LCP/INP/CLS am 75. Perzentil), Screenreader, Firefox/Safari, ein echtes Touchgerät, das tatsächliche Hosting.
|
||||
|
||||
## Ergänzung: Elektro Bernhard gemessen, 14.09.2026 (1.12.1)
|
||||
|
||||
Lighthouse **13** (`scripts/audit.mjs`, je drei Läufe Mobil und Desktop, Mediane) gegen den lokalen Produktionsmodus der Referenzwebsite Elektro Bernhard (`var/elektro-bernhard.db`, Port 8090), vier Adressen: `/`, `/leistungen`, `/leistungen/notdienst`, `/kontakt`. Ergebnis nach den Korrekturen: **Barrierefreiheit, Best Practices, SEO und agentisches Browsing überall 100**, Leistung überall 100 bis auf die mobile Startseite mit **99** (LCP 2,1 s durch das Hero-Foto als Hintergrund unter Mobildrosselung; CLS 0, TBT 30 ms). Berichte unter `reports/lighthouse-elektro-bernhard-2/`; der erste Lauf davor (`reports/lighthouse-elektro-bernhard/`) zeigte Barrierefreiheit 95–97.
|
||||
|
||||
axe (WCAG 2.0/2.1 A+AA und Best-Practice-Regeln, Playwright, 1440 und 390 px) auf sechs Seiten: **null Verstöße**. Vorher gab es drei echte Befunde, alle behoben: Markennamen `#9aa0a6` auf Weiß (2,6:1 → `#6b7178`, 4,7:1), der rote Link „Kant-IT Solutions“ im dunklen Fuß (3,0:1 → hell), und auf der Leistungsübersicht sprang die Gliederung von H1 auf H3 (jetzt eine H2 vor den Karten). Ein vierter Befund lag im CMS: der Pause-Knopf des Sliders hatte den sichtbaren Text „Pause“, aber den zugänglichen Namen „Automatische Wiedergabe pausieren“ (WCAG 2.5.3); der Name beginnt jetzt mit dem sichtbaren Text. Achtung beim Messen: unmittelbar nach dem Laden meldet axe Kontrastverstöße an Elementen, die gerade eingeblendet werden (halb transparente Zwischenzustände); erst nach dem Durchscrollen und zwei Sekunden Wartezeit sind die Befunde belastbar.
|
||||
|
||||
Maschinenlesbar: `llms.txt`, `robots.txt`, `sitemap.xml` werden ausgeliefert, `LocalBusiness` mit drei `sameAs`-Profilen auf der Startseite, `BreadcrumbList` auf Unterseiten, `og:image` auf allen Inhaltsseiten. Nicht gemessen: Felddaten (LCP/INP/CLS am 75. Perzentil), Screenreader, Firefox/Safari, das tatsächliche Hosting.
|
||||
|
||||
## Ergänzung: Sichtbare leere Layouts, 14.09.2026 (1.6.2)
|
||||
|
||||
30 JavaScript-Tests und 27 PHP-Editor-/Sicherheitstests mit 312 Assertions bestanden. Vollständiger isolierter Chromium-Editortest einschließlich axe ohne Verstöße: direkte Tastatureinfügung in leere Sektionen/Spalten, genaues Drag-and-drop-Ziel und Scrollen am Rand, Spaltenreihenfolge auf Desktop und Mobil, Undo/Redo, Speichern/Neuladen, Veröffentlichung und Konflikte. Die Tests prüfen ausdrücklich, dass die neuen Editorhilfen weder im gespeicherten Blockbaum noch im öffentlichen HTML stehen. Desktop-, Mobil- und Drag-Screenshots visuell geprüft; Bericht unter `reports/editor/result.json`.
|
||||
|
||||
Vergleich mit lokalen Flatsome-3.20.9-Quelldateien, keine laufende WordPress-Referenz geprüft. Kein neuer Lighthouse-Lauf, da der öffentliche Renderer und dessen CSS unverändert sind; die Messung von 1.6.1 ist weiterhin als solche zu lesen. Kein neuer Screenreader-, Firefox-, Safari-, Touchgeräte- oder Felddatennachweis.
|
||||
|
||||
## Ergänzung: Flexible Editorfläche und Inhaltsbreiten, 14.09.2026 (1.6.1)
|
||||
|
||||
`npm.cmd test`: 30 Modelltests; PHP 8.4.22: 452 Tests / 3476 Assertions. Der vollständige isolierte Playwright-Lauf einschließlich axe, Speichern/Neuladen, Veröffentlichung, Konfliktbehandlung und neuer Panel-/Sektionsprüfungen besteht. Bildschirmbreiten 390/850/1100/1920 px ohne horizontalen Überlauf geprüft. Screenshotprüfung für Desktop, Bildinspektor und Telefon; kein neuer Screenreader-Test.
|
||||
|
||||
`PHP_BINARY=C:\php84\php.exe`, `npm.cmd run test:editor -- --audit`: Lighthouse **13.4.1**, lokaler Symfony-Produktionsmodus mit isolierter Datenbank und den vom Editortest erstellten Inhalten/Bildern. Je **drei** Läufe im Mobil- und Desktopprofil aus `scripts/audit.mjs`. Leistung, Barrierefreiheit, Best Practices, SEO und agentisches Browsing in jedem Lauf und damit in beiden Medianen **100**. Vollständige Geräte-/Drosselungs- und Umgebungseinstellungen sowie Ergebnisse stehen in `reports/lighthouse-editor/summary.json` und den sechs Einzelberichten; funktionale Ergebnisse in `reports/editor/result.json`.
|
||||
|
||||
Die Messung prüft die gemeinsame öffentliche Ausgabe nach Entfernung der festen Text-/Überschriftenbreiten. Sie ist keine Abnahme aller Pilot- oder Kundenseiten. Felddaten zu LCP/INP/CLS wurden nicht erhoben; INP wird aus diesem Ladelauf nicht abgeleitet. GEO/Auffindbarkeit ist durch den Agentic-Browsing-Score nicht nachgewiesen.
|
||||
|
||||
## Ergänzung: Autorotation, Blättern, eigene Texte, 13.09.2026
|
||||
|
||||
- **395 PHP-Tests / 1672 Assertions** unter PHP 8.2.12 und 8.4.22, 18 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. axe: null Verstöße auf Startseite, Projekteseite, Beitrag.
|
||||
- **Die Autorotation ist als Verlauf geprüft, nicht als Schnappschuss:** 0 → 666 → 1332 → 1998 in drei Schritten zu je sieben Sekunden, und nach einem Mausrad-Ereignis bleibt der Wert stehen. Dabei fiel der Fehler der ersten Fassung auf: das Sprungziel wurde aus `offsetLeft` berechnet und der Offset des Sliders doppelt abgezogen – Ergebnis −78, also keine Bewegung.
|
||||
- Belegt ist außerdem, dass das Skript **nur auf Seiten mit einem solchen Slider** geladen wird und die Richtlinie sonst bei `script-src 'none'` bleibt.
|
||||
- **Blättern in der Beitragsliste** mit sieben Beiträgen geprüft: erste Seite ohne „Neuere“, letzte ohne „Ältere“, Seite 99 landet auf der letzten, und ohne den Schalter bleibt es eine Auswahl ohne Leiste.
|
||||
- **Acht Projekttexte sind geschrieben, nicht übernommen** – für die Beiträge, die auf der laufenden Website keinen Text haben (zwei davon nicht einmal einen Auszug). Sie beschreiben Aufgabe, Weg und Ergebnis, ohne Zahlen, Namen oder Zusagen zu erfinden. **Sie gehören vor einem Livegang gegengelesen**, denn sie sind meine Formulierung über Umuts Projekte.
|
||||
- **Nicht geprüft:** die Autorotation auf einem echten Touchgerät und in Firefox oder Safari; gemessen ist sie im Chromium der Browserprüfung.
|
||||
|
||||
## Ergänzung: Nachbau vollständig, 13.09.2026
|
||||
|
||||
- **390 PHP-Tests / 1641 Assertions** unter PHP 8.2.12 und 8.4.22, 18 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. **axe: null Verstöße** auf Startseite, Projekteseite, Datenschutzerklärung, Projektbeitrag und mobiler Projekteseite.
|
||||
- **Der Nachbau ist jetzt vollständig:** 29 Projektbeiträge mit den echten Bildern, Titeln, Auszügen und Texten der laufenden Website, die vollständige Datenschutzerklärung (27 Überschriften, rund 22.000 Zeichen), Impressum, Startseite, Projekteseite. 41 Medien, 33 Seiten.
|
||||
- **Ein Befund, der für den echten Umzug zählt:** **11 der 29 Projektbilder** hätte die Mediathek auf einem Hosting mit 128 MB abgewiesen, zwei davon (13,5 und 12,0 Megapixel) auch mit mehr Speicher – sie überschreiten die Pixelgrenze. Der Umzug muss solche Bilder vorher verkleinern; für den Nachbau ist genau das geschehen (9 Bilder auf 2400 px). Die Meldung der Mediathek ist dabei richtig und verständlich, sie kommt nur an einer Stelle, an der niemand mit ihr rechnet.
|
||||
- Die Größe der **Kennzahl** und der **Kundenstimme** ließ sich nicht einstellen: `.block-stat strong` und `.block-quote p` hatten feste Werte, gegen die eine Blockeinstellung nicht ankam. Beide erben jetzt vom Block – dieselbe Familie wie die Theme-Skala, die die Kartenüberschriften überschrieb.
|
||||
- Gemessen: Startseite 56 KB HTML mit 15 Bildern, Projekteseite 32 KB mit 13, Datenschutzerklärung 38 KB. **Kein ausführbares Skript** auf keiner Seite; das einzige `<script>` ist strukturierte Daten als `application/ld+json`, und die Richtlinie sendet weiterhin `script-src 'none'`.
|
||||
- **Nicht geprüft:** die Projektbeiträge sind inhaltlich übernommen, nicht redaktionell überarbeitet – einzelne Texte der laufenden Website sind kurz oder fehlen dort ganz (8 von 29 haben nur den Auszug). Und nichts davon steht auf echtem Hosting.
|
||||
|
||||
## Ergänzung: Detailabgleich mit dem Original, 13.09.2026
|
||||
|
||||
- **390 PHP-Tests / 1641 Assertions** unter PHP 8.2.12 und 8.4.22, 18 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung. **axe: null Verstöße** auf Startseite, Projekteseite und Handybreite des Nachbaus.
|
||||
- **Der ernsteste Fund ist eine beinahe entstandene Lücke:** beim Freigeben von Auszeichnungen in Überschriften war die Dokumentfassung in der Schliessung des Filters nicht gebunden – der Filter lief nie, während die Ausgabe schon unescaped war. Ein Test, der `<script>alert(1)</script>` in eine Überschrift schreibt, hat es im ersten Lauf gezeigt. Er prüft jetzt beides: das hervorgehobene Wort kommt durch, das Skript nicht.
|
||||
- Gegen das Original nachgemessen statt geschätzt: Kartenbreite 316 px (Original 324), Kartenhöhe 342 (330), Kartentitel 19 px/800 in beiden, Überschrift zweiter Ebene 44 px in beiden, Hauptüberschrift 66 px (68).
|
||||
- **Nicht geprüft:** Punkte am Slider in Firefox und Safari – dort greift der Rückfall auf ein wischbares Schiebefeld, gesehen habe ich ihn in diesen Browsern nicht.
|
||||
|
||||
## Ergänzung: Nachbau überarbeitet, Dokumentschema v17, 13.09.2026
|
||||
|
||||
- **389 PHP-Tests / 1636 Assertions** unter PHP 8.2.12 und 8.4.22, **18 JavaScript-Modelltests**, Playwright-Editortest, öffentliche Browserprüfung.
|
||||
- **Der schwerste Fund des Tages kommt aus dem Editor, nicht aus dem Nachbau:** in einem Dokument ab v13 ließ sich kein Baustein mehr einfügen, und Symbol, Fortschritt, Slider und eingebetteter Inhalt in gar keinem. Der bestehende Editortest hat das nicht bemerkt, weil er ein altes Dokument bearbeitet. Der neue Test fügt jeden Typ in Dokumente von v1 bis v17 ein.
|
||||
- **Ein zweiter, der nur in der Gegenprobe sichtbar wird:** `|default(true)` macht in Twig aus einem gespeicherten `false` ein `true`. Die neuen Anzeigeoptionen der Beitragsliste hätten sich speichern lassen und nichts getan – dieselbe Krankheit wie Rahmenstärke und Bildrundung. Der Test prüft beide Richtungen.
|
||||
- Neu geprüft: verlinktes Bild (mit denselben Linkregeln, sechs Fälle), klebender Kopfbereich als Einstellung, Projektraster gegen Blogarchiv.
|
||||
- Am Nachbau nachgemessen: Lösungskarte 213 px statt 300, Abstand zur Projektliste 54 px, Projektkarte 337 px mit Bild und Titel – das Original liegt bei 324 px.
|
||||
- **Nicht geprüft:** ob der klebende Kopfbereich auf einem Telefon mit dynamischer Adressleiste gut aussieht, und der Slider ist nicht auf einem echten Touchgerät gewischt worden.
|
||||
|
||||
## Ergänzung: kant-it.de nachgebaut, 12.09.2026
|
||||
|
||||
- **381 PHP-Tests / 1619 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung.
|
||||
- **Der Nachbau ist die Prüfung:** Startseite mit neun Abschnitten, Projekteseite, vier Beiträge, Impressum, Datenschutzgerüst, Kopf- und Fußbereich, zwei Menüs, Kontaktformular, zwölf Bilder, eigene Palette und Schrift – gegen eine eigene Datenbank und einen eigenen Medienordner, nicht gegen die Entwicklungsinstallation.
|
||||
- **Gefunden dabei: fünf Fehler** (ADR 042), darunter der größte: die Palette färbte Symbole, Knöpfe, Rahmen und den Einwilligungsdialog überhaupt nicht. Vier davon sind behoben und durch Tests abgesichert, der fünfte war mein eigener Kontrastfehler beim Beheben des ersten.
|
||||
- **axe: null Verstöße** auf Startseite, Projekteseite, Beitrag und Impressum, in 1440 px und 390 px – gemessen an der nachgebauten Seite, nicht an der Demo.
|
||||
- Gemessen am Nachbau: Startseite 45 KB HTML, 33 KB CSS, rund 430 KB Bilder in WebP, **0 Skripte**, 21 Abfragen. Die Abfragezahl ist die höchste bisher gemessene und erklärt sich aus der Beitragsliste; sie liegt unter der Obergrenze, ist aber der nächste Kandidat fürs Zusammenfassen.
|
||||
- **Nicht geprüft:** die vollständige Datenschutzerklärung der laufenden Website (nur das Gerüst), die restlichen Projektbeiträge, die Produktseiten von HausKlar und KantTresor, und nichts davon auf echtem Hosting. Der Nachbau ist ein Abbild, keine Migration: die Inhalte sind neu geschrieben, nicht aus WordPress exportiert.
|
||||
|
||||
## Ergänzung: Installer, 12.09.2026
|
||||
|
||||
- **379 PHP-Tests / 1606 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung.
|
||||
- **Die Abnahme des Pakets ist ein Test, kein Bericht:** ein Test geht die sechs Bildschirme durch die echte HTTP-Schicht, mit einem Kernel in einem wegwerfbaren Projektverzeichnis – leere Adresse → Weiterleitung auf `/install`, Datenbank, Migration, Website, Konto, fertig. Danach antwortet `/install` mit **404** und `/` mit **200**, und auf der Startseite steht der Name, den das Formular bekommen hat. Die Migrationen darin sind die echten.
|
||||
- **Von Hand im Browser nachgestellt**, gegen eine Kopie der Anwendung in einem leeren Verzeichnis mit leerer Datenbank: erste Anfrage 302 auf `/install`, Systemprüfung, Datenbank (SQLite), 13 Migrationen, Website „Tischlerei Berg“. Den letzten Schritt – das Konto – habe ich nicht im Browser ausgefüllt, sondern dem Test überlassen; ich tippe keine Passwörter in Formulare.
|
||||
- **axe auf allen vier erreichbaren Installationsbildschirmen: null Verstöße** (wcag2a, wcag2aa, wcag21a, wcag21aa).
|
||||
- **Das Releasearchiv wurde entpackt und gestartet**, nicht nur gebaut: `kantsites-1.3.0.zip` in ein leeres Verzeichnis, Server darauf, erste Anfrage → Installer. Damit ist belegt, dass `vendor/` vollständig ist und auf dem Zielsystem weder Composer noch eine Konsole gebraucht wird.
|
||||
- Die einzelne `install.php` ist dort geprüft, wo sie gefährlich wäre: neun Archiveinträge, die aus dem Zielverzeichnis ausbrechen wollen (`../`, absolut, Windows-Laufwerk, Rückwärtsschrägstrich, Nullbyte), landen nirgends – und ein Archiv mit einem solchen Eintrag bricht ab, ohne die Datei außerhalb anzulegen. Der Eintrag im Testarchiv ist echt: ZIP speichert `kantsites-1.2.0/../entwischt.txt` unverändert.
|
||||
- **Nicht geprüft:** der Download selbst. Solange das Archiv nicht unter seiner Adresse liegt, ist die Kette „Datei hochladen → lädt → entpackt“ nur bis zum Entpacken belegt. Ebenso ungeprüft: MySQL als Zieldatenbank im Installer (der Verbindungstest ist da, ein echter MySQL-Server war es nicht), echtes Hosting, und ein Screenreader.
|
||||
|
||||
## Ergänzung: Bildgrößen und Releasearchiv, 12.09.2026
|
||||
|
||||
- **352 PHP-Tests / 1479 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung.
|
||||
- **Der Browsertest hat den Fehler gefunden, den die Unittests nicht sehen konnten:** der Dokumentprüfer ließ höchstens **drei** `sources` je Bild zu. Mit der vierten Variante war jedes Dokument mit einem Bild darin ungültig – und eine abgewiesene Speicherung sieht im Editor so aus, als wäre nichts passiert. Gefunden wurde er, weil ein Galeriebild im Vorschaurahmen nicht erschien. Die Grenze hängt jetzt an `MediaLibrary::WIDTHS` statt an einer Zahl, und ein Test prüft beide Seiten: vier Quellen werden angenommen, fünf abgewiesen.
|
||||
- **Die Bildpipeline ist nachgerechnet, nicht behauptet:** ein Test lädt ein erzeugtes Foto hoch, öffnet jede entstandene Datei und prüft, dass sie WebP ist, dass die vier Breiten 480/960/1600/2400 entstehen und dass das Seitenverhältnis erhalten bleibt. Ein zweiter Upload mit 700 px Breite bekommt zwei Stufen – 480 und 700 –, womit „nie hochskalieren“ geprüft ist und nicht nur im Kommentar steht.
|
||||
- **Ein Testfehlschlag, der keiner war:** derselbe Test fiel um, sobald er in der gesamten Suite lief, mit der Meldung „dieses Bild benötigt mehr Arbeitsspeicher als das Hosting bereitstellt“. Das ist die Schutzprüfung vor dem Decodieren, die den bereits belegten Speicher mitrechnet – sie hat richtig gehandelt. Der Test benutzt jetzt eine kleinere Quelle und hebt das Limit für seine Dauer an, weil sein Gegenstand die Pipeline ist und nicht die Schutzprüfung.
|
||||
- Hintergrundbilder sind je Gerät geprüft: die Desktopregel nennt die größte Variante, die Regel im `max-width:767px`-Block die 960er.
|
||||
- **Das Releasearchiv ist gebaut, nicht geplant:** `kantsites-1.2.0.zip`, 3465 Dateien, 4,4 MB, SHA-256 `950eaa4c…`. Nachgesehen wurde, was **nicht** drin ist: kein PHPUnit, keine Tests, kein `var/`, kein `.env`.
|
||||
- Der Abbruch bei geänderten Abhängigkeiten ohne Composer ist mit zwei Tests belegt: geänderte `composer.lock` ohne Composer bricht ab und stellt den alten Stand wieder her; unveränderte `composer.lock` läuft durch.
|
||||
- **Nicht geprüft:** das Archiv ist auf keinem echten Hostingpaket entpackt und gestartet worden – das gehört zu den Arbeitspaketen J und N. Der Installer, der es herunterlädt, existiert noch nicht.
|
||||
|
||||
## Ergänzung: Ladeverhalten, 12.09.2026
|
||||
|
||||
- **347 PHP-Tests / 1422 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung mit neuem Ladebudget.
|
||||
- **Die Messung hat sofort etwas gefunden:** die Startseite brauchte 18 Datenbankabfragen, weil Einstellungen und Theme je Aufrufer erneut gelesen wurden. Mit einem Merker für die Dauer einer Anfrage sind es 8. Das ist der Unterschied zwischen „vermutlich in Ordnung“ und einer Zahl.
|
||||
- **Und die Messung hat einen Fehler von mir gefunden**, den ich beim Merken eingebaut hatte: der Merker wurde am Anfang des Speicherns geleert, das Speichern liest den Stand aber noch einmal und füllte ihn damit erneut – der Rest der Anfrage bekam den alten Stand. Ein bestehender Test über die Gestaltungsvorlagen fiel sofort um. Geleert wird jetzt nach dem Schreiben.
|
||||
- Das Ladebudget prüft je Seite HTML-Größe, CSS-Größe, Anzahl Anfragen, Anzahl Skripte und Abfragen gegen Obergrenzen. Heute: Startseite 7,4 KB HTML, 22,7 KB CSS, 2 Anfragen, 8 Abfragen, 0 Skripte; Beitragsübersicht 3,5 KB HTML, 29,8 KB CSS, 3 Anfragen, 13 Abfragen, 0 Skripte. **Die Null bei den Skripten ist die wichtigste Zahl** – sie hält als Test fest, was bisher nur Absicht war.
|
||||
- Blättern ist mit 30 Fassungen und 12 Weiterleitungen geprüft, einschließlich einer Seitenzahl jenseits des Endes.
|
||||
- **Nicht geprüft:** die Grenzen sind an der heutigen Demoseite gemessen; eine Seite mit vielen Bildern wird andere Zahlen haben, und die Obergrenzen müssen dann begründet angehoben werden statt stillschweigend. Auf echtem Hosting ist nichts davon gemessen (Arbeitspaket J).
|
||||
|
||||
## Ergänzung: Sicherung und Wiederherstellung, 12.09.2026
|
||||
|
||||
- **344 PHP-Tests / 1397 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung.
|
||||
- **Der Nachweis, um den es geht:** Sicherung anlegen, eine Seite und eine Bilddatei löschen, zurückspielen – beides ist wieder da. Einmal als Test gegen eine wegwerfbare Installation, einmal von Hand auf der Entwicklungsinstallation mit einer eigens angelegten Testseite (5 Seiten → gesichert → gelöscht, 4 → zurückgespielt, 5 → Testseite entfernt, 4). Damit ist der älteste offene Punkt aus Arbeitspaket I erledigt: „Eine Wiederherstellung ist nachgewiesen.“
|
||||
- Geprüft ist außerdem, dass die Kopie der Datenbank eine **benutzbare** Datenbank ist, nicht nur eine Datei, die existiert: der Test öffnet sie und zählt die Seiten darin.
|
||||
- Eine manipulierte Sicherung wird abgewiesen, bevor irgendetwas angefasst wird – nicht einmal die Sicherheitskopie entsteht. Eine Sicherung aus einer neueren Fassung wird abgewiesen, mit `--force` geht sie durch. Rotation behält die neuesten und meldet, was sie entfernt hat.
|
||||
- **Ein Fall, den erst der Test zeigte:** zwei Sicherungen in derselben Sekunde. Nicht akademisch, denn die Wiederherstellung legt selbst eine Sicherheitskopie an – die landet in derselben Sekunde wie die Sicherung, die gleich eingespielt wird. Statt die zweite scheitern zu lassen, bekommt der Name eine Nummer.
|
||||
- **Nicht geprüft:** MySQL (dort fehlt das Sichern über `mysqldump`, der Code sagt es), sehr große Medienordner, und ein Download der Sicherung über den Browser gibt es bewusst noch nicht.
|
||||
|
||||
## Ergänzung: Breite, Menüs, Seiten ohne Kopfbereich, 12.09.2026
|
||||
|
||||
- **336 PHP-Tests / 1354 Assertions** unter PHP 8.2.12 und 8.4.22, 17 JavaScript-Modelltests, Playwright-Editortest, öffentliche Browserprüfung.
|
||||
- **Ein bestehender Test ist absichtlich umgeschrieben worden**, und das ist erwähnenswert: er sicherte zu, dass ein zweiter Kopfbereich abgewiesen wird. Diese Regel war richtig, solange keine Seite wählen konnte, und wurde falsch, sobald sie es kann. Der Test prüft jetzt die neue Regel – mehrere Bereiche mit eigener Adresse, doppelte Adresse weiter abgewiesen, Standardbereich nicht löschbar.
|
||||
- **Der dritte Fehler des Tages aus derselben Familie:** die globale Seitenbreite ließ sich im Designbereich einstellen, aber `site.css` deckelte bei 1180 px. Schmaler wirkte, breiter nicht. Wie bei der Rahmenstärke und der Bildrundung: eine Einstellung, die sich speichern lässt und nichts tut, ohne jede Fehlermeldung. Ein Test liest jetzt die Regel aus `site.css` und prüft, dass sie den Theme-Wert nimmt.
|
||||
- Seitenbereiche sind über echte Auslieferung geprüft: mit „keiner“ steht weder `site-header` noch `site-footer` im HTML, der Seiteninhalt aber schon; ein Verweis auf einen gelöschten Bereich fällt auf den Standard zurück.
|
||||
- **Nicht geprüft:** der Burger ist nicht auf einem Touchgerät bedient worden, und kein Screenreader war beteiligt. Die vier Menüplätze sind im Test benutzt, aber nicht alle vier von Hand in der Menüverwaltung durchgeklickt.
|
||||
|
||||
## Ergänzung: Designer ausgebaut, 12.09.2026
|
||||
|
||||
- **315 PHP-Tests / 1309 Assertions** unter PHP 8.2.12 und 8.4.22, 16 JavaScript-Modelltests, Playwright-Editortest (axe auf dreizehn Bildschirmen, null Verstöße), öffentliche Browserprüfung.
|
||||
- Die Gestaltungseigenschaften sind nicht „vorhanden“, sondern einzeln nachgerechnet: 16 Datensätze vergleichen die erzeugte Deklaration Zeichen für Zeichen mit der erwarteten. Das ist die Prüfung, die die beiden Fehler unten gefunden hätte, wenn es sie vorher gegeben hätte.
|
||||
- **Zwei Fehler, die vorher niemand gesehen hat.** Erstens: eine Rahmenstärke je Seite wurde zu `border-width-top`. Das ist kein CSS, der Browser verwirft die Zeile stillschweigend – Rahmenstärken je Seite hatten **nie** eine Wirkung, und es gab keine Fehlermeldung, die darauf hingewiesen hätte. Zweitens: eine Rundung auf einem Bildblock landete auf dem `figure`, das Bild darin blieb eckig. Beides ist genau das, wonach Umut gefragt hat – er hat zwei echte Defekte gefunden, indem er die Funktion benutzen wollte.
|
||||
- Ein Test hält das Schema und das Editorpanel zusammen: jede Gruppe im Schema muss im Panel einen Namen haben, sonst landen ihre Eigenschaften in einem unbenannten Aufklappfeld.
|
||||
- Die drei neuen Bausteine sind über echte Seitenauslieferung geprüft, nicht nur über den Validator: Markup, Rollen, Klassen – und dass die Seite weiterhin `script-src 'none'` sendet, denn keiner von ihnen braucht ein Skript.
|
||||
- **Nicht geprüft:** kein Screenreader. Der Slider ist nicht mit Touch auf einem echten Gerät bedient worden, nur mit Tastatur und Mausrad im Browser. Und der Vergleich mit Flatsome ist Wissen, keine Messung – die Erweiterung war nicht installierbar, das steht so im Vergleichsdokument.
|
||||
|
||||
## Ergänzung: Updater, 12.09.2026
|
||||
|
||||
- **285 PHP-Tests / 1244 Assertions** unter PHP 8.2.12 und 8.4.22, Playwright-Editortest (axe auf dreizehn Bildschirmen, null Verstöße), öffentliche Browserprüfung.
|
||||
@@ -7,7 +142,8 @@
|
||||
- Der wichtigste davon ist der Fehlschlag: die neue Fassung bringt ein Migrationsskript, das mit Exitcode 1 endet. Geprüft wird danach, dass der Code auf dem alten Commit steht, die Datenbank den Inhalt von vor dem Update enthält, der Wartungszustand aufgehoben ist (weil der alte Stand antwortet) und im Protokoll `FEHLER` steht.
|
||||
- Die Reihenfolge der Schritte ist als Liste festgehalten, nicht nur ihr Ergebnis: Vorprüfung, Ziel, Sicherung, Wartung, Checkout, Fassung prüfen, Abhängigkeiten, Migration, Cache, Rauchtest, Fertig. Wer sie umstellt, bricht den Test.
|
||||
- Geprüft ist außerdem, dass die Sicherung vor dem Checkout entsteht, dass sie die Datenbank enthält und den Medienordner **nicht**, und dass Datenbank und Medienordner der Installation unverändert bleiben.
|
||||
- **Am echten Repository durchgespielt**, nicht nur im Test – siehe den Bericht in STATUS: Tag v1.0.0 auf `git.kant-it.de` gesetzt, Installation darauf gestellt, v1.0.1 veröffentlicht, Update ausgeführt.
|
||||
- **Am echten Repository durchgespielt**, nicht nur im Test: `git.kant-it.de/kantarci/KantSites`, Tag `v1.0.0` gesetzt, die Entwicklungsinstallation darauf gestellt (`git checkout v1.0.0`), `v1.0.1` veröffentlicht und `php bin/console cms:update` ausgeführt. Ergebnis: elf Schritte, alle ok, in dieser Reihenfolge – Vorprüfung, Ziel (v1.0.1 von 52f0ab38, Fassung 1.0.0), Sicherung (220 KB nach `var/backups/update-20260912-181159`, darin `cms.db`, `.env`, `.env.local`, kein Medienordner), Wartungszustand, Checkout, Fassung geprüft, Abhängigkeiten (Composer fehlt, vendor/ bleibt liegen), Migration („Already at the latest version“), Cache, Rauchtest („Datenbank erreichbar, Migration 20260912001200, Startseite /home lesbar, Fassung 1.0.1“), fertig. Danach: `VERSION` 1.0.1, `git describe` v1.0.1, kein Wartungszustand, `/` antwortet 200, `var/updates.log` enthält `OK v1.0.1 (11 Schritte)`.
|
||||
- Der Wartungszustand ist danach einzeln geprüft: eingeschaltet antwortet `/` mit **503 und `Retry-After: 600`** und der Wartungsseite, `/admin/login` mit 200; ausgeschaltet wieder 200.
|
||||
- **Nicht geprüft:** MySQL (dafür fehlt die Installation, siehe `docs/UPDATE.md`, „Was anders wurde“), ein Hosting ohne `proc_open` (die Vorprüfung meldet es, der Fall selbst ist nicht durchgespielt), und ein Update über die Weboberfläche unter einem knappen Zeitlimit.
|
||||
|
||||
## Ergänzung: Statistik, 12.09.2026
|
||||
@@ -133,7 +269,7 @@
|
||||
- **154 PHP-Tests / 563 Assertions** unter PHP 8.2.12 und PHP 8.4.22 erfolgreich, dazu **15 JavaScript-Modelltests**.
|
||||
- Ein v10-Dokument mit Palettenfarbe, eigenem Farbwert, Abständen je Seite, Ecken je Ecke, Schatten, Buchstabenabstand, Hover-Zustand und eigenem Mobilwert wird Byte für Byte gespeichert und zu blockgenauem CSS: Regel für den Block, eigene Regel für `:hover,:focus-within` und eine Media Query für die schmale Breite.
|
||||
- **Vierzehn abgewiesene Eingaben schreiben nichts.** Darunter gezielt die Ausbruchsversuche: eine Farbe als Funktion (`rgb(1,2,3)`), eine Länge als Ausdruck (`calc(1px + 2px)`), eine angehängte Deklaration (`18px;position:fixed`), ein Schriftschlüssel mit Anführungszeichen und ein Hintergrundbild als Dateipfad. Jeder dieser Werte scheitert am verankerten Muster seines Typs, bevor er gespeichert wird.
|
||||
- **Die Sicherheitsrichtlinie wurde eigens geprüft:** das erzeugte Stylesheet ist über seinen eigenen SHA-256-Hash erlaubt, `unsafe-inline` kommt im Kopf nicht vor, und Skripte bleiben auf Seiten ohne Tabs abgeschaltet. Ohne eingestellte Google-Schrift wird kein fremder Host erlaubt und keiner kontaktiert.
|
||||
- **Die Sicherheitsrichtlinie wurde eigens geprüft:** das erzeugte Stylesheet ist über seinen eigenen SHA-256-Hash erlaubt, `unsafe-inline` kommt im Kopf nicht vor, und Skripte bleiben auf Seiten ohne Tabs abgeschaltet. Ein fremder Schrifthost wird nie erlaubt und nie kontaktiert: Schriften liefert seit 1.14.0 ausschliesslich der eigene Server (ADR 096).
|
||||
- **Acht ungültige Themes** werden abgewiesen, ohne das gespeicherte Theme zu verändern; danach liefert das Theme weiterhin die Voreinstellungen.
|
||||
- Im Editormodell geprüft: Vererbung, einzelnes Zurücksetzen, Entfernen leerer Reste, Kopieren ohne geteilte Objekte und vollständiges Undo.
|
||||
- **Im Browser geprüft wurde nur der Aufbau, nicht die Bedienung.** Im laufenden Editor wurde über das DOM bestätigt, dass das Designpanel aus dem Serverschema entsteht: sieben Gruppen, 58 Felder, 11 Farbwähler, 12 Schieberegler, 34 Einheitenauswahlen. **Es wurde kein Gestaltungsvorgang von Hand ausgeführt**, keine Tastaturbedienung erprobt und keine axe- oder Lighthouse-Messung gemacht. Für ein Werkzeug dieser Größe ist das ein erheblicher Vorbehalt.
|
||||
|
||||
+1628
-4
File diff suppressed because it is too large.
Load diff
+64
-3
@@ -29,7 +29,7 @@ Jeder Schritt hat eine Abbruchbedingung. Bricht ein Schritt ab, bleibt der Wartu
|
||||
3. **Sichern.** Datenbank kopieren (bei SQLite Datei, bei MySQL `mysqldump`), dazu `.env` und den aktuellen Commit-Hash, nach `var/backups/update-<zeitstempel>/`. Der Medienordner wird **nicht** mitkopiert – er ist zu groß, und kein Update schreibt darin. Das steht auch so in der Oberfläche, damit niemand die Sicherung für vollständig hält.
|
||||
4. **Wartungszustand an.** Eine Datei `var/maintenance`. Öffentliche Anfragen antworten mit 503, einer erklärenden Seite und `Retry-After`; `/admin` bleibt erreichbar, sonst kann niemand das Update beenden. Suchmaschinen verstehen 503 als „später wieder“, nicht als „weg“.
|
||||
5. **Code wechseln.** `git checkout --force <tag>`. Danach prüfen, dass `VERSION` den erwarteten Wert hat – erst das beweist, dass der Wechsel wirklich stattgefunden hat.
|
||||
6. **Abhängigkeiten.** `composer install --no-dev --optimize-autoloader`, wenn Composer auf dem Server läuft. Wenn nicht – der wahrscheinliche Fall auf geteiltem Hosting – muss `vendor/` Teil des Tags sein. **Das ist die Entscheidung, die ansteht:** entweder Composer auf dem Server, oder ein Releasebranch, in dem `vendor/` mitgeliefert wird und den ein Build-Rechner erzeugt. Beides ist vertretbar, aber es muss vor dem ersten Livegang entschieden sein, weil der Updater danach anders aussieht.
|
||||
6. **Abhängigkeiten.** `composer install --no-dev --optimize-autoloader`, wenn Composer auf dem Server aufrufbar ist. Wenn nicht – der wahrscheinliche Fall auf geteiltem Hosting – gilt: **ändert dieses Update `composer.lock`, bricht der Updater hier ab** und verweist auf das Releasearchiv, das `vendor/` mitbringt (`scripts/build-release.php`). Ändert das Update die Abhängigkeiten nicht, läuft es ohne Composer durch. Der Vergleich läuft über die Prüfsumme der Datei vor und nach dem Checkout, nicht über Vertrauen.
|
||||
7. **Migrieren.** `doctrine-migrations migrate --no-interaction`. Die Migrationen sind bewusst nicht umkehrbar: `down()` würde Spalten entfernen, in denen Inhalte stehen. Der Rückweg ist die Sicherung aus Schritt 3, nicht ein `down()`.
|
||||
8. **Cache.** `cache:clear --env=prod` und Aufwärmen. Der Grund steht in `docs/QUALITY.md`: ein laufender Prozess kann einen älteren kompilierten Container halten und dann Seiten mit 500 beantworten, obwohl der Code richtig ist. Bei PHP-FPM gehört ein Neuladen dazu.
|
||||
9. **Rauchtest.** Intern `/`, `/admin/login` und `/sitemap.xml` abrufen und auf 200 prüfen, dazu eine Abfrage auf `page` und `revision`. Schlägt das fehl, gilt das Update als gescheitert, auch wenn jeder Schritt davor gemeldet hat, dass er fertig ist.
|
||||
@@ -62,9 +62,58 @@ Der teuerste Fall ist ein Update, das durchläuft und erst Tage später als fals
|
||||
- Kein `down()`-Rückweg für Migrationen. Der Rückweg ist die Sicherung, und das ist eine Entscheidung, nicht eine Lücke.
|
||||
- Kein Update ohne Sicherung, auch nicht mit Häkchen „ich weiß, was ich tue“.
|
||||
|
||||
## Der zweite Weg: Update aus dem Releasearchiv
|
||||
|
||||
Alles bis hierhin setzt eine Git-Arbeitskopie voraus. Auf geteiltem Hosting gibt es die nicht – dort wird über `install.php` aus dem Releasearchiv installiert, und danach stand in der Verwaltung „Dieses Verzeichnis ist keine Git-Arbeitskopie". **`src/Ops/ArchiveUpdate.php` ist der Weg für genau diese Installationen** (ADR 068). Er steht im Bildschirm „System" als eigene Karte neben dem Update über Git.
|
||||
|
||||
Der Ablauf ist derselbe wie oben, nur ohne `git`:
|
||||
|
||||
1. **Fassung ermitteln.** Die Releaseliste nennt den neuesten Tag. Antwortet sie nicht – viele Hoster lassen keine ausgehenden Verbindungen zu –, endet es hier, und es wurde nichts angefasst.
|
||||
2. **Sichern.** Datenbank und Medien nach `var/backups/`, vor dem ersten Handgriff.
|
||||
3. **Archiv laden und Prüfsumme vergleichen.** Stimmt sie nicht, wird nicht entpackt. Die Adresse steht in der Konfiguration, nicht in der Anfrage.
|
||||
4. **Entpacken und auf Vollständigkeit prüfen.** Fehlen `VERSION` oder `vendor/`, ist es kein brauchbares Release – und das fällt auf, bevor der Wartungszustand angeht.
|
||||
5. **Wartungszustand an, Dateien ersetzen.** `bin`, `config`, `migrations`, `src`, `templates`, `vendor`, `composer.json`, `composer.lock`, `migrations.php`, `cli-config.php`, `VERSION`. Der alte Stand wandert nach `var/update/<zeitstempel>/alt/`, er wird nicht gelöscht.
|
||||
6. **`public/` wird zusammengeführt, nicht ersetzt.** Dort liegen Dateien der Anwendung und Dateien des Betreibers nebeneinander: `assets/fonts` (hochgeladene Schriften) und `assets/build` (das erzeugte Stylesheet-Bündel) bleiben unangetastet.
|
||||
7. **Zwischenspeicher leeren.**
|
||||
8. **Zweiter Knopf: Migrationen.** Sie laufen in einer **eigenen Anfrage**, weil ein PHP-Prozess, der sich selbst ausgetauscht hat, mit den Klassen weiterarbeitet, die er beim Start geladen hat. Erst danach geht der Wartungszustand aus.
|
||||
|
||||
Unberührt bleiben in jedem Fall `var/`, `.env` und `.env.local`. Scheitert das Ersetzen mittendrin, schiebt der Updater zurück, was er bewegt hat, und nimmt den Wartungszustand wieder heraus.
|
||||
|
||||
**Geprüft wird ohne Netz** (`tests/ArchiveUpdateTest.php`): eine Releaseseite im Speicher, echte Zip-Archive, echte Prüfsummen, echte Umbenennungen in einem Wegwerfverzeichnis. Ein Test hält zusätzlich die Ersetzungsliste gegen die Mitnahmeliste von `scripts/build-release.php`: Was neu ins Archiv kommt, muss beim Update auch ersetzt werden.
|
||||
|
||||
## Der dritte Weg: von Hand über FTP
|
||||
|
||||
Der Weg für den einen Fall, in dem die Karte im Bildschirm „System" noch nicht existiert: **das Update auf die Fassung, die sie mitbringt.** Eine Installation der Fassung 1.4.2 kennt sie nicht – sie muss einmal von Hand nachgezogen werden, danach gibt es den Knopf.
|
||||
|
||||
1. **Sichern.** Verwaltung → System → „Sicherung anlegen". Zusätzlich per FTP `var/` und `.env` herunterladen. Migrationen sind nicht umkehrbar; der Rückweg ist die Sicherung.
|
||||
2. **Archiv holen und auspacken.** `kantsites-<fassung>.zip` von der Releaseseite, auf dem eigenen Rechner entpacken. Darin liegt ein Ordner `kantsites-<fassung>/`.
|
||||
3. **Wartungszustand einschalten.** Verwaltung → System → „Wartung". Sonst sieht ein Besucher währenddessen eine halb ersetzte Anwendung.
|
||||
4. **Diese Einträge hochladen und ersetzen:** `bin`, `config`, `migrations`, `src`, `templates`, `vendor`, `composer.json`, `composer.lock`, `migrations.php`, `cli-config.php`, `VERSION`. Verzeichnisse ersetzen, nicht hineinkopieren – eine Datei, die es in der neuen Fassung nicht mehr gibt, muss weg.
|
||||
5. **`public/` Datei für Datei** – und **`public/assets/fonts` und `public/assets/build` auslassen**. Dort liegen die hochgeladenen Schriften und das erzeugte Stylesheet-Bündel; beide gehören dem Betreiber und stehen in keinem Archiv.
|
||||
6. **Nicht anfassen:** `var/`, `.env`, `.env.local`. Dort stecken Inhalte, Medien und `APP_SECRET`.
|
||||
7. **`var/cache` vollständig löschen.** Sonst läuft die neue Fassung mit den Wegweisern der alten und antwortet mit 500, obwohl der Code richtig ist.
|
||||
8. **Migrationen ausführen.** Verwaltung → System → Karte „Migrationen stehen aus" → Knopf. Die Karte erscheint nur, wenn welche ausstehen. Wer eine Konsole hat, kann stattdessen `php vendor/bin/doctrine-migrations migrate --no-interaction` aufrufen.
|
||||
9. **Wartungszustand aus**, Startseite und Verwaltung einmal aufrufen, Fassungsnummer im Bildschirm „System" prüfen.
|
||||
|
||||
Das ist einmalig: Ab 1.4.3 kennt jede Installation den Weg über das Archiv, und dann entfällt das alles. Dann steht im selben Bildschirm die Karte „Update aus dem Releasearchiv", und die Schritte 2 bis 8 erledigt sie selbst.
|
||||
|
||||
## Ein Release veröffentlichen
|
||||
|
||||
`php scripts/build-release.php` baut auf einem Rechner mit Composer das Archiv – Anwendung plus `vendor/` ohne Entwicklungspakete – und schreibt die SHA-256-Summe in eine Kopie von `install.php`. Beides gehört anschließend an das Git-Release des passenden Tags:
|
||||
|
||||
```
|
||||
var/releases/kantsites-<fassung>.zip → Release-Anhang
|
||||
var/releases/kantsites-<fassung>.zip.sha256 → Release-Anhang
|
||||
var/releases/install.php → Release-Anhang
|
||||
```
|
||||
|
||||
`install.php` erwartet das Archiv unter `https://git.kant-it.de/kantarci/KantSites/releases/download/v<fassung>/kantsites-<fassung>.zip`. Genau dort landet es, wenn es als Anhang am Release dieses Tags hängt – eine andere Adresse lässt sich beim Bauen mit `--url=` setzen.
|
||||
|
||||
**Ein privates Repository liefert diese Adresse nicht aus.** Ohne Anmeldung antwortet sie mit 404, und `install.php` kann nichts herunterladen; das ist der Zweck der Prüfsumme, nicht ihr Fehler. Damit die Installation auf einem fremden Hosting funktioniert, müssen Archiv und Installer ohne Anmeldung erreichbar sein – entweder weil das Repository öffentlich ist, oder weil beide Dateien auf einem eigenen Webspace liegen und das Release mit `--url=` darauf gebaut wurde.
|
||||
|
||||
## Offen, bevor das gebaut wird
|
||||
|
||||
1. **Composer auf dem Server oder `vendor/` im Tag?** Hängt am Hosting aus Arbeitspaket J.
|
||||
1. ~~**Composer auf dem Server oder `vendor/` im Tag?**~~ Entschieden: `vendor/` gehört nicht ins Repository, sondern in ein Releasearchiv, das ein Build-Rechner erzeugt (`scripts/build-release.php`, SHA-256 daneben). Der Updater über Git bleibt für Installationen mit Composer und für Updates ohne Abhängigkeitsänderung.
|
||||
2. **Wie kommt der Code auf den Server?** Git-Klon im Projektstamm setzt Shell-Zugriff voraus. Gibt es den nicht, ist der Updater in dieser Form nicht möglich, und der Weg ist ein Deployment von außen (rsync eines Releaseverzeichnisses, Umschalten eines Symlinks). Das ist derselbe Ablauf, nur mit einem anderen Schritt 5.
|
||||
3. **Ein Repository oder zwei?** Für den Piloten reicht eines. Sobald mehrere Websites dasselbe CMS nutzen, gehört der Code in ein eigenes Repository und die Inhalte bleiben je Installation.
|
||||
|
||||
@@ -75,4 +124,16 @@ Der teuerste Fall ist ein Update, das durchläuft und erst Tage später als fals
|
||||
- **Die Datenbank wird nur zurückgeschrieben, wenn die Migration schon begonnen hatte.** Sonst würde der Rückweg Inhalte verlieren, die während des Updates entstanden sind.
|
||||
- **Der Rauchtest ist `cms:health`** und läuft als eigener Prozess, weil nur ein frischer Prozess den neuen Code ausführt.
|
||||
- **Noch offen:** MySQL wird gesichert, indem `mysqldump` aufgerufen wird – das ist **nicht** umgesetzt, weil noch keine MySQL-Installation existiert (Arbeitspaket J). Bei einer Datenbank, die nicht als Datei vorliegt, sichert der Updater derzeit nur die Umgebungsdateien und sagt das im Schritt „Sicherung“. Vor dem ersten Livegang mit MySQL muss das nachgezogen werden.
|
||||
- **Ebenfalls offen:** der Installer aus Arbeitspaket N. Die Systemprüfung, die er braucht, existiert jetzt.
|
||||
- **Inzwischen umgesetzt:** der Installer (`/install`, ADR 041) und der Weg ohne Git über das Releasearchiv (`scripts/install.php`, System → Update aus dem Archiv). Offen bleibt allein die MySQL-Sicherung.
|
||||
|
||||
## Übergang auf 1.7.0: `.env` gehört zur Installation
|
||||
|
||||
Der Archiv-Updater erhält die Konfiguration auch bei diesem Übergang. Der erste Start führt eine bestehende `.env.local` mit `.env` zusammen und sichert beide Originale in `var/backups/environment-*`. Danach ist allein `.env` maßgeblich; tatsächliche Servervariablen bleiben möglich. Datenbank, Medienordner und Secret bleiben die bisher wirksamen Werte. Siehe ADR 076 und README.
|
||||
|
||||
In alten Git-Installationen war `.env` noch versioniert. Beim erstmaligen Wechsel kann ein alter Git-Updater diese neue Schutzlogik noch nicht besitzen. Nutze dafür den Archiv-Updater. Bei einem manuellen Git-Checkout zuerst `.env` und gegebenenfalls `.env.local` außerhalb des Checkout-Verzeichnisses sichern, beide nach dem Checkout wiederherstellen und erst danach Setup/Migration/Cache starten. Ab 1.7.0 erhält der Git-Updater die Dateien selbst vor dem ersten neuen PHP-Prozess. `.env` nicht erneut ins Repository aufnehmen.
|
||||
|
||||
Bei FTP-Updates die `.env` aus dem neuen Archiv überspringen. Die Vorlage im Archiv ist ausschließlich für Erstinstallationen bestimmt. Ein Rückweg benötigt den alten Code plus die beiden gesicherten Konfigurationsdateien.
|
||||
|
||||
## MySQL und MariaDB (seit 16.09.2026, ADR 104)
|
||||
|
||||
Sicherung und Wiederherstellung brauchen auf einer MySQL-Installation die Clients `mysqldump` und `mysql` im Pfad des PHP-Prozesses (auf geteiltem Hosting beim Anbieter erfragen; ein voller Pfad lässt sich nicht eintragen, aber `mysqldump.phar`/`mysql.phar` im Projektstamm würden gefunden). Die Sicherung schreibt `datenbank.sql` mit `--single-transaction`; die Wiederherstellung spielt sie mit `mysql --execute=source` ein. Fehlt der Client zum Einspielen, sagt die Wiederherstellung das und nennt den Pfad des Dumps. Der Archiv-Updater und der Git-Updater sichern vorher über denselben Weg und spielen beim Rückweg dieselbe Sicherung ein. Beide Wege sperren sich gegenseitig über `var/update.lock`; ein zweiter Aufruf bekommt die Meldung „Ein anderes Update läuft gerade“.
|
||||
@@ -0,0 +1,434 @@
|
||||
[
|
||||
{
|
||||
"path": "/",
|
||||
"profile": "mobile",
|
||||
"scores": {
|
||||
"performance": 0.99,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"settings": {
|
||||
"output": "json",
|
||||
"maxWaitForFcp": 30000,
|
||||
"maxWaitForLoad": 45000,
|
||||
"pauseAfterFcpMs": 1000,
|
||||
"pauseAfterLoadMs": 1000,
|
||||
"networkQuietThresholdMs": 1000,
|
||||
"cpuQuietThresholdMs": 1000,
|
||||
"formFactor": "mobile",
|
||||
"throttling": {
|
||||
"rttMs": 150,
|
||||
"throughputKbps": 1638.4,
|
||||
"requestLatencyMs": 562.5,
|
||||
"downloadThroughputKbps": 1474.5600000000002,
|
||||
"uploadThroughputKbps": 675,
|
||||
"cpuSlowdownMultiplier": 4
|
||||
},
|
||||
"throttlingMethod": "simulate",
|
||||
"screenEmulation": {
|
||||
"mobile": true,
|
||||
"width": 412,
|
||||
"height": 823,
|
||||
"deviceScaleFactor": 1.75,
|
||||
"disabled": false
|
||||
},
|
||||
"emulatedUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36",
|
||||
"auditMode": false,
|
||||
"gatherMode": false,
|
||||
"clearStorageTypes": [
|
||||
"file_systems",
|
||||
"shader_cache",
|
||||
"service_workers",
|
||||
"cache_storage"
|
||||
],
|
||||
"disableStorageReset": false,
|
||||
"debugNavigation": false,
|
||||
"channel": "node",
|
||||
"usePassiveGathering": false,
|
||||
"disableFullPageScreenshot": false,
|
||||
"skipAboutBlank": false,
|
||||
"blankPage": "about:blank",
|
||||
"ignoreStatusCode": false,
|
||||
"locale": "en-US",
|
||||
"blockedUrlPatterns": null,
|
||||
"additionalTraceCategories": null,
|
||||
"extraHeaders": null,
|
||||
"precomputedLanternData": null,
|
||||
"onlyAudits": null,
|
||||
"onlyCategories": null,
|
||||
"skipAudits": null
|
||||
},
|
||||
"environment": {
|
||||
"networkUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Mobile Safari/537.36",
|
||||
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||
"benchmarkIndex": 5208.5,
|
||||
"credits": {
|
||||
"axe-core": "4.13.0"
|
||||
}
|
||||
},
|
||||
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||
},
|
||||
{
|
||||
"path": "/",
|
||||
"profile": "desktop",
|
||||
"scores": {
|
||||
"performance": 0.97,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"settings": {
|
||||
"output": "json",
|
||||
"maxWaitForFcp": 30000,
|
||||
"maxWaitForLoad": 45000,
|
||||
"pauseAfterFcpMs": 1000,
|
||||
"pauseAfterLoadMs": 1000,
|
||||
"networkQuietThresholdMs": 1000,
|
||||
"cpuQuietThresholdMs": 1000,
|
||||
"formFactor": "desktop",
|
||||
"throttling": {
|
||||
"rttMs": 40,
|
||||
"throughputKbps": 10240,
|
||||
"requestLatencyMs": 0,
|
||||
"downloadThroughputKbps": 0,
|
||||
"uploadThroughputKbps": 0,
|
||||
"cpuSlowdownMultiplier": 1
|
||||
},
|
||||
"throttlingMethod": "simulate",
|
||||
"screenEmulation": {
|
||||
"mobile": false,
|
||||
"width": 1350,
|
||||
"height": 940,
|
||||
"deviceScaleFactor": 1,
|
||||
"disabled": false
|
||||
},
|
||||
"emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||
"auditMode": false,
|
||||
"gatherMode": false,
|
||||
"clearStorageTypes": [
|
||||
"file_systems",
|
||||
"shader_cache",
|
||||
"service_workers",
|
||||
"cache_storage"
|
||||
],
|
||||
"disableStorageReset": false,
|
||||
"debugNavigation": false,
|
||||
"channel": "node",
|
||||
"usePassiveGathering": false,
|
||||
"disableFullPageScreenshot": false,
|
||||
"skipAboutBlank": false,
|
||||
"blankPage": "about:blank",
|
||||
"ignoreStatusCode": false,
|
||||
"locale": "en-US",
|
||||
"blockedUrlPatterns": null,
|
||||
"additionalTraceCategories": null,
|
||||
"extraHeaders": null,
|
||||
"precomputedLanternData": null,
|
||||
"onlyAudits": null,
|
||||
"onlyCategories": null,
|
||||
"skipAudits": null
|
||||
},
|
||||
"environment": {
|
||||
"networkUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
|
||||
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||
"benchmarkIndex": 5277.5,
|
||||
"credits": {
|
||||
"axe-core": "4.13.0"
|
||||
}
|
||||
},
|
||||
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||
},
|
||||
{
|
||||
"path": "/projekte",
|
||||
"profile": "mobile",
|
||||
"scores": {
|
||||
"performance": 1,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"settings": {
|
||||
"output": "json",
|
||||
"maxWaitForFcp": 30000,
|
||||
"maxWaitForLoad": 45000,
|
||||
"pauseAfterFcpMs": 1000,
|
||||
"pauseAfterLoadMs": 1000,
|
||||
"networkQuietThresholdMs": 1000,
|
||||
"cpuQuietThresholdMs": 1000,
|
||||
"formFactor": "mobile",
|
||||
"throttling": {
|
||||
"rttMs": 150,
|
||||
"throughputKbps": 1638.4,
|
||||
"requestLatencyMs": 562.5,
|
||||
"downloadThroughputKbps": 1474.5600000000002,
|
||||
"uploadThroughputKbps": 675,
|
||||
"cpuSlowdownMultiplier": 4
|
||||
},
|
||||
"throttlingMethod": "simulate",
|
||||
"screenEmulation": {
|
||||
"mobile": true,
|
||||
"width": 412,
|
||||
"height": 823,
|
||||
"deviceScaleFactor": 1.75,
|
||||
"disabled": false
|
||||
},
|
||||
"emulatedUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36",
|
||||
"auditMode": false,
|
||||
"gatherMode": false,
|
||||
"clearStorageTypes": [
|
||||
"file_systems",
|
||||
"shader_cache",
|
||||
"service_workers",
|
||||
"cache_storage"
|
||||
],
|
||||
"disableStorageReset": false,
|
||||
"debugNavigation": false,
|
||||
"channel": "node",
|
||||
"usePassiveGathering": false,
|
||||
"disableFullPageScreenshot": false,
|
||||
"skipAboutBlank": false,
|
||||
"blankPage": "about:blank",
|
||||
"ignoreStatusCode": false,
|
||||
"locale": "en-US",
|
||||
"blockedUrlPatterns": null,
|
||||
"additionalTraceCategories": null,
|
||||
"extraHeaders": null,
|
||||
"precomputedLanternData": null,
|
||||
"onlyAudits": null,
|
||||
"onlyCategories": null,
|
||||
"skipAudits": null
|
||||
},
|
||||
"environment": {
|
||||
"networkUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Mobile Safari/537.36",
|
||||
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||
"benchmarkIndex": 5231,
|
||||
"credits": {
|
||||
"axe-core": "4.13.0"
|
||||
}
|
||||
},
|
||||
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||
},
|
||||
{
|
||||
"path": "/projekte",
|
||||
"profile": "desktop",
|
||||
"scores": {
|
||||
"performance": 1,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"settings": {
|
||||
"output": "json",
|
||||
"maxWaitForFcp": 30000,
|
||||
"maxWaitForLoad": 45000,
|
||||
"pauseAfterFcpMs": 1000,
|
||||
"pauseAfterLoadMs": 1000,
|
||||
"networkQuietThresholdMs": 1000,
|
||||
"cpuQuietThresholdMs": 1000,
|
||||
"formFactor": "desktop",
|
||||
"throttling": {
|
||||
"rttMs": 40,
|
||||
"throughputKbps": 10240,
|
||||
"requestLatencyMs": 0,
|
||||
"downloadThroughputKbps": 0,
|
||||
"uploadThroughputKbps": 0,
|
||||
"cpuSlowdownMultiplier": 1
|
||||
},
|
||||
"throttlingMethod": "simulate",
|
||||
"screenEmulation": {
|
||||
"mobile": false,
|
||||
"width": 1350,
|
||||
"height": 940,
|
||||
"deviceScaleFactor": 1,
|
||||
"disabled": false
|
||||
},
|
||||
"emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||
"auditMode": false,
|
||||
"gatherMode": false,
|
||||
"clearStorageTypes": [
|
||||
"file_systems",
|
||||
"shader_cache",
|
||||
"service_workers",
|
||||
"cache_storage"
|
||||
],
|
||||
"disableStorageReset": false,
|
||||
"debugNavigation": false,
|
||||
"channel": "node",
|
||||
"usePassiveGathering": false,
|
||||
"disableFullPageScreenshot": false,
|
||||
"skipAboutBlank": false,
|
||||
"blankPage": "about:blank",
|
||||
"ignoreStatusCode": false,
|
||||
"locale": "en-US",
|
||||
"blockedUrlPatterns": null,
|
||||
"additionalTraceCategories": null,
|
||||
"extraHeaders": null,
|
||||
"precomputedLanternData": null,
|
||||
"onlyAudits": null,
|
||||
"onlyCategories": null,
|
||||
"skipAudits": null
|
||||
},
|
||||
"environment": {
|
||||
"networkUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
|
||||
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||
"benchmarkIndex": 5272.5,
|
||||
"credits": {
|
||||
"axe-core": "4.13.0"
|
||||
}
|
||||
},
|
||||
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||
},
|
||||
{
|
||||
"path": "/impressum",
|
||||
"profile": "mobile",
|
||||
"scores": {
|
||||
"performance": 1,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"settings": {
|
||||
"output": "json",
|
||||
"maxWaitForFcp": 30000,
|
||||
"maxWaitForLoad": 45000,
|
||||
"pauseAfterFcpMs": 1000,
|
||||
"pauseAfterLoadMs": 1000,
|
||||
"networkQuietThresholdMs": 1000,
|
||||
"cpuQuietThresholdMs": 1000,
|
||||
"formFactor": "mobile",
|
||||
"throttling": {
|
||||
"rttMs": 150,
|
||||
"throughputKbps": 1638.4,
|
||||
"requestLatencyMs": 562.5,
|
||||
"downloadThroughputKbps": 1474.5600000000002,
|
||||
"uploadThroughputKbps": 675,
|
||||
"cpuSlowdownMultiplier": 4
|
||||
},
|
||||
"throttlingMethod": "simulate",
|
||||
"screenEmulation": {
|
||||
"mobile": true,
|
||||
"width": 412,
|
||||
"height": 823,
|
||||
"deviceScaleFactor": 1.75,
|
||||
"disabled": false
|
||||
},
|
||||
"emulatedUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Mobile Safari/537.36",
|
||||
"auditMode": false,
|
||||
"gatherMode": false,
|
||||
"clearStorageTypes": [
|
||||
"file_systems",
|
||||
"shader_cache",
|
||||
"service_workers",
|
||||
"cache_storage"
|
||||
],
|
||||
"disableStorageReset": false,
|
||||
"debugNavigation": false,
|
||||
"channel": "node",
|
||||
"usePassiveGathering": false,
|
||||
"disableFullPageScreenshot": false,
|
||||
"skipAboutBlank": false,
|
||||
"blankPage": "about:blank",
|
||||
"ignoreStatusCode": false,
|
||||
"locale": "en-US",
|
||||
"blockedUrlPatterns": null,
|
||||
"additionalTraceCategories": null,
|
||||
"extraHeaders": null,
|
||||
"precomputedLanternData": null,
|
||||
"onlyAudits": null,
|
||||
"onlyCategories": null,
|
||||
"skipAudits": null
|
||||
},
|
||||
"environment": {
|
||||
"networkUserAgent": "Mozilla/5.0 (Linux; Android 11; moto g power (2022)) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Mobile Safari/537.36",
|
||||
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||
"benchmarkIndex": 5205,
|
||||
"credits": {
|
||||
"axe-core": "4.13.0"
|
||||
}
|
||||
},
|
||||
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||
},
|
||||
{
|
||||
"path": "/impressum",
|
||||
"profile": "desktop",
|
||||
"scores": {
|
||||
"performance": 1,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"settings": {
|
||||
"output": "json",
|
||||
"maxWaitForFcp": 30000,
|
||||
"maxWaitForLoad": 45000,
|
||||
"pauseAfterFcpMs": 1000,
|
||||
"pauseAfterLoadMs": 1000,
|
||||
"networkQuietThresholdMs": 1000,
|
||||
"cpuQuietThresholdMs": 1000,
|
||||
"formFactor": "desktop",
|
||||
"throttling": {
|
||||
"rttMs": 40,
|
||||
"throughputKbps": 10240,
|
||||
"requestLatencyMs": 0,
|
||||
"downloadThroughputKbps": 0,
|
||||
"uploadThroughputKbps": 0,
|
||||
"cpuSlowdownMultiplier": 1
|
||||
},
|
||||
"throttlingMethod": "simulate",
|
||||
"screenEmulation": {
|
||||
"mobile": false,
|
||||
"width": 1350,
|
||||
"height": 940,
|
||||
"deviceScaleFactor": 1,
|
||||
"disabled": false
|
||||
},
|
||||
"emulatedUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36",
|
||||
"auditMode": false,
|
||||
"gatherMode": false,
|
||||
"clearStorageTypes": [
|
||||
"file_systems",
|
||||
"shader_cache",
|
||||
"service_workers",
|
||||
"cache_storage"
|
||||
],
|
||||
"disableStorageReset": false,
|
||||
"debugNavigation": false,
|
||||
"channel": "node",
|
||||
"usePassiveGathering": false,
|
||||
"disableFullPageScreenshot": false,
|
||||
"skipAboutBlank": false,
|
||||
"blankPage": "about:blank",
|
||||
"ignoreStatusCode": false,
|
||||
"locale": "en-US",
|
||||
"blockedUrlPatterns": null,
|
||||
"additionalTraceCategories": null,
|
||||
"extraHeaders": null,
|
||||
"precomputedLanternData": null,
|
||||
"onlyAudits": null,
|
||||
"onlyCategories": null,
|
||||
"skipAudits": null
|
||||
},
|
||||
"environment": {
|
||||
"networkUserAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/153.0.0.0 Safari/537.36",
|
||||
"hostUserAgent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/153.0.8010.12 Safari/537.36",
|
||||
"benchmarkIndex": 5179.5,
|
||||
"credits": {
|
||||
"axe-core": "4.13.0"
|
||||
}
|
||||
},
|
||||
"note": "Local prototype only. No customer-site acceptance or field-data claim."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,106 @@
|
||||
[
|
||||
{
|
||||
"path": "/",
|
||||
"profile": "mobile",
|
||||
"scores": {
|
||||
"performance": 0.98,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
|
||||
},
|
||||
{
|
||||
"path": "/",
|
||||
"profile": "desktop",
|
||||
"scores": {
|
||||
"performance": 0.96,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
|
||||
},
|
||||
{
|
||||
"path": "/projekte",
|
||||
"profile": "mobile",
|
||||
"scores": {
|
||||
"performance": 1,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
|
||||
},
|
||||
{
|
||||
"path": "/projekte",
|
||||
"profile": "desktop",
|
||||
"scores": {
|
||||
"performance": 1,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
|
||||
},
|
||||
{
|
||||
"path": "/simplescan",
|
||||
"profile": "mobile",
|
||||
"scores": {
|
||||
"performance": 1,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
|
||||
},
|
||||
{
|
||||
"path": "/simplescan",
|
||||
"profile": "desktop",
|
||||
"scores": {
|
||||
"performance": 1,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
|
||||
},
|
||||
{
|
||||
"path": "/impressum",
|
||||
"profile": "mobile",
|
||||
"scores": {
|
||||
"performance": 1,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
|
||||
},
|
||||
{
|
||||
"path": "/impressum",
|
||||
"profile": "desktop",
|
||||
"scores": {
|
||||
"performance": 1,
|
||||
"accessibility": 1,
|
||||
"best-practices": 1,
|
||||
"seo": 1,
|
||||
"agentic-browsing": 1
|
||||
},
|
||||
"lighthouseVersion": "13.4.1",
|
||||
"note": "Lokale Messung gegen den Nachbau von kant-it.de im Produktionsmodus, PHP-Entwicklungsserver mit denselben Kopfzeilen wie public/.htaccess. Keine Felddaten."
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,9 @@
|
||||
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
|
||||
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
@@ -0,0 +1,34 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260912001300 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Per page choice of header and footer area, including none at all.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Columns only: on SQLite an index change rebuilds the table, and this one holds the
|
||||
// pages. NULL means "the standard area", the string 'none' means no area at all, and any
|
||||
// other value is the address of another header or footer.
|
||||
$page = $schema->getTable('page');
|
||||
$page->addColumn('header_area', 'string', ['length' => 80, 'notnull' => false]);
|
||||
$page->addColumn('footer_area', 'string', ['length' => 80, 'notnull' => false]);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->throwIrreversibleMigrationException('Dropping these would silently put the standard header back onto landing pages that deliberately have none. Restore a verified backup with matching code.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260913000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'A separate title for search results, independent of the page title.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Der Titel in der Suche muss nicht der Titel der Seite sein: auf der Seite steht
|
||||
// "Leistungen", im Suchergebnis hilft "IT-Leistungen für Mittelbaden – Kant-IT". NULL
|
||||
// heisst: der Titel des Dokuments gilt, wie bisher.
|
||||
$schema->getTable('page')->addColumn('seo_title', 'string', ['length' => 160, 'notnull' => false]);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$this->throwIrreversibleMigrationException('Dropping this would silently discard every title written for search results. Restore a verified backup with matching code.');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260914000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Der Aufgabenplan: eine Zeile je wiederkehrender Aufgabe, mit Faelligkeit, Sperre und letztem Ergebnis.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Warum eine Tabelle und keine Datei: die Sperre. Zwei Besucher koennen im selben
|
||||
// Moment dieselbe Aufgabe faellig sehen, und nur einer darf sie ausfuehren. Ein
|
||||
// bedingtes UPDATE ist auf MySQL wie auf SQLite atomar; zwei Prozesse, die eine Datei
|
||||
// sperren, sind es auf geteiltem Hosting nicht zuverlaessig.
|
||||
//
|
||||
// Die Tabelle bleibt klein - eine Zeile je registrierter Aufgabe, heute fuenf. Deshalb
|
||||
// steht hier kein Index: ein voller Durchlauf ueber fuenf Zeilen ist billiger als ein
|
||||
// Index, den jede Aenderung mitpflegen muss.
|
||||
$tasks = $schema->createTable('scheduled_task');
|
||||
// Der technische Name aus dem Code, zum Beispiel `publish-due`. Er ist der Schluessel:
|
||||
// verschwindet eine Aufgabe aus dem Code, verschwindet ihre Zeile beim naechsten Abgleich.
|
||||
$tasks->addColumn('name', 'string', ['length' => 64]);
|
||||
// Der Abstand zwischen zwei Laeufen. Kommt als Vorgabe aus dem Code und ist in der
|
||||
// Verwaltung aenderbar, weil eine taegliche Sicherung auf der einen Installation
|
||||
// richtig und auf der naechsten zu selten ist.
|
||||
$tasks->addColumn('interval_seconds', 'integer', ['default' => 3600]);
|
||||
$tasks->addColumn('enabled', 'boolean', ['default' => true]);
|
||||
// Alle Zeitpunkte in UTC, als Zeichenkette verglichen - wie ueberall sonst im Projekt.
|
||||
$tasks->addColumn('next_run_at', 'datetime_immutable');
|
||||
$tasks->addColumn('last_run_at', 'datetime_immutable', ['notnull' => false]);
|
||||
// Bis wann diese Aufgabe als "laeuft gerade" gilt. Danach darf sie ein anderer
|
||||
// Durchlauf uebernehmen: ein Prozess, den das Hosting nach 30 Sekunden abgeschossen
|
||||
// hat, kann seine Sperre nicht mehr selbst aufraeumen.
|
||||
$tasks->addColumn('locked_until', 'datetime_immutable', ['notnull' => false]);
|
||||
$tasks->addColumn('last_status', 'string', ['length' => 16, 'notnull' => false]);
|
||||
$tasks->addColumn('last_message', 'string', ['length' => 500, 'default' => '']);
|
||||
$tasks->addColumn('last_duration_ms', 'integer', ['default' => 0]);
|
||||
// Wodurch der letzte Lauf ausgeloest wurde: besuch, verwaltung, cron, konsole, hand.
|
||||
// Steht hier, weil die erste Frage bei einer Stoerung immer dieselbe ist: laeuft der
|
||||
// Ausloeser ueberhaupt, den der Betreiber eingerichtet zu haben glaubt?
|
||||
$tasks->addColumn('last_trigger', 'string', ['length' => 16, 'default' => '']);
|
||||
// Aufeinanderfolgende Fehlschlaege. Sie verlaengern den Abstand bis zum naechsten
|
||||
// Versuch, damit eine Aufgabe, die nicht laufen kann, nicht bei jedem Seitenaufruf
|
||||
// wieder anfaengt.
|
||||
$tasks->addColumn('failures', 'integer', ['default' => 0]);
|
||||
$tasks->setPrimaryKey(['name']);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Faellt die Tabelle, faellt nur der Plan: Zeitpunkte und letzte Meldungen. Kein Inhalt,
|
||||
// kein Konto, keine Datei. Beim naechsten Start legt der Abgleich sie neu an.
|
||||
$schema->dropTable('scheduled_task');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260915000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Plugins: eine Zeile je Erweiterung mit dem Schalter, ob sie aktiv ist.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Was ein Plugin kann, steht in seinem Manifest im Ordner plugins/. Hier steht nur, ob der
|
||||
// Betreiber es eingeschaltet hat - eine Entscheidung der Installation, nicht des Codes,
|
||||
// und deshalb in der Datenbank statt in einer Datei, die ein Update ueberschreiben koennte.
|
||||
$plugin = $schema->createTable('plugin');
|
||||
$plugin->addColumn('name', 'string', ['length' => 40]);
|
||||
$plugin->addColumn('active', 'boolean', ['default' => false]);
|
||||
$plugin->addColumn('activated_at', 'datetime_immutable', ['notnull' => false]);
|
||||
// Die Fassung, mit der es zuletzt eingeschaltet wurde - damit die Verwaltung sagen kann,
|
||||
// dass ein Plugin seit dem Einschalten aktualisiert wurde.
|
||||
$plugin->addColumn('version', 'string', ['length' => 32, 'default' => '']);
|
||||
$plugin->setPrimaryKey(['name']);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$schema->dropTable('plugin');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260916000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Plugins: die Einstellungen je Erweiterung, wie sie der Betreiber in der Verwaltung setzt.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Was ein Plugin einstellbar macht, steht in seinem Manifest; was der Betreiber gewaehlt
|
||||
// hat, hier - als JSON, weil jedes Plugin andere Schluessel hat. Eine Spalte, kein
|
||||
// Tabellenneubau: auf SQLite ist ADD COLUMN eine Zeile im Katalog.
|
||||
$schema->getTable('plugin')->addColumn('settings', 'text', ['default' => '{}']);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$schema->getTable('plugin')->dropColumn('settings');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20260917000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Dokumente: PDFs in der Mediathek, zum Verlinken und fuer den Betrachter.';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Eine eigene Tabelle statt einer Art-Spalte in media: jede Stelle, die ein Bild sucht,
|
||||
// erwartet Varianten mit Breite und Hoehe. Ein PDF dort waere ein Bild ohne Bild - an
|
||||
// einem Dutzend Stellen. Getrennt kann keine Bildauswahl je ein Dokument finden.
|
||||
$document = $schema->createTable('document');
|
||||
$document->addColumn('id', 'string', ['length' => 32]);
|
||||
$document->addColumn('title', 'string', ['length' => 160]);
|
||||
$document->addColumn('bytes', 'integer');
|
||||
$document->addColumn('archived', 'boolean', ['default' => false]);
|
||||
$document->addColumn('version', 'integer', ['default' => 1]);
|
||||
$document->addColumn('created_at', 'datetime_immutable');
|
||||
$document->setPrimaryKey(['id']);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$schema->dropTable('document');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20261009000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Mehrsprachigkeit: die öffentlichen Adressen der übersetzten Seiten (ADR 111).';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Die Texte einer Übersetzung stehen im Dokument der Seite und laufen so mit Entwurf,
|
||||
// Veröffentlichung und Fassungen mit. Hier steht nur, was die Website zum Auffinden
|
||||
// braucht: unter welcher Adresse eine Seite in welcher Sprache veröffentlicht ist. Die
|
||||
// Zeilen entstehen beim Veröffentlichen und spiegeln genau den öffentlichen Stand.
|
||||
// Eine neue Tabelle, keine Änderung an `page`: auf SQLite baut jede Indexänderung an einer
|
||||
// bestehenden Tabelle sie neu auf (siehe ADR 010).
|
||||
$table = $schema->createTable('page_translation');
|
||||
$table->addColumn('page_id', 'string', ['length' => 32]);
|
||||
$table->addColumn('website_id', 'string', ['length' => 32]);
|
||||
$table->addColumn('locale', 'string', ['length' => 12]);
|
||||
$table->addColumn('slug', 'string', ['length' => 80]);
|
||||
$table->addColumn('path', 'string', ['length' => 400]);
|
||||
$table->setPrimaryKey(['page_id', 'locale']);
|
||||
$table->addUniqueIndex(['website_id', 'locale', 'path'], 'uniq_page_translation_path');
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
// Die Übersetzungen selbst bleiben in den Dokumenten; ohne diese Tabelle sind sie nur
|
||||
// nicht mehr öffentlich erreichbar.
|
||||
$schema->dropTable('page_translation');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
|
||||
namespace DoctrineMigrations;
|
||||
|
||||
use Doctrine\DBAL\Schema\Schema;
|
||||
use Doctrine\Migrations\AbstractMigration;
|
||||
|
||||
final class Version20261012000100 extends AbstractMigration
|
||||
{
|
||||
public function getDescription(): string
|
||||
{
|
||||
return 'Geplante Veröffentlichung: welche Fassung zum Termin online geht (ADR 116).';
|
||||
}
|
||||
|
||||
public function isTransactional(): bool
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
public function up(Schema $schema): void
|
||||
{
|
||||
// Bisher ging zum Termin der jeweils neueste Entwurf online - auch einer, den nach dem
|
||||
// Planen jemand ohne Recht zum Veröffentlichen gespeichert hatte. Jetzt merkt sich die
|
||||
// Planung die Fassung, die beim Planen vorlag. Eine Spalte, kein Tabellenneubau.
|
||||
$schema->getTable('page')->addColumn('publish_revision_id', 'string', ['length' => 32, 'notnull' => false, 'default' => null]);
|
||||
}
|
||||
|
||||
public function down(Schema $schema): void
|
||||
{
|
||||
$schema->getTable('page')->dropColumn('publish_revision_id');
|
||||
}
|
||||
}
|
||||
+19
-1
@@ -3,11 +3,29 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build:buttons": "node scripts/build-buttons.mjs",
|
||||
"test:buttons": "node scripts/button-check.mjs",
|
||||
"test:button-icons": "node scripts/button-icon-check.mjs",
|
||||
"test:menu-buttons": "node scripts/menu-button-check.mjs",
|
||||
"build:icons": "node scripts/build-icons.mjs",
|
||||
"test:navigation-ux": "node scripts/navigation-ux-check.mjs",
|
||||
"test:responsive-navigation": "node scripts/responsive-navigation-check.mjs",
|
||||
"build:design": "node scripts/build-design.mjs",
|
||||
"test": "node --test tests/*.test.mjs",
|
||||
"test:editor": "node scripts/editor-check.mjs",
|
||||
"test:browser": "node scripts/browser-check.mjs",
|
||||
"audit": "node scripts/audit.mjs"
|
||||
"test:links": "node scripts/link-check.mjs",
|
||||
"audit": "node scripts/audit.mjs",
|
||||
"test:admin": "node scripts/admin-quality-check.mjs",
|
||||
"test:search": "node scripts/live-search-check.mjs",
|
||||
"test:image-size": "node scripts/image-size-check.mjs",
|
||||
"test:image-alignment": "node scripts/image-alignment-check.mjs",
|
||||
"test:link-media": "node scripts/link-media-check.mjs",
|
||||
"test:404": "node scripts/not-found-check.mjs",
|
||||
"test:colors": "node scripts/color-picker-check.mjs",
|
||||
"test:accordion": "node scripts/accordion-check.mjs",
|
||||
"test:motion": "node scripts/motion-check.mjs",
|
||||
"test:archive-update": "node scripts/archive-update-check.mjs"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@axe-core/playwright": "^4.10.0",
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
# Dieser Ordner gehoert nicht ins Netz (ADR 116). Zeigt die Domain richtig auf public/, wird diese
|
||||
# Datei nie gelesen; zeigt sie versehentlich auf den Projektordner, sperrt sie hier alles.
|
||||
<IfModule mod_authz_core.c>
|
||||
Require all denied
|
||||
</IfModule>
|
||||
<IfModule !mod_authz_core.c>
|
||||
Order deny,allow
|
||||
Deny from all
|
||||
</IfModule>
|
||||
@@ -0,0 +1,23 @@
|
||||
/* Bildvergleich. Die Position kommt als --pos (Prozent) vom Skript; ohne Skript gilt die Mitte. */
|
||||
.block-bildvergleich{--pos:50%;margin:0}
|
||||
.bildvergleich-buehne{position:relative;margin:0;overflow:hidden;user-select:none;-webkit-user-select:none}
|
||||
.bildvergleich-rund .bildvergleich-buehne{border-radius:12px}
|
||||
.bildvergleich-buehne img{display:block;width:100%;height:auto;border-radius:0}
|
||||
.bildvergleich-vorher{position:absolute;inset:0;height:100%;object-fit:cover;clip-path:inset(0 calc(100% - var(--pos)) 0 0)}
|
||||
.bildvergleich-linie{position:absolute;top:0;bottom:0;left:var(--pos);width:2px;margin-left:-1px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.25);pointer-events:none}
|
||||
.bildvergleich-linie::after{content:'⇔';position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);width:44px;height:44px;border-radius:50%;background:#fff;color:#182d2b;display:grid;place-items:center;font-size:20px;box-shadow:0 4px 14px rgba(0,0,0,.3)}
|
||||
.bildvergleich-etikett{position:absolute;top:14px;padding:6px 10px;border-radius:6px;background:rgba(0,0,0,.55);color:#fff;font-size:12px;font-weight:700;letter-spacing:.08em;text-transform:uppercase;pointer-events:none}
|
||||
.bildvergleich-etikett-vorher{left:14px}
|
||||
.bildvergleich-etikett-nachher{right:14px}
|
||||
.bildvergleich-regler{position:absolute;inset:0;display:block;cursor:ew-resize}
|
||||
.bildvergleich-regler input{position:absolute;inset:0;width:100%;height:100%;margin:0;opacity:0;cursor:ew-resize}
|
||||
.bildvergleich-regler input:focus-visible{opacity:1;appearance:none;-webkit-appearance:none;background:transparent;outline:3px solid #fff;outline-offset:-6px}
|
||||
.block-bildvergleich .block-unavailable{margin:0}
|
||||
/* Griff als Punkt statt Pfeile (Plugin-Einstellung). */
|
||||
.bildvergleich-griff-punkt .bildvergleich-linie::after{content:'';width:22px;height:22px}
|
||||
/* Die Vergleichsgalerie: ein Raster aus Bildvergleichen. */
|
||||
.block-vergleichsgalerie{margin:0}
|
||||
.vergleichsgalerie-titel{margin:0 0 18px}
|
||||
.vergleichsgalerie-raster{display:grid;gap:20px;grid-template-columns:repeat(2,minmax(0,1fr))}
|
||||
.vergleichsgalerie-3 .vergleichsgalerie-raster{grid-template-columns:repeat(3,minmax(0,1fr))}
|
||||
@media(max-width:767px){.vergleichsgalerie-raster,.vergleichsgalerie-3 .vergleichsgalerie-raster{grid-template-columns:1fr}}
|
||||
@@ -0,0 +1,14 @@
|
||||
/**
|
||||
* Bildvergleich: der Regler setzt --pos auf der Bühne. Ohne dieses Skript steht der Vergleich
|
||||
* bei der gespeicherten Startposition; Tastatur und Vorlesesoftware bedienen das native
|
||||
* Bereichsfeld in beiden Fällen.
|
||||
*/
|
||||
(() => {
|
||||
for (const block of document.querySelectorAll('[data-bildvergleich]')) {
|
||||
const range = block.querySelector('input[type="range"]');
|
||||
if (!range) continue;
|
||||
const apply = () => block.style.setProperty('--pos', `${range.value}%`);
|
||||
range.addEventListener('input', apply);
|
||||
apply();
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,24 @@
|
||||
{# Bildvergleich: das Nachher-Bild liegt unten, das Vorher-Bild darüber und wird per clip-path
|
||||
auf den linken Teil beschnitten. Der Regler ist ein natives Bereichsfeld – ohne Skript bleibt
|
||||
er stehen, beide Bilder sind trotzdem zu sehen. Die Vorlage bekommt nur, was der Kern geprüft
|
||||
hat: `props` nach dem Schema, `settings` des Plugins, `block.id`, `designClasses`. #}
|
||||
{% set vorher = props.before ? kant_plugin_media(props.before) : null %}
|
||||
{% set nachher = props.after ? kant_plugin_media(props.after) : null %}
|
||||
{% set labelVorher = props.labelBefore ?: settings.labelBefore|default('Vorher') %}
|
||||
{% set labelNachher = props.labelAfter ?: settings.labelAfter|default('Nachher') %}
|
||||
<div id="{{ block.id }}" class="block-bildvergleich{{ props.rounded ? ' bildvergleich-rund' : '' }} bildvergleich-griff-{{ settings.handle|default('pfeile') }}{{ designClasses }}" data-bildvergleich data-position="{{ props.position }}">
|
||||
{% if vorher and nachher %}
|
||||
{% set gross = nachher.variants|last %}
|
||||
<figure class="bildvergleich-buehne">
|
||||
<img class="bildvergleich-nachher" src="{{ gross.src }}" srcset="{% for v in nachher.variants %}{{ v.src }} {{ v.width }}w{{ loop.last ? '' : ', ' }}{% endfor %}" sizes="(max-width: 1320px) 100vw, 1320px" width="{{ gross.width }}" height="{{ gross.height }}" alt="{{ props.alt ?: nachher.alt }}" loading="lazy" decoding="async">
|
||||
{% set grossVorher = vorher.variants|last %}
|
||||
<img class="bildvergleich-vorher" src="{{ grossVorher.src }}" srcset="{% for v in vorher.variants %}{{ v.src }} {{ v.width }}w{{ loop.last ? '' : ', ' }}{% endfor %}" sizes="(max-width: 1320px) 100vw, 1320px" width="{{ grossVorher.width }}" height="{{ grossVorher.height }}" alt="" loading="lazy" decoding="async">
|
||||
<span class="bildvergleich-etikett bildvergleich-etikett-vorher" aria-hidden="true">{{ labelVorher }}</span>
|
||||
<span class="bildvergleich-etikett bildvergleich-etikett-nachher" aria-hidden="true">{{ labelNachher }}</span>
|
||||
<span class="bildvergleich-linie" aria-hidden="true"></span>
|
||||
<label class="bildvergleich-regler"><span class="visually-hidden">{{ labelVorher }} und {{ labelNachher }} vergleichen: Regler verschieben</span><input type="range" min="0" max="100" value="{{ props.position }}" step="1"></label>
|
||||
</figure>
|
||||
{% else %}
|
||||
<p class="block-unavailable">{{ editing ? 'Bildvergleich: bitte unter Inhalt ein Bild für vorher und eines für nachher wählen.' : '' }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,10 @@
|
||||
{# Die Vergleichsgalerie: ein Container. Seine Kinder kommen fertig gerendert als `children`
|
||||
herein - der Kern hat sie mit demselben Kontext gerendert wie jeden anderen Baustein. #}
|
||||
<div id="{{ block.id }}" class="block-vergleichsgalerie vergleichsgalerie-{{ props.columns }}{{ designClasses }}">
|
||||
{% if props.title %}<h3 class="vergleichsgalerie-titel">{{ props.title }}</h3>{% endif %}
|
||||
{% if children|trim %}
|
||||
<div class="vergleichsgalerie-raster">{{ children }}</div>
|
||||
{% elseif editing %}
|
||||
<p class="block-unavailable">Vergleichsgalerie: Bildvergleiche hier hineinziehen.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "vergleich",
|
||||
"title": "Bildvergleich",
|
||||
"description": "Vorher und nachher in einem Bild: ein Regler schiebt zwischen zwei Aufnahmen – für Renovierungen, Malerarbeiten, Sanierungen.",
|
||||
"version": "1.1.0",
|
||||
"requires": "1.17.0",
|
||||
"settings": {
|
||||
"labelBefore": { "kind": "text", "label": "Standardbeschriftung vorher", "default": "Vorher", "maxLength": 40 },
|
||||
"labelAfter": { "kind": "text", "label": "Standardbeschriftung nachher", "default": "Nachher", "maxLength": 40 },
|
||||
"handle": { "kind": "select", "label": "Griff des Reglers", "choices": { "pfeile": "Pfeile", "punkt": "Punkt" }, "default": "pfeile" }
|
||||
},
|
||||
"blocks": {
|
||||
"bildvergleich": {
|
||||
"label": "Bildvergleich",
|
||||
"description": "Zwei Bilder übereinander, ein Regler dazwischen.",
|
||||
"icon": "⇔",
|
||||
"template": "blocks/bildvergleich.html.twig",
|
||||
"css": "assets/bildvergleich.css",
|
||||
"script": "assets/bildvergleich.js",
|
||||
"props": {
|
||||
"before": { "kind": "media", "label": "Bild vorher" },
|
||||
"after": { "kind": "media", "label": "Bild nachher" },
|
||||
"labelBefore": { "kind": "text", "label": "Beschriftung vorher", "help": "Leer: die Standardbeschriftung aus den Plugin-Einstellungen.", "maxLength": 40 },
|
||||
"labelAfter": { "kind": "text", "label": "Beschriftung nachher", "maxLength": 40 },
|
||||
"alt": { "kind": "text", "label": "Was die Bilder zeigen", "help": "Ein Satz für Vorlesesoftware, zum Beispiel „Fassade vor und nach dem Anstrich“.", "maxLength": 200 },
|
||||
"position": { "kind": "number", "label": "Startposition des Reglers (%)", "min": 5, "max": 95, "default": 50 },
|
||||
"rounded": { "kind": "boolean", "label": "Abgerundete Ecken", "default": true }
|
||||
}
|
||||
},
|
||||
"galerie": {
|
||||
"label": "Vergleichsgalerie",
|
||||
"description": "Mehrere Bildvergleiche nebeneinander im Raster.",
|
||||
"icon": "⊞",
|
||||
"template": "blocks/galerie.html.twig",
|
||||
"css": "assets/bildvergleich.css",
|
||||
"container": true,
|
||||
"children": ["vergleich.bildvergleich"],
|
||||
"props": {
|
||||
"columns": { "kind": "select", "label": "Spalten am Schreibtisch", "choices": { "2": "Zwei", "3": "Drei" }, "default": "2" },
|
||||
"title": { "kind": "text", "label": "Überschrift der Galerie", "maxLength": 120 }
|
||||
}
|
||||
}
|
||||
},
|
||||
"presets": {
|
||||
"abschnitt": {
|
||||
"label": "Vorher-Nachher-Abschnitt",
|
||||
"description": "Eine Sektion mit Überschrift, kurzem Text und einem Bildvergleich.",
|
||||
"blocks": [
|
||||
{
|
||||
"id": "vergleich-abschnitt", "type": "section", "layout": { "tone": "paper", "width": "normal" },
|
||||
"children": [
|
||||
{ "id": "vergleich-titel", "type": "heading", "props": { "text": "Vorher und nachher", "level": 2 } },
|
||||
{ "id": "vergleich-text", "type": "text", "props": { "text": "Ziehen Sie den Regler und sehen Sie den Unterschied." } },
|
||||
{ "id": "vergleich-bild", "type": "vergleich.bildvergleich", "props": { "before": "", "after": "", "labelBefore": "", "labelAfter": "", "alt": "", "position": 50, "rounded": true } }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"routes": {
|
||||
"daten": { "path": "/vergleich/daten", "template": "routes/daten.json.twig", "type": "json" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
{# Eine eigene Adresse des Plugins: die Einstellungen als JSON - fuer eine App oder ein Skript,
|
||||
das die Beschriftungen kennen will. Die Vorlage bekommt `settings`, `query` und `route`. #}
|
||||
{{ {plugin: 'vergleich', labels: {before: settings.labelBefore, after: settings.labelAfter}, handle: settings.handle, echo: query}|json_encode(constant('JSON_UNESCAPED_UNICODE') b-or constant('JSON_UNESCAPED_SLASHES'))|raw }}
|
||||
@@ -1,5 +1,91 @@
|
||||
DirectoryIndex index.php
|
||||
Options -Indexes -MultiViews
|
||||
|
||||
# Alles, was keine Datei ist, beantwortet PHP.
|
||||
#
|
||||
# Die Zeile fuer index.php selbst ist kein Zierrat: Bei IONOS erkennt !-f die bereits
|
||||
# umgeschriebene index.php nicht als Datei, die Regel greift wieder und wieder, und Apache bricht
|
||||
# nach zehn Runden mit einem weissen 500er ab (huefner-gartenbau.de, 12.10.2026). Dieselbe
|
||||
# Absicherung steht in der .htaccess von WordPress.
|
||||
RewriteEngine On
|
||||
RewriteBase /
|
||||
|
||||
# HTTPS erzwingen. Auskommentiert, weil diese Datei auch in Installationen ohne Zertifikat
|
||||
# liegt und eine Endlosschleife schlimmer waere als fehlendes HTTPS. Einschalten am besten in
|
||||
# der Verwaltung unter System -> "HTTPS erzwingen": dort wird vorher und nachher geprueft und
|
||||
# bei einer Schleife zurueckgenommen (ADR 120). Von Hand: die drei Zeilen einschalten - und erst
|
||||
# danach die HSTS-Zeile weiter unten, denn HSTS laesst sich in einem Browser ein Jahr lang nicht
|
||||
# zuruecknehmen.
|
||||
#
|
||||
# Die Zeilen stehen vor den Regeln fuer index.php, nicht dahinter: Nach dem Umschreiben auf
|
||||
# index.php beginnt die Runde neu, und "^index\.php$ - [L]" beendet sie, bevor eine spaetere
|
||||
# Regel an die Reihe kommt. Weiter unten griff die Umleitung nur fuer echte Dateien.
|
||||
#RewriteCond %{HTTPS} !=on
|
||||
#RewriteCond %{HTTP:X-Forwarded-Proto} !=https
|
||||
#RewriteRule ^ https://%{HTTP_HOST}%{REQUEST_URI} [R=301,L]
|
||||
|
||||
RewriteRule ^index\.php$ - [L]
|
||||
RewriteCond %{REQUEST_FILENAME} !-f
|
||||
RewriteRule ^ index.php [L]
|
||||
|
||||
# Eine Domain, eine Adresse - mit oder ohne www. Das erledigt die Anwendung selbst nach der
|
||||
# Einstellung SITE_ORIGIN in .env: die jeweils andere Schreibweise antwortet mit 301 dorthin.
|
||||
# Hier braucht es dafuer keine Regel; eine zweite Stelle wuerde nur auseinanderlaufen.
|
||||
|
||||
# Uebertragen wird komprimiert. HTML, CSS, JavaScript und XML schrumpfen dabei auf etwa ein
|
||||
# Viertel; Bilder und Schriften sind bereits komprimiert und bleiben aussen vor.
|
||||
<IfModule mod_deflate.c>
|
||||
AddOutputFilterByType DEFLATE text/html text/plain text/xml text/css
|
||||
AddOutputFilterByType DEFLATE application/javascript application/json application/xml
|
||||
AddOutputFilterByType DEFLATE application/rss+xml image/svg+xml
|
||||
</IfModule>
|
||||
<IfModule mod_brotli.c>
|
||||
AddOutputFilterByType BROTLI_COMPRESS text/html text/css application/javascript image/svg+xml
|
||||
</IfModule>
|
||||
|
||||
# Wie lange ein Browser eine Datei behalten darf.
|
||||
#
|
||||
# Bilder und Schriften tragen ihre Kennung im Namen: aendert sich der Inhalt, aendert sich die
|
||||
# Adresse. Sie duerfen deshalb ein Jahr bleiben. Stylesheets und Skripte tragen die Fassung als
|
||||
# Parameter, also ebenfalls ein Jahr. HTML wird nie zwischengespeichert - eine veroeffentlichte
|
||||
# Aenderung soll beim naechsten Aufruf sichtbar sein; die Seiten senden dafuer selbst no-store.
|
||||
<IfModule mod_expires.c>
|
||||
ExpiresActive On
|
||||
ExpiresByType image/webp "access plus 1 year"
|
||||
ExpiresByType image/png "access plus 1 year"
|
||||
ExpiresByType image/jpeg "access plus 1 year"
|
||||
ExpiresByType image/svg+xml "access plus 1 year"
|
||||
ExpiresByType font/woff2 "access plus 1 year"
|
||||
ExpiresByType text/css "access plus 1 year"
|
||||
ExpiresByType application/javascript "access plus 1 year"
|
||||
</IfModule>
|
||||
<IfModule mod_headers.c>
|
||||
<FilesMatch "\.(webp|png|jpe?g|gif|svg|woff2|css|js)$">
|
||||
Header set Cache-Control "public, max-age=31536000, immutable"
|
||||
</FilesMatch>
|
||||
# Schriften werden von derselben Adresse geladen; die Regel steht hier, damit sie auch dann
|
||||
# gilt, wenn eine Installation die Dateien ueber ein CDN spiegelt.
|
||||
<FilesMatch "\.woff2$">
|
||||
Header set Access-Control-Allow-Origin "*"
|
||||
</FilesMatch>
|
||||
# Was der Server ueber sich selbst erzaehlt, geht niemanden etwas an: die genaue PHP-Fassung
|
||||
# ist fuer einen Besucher wertlos und fuer einen Scanner eine Einkaufsliste.
|
||||
Header always unset X-Powered-By
|
||||
Header always unset X-AspNet-Version
|
||||
# Erst einschalten, wenn HTTPS steht und bleiben soll (siehe oben; System -> HSTS).
|
||||
#Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains"
|
||||
</IfModule>
|
||||
|
||||
# Nichts ausliefern, was nur intern gebraucht wird - falls jemand das Paket versehentlich
|
||||
# vollstaendig in den oeffentlichen Ordner legt statt nur public/.
|
||||
# .well-known ausgenommen: Unter /.well-known/kantsites-origin prueft die Verwaltung, ob die
|
||||
# Website unter einer Adresse hier ankommt (Adresse speichern, HTTPS erzwingen), und Apache
|
||||
# prueft den ersten nicht vorhandenen Pfadteil gegen diese Regel - vorher antwortete die
|
||||
# Probe mit 403. Zertifikatsanbieter (acme-challenge) brauchen den Ordner ebenso.
|
||||
<FilesMatch "^\.(?!well-known$)|\.(env|db|sqlite|lock|md|yaml|yml|dist|log)$">
|
||||
Require all denied
|
||||
</FilesMatch>
|
||||
# Eine Ausnahme mit Grund: der Editor liest die Liste der Gestaltungswerte als JSON.
|
||||
<Files "design-tokens.json">
|
||||
Require all granted
|
||||
</Files>
|
||||
+541
-58
@@ -1,3 +1,5 @@
|
||||
/* Der Strich aller Symbole aus icon.html.twig, wie auf der Website (site.css). */
|
||||
svg.block-icon{fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}
|
||||
/* Administration: shared foundation and the management screens.
|
||||
The editor has its own stylesheet (editor.css) that only the editor and the media page load. */
|
||||
|
||||
@@ -70,8 +72,13 @@ textarea { resize: vertical; min-height: 110px; line-height: 1.6 }
|
||||
label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
.field-help { font-size: 11px; line-height: 1.55; color: var(--muted); margin: 7px 0 0 }
|
||||
.field-help code, .card code { background: #eef2ea; padding: 1px 5px; border-radius: 4px; font-size: .95em }
|
||||
.check-label { display: flex; align-items: center; gap: 8px; font-weight: 500 }
|
||||
/* Nur Häkchen und Text sind klickbar: über die volle Breite schaltete ein Klick in die leere Mitte
|
||||
der Zeile das Häkchen um - in der Menüverwaltung klappten so die Button-Einstellungen zu. */
|
||||
.check-label { display: flex; align-items: center; gap: 8px; font-weight: 500; width: fit-content; max-width: 100% }
|
||||
.check-label input { width: 16px; min-height: 16px }
|
||||
/* Eine Gruppe von Häkchen nebeneinander, die auf schmalen Bildschirmen umbricht (Sprachen). */
|
||||
.check-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 10px 20px; border: 0; padding: 0; margin: 0 0 12px; min-width: 0 }
|
||||
.check-grid legend { font-weight: 600; margin-bottom: 8px; padding: 0 }
|
||||
/* A grid of fields that becomes one column when there is no room. */
|
||||
.fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 0 24px }
|
||||
.fields > div > label:first-child, .field > label:first-child { margin-top: 14px }
|
||||
@@ -144,34 +151,64 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
.nav-item:hover { background: #21362d }
|
||||
.nav-item[aria-current=page] { background: #2b4a3b; color: #fff; font-weight: 650 }
|
||||
.nav-item[aria-current=page] .nav-icon { color: var(--mint) }
|
||||
.nav-icon { width: 19px; text-align: center; font-size: 14px; color: var(--shell-muted); flex-shrink: 0 }
|
||||
.nav-icon { width: 18px; height: 18px; color: var(--shell-muted); flex-shrink: 0 }
|
||||
.nav-out::after { content: '↗'; margin-left: auto; color: var(--shell-muted); font-size: 11px }
|
||||
|
||||
.shell-foot { margin-top: auto; padding-top: 20px; border-top: 1px solid #2a3d35; display: grid; gap: 6px }
|
||||
.shell-user { display: flex; align-items: center; gap: 10px; padding: 8px 10px; font-size: 12px }
|
||||
.shell-user strong { font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
/* Der Fuss der Leiste: Kontokarte, Abmeldesymbol, Fassung. Eine Einheit statt dreier Zeilen. */
|
||||
.shell-foot { margin-top: auto; padding-top: 14px; border-top: 1px solid #2a3d35; display: grid; gap: 8px }
|
||||
.shell-account {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 5px;
|
||||
border-radius: 13px;
|
||||
background: #ffffff0b;
|
||||
border: 1px solid #ffffff10;
|
||||
}
|
||||
.shell-user {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 5px 7px;
|
||||
border-radius: 9px;
|
||||
text-decoration: none;
|
||||
color: var(--shell-ink);
|
||||
}
|
||||
.shell-user:hover, .shell-user[aria-current=page] { background: #ffffff12 }
|
||||
.shell-user[aria-current=page] .shell-avatar { box-shadow: 0 0 0 2px var(--mint) }
|
||||
.shell-user-text { display: grid; min-width: 0; line-height: 1.3 }
|
||||
.shell-user-text strong { font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.shell-user-text span { font-size: 11px; color: var(--shell-muted) }
|
||||
.shell-avatar {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 28px; height: 28px;
|
||||
width: 32px; height: 32px;
|
||||
border-radius: 50%;
|
||||
background: #2b4a3b;
|
||||
color: var(--mint);
|
||||
font-size: 12px;
|
||||
background: var(--mint);
|
||||
color: var(--shell);
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.shell-foot form { margin: 0 }
|
||||
.shell-foot button {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
.shell-logout {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 38px; height: 38px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 9px;
|
||||
background: transparent;
|
||||
color: var(--shell-muted);
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
min-height: 34px;
|
||||
padding: 6px 10px;
|
||||
}
|
||||
.shell-foot button:hover { background: #21362d; color: var(--shell-ink) }
|
||||
.shell-logout .nav-icon { color: inherit }
|
||||
.shell-logout:hover { background: #ffffff14; color: #fff }
|
||||
.shell-logout:focus-visible { outline-color: var(--mint); outline-offset: -2px }
|
||||
.shell-version { display: flex; flex-wrap: wrap; align-items: center; gap: 4px 10px; margin: 0; padding: 0 8px 2px; font-size: 11px; color: var(--shell-muted) }
|
||||
.shell-version a { text-decoration: none }
|
||||
.shell-version > a:hover { color: var(--shell-ink); text-decoration: underline }
|
||||
|
||||
.brand {
|
||||
display: inline-flex;
|
||||
@@ -211,13 +248,37 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
.shell-heading p { font-size: 13px; margin: 7px 0 0; max-width: 62ch }
|
||||
.shell-actions { display: flex; align-items: center; gap: 10px; flex-wrap: wrap }
|
||||
.shell-body { padding: 30px 38px 60px; max-width: 1180px; width: 100% }
|
||||
.stack { display: grid; gap: 20px }
|
||||
/* Eine Spalte, die nie breiter wird als der Bildschirm: ohne `minmax(0, 1fr)` waechst
|
||||
die automatische Rasterspalte auf die Mindestbreite ihres breitesten Inhalts - auf dem
|
||||
Handy schiebt eine einzige lange Pfadangabe dann die ganze Seite zur Seite. */
|
||||
.stack { display: grid; grid-template-columns: minmax(0, 1fr); gap: 20px }
|
||||
|
||||
.notice { padding: 13px 17px; border-radius: 8px; line-height: 1.6; font-size: 13px; margin: 0 }
|
||||
.notice.ok { background: #edf3dc; color: #2c5340; border: 1px solid #c5d89a }
|
||||
.error, .notice.error { background: #fff0eb; color: var(--danger); border: 1px solid #e2b2a7 }
|
||||
.shell-body > .notice { margin-bottom: 4px }
|
||||
|
||||
/* Der Hinweisstreifen ueber jedem Verwaltungsbildschirm: eine Sache, die getan werden will, und
|
||||
gleich daneben der Knopf, der sie tut. Warm statt rot - es ist eine Aufgabe, kein Fehler. */
|
||||
.shell-notice {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px 18px;
|
||||
margin: 0 0 4px;
|
||||
padding: 12px 14px 12px 17px;
|
||||
border: 1px solid #e3cf9b;
|
||||
border-radius: 10px;
|
||||
background: #fdf6e4;
|
||||
color: #5b4412;
|
||||
line-height: 1.6;
|
||||
font-size: 13px;
|
||||
}
|
||||
.shell-notice > span { min-width: 0; flex: 1 1 260px }
|
||||
.shell-notice .secondary { flex: none; border-color: #dcc48a; background: var(--paper); color: #5b4412 }
|
||||
.shell-notice .secondary:hover { background: #fbeecd; border-color: #cbb072 }
|
||||
|
||||
/* ---- cards and sections ---------------------------------------------------------------- */
|
||||
|
||||
.card { background: var(--paper); border: 1px solid var(--line); border-radius: 12px }
|
||||
@@ -232,7 +293,12 @@ label { display: block; margin: 18px 0 7px; font-size: 13px; font-weight: 600 }
|
||||
}
|
||||
.card-head h2, .card-head h3 { margin: 0 }
|
||||
.card-head .muted { font-size: 12px }
|
||||
.card-body { padding: 8px 22px 24px }
|
||||
.card-body { padding: 8px 22px 24px; min-width: 0 }
|
||||
.card-body code { overflow-wrap: anywhere }
|
||||
/* Das Kleingedruckte einer Karte: zugeklappt, damit auf einem Handy der Knopf oben bleibt. */
|
||||
.hint { margin: 12px 0 0 }
|
||||
.hint > summary { font-size: 12px; font-weight: 600; color: var(--muted); cursor: pointer; min-height: 44px; display: flex; align-items: center }
|
||||
.hint[open] > summary { margin-bottom: 2px }
|
||||
/* A heading for a group of rows, outside any card. */
|
||||
.section-head { display: flex; align-items: baseline; justify-content: space-between; gap: 8px 20px; flex-wrap: wrap; margin: 16px 2px -6px }
|
||||
.section-head h2 { margin: 0 }
|
||||
@@ -428,7 +494,7 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.table .check-cell input { width: 18px; min-height: 18px }
|
||||
.table-new input, .table-new select { background: #fafbf8 }
|
||||
.table form { margin: 0 }
|
||||
/* Column widths per screen: the two tables carry different things. */
|
||||
/* Column widths per screen: the menu table carries text, links and switches. */
|
||||
.menu-table td:first-child input { width: 82px }
|
||||
.menu-table td:nth-child(2) select { width: 148px }
|
||||
.menu-table td:nth-child(3) input { min-width: 170px }
|
||||
@@ -436,27 +502,23 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.menu-table td:nth-child(5) select { min-width: 190px }
|
||||
.menu-table td:nth-child(6) input { width: 130px }
|
||||
.menu-table td:nth-child(7) input { min-width: 190px }
|
||||
.field-table td:first-child input { width: 140px }
|
||||
.field-table td:nth-child(2) select { width: 150px }
|
||||
.field-table td:nth-child(3) input { min-width: 170px }
|
||||
.field-table td:nth-child(5) input { min-width: 180px }
|
||||
.field-table td:nth-child(6) input { min-width: 170px }
|
||||
.theme-table td input { width: 190px }
|
||||
.theme-table td select { width: 150px }
|
||||
.theme-table td input[type=color] { width: 52px }
|
||||
.theme-table .weights-cell { white-space: nowrap }
|
||||
.redirect-table td { padding: 8px 12px 8px 0; font-size: 12px }
|
||||
/* Der Aufgabenplan: die erste Spalte traegt Text, die schmalen tragen Zahlen und Schalter. */
|
||||
.tasks-table td:first-child { min-width: 220px; max-width: 340px }
|
||||
.tasks-table td input[type=number] { width: 92px }
|
||||
.tasks-table td .field-help { margin-top: 4px }
|
||||
.tasks-table .row-actions, .tasks-table td:last-child { white-space: nowrap }
|
||||
/* Headings inside a card separate its parts without a second border. */
|
||||
.card-body .section-head { margin: 28px 0 12px }
|
||||
.card-body .section-head:first-child { margin-top: 14px }
|
||||
.card-body > .primary, .card-body > .secondary { margin-top: 24px }
|
||||
|
||||
/* A screen made of several equal sections (menu slots, form definitions). */
|
||||
/* A screen made of several equal sections (menu slots). */
|
||||
.stack-section { display: grid; gap: 10px }
|
||||
.menu-form > label:first-of-type, .form-def > label:first-of-type { margin-top: 16px }
|
||||
.menu-form > label:first-of-type { margin-top: 16px }
|
||||
.menu-form > input[name=name] { max-width: 420px }
|
||||
.menu-form .table, .form-def .table { margin-top: 22px }
|
||||
.menu-form .primary, .form-def .primary { margin-top: 20px }
|
||||
.menu-form .table { margin-top: 22px }
|
||||
.menu-form .primary { margin-top: 20px }
|
||||
|
||||
.empty {
|
||||
padding: 34px;
|
||||
@@ -474,10 +536,6 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.contrast-ok { background: #edf3dc; color: #2c5340; border: 1px solid #c5d89a }
|
||||
.contrast-warn { background: #fff6e2; color: #6f4e18; border: 1px solid #e6cf9c }
|
||||
.contrast-bad { background: #fff0eb; color: var(--danger); border: 1px solid #e2b2a7 }
|
||||
.theme-form .theme-swatch { width: 52px; min-height: 38px; padding: 2px; cursor: pointer }
|
||||
.theme-form .weights-cell { white-space: nowrap }
|
||||
.theme-form .weights-cell label { display: inline-flex; align-items: center; gap: 4px; margin: 0 9px 0 0; font-size: 11px; font-weight: 400 }
|
||||
.theme-form .weights-cell input { width: 14px; min-height: 14px }
|
||||
|
||||
/* ---- submissions ----------------------------------------------------------------------- */
|
||||
|
||||
@@ -513,25 +571,33 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
/* ---- narrow screens: the sidebar becomes a strip above the content --------------------- */
|
||||
|
||||
@media (max-width: 1000px) {
|
||||
.admin-shell { grid-template-columns: 1fr }
|
||||
/* minmax(0, 1fr), nicht 1fr: sonst nimmt die Spalte die Mindestbreite der Leiste an und
|
||||
schiebt die ganze Seite ueber den rechten Rand hinaus. */
|
||||
.admin-shell { grid-template-columns: minmax(0, 1fr) }
|
||||
.shell-nav {
|
||||
position: static;
|
||||
height: auto;
|
||||
min-width: 0;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
flex-wrap: nowrap;
|
||||
overflow-x: auto;
|
||||
overflow: visible;
|
||||
gap: 6px;
|
||||
padding: 11px 14px;
|
||||
}
|
||||
/* Nur die Liste wischt; Konto und Abmelden bleiben rechts im Bild. */
|
||||
.shell-menu { flex: 1 1 auto; min-width: 0; overflow-x: auto }
|
||||
.shell-foot { flex: none }
|
||||
.shell-nav .brand { margin: 0 10px 0 0; font-size: 0 }
|
||||
.shell-nav .brand-icon { margin: 0; font-size: 15px }
|
||||
.nav-label { display: none }
|
||||
.nav-list { display: flex; gap: 4px }
|
||||
.nav-item { white-space: nowrap; padding: 8px 12px }
|
||||
.shell-foot { margin: 0 0 0 auto; padding: 0 0 0 14px; border-top: 0; border-left: 1px solid #2a3d35; display: flex; align-items: center }
|
||||
.shell-user strong { display: none }
|
||||
.shell-foot button { width: auto }
|
||||
.shell-foot { margin: 0 0 0 auto; padding: 0 0 0 12px; border-top: 0; border-left: 1px solid #2a3d35; display: flex; align-items: center; gap: 10px }
|
||||
.shell-account { padding: 3px; gap: 2px }
|
||||
.shell-user { padding: 3px }
|
||||
.shell-user-text { display: none }
|
||||
.shell-version { padding: 0 }
|
||||
.shell-top { padding: 22px 20px 18px }
|
||||
.shell-body { padding: 20px 20px 48px }
|
||||
}
|
||||
@@ -545,6 +611,18 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.row.depth-1, .row.depth-2, .row.depth-3 { margin-left: 14px }
|
||||
.login-card { padding: 26px }
|
||||
.login-body { padding: 16px }
|
||||
/* Zwei Rasterspalten aus Bezeichner und Wert sind auf einem Handy eine Spalte zu viel. */
|
||||
.facts { grid-template-columns: minmax(0, 1fr); gap: 2px }
|
||||
.facts dt { margin-top: 10px }
|
||||
.facts dt:first-child { margin-top: 0 }
|
||||
/* Eine sechsspaltige Tabelle passt auf kein Handy. Jede Zeile wird zu einem Block, und die
|
||||
Spaltenueberschrift steht als Bezeichner vor ihrem Wert. */
|
||||
.table-stack thead { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%) }
|
||||
.table-stack, .table-stack tbody, .table-stack tr, .table-stack td { display: block; width: 100% }
|
||||
.table-stack tr { border: 1px solid var(--line); border-radius: 10px; padding: 8px 12px; margin-top: 10px }
|
||||
.table-stack tbody tr + tr td { border-top: 0 }
|
||||
.table-stack td { display: flex; gap: 14px; justify-content: space-between; align-items: baseline; padding: 5px 0; text-align: right }
|
||||
.table-stack td::before { content: attr(data-label); color: var(--muted); text-align: left; flex: none }
|
||||
}
|
||||
|
||||
/* A group of related checkboxes needs a name of its own, not just a label per box. */
|
||||
@@ -552,40 +630,80 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.field-set > legend { font-size: 12px; font-weight: 700; padding: 0 6px }
|
||||
.field-set .check-label { margin-top: 14px }
|
||||
|
||||
/* ---- statistics: charts drawn by the server, styled by class ---------------------------- */
|
||||
/* ---- statistics: tiles, charts drawn by the server, ranked lists ------------------------ */
|
||||
|
||||
.range-switch { display: flex; gap: 8px; flex-wrap: wrap }
|
||||
.range-switch a { font-size: 12px; padding: 8px 14px; min-height: 36px }
|
||||
.chart { display: block; width: 100%; height: 180px; overflow: visible }
|
||||
/* Der Zeitraum als eine Leiste mit einem gedrueckten Knopf, nicht vier lose Schaltflaechen. */
|
||||
.range-switch { display: inline-flex; gap: 2px; padding: 3px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper) }
|
||||
.range-switch a { display: inline-flex; align-items: center; min-height: 32px; padding: 6px 13px; border-radius: 7px; font-size: 12px; font-weight: 600; text-decoration: none; color: var(--muted) }
|
||||
.range-switch a:hover { color: var(--ink); background: #eef2ea }
|
||||
.range-switch a[aria-current=page] { background: var(--shell); color: #fff }
|
||||
/* Die Kennzahlen: eine grosse dunkle Kachel fuer die Summe, kleine helle fuer alles, was sie einordnet. */
|
||||
.stat-tiles { display: grid; grid-template-columns: repeat(auto-fit, minmax(136px, 1fr)); gap: 12px }
|
||||
.stat-tile { display: grid; align-content: start; gap: 6px; min-width: 0; padding: 16px 18px; background: var(--paper); border: 1px solid var(--line); border-radius: 12px }
|
||||
.stat-tile-main { grid-column: span 2; background: var(--shell); border-color: var(--shell); color: var(--shell-ink) }
|
||||
.stat-label { font-size: 11px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--muted) }
|
||||
.stat-value { font-size: 28px; font-weight: 650; letter-spacing: -.8px; line-height: 1.1; font-variant-numeric: tabular-nums }
|
||||
.stat-note { font-size: 12px; line-height: 1.45; color: var(--muted); overflow-wrap: anywhere }
|
||||
.stat-tile-main .stat-label, .stat-tile-main .stat-note { color: var(--shell-muted) }
|
||||
.stat-tile-main .stat-value { font-size: 40px; color: #fff }
|
||||
.stat-tile-main .stat-up { color: #bfe3a5 }
|
||||
.stat-tile-main .stat-down { color: #f3b9a8 }
|
||||
.chart { display: block; width: 100%; height: 190px; overflow: visible }
|
||||
.chart-hours, .chart-weekdays { height: 150px }
|
||||
.chart-bar { fill: var(--accent) }
|
||||
.chart-bar:hover { fill: #1c4735 }
|
||||
.chart-axis { stroke: var(--line); stroke-width: 1 }
|
||||
.chart-legend { display: flex; justify-content: space-between; margin: 8px 0 0; font-size: 11px; color: var(--muted) }
|
||||
.chart-bar-peak { fill: var(--ring) }
|
||||
.chart-grid { stroke: var(--line); stroke-width: 1; stroke-dasharray: 3 3 }
|
||||
.chart-axis { stroke: #c3cec7; stroke-width: 1 }
|
||||
.chart-legend { display: flex; justify-content: space-between; gap: 8px; margin: 8px 0 0; font-size: 11px; color: var(--muted) }
|
||||
.chart-legend-even span { flex: 1; text-align: center }
|
||||
.chart-table { margin-top: 18px }
|
||||
.chart-table > summary { cursor: pointer; font-size: 12px; font-weight: 600; padding: 6px 0 }
|
||||
.table-wrap { max-height: 320px; overflow: auto; margin-top: 10px }
|
||||
.stats-columns { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 16px }
|
||||
.stats-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 2px; counter-reset: rank }
|
||||
.stats-columns-2 { grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)) }
|
||||
/* Rang, Name, Anteil am Spitzenwert als Balken, Zahl. */
|
||||
.stats-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 2px }
|
||||
.stats-list li {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 8px 0;
|
||||
display: grid;
|
||||
grid-template-columns: 22px minmax(0, 1fr) 72px auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 7px 0;
|
||||
border-top: 1px solid #eef1ed;
|
||||
font-size: 13px;
|
||||
}
|
||||
.stats-list li:first-child { border-top: 0 }
|
||||
.stats-list span, .stats-list a { overflow-wrap: anywhere; min-width: 0 }
|
||||
.stats-list strong { font-variant-numeric: tabular-nums; white-space: nowrap }
|
||||
.stats-rank { font-size: 11px; font-weight: 700; color: var(--muted); text-align: center }
|
||||
.stats-list span:not(.stats-rank), .stats-list a { overflow-wrap: anywhere; min-width: 0 }
|
||||
.stats-share { display: block; width: 100%; height: 6px; overflow: visible }
|
||||
.stats-share-bar { fill: var(--accent); opacity: .5 }
|
||||
.stats-list li:first-child .stats-share-bar { opacity: 1 }
|
||||
.stats-list strong { font-variant-numeric: tabular-nums; white-space: nowrap; text-align: right }
|
||||
@media (max-width: 620px) {
|
||||
.stat-tile-main { grid-column: 1 / -1 }
|
||||
.stats-list li { grid-template-columns: 22px minmax(0, 1fr) auto }
|
||||
.stats-share { display: none }
|
||||
}
|
||||
|
||||
/* ---- system: preflight, update steps, log ---------------------------------------------- */
|
||||
|
||||
.facts { display: grid; grid-template-columns: auto 1fr; gap: 8px 20px; margin: 14px 0 0; font-size: 13px }
|
||||
.facts { display: grid; grid-template-columns: minmax(0, auto) minmax(0, 1fr); gap: 8px 20px; margin: 14px 0 0; font-size: 13px }
|
||||
.facts dt { color: var(--muted) }
|
||||
.facts dd { margin: 0; overflow-wrap: anywhere }
|
||||
/* Die Adresse der Website auf der Systemseite: Feld und Knopf in einer Zeile, am Telefon untereinander. */
|
||||
.origin-form { margin-top: 4px; max-width: 560px }
|
||||
.origin-form label:first-of-type { margin-top: 10px }
|
||||
.origin-row { display: flex; flex-wrap: wrap; gap: 8px }
|
||||
.origin-row input { flex: 1 1 240px; width: auto; box-sizing: border-box; min-height: 40px; height: 40px }
|
||||
.card-body .origin-row .secondary { margin: 0 }
|
||||
.https-actions { display: flex; flex-wrap: wrap; gap: 8px; margin-top: 8px }
|
||||
.card-body .https-actions .secondary { margin: 0 }
|
||||
.origin-form .check-label { margin-top: 10px }
|
||||
.link-button { border: 0; background: none; padding: 0; min-height: 0; font: inherit; color: var(--accent); text-decoration: underline; cursor: pointer }
|
||||
.check-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 10px; font-size: 13px; line-height: 1.55 }
|
||||
.check-list li { display: flex; gap: 11px; align-items: flex-start }
|
||||
.check-list li > span { min-width: 0; overflow-wrap: anywhere }
|
||||
.check-mark {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
@@ -600,4 +718,369 @@ details.card > summary:hover { background: #fafbf8 }
|
||||
.check-bad { color: var(--danger) }
|
||||
.log-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 6px }
|
||||
.log-list code { font-size: 11px; overflow-wrap: anywhere }
|
||||
.shell-version { margin: 0 0 4px; padding: 0 10px; font-size: 10px; color: var(--shell-muted) }
|
||||
|
||||
/* ---- the installer: six screens on an otherwise empty server ---------------------------- */
|
||||
|
||||
.install-body {
|
||||
min-height: 100dvh;
|
||||
display: grid;
|
||||
place-items: start center;
|
||||
background: radial-gradient(ellipse at 20% 0%, #dfeab0, transparent 60%), var(--ground);
|
||||
padding: 40px 20px 60px;
|
||||
}
|
||||
.install-shell { width: 100%; max-width: 640px }
|
||||
.install-shell .card { margin-top: 18px }
|
||||
.install-shell h1 { font-size: 24px }
|
||||
.install-shell form { margin-top: 4px }
|
||||
.install-shell .sheet-actions { margin-top: 24px }
|
||||
.install-shell h2 { font-size: 15px; margin: 30px 0 0 }
|
||||
|
||||
/* The step list is the only orientation there is: no navigation exists yet. */
|
||||
.install-steps {
|
||||
list-style: none;
|
||||
margin: 14px 0 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px 16px;
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
}
|
||||
.install-steps li { display: flex; align-items: center; gap: 7px }
|
||||
.install-steps .current { color: var(--ink); font-weight: 700 }
|
||||
.install-step-number {
|
||||
display: grid;
|
||||
place-items: center;
|
||||
width: 19px; height: 19px;
|
||||
border-radius: 50%;
|
||||
background: #e4e9df;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
.install-steps .current .install-step-number { background: var(--accent); color: #fff }
|
||||
.install-steps .done .install-step-number { background: #e7f0d6; color: #2c5340 }
|
||||
|
||||
/* A finding that is worth saying but does not block anything. */
|
||||
.check-warn .check-mark { background: #f3f0e2; color: #6b6550 }
|
||||
|
||||
.button-link { text-decoration: none }
|
||||
.install-details { margin-top: 18px; border: 1px solid var(--line); border-radius: 10px; padding: 0 16px 16px }
|
||||
.install-details > summary { padding: 14px 0; font-size: 13px; font-weight: 600; cursor: pointer }
|
||||
.install-details[open] > summary { border-bottom: 1px solid var(--line); margin-bottom: 6px }
|
||||
|
||||
.favicon-field{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:12px}
|
||||
.favicon-current{display:flex;align-items:center;gap:10px;font-size:13px;color:var(--muted)}
|
||||
.favicon-actions{display:flex;flex-wrap:wrap;align-items:center;gap:8px}
|
||||
.favicon-preview{border-radius:6px;background:#fff;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
|
||||
.favicon-editor{display:grid;grid-template-columns:minmax(0,1fr) 220px;gap:20px;align-items:start;margin-top:14px}
|
||||
.favicon-editor[hidden]{display:none}
|
||||
.favicon-stage{position:relative;justify-self:start;max-width:100%;line-height:0;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/16px 16px;user-select:none;touch-action:none}
|
||||
.favicon-stage img{display:block;max-width:100%;max-height:360px;width:auto;height:auto}
|
||||
.favicon-frame{position:absolute;box-sizing:border-box;border:2px solid #fff;outline:1px solid rgba(0,0,0,.6);box-shadow:0 0 0 9999px rgba(20,30,25,.55);cursor:move}
|
||||
.favicon-stage{overflow:hidden}
|
||||
.favicon-frame:focus-visible{outline:3px solid var(--accent);outline-offset:0}
|
||||
.favicon-handle{position:absolute;right:-7px;bottom:-7px;width:14px;height:14px;background:#fff;border:1px solid rgba(0,0,0,.6);border-radius:3px;cursor:nwse-resize}
|
||||
.favicon-side input[type=range]{width:100%}
|
||||
.favicon-samples{display:flex;align-items:flex-end;gap:16px;margin:12px 0}
|
||||
.favicon-samples figure{margin:0;text-align:center;font-size:11px;color:var(--muted)}
|
||||
.favicon-samples canvas{display:block;margin:0 auto 6px;border-radius:8px;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/12px 12px;box-shadow:0 0 0 1px rgba(0,0,0,.12)}
|
||||
.favicon-sample-large{width:90px;height:90px}
|
||||
.favicon-sample-small{width:32px;height:32px;border-radius:4px!important}
|
||||
@media(max-width:700px){.favicon-editor{grid-template-columns:1fr}}
|
||||
|
||||
.color-field { display: flex; gap: 8px; align-items: center }
|
||||
.color-field input[type="text"], .color-field > input:not([type="color"]) { flex: 1; min-width: 0 }
|
||||
.color-field-swatch { width: 46px; min-height: 38px; padding: 2px; flex: 0 0 auto; cursor: pointer }
|
||||
.color-field-palette { flex: 0 0 auto; max-width: 40% }
|
||||
/* Die Verwaltung auf dem Telefon.
|
||||
|
||||
Bis 1000px stand die Navigation als waagerechte Wischleiste da - zwei Listen untereinander,
|
||||
also zwei Zeilen, und der letzte Eintrag ausserhalb des Bildes. Auf einem Telefon ist ein
|
||||
Klappmenue ehrlicher: eine Zeile im Ruhezustand, aufgeklappt die vollstaendige Liste mit
|
||||
Flaechen, die ein Daumen trifft. Das details-Element macht das ohne Skript. */
|
||||
/* Der Schalter traegt seine Gestalt hier, nicht erst im Telefonabschnitt: am Schreibtisch
|
||||
wird er ausgeblendet, aber nur dort, wo der Inhalt sicher sichtbar ist. */
|
||||
.shell-menu > summary {
|
||||
display: flex; align-items: center; gap: 10px; list-style: none; cursor: pointer;
|
||||
min-height: 44px; padding: 0 14px; border-radius: 10px;
|
||||
background: #ffffff14; color: #fff; font-weight: 600; font-size: 15px;
|
||||
}
|
||||
.shell-menu > summary::-webkit-details-marker { display: none }
|
||||
.shell-menu > summary:focus-visible { outline: 3px solid var(--mint); outline-offset: 2px }
|
||||
.shell-menu[open] > summary { margin-bottom: 8px }
|
||||
.shell-menu-icon { font-size: 17px }
|
||||
/* Am Schreibtisch steht die Liste dauerhaft da. Ein geschlossenes details-Element versteckt
|
||||
seinen Inhalt aber von sich aus - der Schalter allein auszublenden loescht die Navigation.
|
||||
Erst ::details-content holt sie zurueck. Kennt ein Browser die Regel nicht, faellt die
|
||||
ganze Gruppe weg und der Schalter bleibt stehen: die Liste ist dann einen Klick entfernt
|
||||
statt gar nicht da. */
|
||||
@supports selector(::details-content) {
|
||||
.shell-menu > summary { display: none }
|
||||
.shell-menu::details-content { content-visibility: visible; block-size: auto }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.shell-nav { flex-wrap: wrap; padding: 10px 14px; gap: 10px; overflow: visible }
|
||||
.shell-menu { flex: 1 1 100%; order: 3 }
|
||||
.shell-menu > summary { display: flex }
|
||||
.shell-menu nav { display: block }
|
||||
.shell-menu:not([open]) nav { display: none }
|
||||
.shell-menu:not([open])::details-content { content-visibility: hidden; block-size: 0 }
|
||||
.nav-label { display: block; margin: 12px 4px 6px; font-size: 11px; letter-spacing: 1.4px; color: #9db3a8 }
|
||||
.nav-list { display: grid; gap: 2px }
|
||||
/* Vierundvierzig Pixel: die Flaeche, die ein Daumen zuverlaessig trifft. */
|
||||
.nav-item { min-height: 44px; display: flex; align-items: center; gap: 12px; padding: 10px 14px; white-space: normal }
|
||||
.shell-foot { margin-left: auto; border-left: 0; padding-left: 0 }
|
||||
.shell-version { display: none }
|
||||
}
|
||||
|
||||
/* Search and forms share usable widths throughout administration. */
|
||||
.field-wide{grid-column:1/-1;min-width:0}.field-wide textarea{width:100%;min-height:100px;resize:vertical}
|
||||
.selected-media-preview{margin:12px 0;padding:10px;border:1px solid var(--line);border-radius:9px;background:#fff;min-width:0}.selected-media-preview img{display:block;width:100%;height:140px;object-fit:contain;background:repeating-conic-gradient(#eef1eb 0% 25%,#fff 0% 50%) 0 0/16px 16px}.selected-media-preview figcaption{font-size:12px;line-height:1.5;overflow-wrap:anywhere;margin-top:8px;color:var(--muted)}
|
||||
.live-search-ready .search-submit{display:none}.search-status{min-height:20px;margin:-12px 0 0}#list-results{scroll-margin-top:16px}#list-results>.section-head{margin-top:0}#list-results:focus-visible{outline:2px solid var(--accent);outline-offset:5px}#search-error p{margin-top:0}
|
||||
.list-toolbar{display:flex;flex-wrap:wrap;align-items:end;gap:14px;padding:18px;margin:24px 0;background:var(--paper,#fff);border:1px solid var(--line,#dce3da);border-radius:12px}
|
||||
.list-toolbar>div{min-width:140px}.list-toolbar .list-search{flex:1;min-width:220px}.list-toolbar input,.list-toolbar select{width:100%}
|
||||
.content-picker{position:relative;min-width:0}.picker-input{display:flex;gap:6px}.picker-input input{width:100%;min-width:0}.picker-input button{flex:none}
|
||||
.picker-results{position:absolute;z-index:30;top:100%;left:0;right:0;min-width:220px;max-height:300px;overflow-y:auto;background:white;border:1px solid #a7b9af;border-radius:8px;box-shadow:0 10px 30px #18382b24;padding:5px}
|
||||
.picker-results[hidden]{display:none}.picker-results button{display:flex;flex-direction:column;align-items:start;width:100%;padding:10px;text-align:left;color:#163d30;background:white;border:0;border-radius:5px}
|
||||
.picker-results button:hover,.picker-results button[aria-selected=true]{background:#e8f2e9}.picker-results span{font-size:12px;color:#496458;overflow-wrap:anywhere}.picker-status:empty{display:none}
|
||||
@media(max-width:650px){.list-toolbar>div,.list-toolbar .list-search{min-width:0;flex-basis:100%}.picker-results{min-width:0}.picker-input button{min-width:44px;min-height:44px}}
|
||||
|
||||
/* One menu at a time: content picker on the left, ordered structure on the right. */
|
||||
.menu-slots{display:flex;flex-wrap:wrap;gap:8px;margin:0 0 24px}.menu-slots a{padding:10px 16px;border:1px solid var(--line);border-radius:8px;text-decoration:none;background:white}.menu-slots a[aria-current=page]{background:var(--shell);color:white;border-color:var(--shell)}
|
||||
.menu-workspace{display:grid;grid-template-columns:minmax(260px,320px) minmax(0,1fr);gap:24px;align-items:start}.menu-add{display:grid;gap:16px;position:sticky;top:20px}.menu-add .card{margin:0}.menu-add .content-picker{margin-bottom:16px}.menu-structure{min-width:0}.menu-items{list-style:none;padding:0;margin:24px 0;display:grid;gap:12px}.menu-entry{border:1px solid var(--line);border-radius:9px;background:#fff;min-width:0}.menu-entry.menu-child{margin-left:32px;border-left:3px solid #8eaa99}.menu-entry summary{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:16px;cursor:pointer}.menu-entry summary::after{content:'⌄';font-size:18px}.menu-entry-title{overflow-wrap:anywhere;flex:1}.menu-entry-fields{padding:0 16px 16px}.menu-entry-fields .content-picker{margin-top:14px}.menu-entry-actions{display:flex;flex-wrap:wrap;gap:4px;padding:8px 12px;border-top:1px solid var(--line);background:#f7f9f6;border-radius:0 0 9px 9px}.menu-entry-actions button{min-height:36px}.menu-entry-actions .danger-text{margin-left:auto}.menu-save{display:flex;flex-wrap:wrap;align-items:center;gap:12px;padding-top:16px;border-top:1px solid var(--line)}.menu-save [role=status]{font-size:13px;color:var(--muted)}
|
||||
@media(max-width:1100px){.menu-workspace{grid-template-columns:minmax(220px,280px) minmax(0,1fr)}}
|
||||
@media(max-width:850px){.menu-workspace{grid-template-columns:1fr}.menu-add{position:static;grid-template-columns:1fr 1fr}.menu-add>p{grid-column:1/-1}}
|
||||
@media(max-width:560px){.menu-add{grid-template-columns:1fr}.menu-entry.menu-child{margin-left:18px}.menu-entry-actions button{min-height:44px}.menu-slots a{flex:1;min-width:130px}}
|
||||
|
||||
/* Field definitions stay editable on narrow screens instead of stretching the entire page. */
|
||||
.form-def{min-width:0;margin:0}
|
||||
|
||||
.version-update{display:inline-block;padding:4px 8px;border:1px solid #679576;border-radius:6px;color:#17452c;background:#e8f4e8;font-size:11px;font-weight:700;text-decoration:none}
|
||||
|
||||
.menu-entry summary::after{content:none}.menu-entry summary{list-style:none;gap:10px;padding:10px 14px}.menu-entry summary::-webkit-details-marker{display:none}.menu-drag-handle{display:grid;place-items:center;width:36px;min-width:36px;height:40px;padding:0;border:0;background:transparent;color:#576e60;cursor:grab;touch-action:none}.menu-drag-handle:active{cursor:grabbing}.menu-chevron{flex:none;color:#52665a;transition:transform .15s}.menu-entry details[open] .menu-chevron{transform:rotate(180deg)}.menu-entry-kind{font-size:11px;border:1px solid #dae4dc;border-radius:5px;padding:3px 6px;color:#506659;background:#f7f9f6;white-space:nowrap}.menu-entry-title{display:flex;align-items:center;gap:8px;min-width:0}.menu-entry-title svg{width:19px;height:19px;flex:none}.menu-dragging{opacity:.6;box-shadow:0 4px 16px #203b3426;border-color:#2c6b4f}.menu-icon-fields{display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-top:16px}@media(max-width:560px){.menu-entry summary{padding:8px}.menu-entry-kind{display:none}.menu-icon-fields{grid-template-columns:1fr}}
|
||||
|
||||
/* ---- the form editor: a card per field, the look, and a preview beside it -------------- */
|
||||
|
||||
.form-screens { display: grid; gap: 20px }
|
||||
.form-editor { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 360px); gap: 20px 36px; padding-top: 2px }
|
||||
.form-editor-main { min-width: 0 }
|
||||
.form-part { margin: 26px 0 0; font-size: 11px; font-weight: 700; letter-spacing: .9px; text-transform: uppercase; color: var(--muted) }
|
||||
.form-editor-main > .form-part:first-child, .form-editor-preview .form-part, .type-preview-column .form-part { margin-top: 14px }
|
||||
.form-fields-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; flex-wrap: wrap; margin-top: 28px }
|
||||
.form-fields-head .form-part { margin: 0 }
|
||||
.card-body .form-fields-head .secondary, .card-body .list-actions .secondary { margin: 0; min-height: 36px; padding: 6px 12px; font-size: 12px }
|
||||
.form-fields-head .secondary .nav-icon, .list-actions .secondary .nav-icon { width: 15px; height: 15px; color: var(--accent) }
|
||||
.field-list { list-style: none; margin: 12px 0 0; padding: 0; display: grid; gap: 10px }
|
||||
.field-card { position: relative; padding: 8px 10px; border: 1px solid var(--line); border-radius: 10px; background: #fafbf8 }
|
||||
.field-card.is-open { padding-bottom: 14px; background: #fff; box-shadow: 0 1px 6px #203b3414 }
|
||||
.field-card-new { border-style: dashed; background: var(--paper) }
|
||||
.field-card-head { display: grid; grid-template-columns: 24px minmax(0, 1fr) 170px auto auto 34px; gap: 0 10px; align-items: center }
|
||||
.field-card-head input, .field-card-head select { min-height: 36px }
|
||||
.field-card-index { display: grid; place-items: center; width: 24px; height: 24px; border-radius: 50%; background: #e4ebe2; color: var(--accent); font-size: 12px; font-weight: 700 }
|
||||
.field-card label { margin-top: 12px }
|
||||
.field-card-head .field-card-required { margin: 0; font-size: 13px; white-space: nowrap }
|
||||
.field-toggle { display: inline-flex; align-items: center; gap: 4px; min-height: 34px; padding: 0 8px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--accent); font-size: 12px; font-weight: 650; white-space: nowrap }
|
||||
.field-toggle:hover { background: #eef3ec }
|
||||
.field-toggle .form-chevron { transition: transform .15s }
|
||||
.field-card.is-open .field-toggle .form-chevron { transform: rotate(180deg) }
|
||||
.field-card-meta { margin: 4px 0 0 34px; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.field-card-body { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 16px; padding-left: 34px }
|
||||
.field-card-body[hidden] { display: none }
|
||||
.field-translation { grid-column: 1 / -1; margin-top: 12px; padding-top: 8px; border-top: 1px dashed var(--line) }
|
||||
.field-translation summary { cursor: pointer; font-size: 13px; font-weight: 650 }
|
||||
/* Ein Formular je zuklappbarer Karte, Abschnitte darin ebenfalls zuklappbar. */
|
||||
.form-card > .form-card-summary { display: flex; align-items: center; gap: 12px; padding: 16px 20px; cursor: pointer; list-style: none }
|
||||
details.form-card > summary.form-card-summary::before, details.form-card[open] > summary.form-card-summary::before { content: none }
|
||||
.form-card > .form-card-summary::-webkit-details-marker, .form-section > summary::-webkit-details-marker { display: none }
|
||||
.form-card-name { font-size: 16px; font-weight: 700 }
|
||||
.form-card > .form-card-summary { cursor: default; border-bottom: 1px solid var(--line) }
|
||||
.form-list-name { margin: 0; font-size: 15px; font-weight: 650 }
|
||||
.form-list-name a { text-decoration: none }
|
||||
.form-list-name a:hover { text-decoration: underline }
|
||||
.form-list .row { align-items: center }
|
||||
.back-to-list { display: inline-block; margin-top: 4px }
|
||||
.form-card-facts { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.form-chevron { flex: none; color: #52665a; transition: transform .15s }
|
||||
.form-card[open] > .form-card-summary { border-bottom: 1px solid var(--line) }
|
||||
.form-card[open] > .form-card-summary > .form-chevron, .form-section[open] > summary > .form-chevron { transform: rotate(180deg) }
|
||||
.form-section { border-bottom: 1px solid var(--line); padding-bottom: 12px }
|
||||
.form-section > summary { display: flex; align-items: center; gap: 10px; padding: 14px 0 2px; cursor: pointer; list-style: none }
|
||||
.form-section > summary .form-part { margin: 0 }
|
||||
.form-section-hint { flex: 1; min-width: 0; font-size: 12px; color: var(--muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap }
|
||||
.field-card-body .field-help { margin-top: 5px }
|
||||
.field-card-flags { align-self: end; padding-bottom: 11px }
|
||||
.field-card-flags .check-label { margin: 12px 0 0 }
|
||||
.field-remove { display: grid; place-items: center; width: 38px; height: 38px; margin-bottom: 2px; padding: 0; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--muted) }
|
||||
.field-remove .nav-icon { width: 17px; height: 17px; color: inherit }
|
||||
.field-remove:hover { background: #fff0eb; border-color: #e2b2a7; color: var(--danger) }
|
||||
/* Die Vorschau bleibt beim Blaettern durch lange Feldlisten im Bild. */
|
||||
.form-editor-preview { min-width: 0; position: sticky; top: 16px; align-self: start }
|
||||
/* Die Vorschau uebernimmt Text- und Seitenfarbe der Website per Skript; die Felder lesen dieselben
|
||||
Variablen wie auf der Website selbst. */
|
||||
.form-preview { display: grid; gap: 16px; margin-top: 12px; padding: 22px; border: 1px solid var(--line); border-radius: 12px; background: #fff; font-size: 14px; line-height: 1.5 }
|
||||
.form-preview-field { display: grid; gap: 6px }
|
||||
.form-preview-label { font-weight: 600 }
|
||||
.form-preview-input { display: block; min-height: 40px; padding: 9px 12px; border: 1px solid var(--form-border, currentColor); border-radius: 8px; background: var(--form-bg, #fff); color: inherit }
|
||||
.form-preview-select { display: flex; align-items: center; justify-content: space-between }
|
||||
.form-preview-select::after { content: '⌄'; font-size: 16px; line-height: 1 }
|
||||
.form-preview-area { min-height: 92px }
|
||||
.form-preview-check { display: flex; align-items: flex-start; gap: 9px }
|
||||
.form-preview-box { flex: none; width: 18px; height: 18px; margin-top: 2px; border: 1px solid var(--form-border, currentColor); border-radius: 4px; background: var(--form-bg, #fff) }
|
||||
.form-preview-help { font-size: 12px }
|
||||
.form-preview-step { padding-top: 6px; border-top: 1px solid var(--line); display: grid; gap: 2px }
|
||||
.form-preview-step-title { font-weight: 700; font-size: 15px }
|
||||
.form-preview-radio { border-radius: 50% }
|
||||
.form-preview-number { display: flex; align-items: center; justify-content: flex-end; opacity: .7 }
|
||||
.form-preview-conditional { padding-left: 10px; border-left: 2px dashed var(--line) }
|
||||
.field-card-number { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0 12px }
|
||||
.field-card-condition { grid-column: 1 / -1; display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 16px }
|
||||
.field-card-condition .field-help { grid-column: 1 / -1 }
|
||||
.field-card-images { grid-column: 1 / -1 }
|
||||
.field-image-pickers { display: grid; gap: 6px; margin-top: 6px }
|
||||
.field-image-picker { display: grid; grid-template-columns: minmax(0, 1fr) 64px minmax(0, 1fr) auto auto; gap: 10px; align-items: center; font-size: 13px }
|
||||
.field-image-picker-thumb { width: 64px; height: 48px; object-fit: cover; border-radius: 6px; background: #eef1eb }
|
||||
.field-image-picker-thumb[hidden] { display: block; visibility: hidden }
|
||||
.field-image-picker-name { color: var(--muted); overflow-wrap: anywhere; min-width: 0 }
|
||||
@media (max-width: 767px) { .field-image-picker { grid-template-columns: 64px minmax(0, 1fr); } .field-image-picker-option { grid-column: 1 / -1; font-weight: 650 } }
|
||||
.field-condition-picker { display: grid; gap: 6px; grid-column: 1 / -1 }
|
||||
.field-condition-values { border: 0; padding: 0; margin: 4px 0 0; display: flex; flex-wrap: wrap; gap: 4px 16px }
|
||||
.field-condition-values legend { font-size: 13px; font-weight: 650; margin-bottom: 4px; padding: 0 }
|
||||
.form-preview-empty { margin: 0; font-size: 13px }
|
||||
.form-preview + .contrast-note { margin-top: 10px }
|
||||
.form-preview .cms-button { justify-self: start; pointer-events: none }
|
||||
.form-editor-foot { display: flex; align-items: center; gap: 16px; flex-wrap: wrap }
|
||||
.form-editor-foot .muted { font-size: 12px }
|
||||
.form-look .color-control { margin-top: 0 }
|
||||
@media (max-width: 1100px) { .form-editor { grid-template-columns: 1fr } }
|
||||
@media (max-width: 700px) {
|
||||
.field-card-head { grid-template-columns: 24px minmax(0, 1fr) auto; gap: 8px }
|
||||
.field-card-title { grid-column: 2 / 4; padding-right: 36px }
|
||||
.field-card-type { grid-column: 2 }
|
||||
.field-card-head .field-card-required { grid-column: 3 }
|
||||
.field-toggle { grid-column: 2 }
|
||||
.field-card-head .field-remove { position: absolute; top: 8px; right: 8px }
|
||||
.field-card-meta { margin-left: 0 }
|
||||
.field-card-body { padding-left: 0 }
|
||||
.form-card-facts { white-space: normal }
|
||||
.form-card > .form-card-summary { flex-wrap: wrap; padding: 14px 16px }
|
||||
}
|
||||
|
||||
/* ---- design: palette cards, ready-made palettes, font cards, typography preview -------- */
|
||||
|
||||
.theme-form > .card { margin: 0 }
|
||||
.palette-presets { margin-top: 14px }
|
||||
.palette-presets-label { display: block; margin-bottom: 8px; font-size: 12px; font-weight: 600 }
|
||||
.palette-preset-row { display: flex; flex-wrap: wrap; gap: 8px }
|
||||
.palette-preset { display: grid; gap: 6px; justify-items: center; padding: 8px 10px 7px; border: 1px solid var(--line); border-radius: 10px; background: var(--paper); color: var(--ink); font-size: 11px; font-weight: 600 }
|
||||
.palette-preset svg { display: block; width: 90px; height: 24px; border-radius: 6px; overflow: hidden; box-shadow: inset 0 0 0 1px #203b3426 }
|
||||
.palette-preset:hover { border-color: var(--accent); background: #f5f8f1 }
|
||||
.palette-presets .field-help { margin-top: 10px }
|
||||
.swatch-list { list-style: none; margin: 18px 0 0; padding: 0; display: grid; grid-template-columns: repeat(auto-fill, minmax(190px, 1fr)); gap: 12px }
|
||||
.swatch-card { position: relative; display: grid; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); overflow: hidden }
|
||||
.swatch-card-new { border-style: dashed }
|
||||
/* Der Farbwaehler ist die Flaeche selbst: nichts liegt dazwischen. */
|
||||
.swatch-well { position: relative; display: grid; margin: 0; cursor: pointer }
|
||||
.swatch-input { display: block; width: 100%; height: 76px; min-height: 0; padding: 0; border: 0; border-radius: 0; background: none; cursor: pointer }
|
||||
.swatch-input::-webkit-color-swatch-wrapper { padding: 0 }
|
||||
.swatch-input::-webkit-color-swatch { border: 0; border-radius: 0 }
|
||||
.swatch-input::-moz-color-swatch { border: 0 }
|
||||
.swatch-hex { position: absolute; left: 10px; bottom: 8px; padding: 2px 7px; border-radius: 5px; background: #ffffffe6; color: var(--ink); font-size: 11px; box-shadow: 0 1px 2px #0000001f }
|
||||
.swatch-fields { display: grid; padding: 0 12px 12px }
|
||||
.swatch-fields label { margin: 10px 0 4px; font-size: 11px }
|
||||
.swatch-fields input { min-height: 36px; padding: 6px 10px; font-size: 13px }
|
||||
.swatch-card .field-remove { position: absolute; top: 6px; right: 6px; width: 32px; height: 32px; background: #ffffffe6 }
|
||||
.list-actions { display: flex; align-items: center; gap: 16px; flex-wrap: wrap; margin-top: 16px }
|
||||
.list-actions .field-help { flex: 1 1 300px; margin: 0 }
|
||||
.font-list { list-style: none; margin: 14px 0 0; padding: 0; display: grid; gap: 12px }
|
||||
.font-card { position: relative; border: 1px solid var(--line); border-radius: 12px; background: var(--paper); overflow: hidden }
|
||||
.font-card-new { border-style: dashed }
|
||||
/* Die Probe steht in der Schrift selbst - das Skript setzt die Familie, die Datei liegt auf dem Server. */
|
||||
.font-sample { display: flex; align-items: center; gap: 18px; min-height: 88px; padding: 18px 60px 18px 22px; background: #fafbf8; border-bottom: 1px solid var(--line) }
|
||||
.font-sample-big { flex: none; font-size: 44px; line-height: 1; font-weight: 500 }
|
||||
.font-sample-line { display: grid; gap: 6px; min-width: 0 }
|
||||
.font-sample-text { font-size: 19px; line-height: 1.3; overflow-wrap: anywhere }
|
||||
.font-sample-weights { display: flex; flex-wrap: wrap; gap: 4px 14px; font-size: 13px; color: var(--muted) }
|
||||
.font-weight-100 { font-weight: 100 } .font-weight-200 { font-weight: 200 } .font-weight-300 { font-weight: 300 }
|
||||
.font-weight-400 { font-weight: 400 } .font-weight-500 { font-weight: 500 } .font-weight-600 { font-weight: 600 }
|
||||
.font-weight-700 { font-weight: 700 } .font-weight-800 { font-weight: 800 } .font-weight-900 { font-weight: 900 }
|
||||
.font-fields { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: 0 16px; padding: 0 22px 18px }
|
||||
.font-fields label { margin-top: 12px }
|
||||
.font-weights { grid-column: 1 / -1; display: flex; flex-wrap: wrap; align-items: center; gap: 6px; margin: 16px 0 0; padding: 0; border: 0 }
|
||||
.font-weights legend { float: left; margin-right: 8px; padding: 0; font-size: 12px; font-weight: 600 }
|
||||
.chip { display: inline-flex; align-items: center; gap: 6px; margin: 0; padding: 5px 10px; border: 1px solid var(--line); border-radius: 20px; background: var(--paper); font-size: 12px; font-weight: 500; cursor: pointer }
|
||||
.chip input { width: 14px; min-height: 14px; margin: 0 }
|
||||
.chip:has(input:checked) { border-color: var(--accent); background: #edf4e8; color: #22553e }
|
||||
.font-card .field-remove { position: absolute; top: 10px; right: 10px }
|
||||
.type-layout { display: grid; grid-template-columns: minmax(0, 1fr) minmax(280px, 380px); gap: 16px 36px }
|
||||
.type-fields { min-width: 0 }
|
||||
.type-colors .color-control { margin-top: 0 }
|
||||
.type-preview-column { min-width: 0 }
|
||||
.type-preview { margin-top: 12px; padding: 26px 26px 22px; border: 1px solid var(--line); border-radius: 12px; background: #fff; overflow: hidden }
|
||||
.type-preview p { margin: 0 }
|
||||
.type-preview .type-preview-h1 { margin-bottom: 10px; font-size: 34px; font-weight: 700; line-height: 1.15; letter-spacing: -.5px }
|
||||
.type-preview .type-preview-h2 { margin-bottom: 14px; font-size: 26px; font-weight: 600; line-height: 1.2 }
|
||||
.type-preview-p { font-size: 17px; line-height: 1.6 }
|
||||
.type-preview-link { text-decoration: underline; text-underline-offset: 3px }
|
||||
.theme-save { display: flex; align-items: center; gap: 16px; flex-wrap: wrap }
|
||||
.theme-save .muted { font-size: 12px }
|
||||
.theme-form .button-defaults { margin: 0; background: var(--paper) }
|
||||
@media (max-width: 1000px) { .type-layout { grid-template-columns: 1fr } }
|
||||
/* Ein Dokument in der Mediathek: drei Buchstaben statt eines Zeichens. */
|
||||
.row-icon-text { font-size: 11px; font-weight: 700; letter-spacing: .04em }
|
||||
/* Ein Bild der Mediathek als Formularfeld: Vorschau, Name, Wählen und Entfernen. */
|
||||
.media-field-label { display: block; font-weight: 650; font-size: 13px; margin-bottom: 6px }
|
||||
.media-field { min-width: 0 }
|
||||
.media-field-current { display: flex; align-items: center; gap: 10px; min-height: 64px; padding: 6px; border: 1px solid var(--line); border-radius: 9px; background: #fff }
|
||||
.media-field-thumb { width: 96px; height: 64px; object-fit: cover; border-radius: 6px; flex-shrink: 0; background: #eef1eb }
|
||||
.media-field-name { font-size: 13px; color: var(--muted); overflow-wrap: anywhere; min-width: 0 }
|
||||
.media-field-actions { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; margin-top: 8px }
|
||||
/* Ein Linkfeld: die Adresse zum Tippen, darunter Seite oder PDF zum Wählen. */
|
||||
.link-field-actions { display: flex; flex-wrap: wrap; gap: 4px 12px; margin-top: 4px }
|
||||
.link-field-actions .text-button { padding: 4px 0; font-size: 13px }
|
||||
/* Vorhandene Kategorien und Schlagwörter zum Anklicken. */
|
||||
.term-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px }
|
||||
.term-chip { border: 1px solid var(--line); background: #fff; color: var(--ink); border-radius: 999px; padding: 4px 11px; font-size: 12px; cursor: pointer }
|
||||
.term-chip[aria-pressed="true"] { background: #edf4dc; border-color: var(--accent); font-weight: 650 }
|
||||
.term-chip[aria-pressed="true"]::before { content: '✓ '; }
|
||||
/* Der Ziehknopf eines Menüeintrags liegt über der linken Ecke der Zusammenfassung, gehört aber
|
||||
nicht zu ihr - sonst stünde ein Knopf in einem Knopf. */
|
||||
.menu-entry { position: relative }
|
||||
.menu-entry > .menu-drag-handle { position: absolute; left: 14px; top: 10px; z-index: 1 }
|
||||
.menu-entry summary { padding-left: 58px; min-height: 60px }
|
||||
@media (max-width: 560px) { .menu-entry > .menu-drag-handle { left: 8px; top: 8px } .menu-entry summary { padding-left: 50px; min-height: 56px } }
|
||||
/* Texte der Website in weiteren Sprachen (Einstellungen → Sprachen, ADR 111). */
|
||||
.language-texts { margin-top: 24px; padding-top: 16px; border-top: 1px solid #dfe5e1 }
|
||||
.language-texts h3 { margin: 0 0 6px; font-size: 15px }
|
||||
.language-text-group { display: grid; gap: 6px; border: 1px solid #dfe5e1; border-radius: 8px; padding: 12px 16px; margin: 12px 0; min-width: 0 }
|
||||
.language-text-group legend { font-weight: 600; padding: 0 4px }
|
||||
/* ---- Update mit einem Klick (ADR 113), nach dem Vorbild von reiseSoft --------------------- */
|
||||
.update-wait, .update-aktuell { display: flex; align-items: center; gap: 10px; margin: 0 0 12px }
|
||||
.update-spinner { width: 16px; height: 16px; flex: none; border: 2px solid currentColor; border-right-color: transparent; border-radius: 50%; animation: update-dreh .8s linear infinite }
|
||||
@keyframes update-dreh { to { transform: rotate(360deg) } }
|
||||
@media (prefers-reduced-motion: reduce) { .update-spinner { animation-duration: 2.4s } }
|
||||
.update-neu { border: 1px solid #b9d7c2; background: #f1f8f2; border-radius: 10px; padding: 16px 18px; margin: 0 0 14px; display: grid; gap: 6px; justify-items: start }
|
||||
.update-neu h3 { margin: 0; font-size: 17px }
|
||||
.update-neu .muted { margin: 0 }
|
||||
.update-neu-label { margin: 0; font-size: 11px; font-weight: 700; letter-spacing: .08em; text-transform: uppercase; color: #1f6b45 }
|
||||
.update-notizen { max-height: 220px; overflow: auto; width: 100%; font-size: 13px; line-height: 1.55; color: #33413a; background: #fff; border: 1px solid #dfe5e1; border-radius: 8px; padding: 10px 12px; margin: 6px 0 10px }
|
||||
.update-lauf { background: #1f2d27; color: #e9efe9; border-radius: 12px; padding: 18px 20px; margin: 0 0 14px; display: grid; gap: 12px; justify-items: start }
|
||||
.update-lauf-kopf { display: flex; align-items: center; gap: 10px; margin: 0; font-weight: 650 }
|
||||
.update-schritte { list-style: none; margin: 0; padding: 0; display: grid; gap: 8px; width: 100% }
|
||||
.update-schritt { display: flex; align-items: center; gap: 10px; font-size: 14px }
|
||||
.update-marke { display: grid; place-items: center; width: 22px; height: 22px; flex: none; border-radius: 50%; border: 2px solid #5e6f66; font-size: 12px; font-weight: 700 }
|
||||
.update-schritt.is-erledigt .update-marke { background: #3fa46a; border-color: #3fa46a; color: #fff }
|
||||
.update-schritt.is-aktiv .update-marke { border-color: #f2c14e; box-shadow: 0 0 0 4px #f2c14e33; animation: update-puls 1.2s ease-in-out infinite }
|
||||
.update-schritt.is-offen { color: #8f9e96 }
|
||||
.update-schritt.is-fehler .update-marke { background: #d4553f; border-color: #d4553f; color: #fff }
|
||||
@keyframes update-puls { 50% { box-shadow: 0 0 0 8px #f2c14e11 } }
|
||||
@media (prefers-reduced-motion: reduce) { .update-schritt.is-aktiv .update-marke { animation: none } }
|
||||
.update-meldung { margin: 0; font-size: 13px; color: #c9d4cd }
|
||||
.update-meldung.is-ok { color: #9fdcb5 }
|
||||
.update-meldung.is-fehler { color: #ffb4a6 }
|
||||
.update-lauf .secondary { background: transparent; color: #fff; border-color: #ffffff66 }
|
||||
.update-letztes { margin: 0 0 12px }
|
||||
.update-manual { margin-top: 6px }
|
||||
.update-dialog { max-width: 460px; border: 0; border-radius: 12px; padding: 24px 26px; box-shadow: 0 18px 48px #0003 }
|
||||
.update-dialog::backdrop { background: #1f2d2766 }
|
||||
.update-dialog h2 { margin: 0 0 10px; font-size: 18px }
|
||||
.update-dialog .dialog-actions { display: flex; justify-content: flex-end; gap: 10px; margin-top: 18px }
|
||||
@@ -0,0 +1 @@
|
||||
.button-designer{padding:16px 0;border-block:1px solid #dce4dc;margin-block:16px}.button-designer h3{margin:0 0 12px;font-size:15px}.button-state-previews{display:grid;gap:12px;padding:16px;background:#fff;border:1px solid #dce4dc;border-radius:12px;margin-bottom:16px}.button-state{display:grid;justify-items:start;gap:7px;min-width:0}.button-state>.field-help{margin:0}.button-preset-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px;margin:12px 0 16px}.button-preset{display:grid;justify-items:center;align-content:center;gap:9px;min-width:0;padding:12px 6px;background:#fff;border:1px solid #dce4dc;border-radius:10px;color:#203b34;cursor:pointer}.button-preset[aria-pressed=true]{border-color:#2c6b4f;box-shadow:inset 0 0 0 1px #2c6b4f;background:#f1f6ef}.button-preset:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.button-preset .cms-button{font-size:12px!important;padding:6px 10px;min-height:34px;pointer-events:none}.button-preset-label{font-size:11px;line-height:1.4;text-align:center}.button-designer select{width:100%;margin-bottom:12px}.button-defaults{margin-block:24px;padding:24px;border:1px solid #dce4dc;border-radius:14px;min-width:0}.button-defaults legend{font-size:20px;font-weight:700;padding:0 8px}.button-defaults-layout{display:grid;grid-template-columns:minmax(0,1fr) minmax(260px,.75fr);gap:28px}.button-defaults .fields{align-content:start}.button-defaults .button-preview-column{min-width:0}.button-defaults .button-preview-column h3{font-size:15px;margin:0 0 12px}.button-defaults .color-field{margin:0}.button-defaults .color-trigger{width:100%}@media(max-width:900px){.button-defaults-layout{grid-template-columns:1fr}.button-defaults{padding:16px}}.icon-picker-label{margin:0 0 6px;font-size:13px;font-weight:700;color:#203b34}.icon-field{margin:16px 0 14px}.icon-field-row{display:grid;grid-template-columns:44px minmax(0,1fr) auto;align-items:center;gap:10px;padding:8px;border:1px solid #dce4dc;border-radius:10px;background:#fff}.icon-field-preview{display:grid;place-items:center;width:44px;height:44px;border-radius:8px;background:#eef5ea;color:#1d4d37}.icon-field-preview svg{width:24px;height:24px;fill:none;stroke:currentColor;stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round}.icon-field-preview.is-empty{background:repeating-linear-gradient(135deg,#f3f5f2 0 6px,#fff 6px 12px);border:1px dashed #c5d2c7}.icon-field-name{font-size:14px;font-weight:600;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.icon-field-change{min-height:36px;white-space:nowrap}.icon-setting{margin:0 0 14px}.segmented{display:grid;border:1px solid #dce4dc;border-radius:9px;overflow:hidden}.segmented button{min-height:38px;border:0;background:#fff;color:#203b34;font-size:13px;font-weight:600;cursor:pointer}.segmented button+button{border-left:1px solid #dce4dc}.segmented button[aria-pressed=true]{background:#2c6b4f;color:#fff}.segmented button:focus-visible{outline:3px solid #b55b12;outline-offset:-3px}.icon-design-link{display:block;margin:4px 0 8px;font-weight:600}dialog.icon-dialog{max-width:860px;width:calc(100% - 40px);padding:0;overflow:hidden}.icon-dialog::backdrop{background:#1b2a2466}.icon-dialog-head{display:flex;align-items:center;justify-content:space-between;padding:18px 22px;border-bottom:1px solid #dce4dc}.icon-dialog-head h2{margin:0;font-size:19px}.icon-dialog-close{display:grid;place-items:center;width:40px;height:40px;border:0;border-radius:8px;background:transparent;color:#203b34;cursor:pointer}.icon-dialog-close:hover{background:#eef3ec}.icon-dialog-close svg{width:22px;height:22px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round}.icon-dialog-body{display:grid;grid-template-columns:200px minmax(0,1fr);height:min(520px,calc(100vh - 220px))}.icon-dialog-tabs{display:flex;flex-direction:column;gap:2px;padding:12px;border-right:1px solid #dce4dc;background:#f7faf5;overflow:auto}.icon-dialog-tab{text-align:left;padding:10px 12px;border:0;border-radius:8px;background:transparent;color:#203b34;font-size:14px;font-weight:600;cursor:pointer}.icon-dialog-tab:hover{background:#eaf1e6}.icon-dialog-tab[aria-selected=true]{background:#2c6b4f;color:#fff}.icon-dialog-tab:focus-visible,.icon-choice:focus-visible,.icon-dialog-close:focus-visible{outline:3px solid #b55b12;outline-offset:2px}.icon-dialog-panel{padding:16px;overflow:auto}.icon-dialog-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(92px,1fr));gap:8px}.icon-choice{display:grid;justify-items:center;align-content:center;gap:8px;min-height:92px;padding:12px 6px;border:1px solid #dce4dc;border-radius:10px;background:#fff;color:#203b34;cursor:pointer;transition:border-color .15s,background-color .15s}.icon-choice svg{width:28px;height:28px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round}.icon-choice-name{font-size:12px;line-height:1.3;text-align:center}.icon-choice:hover{border-color:#8fb39f;background:#f7faf5}.icon-choice[aria-pLine truncated
|
||||
@@ -0,0 +1,95 @@
|
||||
import { devices, effectiveStyle, hoverStyle, locate, upgradeDocument, setStyle } from './editor-model.js';
|
||||
|
||||
export function effectiveButton(block, device, defaults) {
|
||||
const result = { ...defaults };
|
||||
for (const name of Object.keys(devices)) {
|
||||
Object.assign(result, block.layout?.button?.[name]);
|
||||
if (name === device) break;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Change appearance without touching copy, URL, typography, sizing or layout placement. */
|
||||
export function setButtonDesign(document, id, device, values) {
|
||||
const block = locate(document.blocks, id)?.block;
|
||||
if (block?.type !== 'button' || !Object.hasOwn(devices, device)) return;
|
||||
if (Object.values(values).some(value => value !== '')) upgradeDocument(document, 21);
|
||||
const target = ((block.layout ??= {}).button ??= {});
|
||||
target[device] ??= {};
|
||||
for (const [key, value] of Object.entries(values)) {
|
||||
if (value === '') delete target[device][key]; else target[device][key] = value;
|
||||
// A chosen shape/appearance must visibly take effect even on an older hand-styled button.
|
||||
// Other groups (font, width, padding, margins, position) remain untouched. Undo is atomic.
|
||||
const reset = key === 'shape' ? ['borderRadius'] : key === 'appearance' ? ['backgroundColor', 'backgroundImage', 'gradientFrom', 'gradientTo', 'color', 'borderColor', 'borderWidth', 'borderStyle'] : [];
|
||||
for (const property of reset) {
|
||||
setStyle(document, id, device, property, '');
|
||||
setStyle(document, id, device, property, '', { hover: true });
|
||||
}
|
||||
}
|
||||
if (!Object.keys(target[device]).length) delete target[device];
|
||||
if (!Object.keys(target).length) delete block.layout.button;
|
||||
}
|
||||
|
||||
const element = (tag, className, text) => {
|
||||
const node = document.createElement(tag); if (className) node.className = className;
|
||||
if (text !== undefined) node.textContent = text; return node;
|
||||
};
|
||||
const color = (value, schema) => value?.startsWith('@') ? schema.colors.find(row => row.key === value.slice(1))?.value || '#203b34' : value;
|
||||
|
||||
export function previewVariables(node, values, schema) {
|
||||
const set = (key, value) => node.style.setProperty(`--kb-${key}`, String(value));
|
||||
set('fill', color(values.backgroundColor, schema)); set('ink', color(values.color, schema));
|
||||
set('hover-fill', color(values.hoverBackgroundColor, schema)); set('hover-ink', color(values.hoverColor, schema));
|
||||
const font = schema.fonts.find(font => font.key === values.fontFamily);
|
||||
set('font', font ? `${font.family ? `"${font.family}",` : ''}${font.fallback}` : 'inherit');
|
||||
set('font-size', `${values.fontSize}px`); set('weight', values.fontWeight);
|
||||
set('tracking', `${values.letterSpacing}px`); set('transform', values.textTransform);
|
||||
}
|
||||
|
||||
export function buttonSample(text, values, schema, hover = false) {
|
||||
const sample = element('span', `cms-button b-desktop-shape-${values.shape} b-desktop-appearance-${values.appearance} b-desktop-size-${values.size}${hover ? ' is-hover' : ''}`, text || 'Button');
|
||||
previewVariables(sample, values, schema);
|
||||
return sample;
|
||||
}
|
||||
|
||||
export function renderButtonControls(panel, { block, device, schema, apply }) {
|
||||
const group = element('section', 'button-designer'); group.setAttribute('aria-label', 'Button-Gestaltung');
|
||||
group.append(element('h3', null, 'Button-Gestaltung'));
|
||||
const current = effectiveButton(block, device, schema.buttons);
|
||||
const previews = element('div', 'button-state-previews');
|
||||
for (const [label, hover] of [['Normal', false], ['Beim Überfahren', true]]) {
|
||||
const state = element('div', 'button-state'); state.append(element('span', 'field-help', label));
|
||||
const sample = buttonSample(block.props.text, current, schema, hover);
|
||||
// Preview the block's own free styling as well as its variant and site defaults.
|
||||
for (const [key, definition] of Object.entries(schema.properties)) {
|
||||
if (!definition.css || definition.target || definition.sides || definition.corners || ['position','display','width','height','minWidth','maxWidth','minHeight','maxHeight'].includes(key)) continue;
|
||||
let value = effectiveStyle(block, device, key).value;
|
||||
if (hover) for (const name of Object.keys(devices)) { value = hoverStyle(block, name, key) ?? value; if (name === device) break; }
|
||||
if (value !== undefined && value !== '') sample.style.setProperty(definition.css, definition.type === 'color' ? color(value, schema) : String(value));
|
||||
}
|
||||
state.append(sample); previews.append(state);
|
||||
}
|
||||
group.append(previews);
|
||||
const cards = element('div', 'button-preset-grid'); cards.setAttribute('role', 'group'); cards.setAttribute('aria-label', 'Button-Vorlagen');
|
||||
for (const [index, preset] of schema.buttonPresets.entries()) {
|
||||
const choice = element('button', 'button-preset'); choice.type = 'button'; choice.id = `button-preset-${index}`;
|
||||
choice.setAttribute('aria-label', `Button-Vorlage ${preset.name}`);
|
||||
choice.setAttribute('aria-pressed', String(current.shape === preset.shape && current.appearance === preset.appearance));
|
||||
const sample = buttonSample('Button', { ...schema.buttons, ...preset, size: 'small' }, schema); sample.setAttribute('aria-hidden', 'true');
|
||||
choice.append(sample, element('span', 'button-preset-label', preset.name));
|
||||
choice.addEventListener('click', () => apply({ shape: preset.shape, appearance: preset.appearance }, choice.id)); cards.append(choice);
|
||||
}
|
||||
group.append(cards);
|
||||
for (const [key, options] of Object.entries(schema.buttonOptions)) {
|
||||
const id = `button-${key}`, label = element('label', null, { shape: 'Form', appearance: 'Darstellung', size: 'Button-Größe' }[key]); label.htmlFor = id;
|
||||
const select = element('select'); select.id = id;
|
||||
select.append(new Option(`Übernehmen: ${options[current[key]]}`, ''));
|
||||
for (const [value, name] of Object.entries(options)) select.append(new Option(name, value));
|
||||
select.value = block.layout?.button?.[device]?.[key] ?? '';
|
||||
select.addEventListener('change', () => apply({ [key]: select.value }, id)); group.append(label, select);
|
||||
}
|
||||
const reset = element('button', 'secondary full-width', device === 'desktop' ? 'Website-Standard übernehmen' : 'Größeres Gerät übernehmen'); reset.type = 'button'; reset.id = 'button-reset';
|
||||
reset.addEventListener('click', () => apply({ shape: '', appearance: '', size: '' }, reset.id));
|
||||
group.append(reset, element('p', 'field-help', `${devices[device]}: Form und Darstellung übernehmen die Website-Vorgaben, solange kein eigener Wert gesetzt ist. Vorlagen ersetzen eigene Füllung und Rahmen; Schrift, Breite und Abstände bleiben erhalten.`));
|
||||
panel.append(group);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
/* Shared by the public renderer and the actual button samples in the editor. */
|
||||
.cms-button{display:inline-flex;align-items:center;justify-content:center;gap:.65em;max-width:100%;min-height:44px;padding:var(--b-py,var(--kb-py,15px)) var(--b-px,var(--kb-px,21px));border:2px solid var(--b-border,var(--kb-border,transparent));border-radius:var(--b-radius,var(--kb-radius,8px));background:var(--b-bg,var(--kb-bg,var(--kb-fill,var(--ink,#203b34))));color:var(--b-fg,var(--kb-fg,var(--kb-ink,var(--paper,#f6f7f4))));font-family:var(--kb-font,inherit);font-size:var(--kb-font-size,14px);font-weight:var(--kb-weight,600);letter-spacing:var(--kb-tracking,0);text-transform:var(--kb-transform,none);line-height:1.6;text-align:center;text-decoration:none;overflow-wrap:anywhere;box-sizing:border-box}
|
||||
.cms-button:hover,.cms-button:focus-visible,.cms-button.is-hover{background:var(--b-hover-bg,var(--kb-hover-bg,var(--kb-hover-fill,var(--ink,#203b34))));color:var(--b-hover-fg,var(--kb-hover-fg,var(--kb-hover-ink,var(--paper,#f6f7f4))));text-decoration:none}
|
||||
.cms-button:focus-visible{outline:3px solid currentColor;outline-offset:4px}
|
||||
.cms-button svg{width:1.15em;height:1.15em;flex:none}
|
||||
.cms-button:where(.b-desktop-shape-square){--b-radius:0px}.cms-button:where(.b-desktop-shape-rounded){--b-radius:8px}.cms-button:where(.b-desktop-shape-pill){--b-radius:999px}
|
||||
.cms-button:where(.b-desktop-size-small){--b-py:10px;--b-px:16px}.cms-button:where(.b-desktop-size-normal){--b-py:15px;--b-px:21px}.cms-button:where(.b-desktop-size-large){--b-py:19px;--b-px:30px}
|
||||
.cms-button:where(.b-desktop-appearance-solid){--b-bg:var(--kb-fill);--b-fg:var(--kb-ink);--b-border:transparent;--b-hover-bg:var(--kb-hover-fill);--b-hover-fg:var(--kb-hover-ink)}
|
||||
.cms-button:where(.b-desktop-appearance-outline){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:var(--kb-fill);--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
|
||||
.cms-button:where(.b-desktop-appearance-soft){--b-bg:color-mix(in srgb,var(--kb-fill) 12%,transparent);--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
|
||||
.cms-button:where(.b-desktop-appearance-link){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:transparent;--b-hover-fg:var(--kb-hover-fill);text-decoration:underline;text-underline-offset:.25em}
|
||||
/* Responsive variants */
|
||||
@media(max-width:1024px){
|
||||
.cms-button:where(.b-tablet-shape-square){--b-radius:0px}.cms-button:where(.b-tablet-shape-rounded){--b-radius:8px}.cms-button:where(.b-tablet-shape-pill){--b-radius:999px}
|
||||
.cms-button:where(.b-tablet-size-small){--b-py:10px;--b-px:16px}.cms-button:where(.b-tablet-size-normal){--b-py:15px;--b-px:21px}.cms-button:where(.b-tablet-size-large){--b-py:19px;--b-px:30px}
|
||||
.cms-button:where(.b-tablet-appearance-solid){--b-bg:var(--kb-fill);--b-fg:var(--kb-ink);--b-border:transparent;--b-hover-bg:var(--kb-hover-fill);--b-hover-fg:var(--kb-hover-ink)}
|
||||
.cms-button:where(.b-tablet-appearance-outline){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:var(--kb-fill);--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
|
||||
.cms-button:where(.b-tablet-appearance-soft){--b-bg:color-mix(in srgb,var(--kb-fill) 12%,transparent);--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
|
||||
.cms-button:where(.b-tablet-appearance-link){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:transparent;--b-hover-fg:var(--kb-hover-fill);text-decoration:underline;text-underline-offset:.25em}
|
||||
}
|
||||
@media(max-width:767px){
|
||||
.cms-button:where(.b-mobile-shape-square){--b-radius:0px}.cms-button:where(.b-mobile-shape-rounded){--b-radius:8px}.cms-button:where(.b-mobile-shape-pill){--b-radius:999px}
|
||||
.cms-button:where(.b-mobile-size-small){--b-py:10px;--b-px:16px}.cms-button:where(.b-mobile-size-normal){--b-py:15px;--b-px:21px}.cms-button:where(.b-mobile-size-large){--b-py:19px;--b-px:30px}
|
||||
.cms-button:where(.b-mobile-appearance-solid){--b-bg:var(--kb-fill);--b-fg:var(--kb-ink);--b-border:transparent;--b-hover-bg:var(--kb-hover-fill);--b-hover-fg:var(--kb-hover-ink)}
|
||||
.cms-button:where(.b-mobile-appearance-outline){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:var(--kb-fill);--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
|
||||
.cms-button:where(.b-mobile-appearance-soft){--b-bg:color-mix(in srgb,var(--kb-fill) 12%,transparent);--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:var(--kb-fill);--b-hover-fg:var(--kb-ink)}
|
||||
.cms-button:where(.b-mobile-appearance-link){--b-bg:transparent;--b-fg:var(--kb-fill);--b-border:transparent;--b-hover-bg:transparent;--b-hover-fg:var(--kb-hover-fill);text-decoration:underline;text-underline-offset:.25em}
|
||||
}
|
||||
@@ -0,0 +1,506 @@
|
||||
import { blockLabel, locate, dropList, isContainer } from './editor-model.js';
|
||||
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// The layer above the preview.
|
||||
//
|
||||
// The preview is a sandboxed iframe without scripts of its own, and every change replaces its
|
||||
// whole document. The selection tools therefore live outside it and get rebound after a refresh.
|
||||
// Empty-container hit areas are rebuilt separately inside the frame by preview-containers.js.
|
||||
// The outline, element toolbar, insert points and drop marker live in the editor document, in an
|
||||
// absolutely positioned layer that sits exactly on top of the frame.
|
||||
//
|
||||
// Geometry: the layer is the same box as the frame and carries the same scale transform, so a
|
||||
// rectangle measured inside the frame can be used here unchanged. Measurements come from
|
||||
// getBoundingClientRect, which is relative to the frame's own viewport - that is why scrolling
|
||||
// the preview only needs a re-measure and no offset arithmetic.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Diameter of a plus sign, in the editor's own pixels. */
|
||||
const INSERT_SIZE = 22;
|
||||
|
||||
/**
|
||||
* What the toolbar offers, in the order it appears.
|
||||
*
|
||||
* Each label names the element it acts on - "Sektion duplizieren", not "Duplizieren". The
|
||||
* sidebar carries the same five actions, and two controls with the same name on one screen are
|
||||
* indistinguishable to anyone listening rather than looking. Naming the subject also says more
|
||||
* than the generic verb did: the toolbar floats next to the element, the announcement should
|
||||
* carry that context instead of assuming it was seen.
|
||||
*/
|
||||
const TOOLS = [
|
||||
{ action: 'parent', glyph: '⤴', title: label => `Element über ${label} auswählen` },
|
||||
{ action: 'up', glyph: '↑', title: label => `${label} nach oben verschieben` },
|
||||
{ action: 'down', glyph: '↓', title: label => `${label} nach unten verschieben` },
|
||||
{ action: 'duplicate', glyph: '⧉', title: label => `${label} duplizieren` },
|
||||
{ action: 'delete', glyph: '✕', title: label => `${label} löschen`, danger: true },
|
||||
];
|
||||
|
||||
export function createCanvasOverlay({ stage, frame, getScale, getDocument, getSelected, onSelect, onAction, onInsert, onDrop, onResize = () => {} }) {
|
||||
const layer = document.createElement('div');
|
||||
layer.className = 'canvas-overlay';
|
||||
const hover = element('div', 'ov-box ov-hover');
|
||||
const hoverLabel = element('span', 'ov-label');
|
||||
hover.append(hoverLabel);
|
||||
const select = element('div', 'ov-box ov-select');
|
||||
const selectName = document.createElement('span');
|
||||
selectName.className = 'ov-toolbar-name';
|
||||
selectName.setAttribute('aria-hidden', 'true');
|
||||
const toolbar = element('div', 'ov-toolbar');
|
||||
toolbar.removeAttribute('aria-hidden');
|
||||
toolbar.setAttribute('role', 'toolbar');
|
||||
toolbar.setAttribute('aria-label', 'Aktionen für das gewählte Element');
|
||||
const drop = element('div', 'ov-drop');
|
||||
const dropLabel = element('div', 'ov-drop-label');
|
||||
const insertBefore = insertButton('before', 'Element davor einfügen');
|
||||
const insertAfter = insertButton('after', 'Element dahinter einfügen');
|
||||
toolbar.append(selectName);
|
||||
layer.append(hover, select, toolbar, insertBefore, insertAfter, drop, dropLabel);
|
||||
stage.append(layer);
|
||||
|
||||
for (const tool of TOOLS) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = `ov-tool${tool.danger ? ' ov-tool-danger' : ''}`;
|
||||
button.dataset.action = tool.action;
|
||||
button.textContent = tool.glyph;
|
||||
button.addEventListener('mousedown', event => event.preventDefault());
|
||||
button.addEventListener('click', () => onAction(tool.action, getSelected()));
|
||||
toolbar.append(button);
|
||||
}
|
||||
|
||||
/**
|
||||
* A part that only draws something. The whole layer must not be aria-hidden - it carries real
|
||||
* buttons, and a focusable control inside an aria-hidden subtree is exactly the contradiction
|
||||
* screen readers cannot resolve. So the decoration is hidden one piece at a time.
|
||||
*/
|
||||
function element(tag, className) {
|
||||
const node = document.createElement(tag);
|
||||
node.className = className;
|
||||
node.setAttribute('aria-hidden', 'true');
|
||||
node.hidden = true;
|
||||
return node;
|
||||
}
|
||||
|
||||
function insertButton(where, title) {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'ov-insert';
|
||||
button.title = title;
|
||||
button.setAttribute('aria-label', title);
|
||||
button.textContent = '+';
|
||||
button.hidden = true;
|
||||
button.addEventListener('mousedown', event => event.preventDefault());
|
||||
button.addEventListener('click', () => onInsert(getSelected(), where));
|
||||
return button;
|
||||
}
|
||||
|
||||
let hovered = null, dragging = null, dropPlan = null;
|
||||
|
||||
const doc = () => frame.contentDocument;
|
||||
/** Is this id a block of the current document rather than some id in the markup? */
|
||||
const known = id => Boolean(id) && Boolean(locate(getDocument().blocks, id));
|
||||
|
||||
/** The nearest element that stands for a block of the document. */
|
||||
function blockElement(node) {
|
||||
for (let current = node; current && current !== doc(); current = current.parentElement) {
|
||||
if (current.id && known(current.id)) return current;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function rectOf(id) {
|
||||
const node = id ? doc()?.getElementById(id) : null;
|
||||
if (!node) return null;
|
||||
const box = node.getBoundingClientRect();
|
||||
return box.width || box.height ? box : null;
|
||||
}
|
||||
|
||||
/** Puts a box on a measured rectangle, or hides it when the block is not on screen. */
|
||||
function place(node, box, inset = 0) {
|
||||
if (!box) { node.hidden = true; return false; }
|
||||
node.hidden = false;
|
||||
node.style.left = `${box.left + inset}px`;
|
||||
node.style.top = `${box.top + inset}px`;
|
||||
node.style.width = `${Math.max(0, box.width - inset * 2)}px`;
|
||||
node.style.height = `${Math.max(0, box.height - inset * 2)}px`;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Keeps the chrome legible when the preview is scaled down. A tablet preview on a narrow
|
||||
* screen runs at about 0.6; without this the toolbar would shrink with it and the buttons
|
||||
* would fall under the size a finger or an eye can deal with.
|
||||
*/
|
||||
function counterScale(node, originX, originY) {
|
||||
const scale = getScale();
|
||||
node.style.transformOrigin = `${originX} ${originY}`;
|
||||
node.style.transform = scale < 1 ? `scale(${1 / scale})` : '';
|
||||
}
|
||||
|
||||
function positionToolbar(box, hasInsert) {
|
||||
if (!box) { toolbar.hidden = true; return; }
|
||||
toolbar.hidden = false;
|
||||
const scale = getScale();
|
||||
// The toolbar is drawn at its natural size and then counter-scaled, so the room it needs
|
||||
// inside the frame's coordinates shrinks as the preview shrinks.
|
||||
const needed = toolbar.offsetHeight / scale;
|
||||
// The upper plus sign straddles the top edge. On a narrow element - a button, a heading of
|
||||
// two words - it sits under the middle of the toolbar, so the toolbar makes room for it.
|
||||
const clearance = hasInsert ? INSERT_SIZE / scale / 2 : 0;
|
||||
const above = box.top >= needed + clearance + 2;
|
||||
toolbar.style.left = `${box.left}px`;
|
||||
toolbar.style.top = `${above
|
||||
? box.top - needed - clearance - 2
|
||||
: Math.min(box.bottom + clearance + 2, frame.clientHeight - needed)}px`;
|
||||
counterScale(toolbar, 'left', 'top');
|
||||
}
|
||||
|
||||
function positionInsert(button, box, where) {
|
||||
if (!box) { button.hidden = true; return; }
|
||||
const scale = getScale();
|
||||
const size = INSERT_SIZE / scale;
|
||||
button.hidden = false;
|
||||
button.style.left = `${box.left + box.width / 2 - size / 2}px`;
|
||||
button.style.top = `${(where === 'before' ? box.top : box.bottom) - size / 2}px`;
|
||||
button.style.width = `${size}px`;
|
||||
button.style.height = `${size}px`;
|
||||
counterScale(button, 'center', 'center');
|
||||
}
|
||||
|
||||
/** Renames the toolbar for whatever is selected right now. */
|
||||
function nameTools(id) {
|
||||
const block = locate(getDocument().blocks, id)?.block;
|
||||
const label = block ? blockLabel(block) : 'Element';
|
||||
selectName.textContent = label;
|
||||
for (const button of toolbar.children) {
|
||||
const tool = TOOLS.find(entry => entry.action === button.dataset.action);
|
||||
if (!tool) continue;
|
||||
button.title = tool.title(label);
|
||||
button.setAttribute('aria-label', tool.title(label));
|
||||
}
|
||||
}
|
||||
|
||||
function paintLabel(node, id) {
|
||||
const block = locate(getDocument().blocks, id)?.block;
|
||||
node.textContent = block ? blockLabel(block) : '';
|
||||
counterScale(node, 'left', 'bottom');
|
||||
}
|
||||
|
||||
/** Re-measures everything. Cheap enough to call on every scroll frame. */
|
||||
function sync() {
|
||||
const scale = getScale();
|
||||
layer.style.width = `${frame.clientWidth}px`;
|
||||
layer.style.height = `${frame.clientHeight}px`;
|
||||
layer.style.transform = `scale(${scale})`;
|
||||
if (!doc()) return;
|
||||
const selectedId = getSelected();
|
||||
const selectedBox = rectOf(selectedId);
|
||||
place(select, selectedBox);
|
||||
if (selectedBox) nameTools(selectedId);
|
||||
// Insert points only make sense for a block that has somewhere to sit next to.
|
||||
const item = selectedId ? locate(getDocument().blocks, selectedId) : null;
|
||||
const sibling = item && dropList(getDocument(), item.parent?.id ?? null, item.block);
|
||||
positionInsert(insertBefore, sibling ? selectedBox : null, 'before');
|
||||
positionInsert(insertAfter, sibling ? selectedBox : null, 'after');
|
||||
// The toolbar is placed last because it has to keep clear of the upper plus sign, and only
|
||||
// now is it settled whether there is one.
|
||||
positionToolbar(selectedBox, Boolean(sibling));
|
||||
placeResizers();
|
||||
const hoverBox = hovered && hovered !== selectedId ? rectOf(hovered) : null;
|
||||
if (place(hover, hoverBox)) paintLabel(hoverLabel, hovered);
|
||||
if (dragging) {
|
||||
dropPlan = dragPoint ? planDrop(dragPoint.x, dragPoint.y, dragging.block) : null;
|
||||
paintDrop();
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Column widths. Umut: "am besten per Drag and Drop im Editor".
|
||||
//
|
||||
// The border between two columns of the selected row is a handle. Dragging it moves share from
|
||||
// one neighbour to the other; the row is restyled inline while the pointer moves, and the
|
||||
// result is written to the document once, when the pointer is released. The arrow keys do the
|
||||
// same in steps of five, so the widths are reachable without a mouse.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
const resizers = [];
|
||||
let focusResizer = null;
|
||||
|
||||
/** The row whose borders can be dragged: the selected row, or the row around the selected column. */
|
||||
function resizableRow() {
|
||||
const item = getSelected() ? locate(getDocument().blocks, getSelected()) : null;
|
||||
if (!item) return null;
|
||||
if (item.block.type === 'row') return item.block;
|
||||
return item.parent?.type === 'row' ? item.parent : null;
|
||||
}
|
||||
|
||||
/** The measured widths of the columns as shares of their sum - the truth a drag starts from. */
|
||||
function sharesOf(rowId) {
|
||||
const kids = childElements(rowId);
|
||||
const total = kids.reduce((sum, kid) => sum + kid.box.width, 0);
|
||||
return total > 0 ? kids.map(kid => kid.box.width / total * 100) : null;
|
||||
}
|
||||
|
||||
/** Moves `delta` points of share from the right neighbour to the left one; neither may vanish. */
|
||||
function shift(shares, index, delta) {
|
||||
const pair = shares[index] + shares[index + 1];
|
||||
const least = Math.min(10, pair / 2);
|
||||
const left = Math.max(least, Math.min(pair - least, shares[index] + delta));
|
||||
const next = shares.slice();
|
||||
next[index] = left;
|
||||
next[index + 1] = pair - left;
|
||||
return next;
|
||||
}
|
||||
|
||||
const tenth = share => Math.round(share * 10) / 10;
|
||||
const formatShares = shares => shares.map(share => String(tenth(share))).join(' ');
|
||||
const tracksOf = shares => shares.map(share => `minmax(0,${tenth(share)}fr)`).join(' ');
|
||||
|
||||
function placeResizers() {
|
||||
const row = dragging ? null : resizableRow();
|
||||
const kids = row ? childElements(row.id) : [];
|
||||
// Stacked columns - a phone, or a single-column layout - have no border to drag.
|
||||
const sideBySide = kids.length > 1 && kids.every((kid, index) => index === 0 || kid.box.left >= kids[index - 1].box.right - 1);
|
||||
const wanted = sideBySide ? kids.length - 1 : 0;
|
||||
while (resizers.length > wanted) resizers.pop().remove();
|
||||
while (resizers.length < wanted) resizers.push(resizeButton());
|
||||
const width = 14 / getScale();
|
||||
resizers.forEach((button, index) => {
|
||||
const left = kids[index].box, right = kids[index + 1].box;
|
||||
button.dataset.row = row.id;
|
||||
button.dataset.index = String(index);
|
||||
const label = `Breite zwischen Spalte ${index + 1} und ${index + 2} ändern`;
|
||||
button.setAttribute('aria-label', label);
|
||||
button.title = `${label}: ziehen oder Pfeiltasten`;
|
||||
button.style.left = `${(left.right + right.left) / 2 - width / 2}px`;
|
||||
button.style.top = `${Math.min(left.top, right.top)}px`;
|
||||
button.style.width = `${width}px`;
|
||||
button.style.height = `${Math.max(left.bottom, right.bottom) - Math.min(left.top, right.top)}px`;
|
||||
if (focusResizer && focusResizer.row === row.id && focusResizer.index === index) { button.focus(); focusResizer = null; }
|
||||
});
|
||||
}
|
||||
|
||||
function resizeButton() {
|
||||
const button = document.createElement('button');
|
||||
button.type = 'button';
|
||||
button.className = 'ov-resize';
|
||||
button.addEventListener('pointerdown', startResize);
|
||||
button.addEventListener('keydown', event => {
|
||||
const step = event.key === 'ArrowLeft' ? -5 : event.key === 'ArrowRight' ? 5 : 0;
|
||||
if (!step) return;
|
||||
event.preventDefault();
|
||||
const rowId = button.dataset.row, index = Number(button.dataset.index);
|
||||
const shares = sharesOf(rowId);
|
||||
if (!shares) return;
|
||||
focusResizer = { row: rowId, index };
|
||||
onResize(rowId, formatShares(shift(shares, index, step)));
|
||||
});
|
||||
layer.append(button);
|
||||
return button;
|
||||
}
|
||||
|
||||
function startResize(event) {
|
||||
if (event.button !== 0) return;
|
||||
const button = event.currentTarget;
|
||||
const rowId = button.dataset.row, index = Number(button.dataset.index);
|
||||
const start = sharesOf(rowId);
|
||||
const node = doc()?.getElementById(rowId);
|
||||
if (!start || !node) return;
|
||||
event.preventDefault();
|
||||
button.setPointerCapture(event.pointerId);
|
||||
layer.classList.add('is-resizing');
|
||||
// Pointer pixels are editor pixels; the shares live in the frame's scale, on the summed
|
||||
// width of the columns without the gaps between them.
|
||||
const content = childElements(rowId).reduce((sum, kid) => sum + kid.box.width, 0);
|
||||
const startX = event.clientX;
|
||||
let current = start;
|
||||
const move = moved => {
|
||||
current = shift(start, index, (moved.clientX - startX) / getScale() / content * 100);
|
||||
node.style.gridTemplateColumns = tracksOf(current);
|
||||
sync();
|
||||
};
|
||||
const finish = () => {
|
||||
button.removeEventListener('pointermove', move);
|
||||
button.removeEventListener('pointerup', finish);
|
||||
button.removeEventListener('pointercancel', finish);
|
||||
layer.classList.remove('is-resizing');
|
||||
if (formatShares(current) !== formatShares(start)) onResize(rowId, formatShares(current));
|
||||
else sync();
|
||||
};
|
||||
button.addEventListener('pointermove', move);
|
||||
button.addEventListener('pointerup', finish);
|
||||
button.addEventListener('pointercancel', finish);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------------------------
|
||||
// Dropping.
|
||||
// -------------------------------------------------------------------------------------------
|
||||
|
||||
/** The child elements of a block, in document order, that stand for its children. */
|
||||
function childElements(id) {
|
||||
const block = id ? locate(getDocument().blocks, id)?.block : null;
|
||||
const children = id ? (block?.children ?? []) : getDocument().blocks;
|
||||
return children.map(child => ({ id: child.id, box: rectOf(child.id) })).filter(entry => entry.box);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a drop would land inside a container. Children that sit side by side - the columns of
|
||||
* a row, the images of a gallery - are compared left to right, everything else top to bottom,
|
||||
* so the insertion line always runs across the gap the pointer is actually in.
|
||||
*/
|
||||
function indexInside(parentId, x, y) {
|
||||
const kids = childElements(parentId);
|
||||
if (!kids.length) return { index: 0, line: null };
|
||||
const sideBySide = kids.length > 1 && kids.some(kid => kid.box.left >= kids[0].box.right - 1);
|
||||
for (let index = 0; index < kids.length; index++) {
|
||||
const box = kids[index].box;
|
||||
const middle = sideBySide ? box.left + box.width / 2 : box.top + box.height / 2;
|
||||
if ((sideBySide ? x : y) < middle) return { index, line: { box, edge: sideBySide ? 'left' : 'top' }, sideBySide };
|
||||
}
|
||||
const last = kids[kids.length - 1].box;
|
||||
return { index: kids.length, line: { box: last, edge: sideBySide ? 'right' : 'bottom' }, sideBySide };
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a pointer position into a concrete destination. The rule, from the inside out: land
|
||||
* inside the hovered block when it is a container that takes this kind of child, otherwise
|
||||
* next to it, otherwise try the same two answers one level up.
|
||||
*/
|
||||
function planDrop(x, y, block) {
|
||||
const document_ = getDocument();
|
||||
if (x < 0 || y < 0 || x > frame.clientWidth || y > frame.clientHeight) return null;
|
||||
const start = blockElement(doc()?.elementFromPoint(x, y));
|
||||
for (let node = start; ; ) {
|
||||
const id = node?.id ?? null;
|
||||
const item = id ? locate(document_.blocks, id) : null;
|
||||
// Never offer to drop a block into itself or into its own subtree.
|
||||
const ownBranch = block && id && (id === block.id || Boolean(locate(block.children || [], id)));
|
||||
if (id && !ownBranch && isContainer(item?.block) && dropList(document_, id, block)) {
|
||||
const inside = indexInside(id, x, y);
|
||||
return { parentId: id, index: inside.index, line: inside.line ?? { box: rectOf(id), edge: 'inside' } };
|
||||
}
|
||||
const parentId = item?.parent?.id ?? null;
|
||||
if (id && !ownBranch && dropList(document_, parentId, block)) {
|
||||
const box = rectOf(id);
|
||||
const horizontal = item.parent?.type === 'row' && indexInside(parentId, x, y).sideBySide;
|
||||
const after = box ? (horizontal ? x > box.left + box.width / 2 : y > box.top + box.height / 2) : false;
|
||||
return { parentId, index: item.index + (after ? 1 : 0), line: box ? { box, edge: horizontal ? (after ? 'right' : 'left') : (after ? 'bottom' : 'top') } : null };
|
||||
}
|
||||
if (!node) break;
|
||||
node = item?.parent ? doc()?.getElementById(item.parent.id) : null;
|
||||
if (!node) {
|
||||
// Fell out of the tree: the top level is the last thing left to try.
|
||||
if (!dropList(document_, null, block)) break;
|
||||
const inside = indexInside(null, x, y);
|
||||
return { parentId: null, index: inside.index, line: inside.line };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function paintDrop() {
|
||||
const line = dropPlan?.line;
|
||||
if (!line?.box) { drop.hidden = true; dropLabel.hidden = true; return; }
|
||||
drop.hidden = false;
|
||||
const scale = getScale();
|
||||
const thickness = 3 / scale;
|
||||
const { box, edge } = line;
|
||||
const target = dropPlan.parentId ? doc()?.getElementById(dropPlan.parentId) : null;
|
||||
const label = target?.dataset.editorLabel || (dropPlan.parentId ? blockLabel(locate(getDocument().blocks, dropPlan.parentId)?.block) : 'Seite');
|
||||
dropLabel.textContent = `${edge === 'inside' ? 'Einfügen in' : 'Einfügen ·'} ${label}`;
|
||||
dropLabel.hidden = false;
|
||||
dropLabel.style.left = `${Math.max(0, box.left + 8)}px`;
|
||||
dropLabel.style.top = `${Math.max(2, box.top + 8)}px`;
|
||||
counterScale(dropLabel, 'left', 'top');
|
||||
drop.style.marginLeft = drop.style.marginTop = '0';
|
||||
drop.classList.toggle('ov-drop-inside', edge === 'inside');
|
||||
if (edge === 'inside') {
|
||||
drop.style.left = `${box.left}px`; drop.style.top = `${box.top}px`;
|
||||
drop.style.width = `${box.width}px`; drop.style.height = `${box.height}px`;
|
||||
return;
|
||||
}
|
||||
const vertical = edge === 'left' || edge === 'right';
|
||||
drop.style.left = `${edge === 'right' ? box.right : box.left}px`;
|
||||
drop.style.top = `${edge === 'bottom' ? box.bottom : box.top}px`;
|
||||
drop.style.width = `${vertical ? thickness : box.width}px`;
|
||||
drop.style.height = `${vertical ? box.height : thickness}px`;
|
||||
if (vertical) drop.style.marginLeft = `${-thickness / 2}px`; else drop.style.marginLeft = '0';
|
||||
if (!vertical) drop.style.marginTop = `${-thickness / 2}px`; else drop.style.marginTop = '0';
|
||||
}
|
||||
|
||||
/** Attaches to a freshly loaded preview document. Called after every preview refresh. */
|
||||
function attach() {
|
||||
const document_ = doc();
|
||||
if (!document_) return;
|
||||
document_.addEventListener('mousemove', event => {
|
||||
if (dragging) return;
|
||||
const node = blockElement(event.target);
|
||||
const id = node?.id ?? null;
|
||||
if (id === hovered) return;
|
||||
hovered = id;
|
||||
sync();
|
||||
});
|
||||
document_.documentElement.addEventListener('mouseleave', () => { hovered = null; sync(); });
|
||||
document_.addEventListener('scroll', sync, { passive: true, capture: true });
|
||||
document_.defaultView?.addEventListener('scroll', sync, { passive: true });
|
||||
// Inside the frame an event already carries frame coordinates, and those are exactly the
|
||||
// coordinates the layer draws in - no conversion, and none of the rounding a trip through
|
||||
// the editor viewport and back through the scale factor would add.
|
||||
document_.addEventListener('dragover', event => {
|
||||
if (!dragging) return;
|
||||
event.preventDefault();
|
||||
dragPoint = { x: event.clientX, y: event.clientY };
|
||||
dropPlan = planDrop(event.clientX, event.clientY, dragging.block);
|
||||
event.dataTransfer.dropEffect = dropPlan ? (dragging.kind === 'move' ? 'move' : 'copy') : 'none';
|
||||
paintDrop();
|
||||
if (!scrollFrame) scrollFrame = requestAnimationFrame(scrollDrag);
|
||||
});
|
||||
document_.addEventListener('dragleave', event => {
|
||||
if (!event.relatedTarget) { dragPoint = null; dropPlan = null; paintDrop(); }
|
||||
});
|
||||
document_.addEventListener('drop', event => {
|
||||
if (!dragging) return;
|
||||
event.preventDefault();
|
||||
// Both are read before the clean-up clears them.
|
||||
const payload = dragging, plan = planDrop(event.clientX, event.clientY, dragging.block);
|
||||
endDrag();
|
||||
if (plan) onDrop(payload, plan);
|
||||
});
|
||||
sync();
|
||||
}
|
||||
|
||||
function startDrag(payload) {
|
||||
dragging = payload;
|
||||
layer.classList.add('is-dragging');
|
||||
doc()?.documentElement.classList.add('editor-dragging');
|
||||
hovered = null;
|
||||
sync();
|
||||
}
|
||||
|
||||
function endDrag() {
|
||||
dragging = null;
|
||||
dragPoint = null;
|
||||
cancelAnimationFrame(scrollFrame); scrollFrame = null;
|
||||
dropPlan = null;
|
||||
drop.hidden = true;
|
||||
dropLabel.hidden = true;
|
||||
layer.classList.remove('is-dragging');
|
||||
doc()?.documentElement.classList.remove('editor-dragging');
|
||||
sync();
|
||||
}
|
||||
|
||||
let dragPoint = null, scrollFrame = null;
|
||||
function scrollDrag() {
|
||||
scrollFrame = null;
|
||||
if (!dragging || !dragPoint) return;
|
||||
const edge = 60 / getScale();
|
||||
const amount = dragPoint.y < edge ? -14 : dragPoint.y > frame.clientHeight - edge ? 14 : 0;
|
||||
if (amount) {
|
||||
doc()?.defaultView?.scrollBy(0, amount / getScale());
|
||||
sync();
|
||||
scrollFrame = requestAnimationFrame(scrollDrag);
|
||||
}
|
||||
}
|
||||
|
||||
return { sync, attach, startDrag, endDrag, get dragPayload() { return dragging; } };
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
.color-control{min-width:0;border:1px solid var(--line);border-radius:12px;background:#fff;overflow:hidden}
|
||||
.color-trigger{display:flex;align-items:center;gap:10px;width:100%;min-height:62px;padding:9px;text-align:left;border:0;border-radius:11px;background:#fff;color:var(--ink);cursor:pointer}
|
||||
.color-trigger:hover{background:#f5f7f3}
|
||||
.color-trigger:focus-visible{outline:2px solid var(--accent);outline-offset:-3px}
|
||||
.color-sample{position:relative;flex:0 0 38px;height:38px;border-radius:8px;overflow:hidden;box-shadow:inset 0 0 0 1px #203b3426;background:repeating-conic-gradient(#e1e6e1 0% 25%,#fff 0% 50%) 0 0 / 10px 10px}
|
||||
.color-sample-ink{position:absolute;inset:0;box-shadow:inset 0 0 0 1px #203b341f;border-radius:inherit}
|
||||
.color-description{display:grid;gap:3px;flex:1;min-width:0}
|
||||
.color-name{font-size:12px;font-weight:600;line-height:1.35;overflow-wrap:anywhere}
|
||||
.color-detail{font-size:10px;line-height:1.4;color:#56645c;overflow-wrap:anywhere}
|
||||
.color-chevron{flex:0 0 12px;font-size:17px;color:#56645c}
|
||||
.color-trigger[aria-expanded=true] .color-chevron{transform:rotate(180deg)}
|
||||
.color-choices{padding:12px 10px;border-top:1px solid var(--line);background:#fafbf8}
|
||||
.color-choices[hidden]{display:none}
|
||||
.color-control .color-heading{display:block;margin:0 0 8px;font-size:10px;line-height:1.5;font-weight:700;letter-spacing:.04em;color:#526258}
|
||||
.color-palette{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:6px;max-height:248px;overflow-y:auto;padding:3px;margin:-3px -3px 13px;scrollbar-width:thin}
|
||||
.color-option{display:flex;align-items:center;gap:6px;min-height:44px;padding:5px;border:1px solid var(--line);border-radius:8px;background:#fff;color:var(--ink);text-align:left;cursor:pointer}
|
||||
.color-option:hover{background:#eef3ea;border-color:#718575}
|
||||
.color-option[aria-pressed=true]{border-color:#2f6b4d;box-shadow:0 0 0 1px #2f6b4d;background:#edf4e8}
|
||||
.color-option:focus-visible,.color-accept:focus-visible,.color-inherit:focus-visible{outline:2px solid var(--accent);outline-offset:1px}
|
||||
.color-option-swatch{flex:0 0 23px;height:28px;border-radius:5px;box-shadow:inset 0 0 0 1px #203b3426}
|
||||
.color-option-name{min-width:0;font-size:10px;line-height:1.3;overflow-wrap:anywhere}
|
||||
.color-custom{display:flex;align-items:center;gap:6px}
|
||||
.color-control .color-native{flex:0 0 36px;width:36px;height:38px;min-height:38px;padding:3px;border-radius:7px;cursor:pointer;background:#fff}
|
||||
.color-control .color-text{flex:1;min-width:0;width:0;font-size:12px;min-height:38px;padding:6px}
|
||||
.color-accept{flex:0 0 30px;min-height:38px;padding:0;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);cursor:pointer}
|
||||
.color-control .field-error{font-size:11px;line-height:1.5;margin:7px 0}
|
||||
.color-inherit{display:block;margin:10px 0 0;padding:3px 0;border:0;background:none;color:#405b4b;font-size:11px;line-height:1.5;text-decoration:underline;cursor:pointer}
|
||||
.color-inherit:disabled{color:#67736a;cursor:default;text-decoration:none}
|
||||
@@ -0,0 +1,125 @@
|
||||
/** A compact palette picker. Palette references stay references when saved. */
|
||||
let sequence = 0;
|
||||
const node = (tag, className, text) => {
|
||||
const el = document.createElement(tag);
|
||||
el.className = className;
|
||||
if (text !== undefined) el.textContent = text;
|
||||
return el;
|
||||
};
|
||||
const hexPattern = /^#[0-9a-f]{6}([0-9a-f]{2})?$/i;
|
||||
|
||||
export function createColorControl({ id, label, value = '', inheritedValue = '', colors, onChange, onComplete = () => {} }) {
|
||||
const wrap = node('div', 'color-control');
|
||||
const trigger = node('button', 'color-trigger');
|
||||
trigger.type = 'button'; trigger.id = id;
|
||||
trigger.setAttribute('aria-expanded', 'false');
|
||||
trigger.setAttribute('aria-controls', `${id}-choices`);
|
||||
const sample = node('span', 'color-sample'); sample.setAttribute('aria-hidden', 'true');
|
||||
const ink = node('span', 'color-sample-ink'); sample.append(ink);
|
||||
const description = node('span', 'color-description');
|
||||
const name = node('span', 'color-name'), detail = node('span', 'color-detail');
|
||||
description.append(name, detail);
|
||||
const arrow = node('span', 'color-chevron', '⌄'); arrow.setAttribute('aria-hidden', 'true');
|
||||
trigger.append(sample, description, arrow);
|
||||
|
||||
const choices = node('div', 'color-choices'); choices.id = `${id}-choices`; choices.hidden = true;
|
||||
const heading = node('p', 'color-heading', 'Website-Farben');
|
||||
const palette = node('div', 'color-palette');
|
||||
palette.setAttribute('role', 'group'); palette.setAttribute('aria-label', `Website-Farben für ${label}`);
|
||||
const buttons = [];
|
||||
let current = value, editing = false, mergeKey = `color-${++sequence}`;
|
||||
const entryFor = raw => colors.find(color => `@${color.key}` === raw);
|
||||
const resolve = raw => hexPattern.test(raw) ? raw : entryFor(raw)?.value || '';
|
||||
const restoreFocus = () => document.getElementById(id)?.focus();
|
||||
const choose = raw => { current = raw; sync(); onChange(raw); restoreFocus(); };
|
||||
for (const color of colors) {
|
||||
const button = node('button', 'color-option'); button.type = 'button';
|
||||
button.dataset.color = `@${color.key}`;
|
||||
button.title = `${color.name} · ${color.value}`;
|
||||
button.setAttribute('aria-label', button.title);
|
||||
const swatch = node('span', 'color-option-swatch'); swatch.style.backgroundColor = color.value;
|
||||
swatch.setAttribute('aria-hidden', 'true');
|
||||
button.append(swatch, node('span', 'color-option-name', color.name));
|
||||
button.addEventListener('click', () => choose(`@${color.key}`));
|
||||
buttons.push(button); palette.append(button);
|
||||
}
|
||||
if (colors.length) choices.append(heading, palette);
|
||||
|
||||
const customLabel = node('label', 'color-heading', 'Eigene Farbe'); customLabel.htmlFor = `${id}-hex`;
|
||||
const custom = node('div', 'color-custom');
|
||||
const picker = node('input', 'color-native'); picker.type = 'color';
|
||||
picker.setAttribute('aria-label', `Eigene Farbe für ${label}`);
|
||||
const text = node('input', 'color-text'); text.type = 'text'; text.id = `${id}-hex`;
|
||||
text.autocomplete = 'off'; text.spellcheck = false; text.placeholder = '#RRGGBB';
|
||||
text.setAttribute('aria-label', `Farbwert für ${label}`);
|
||||
const accept = node('button', 'color-accept', '✓'); accept.type = 'button';
|
||||
accept.setAttribute('aria-label', `Farbwert für ${label} übernehmen`); accept.title = 'Farbwert übernehmen';
|
||||
custom.append(picker, text, accept);
|
||||
const error = node('p', 'field-error'); error.id = `${id}-error`; error.hidden = true;
|
||||
error.setAttribute('role', 'status'); text.setAttribute('aria-describedby', error.id);
|
||||
const clear = node('button', 'color-inherit', 'Geerbten Wert verwenden'); clear.type = 'button';
|
||||
clear.addEventListener('click', () => choose(''));
|
||||
choices.append(customLabel, custom, error, clear);
|
||||
wrap.append(trigger, choices);
|
||||
|
||||
function sync() {
|
||||
const shown = current || inheritedValue, entry = entryFor(shown), hex = resolve(shown);
|
||||
ink.style.backgroundColor = hex || 'transparent';
|
||||
name.textContent = entry?.name || (hex ? 'Eigene Farbe' : current ? 'Unbekannte Palettenfarbe' : 'Übernehmen');
|
||||
detail.textContent = [!current ? 'Geerbt' : entry ? 'Palette' : '', hex || (current || 'Kein eigener Farbwert')].filter(Boolean).join(' · ');
|
||||
trigger.setAttribute('aria-label', `${label}: ${name.textContent}, ${detail.textContent}. Farbe auswählen`);
|
||||
picker.value = hex ? hex.slice(0, 7) : '#ffffff';
|
||||
text.value = hex || current || '';
|
||||
for (const button of buttons) button.setAttribute('aria-pressed', String(button.dataset.color === shown));
|
||||
clear.disabled = current === '';
|
||||
}
|
||||
const finish = () => {
|
||||
if (!editing) return;
|
||||
editing = false;
|
||||
onComplete();
|
||||
};
|
||||
trigger.addEventListener('click', () => {
|
||||
if (!choices.hidden) {
|
||||
choices.hidden = true; trigger.setAttribute('aria-expanded', 'false'); finish(); restoreFocus();
|
||||
} else {
|
||||
choices.hidden = false; trigger.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
});
|
||||
// A native picker emits many input events during one gesture. Keep its DOM and one undo entry.
|
||||
picker.addEventListener('input', () => {
|
||||
current = picker.value + (resolve(current || inheritedValue).slice(7) || '');
|
||||
editing = true; sync(); onChange(current, mergeKey);
|
||||
});
|
||||
picker.addEventListener('change', () => {
|
||||
current = picker.value + (resolve(current || inheritedValue).slice(7) || '');
|
||||
sync(); onChange(current, editing ? mergeKey : undefined);
|
||||
if (editing) finish();
|
||||
restoreFocus();
|
||||
});
|
||||
const applyText = () => {
|
||||
let raw = text.value.trim();
|
||||
if (/^#[0-9a-f]{3}$/i.test(raw)) raw = '#' + [...raw.slice(1)].map(char => char + char).join('');
|
||||
if (raw && !hexPattern.test(raw) && !entryFor(raw)) {
|
||||
text.setAttribute('aria-invalid', 'true'); error.hidden = false;
|
||||
error.textContent = 'Hexfarbe wie #1a2b3c oder eine vorhandene Palettenfarbe eingeben.';
|
||||
text.focus(); return;
|
||||
}
|
||||
choose(raw);
|
||||
};
|
||||
text.addEventListener('input', () => { text.removeAttribute('aria-invalid'); error.hidden = true; });
|
||||
text.addEventListener('keydown', event => {
|
||||
if (event.key === 'Enter') { event.preventDefault(); applyText(); }
|
||||
});
|
||||
accept.addEventListener('click', applyText);
|
||||
wrap.addEventListener('keydown', event => {
|
||||
if (event.key !== 'Escape' || choices.hidden) return;
|
||||
event.preventDefault(); event.stopPropagation(); choices.hidden = true;
|
||||
trigger.setAttribute('aria-expanded', 'false'); finish(); restoreFocus();
|
||||
});
|
||||
// Commit after leaving the native picker even on browsers that omit its change event.
|
||||
wrap.addEventListener('focusout', () => setTimeout(() => {
|
||||
if (wrap.isConnected && !wrap.contains(document.activeElement)) finish();
|
||||
}, 0));
|
||||
sync();
|
||||
return wrap;
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
/*
|
||||
* Farbfelder in den Verwaltungsformularen.
|
||||
*
|
||||
* Eine Farbe tippt man nicht, man wählt sie. Im Editor gibt es dafür längst ein Bedienelement aus
|
||||
* Farbwähler, Textfeld und Palettenliste - in den Formularen unter „Design" und „Einstellungen"
|
||||
* stand bisher nur ein nacktes Textfeld.
|
||||
*
|
||||
* Das Textfeld bleibt die Wahrheit: Es nimmt einen Wert wie `#1a2b3c` **oder** einen Schlüssel aus
|
||||
* der Palette (`@accent`), und das kann ein Farbwähler nicht. Deshalb wird es nicht ersetzt,
|
||||
* sondern ergänzt. Ohne JavaScript bleibt das Feld genau das, was es vorher war.
|
||||
*/
|
||||
(() => {
|
||||
const felder = [...document.querySelectorAll('input[data-color]')];
|
||||
if (0 === felder.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const feld of felder) {
|
||||
const palette = JSON.parse(feld.dataset.palette || '[]');
|
||||
const reihe = document.createElement('div');
|
||||
reihe.className = 'color-field';
|
||||
feld.parentNode.insertBefore(reihe, feld);
|
||||
|
||||
const waehler = document.createElement('input');
|
||||
waehler.type = 'color';
|
||||
waehler.className = 'color-field-swatch';
|
||||
waehler.setAttribute('aria-label', 'Farbwähler für ' + (feld.labels?.[0]?.textContent.trim() || 'diese Farbe'));
|
||||
// Der Wähler kennt nur Hexwerte. Steht im Feld ein Palettenschlüssel, zeigt er die Farbe,
|
||||
// für die der Schlüssel steht - sonst bliebe er auf Schwarz stehen und sähe falsch aus.
|
||||
const alsHex = wert => {
|
||||
if (/^#[0-9a-f]{6}$/i.test(wert)) {
|
||||
return wert;
|
||||
}
|
||||
const treffer = palette.find(eintrag => '@' + eintrag.key === wert);
|
||||
|
||||
return treffer ? treffer.value : '#000000';
|
||||
};
|
||||
waehler.value = alsHex(feld.value || feld.placeholder || '#000000');
|
||||
waehler.addEventListener('input', () => {
|
||||
feld.value = waehler.value;
|
||||
feld.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
|
||||
reihe.append(waehler, feld);
|
||||
|
||||
if (palette.length > 0) {
|
||||
const auswahl = document.createElement('select');
|
||||
auswahl.className = 'color-field-palette';
|
||||
auswahl.setAttribute('aria-label', 'Farbe aus der Palette');
|
||||
auswahl.append(new Option('Aus der Palette …', ''));
|
||||
for (const eintrag of palette) {
|
||||
auswahl.append(new Option(`${eintrag.name} (${eintrag.value})`, '@' + eintrag.key));
|
||||
}
|
||||
auswahl.value = palette.some(eintrag => '@' + eintrag.key === feld.value) ? feld.value : '';
|
||||
auswahl.addEventListener('change', () => {
|
||||
if ('' === auswahl.value) {
|
||||
return;
|
||||
}
|
||||
feld.value = auswahl.value;
|
||||
waehler.value = alsHex(auswahl.value);
|
||||
feld.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
});
|
||||
reihe.append(auswahl);
|
||||
}
|
||||
|
||||
// Tippt jemand von Hand, folgt der Wähler mit - solange der Wert einer ist, den er kennt.
|
||||
feld.addEventListener('input', () => {
|
||||
const hex = alsHex(feld.value);
|
||||
if (waehler.value !== hex) {
|
||||
waehler.value = hex;
|
||||
}
|
||||
});
|
||||
}
|
||||
})();
|
||||
@@ -0,0 +1,91 @@
|
||||
// Einwilligung ohne Neuladen (ADR 115).
|
||||
//
|
||||
// Ohne dieses Skript schicken der Hinweis und der Knopf am eingebetteten Inhalt ein Formular ab,
|
||||
// der Server setzt das Cookie und leitet auf dieselbe Seite zurück - die Seite lädt neu. Mit dem
|
||||
// Skript geht dasselbe Formular im Hintergrund an denselben Endpunkt; die Kopfzeile
|
||||
// X-Kant-Consent sagt dem Server, dass keine Weiterleitung nötig ist. Danach holt das Skript die
|
||||
// Seite noch einmal und übernimmt daraus nur, was sich durch die Entscheidung ändert: die
|
||||
// eingebetteten Inhalte und die Zeile „Rechtliches“. Die Adresse eines Inhalts steht so weiterhin
|
||||
// erst nach der Einwilligung im Dokument.
|
||||
//
|
||||
// Geht dabei etwas schief, schickt das Skript das Formular ganz normal ab: lieber einmal neu
|
||||
// laden als eine Entscheidung, die nicht ankommt.
|
||||
(() => {
|
||||
const forms = document.querySelectorAll('.consent-bar form, .embed-blocked form');
|
||||
if (forms.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const replaceFrom = async () => {
|
||||
const response = await fetch(location.href, { credentials: 'same-origin', headers: { Accept: 'text/html' } });
|
||||
if (!response.ok) {
|
||||
throw new Error('Seite nicht geladen');
|
||||
}
|
||||
const fresh = new DOMParser().parseFromString(await response.text(), 'text/html');
|
||||
for (const blocked of document.querySelectorAll('.embed-blocked[id]')) {
|
||||
const next = fresh.getElementById(blocked.id);
|
||||
if (next && !next.classList.contains('embed-blocked')) {
|
||||
blocked.replaceWith(document.importNode(next, true));
|
||||
}
|
||||
}
|
||||
for (const legal of document.querySelectorAll('.block-legal[id]')) {
|
||||
const next = fresh.getElementById(legal.id);
|
||||
if (next) {
|
||||
legal.replaceWith(document.importNode(next, true));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
for (const form of forms) {
|
||||
form.addEventListener('submit', async (event) => {
|
||||
event.preventDefault();
|
||||
const button = event.submitter;
|
||||
const body = new FormData(form, button);
|
||||
const placeholder = form.closest('.embed-blocked');
|
||||
for (const control of form.querySelectorAll('button')) {
|
||||
control.disabled = true;
|
||||
}
|
||||
try {
|
||||
const saved = await fetch(form.action, { method: 'POST', body, credentials: 'same-origin', headers: { 'X-Kant-Consent': '1' } });
|
||||
if (!saved.ok) {
|
||||
throw new Error('Entscheidung nicht gespeichert');
|
||||
}
|
||||
// Wer etwas erlaubt hat, bekommt die Inhalte jetzt; wer abgelehnt hat, braucht keinen
|
||||
// zweiten Abruf - es ändert sich nichts außer dem Hinweis.
|
||||
if (body.get('choice') !== 'none' && document.querySelector('.embed-blocked')) {
|
||||
await replaceFrom();
|
||||
}
|
||||
document.querySelector('.consent-bar')?.remove();
|
||||
if (placeholder) {
|
||||
// Der Knopf ist mit dem Platzhalter verschwunden; der Fokus geht an den Inhalt, der an
|
||||
// seiner Stelle steht.
|
||||
const frame = document.getElementById(placeholder.id)?.querySelector('iframe');
|
||||
frame?.focus();
|
||||
} else {
|
||||
// Entschieden im Hinweis: der Fokus beginnt wieder am Seitenanfang, wie nach dem
|
||||
// Neuladen - der nächste Tab führt zu „Zum Inhalt springen“.
|
||||
document.getElementById('seitenanfang')?.focus({ preventScroll: true });
|
||||
}
|
||||
} catch {
|
||||
// Rückfall: das Formular normal absenden, samt gedrücktem Knopf.
|
||||
for (const control of form.querySelectorAll('button')) {
|
||||
control.disabled = false;
|
||||
}
|
||||
withChoice(form, button).submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// form.submit() schickt den gedrückten Knopf nicht mit; sein Wert wandert als verstecktes Feld
|
||||
// ins Formular, damit der Rückfall dieselbe Entscheidung sendet.
|
||||
function withChoice(form, button) {
|
||||
if (button?.name) {
|
||||
const field = document.createElement('input');
|
||||
field.type = 'hidden';
|
||||
field.name = button.name;
|
||||
field.value = button.value;
|
||||
form.append(field);
|
||||
}
|
||||
return form;
|
||||
}
|
||||
})();
|
||||
+361
-76
@@ -5,8 +5,9 @@
|
||||
* what the server accepts – nothing more, nothing less. Adding a property on the server makes it
|
||||
* appear here without touching this file.
|
||||
*/
|
||||
import { devices, effectiveStyle, hoverStyle, setStyle, resetStyleDevice, clearStyle } from './editor-model.js';
|
||||
import { openMedia } from './media.js';
|
||||
import { devices, effectiveStyle, hoverStyle, scrolledStyle, setStyle, resetStyleDevice, clearStyle, isContainer, relevantProperty } from './editor-model.js';
|
||||
import { openMedia, createMediaPreview } from './media.js';
|
||||
import { createColorControl } from './color-control.js';
|
||||
|
||||
export const GROUPS = {
|
||||
text: 'Typografie',
|
||||
@@ -14,15 +15,20 @@ export const GROUPS = {
|
||||
spacing: 'Abstand',
|
||||
size: 'Größe',
|
||||
border: 'Rahmen',
|
||||
image: 'Bild',
|
||||
layout: 'Layout',
|
||||
effect: 'Wirkung',
|
||||
menu: 'Menü-Knopf',
|
||||
slider: 'Slider-Steuerung',
|
||||
icon: 'Symbol',
|
||||
};
|
||||
|
||||
const UNITS = ['px', 'rem', 'em', '%', 'vw', 'vh', 'ch'];
|
||||
// dvh before vh: on a phone 100vh is taller than the screen, 100dvh is what people mean.
|
||||
const UNITS = ['px', 'rem', 'em', '%', 'vw', 'dvh', 'vh', 'svh', 'lvh', 'dvw', 'ch'];
|
||||
|
||||
/** Splits "18.5rem" into its number and its unit; an empty value yields empty parts. */
|
||||
function splitLength(value) {
|
||||
const match = /^(-?\d+(?:\.\d+)?)(px|rem|em|%|vw|vh|ch)$/.exec(value ?? '');
|
||||
const match = /^(-?\d+(?:\.\d+)?)(px|rem|em|%|vw|vh|ch|dvh|svh|lvh|dvw)$/.exec(value ?? '');
|
||||
if (match) return { number: match[1], unit: match[2] };
|
||||
return { number: value === '0' ? '0' : '', unit: 'px' };
|
||||
}
|
||||
@@ -42,6 +48,58 @@ export function contrastRatio(foreground, background) {
|
||||
return (light + 0.05) / (dark + 0.05);
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes a number field draggable sideways, the way the Flatsome scrubfield works.
|
||||
*
|
||||
* Typing "24" into a field to see whether 24 is right, then "26", then "22", is three round
|
||||
* trips for one decision. Dragging answers it in one motion. Typing and the arrow keys keep
|
||||
* working untouched - this is added, nothing is taken away.
|
||||
*
|
||||
* Only for mouse and pen: on a touch screen a horizontal drag over a field is how the panel is
|
||||
* scrolled, and stealing that would make the panel unusable on a phone.
|
||||
*/
|
||||
function makeScrubbable(input, { apply, step, mergeKey }) {
|
||||
const size = 'any' === step || !step ? 1 : Number(step);
|
||||
input.classList.add('scrubbable');
|
||||
let startX = 0, startValue = 0, active = false, moved = false;
|
||||
input.addEventListener('pointerdown', event => {
|
||||
if (0 !== event.button || input.disabled || 'touch' === event.pointerType) return;
|
||||
startX = event.clientX;
|
||||
startValue = '' === input.value ? 0 : Number(input.value);
|
||||
active = Number.isFinite(startValue);
|
||||
moved = false;
|
||||
});
|
||||
input.addEventListener('pointermove', event => {
|
||||
if (!active) return;
|
||||
const distance = event.clientX - startX;
|
||||
// A few pixels of slack, so a plain click still lands in the field to type in.
|
||||
if (!moved && Math.abs(distance) < 3) return;
|
||||
if (!moved) { moved = true; input.setPointerCapture(event.pointerId); }
|
||||
event.preventDefault();
|
||||
// Shift is coarse, Alt is fine - the two speeds a value needs while being judged by eye.
|
||||
const scale = event.shiftKey ? 10 : event.altKey ? 0.1 : 1;
|
||||
const raw = startValue + distance * size * scale;
|
||||
const rounded = Math.round(raw / (size * scale)) * (size * scale);
|
||||
const min = '' === input.min ? -Infinity : Number(input.min);
|
||||
const max = '' === input.max ? Infinity : Number(input.max);
|
||||
const value = Math.min(max, Math.max(min, Math.round(rounded * 1000) / 1000));
|
||||
if (String(value) === input.value) return;
|
||||
input.value = String(value);
|
||||
apply(String(value), mergeKey);
|
||||
});
|
||||
const stop = event => {
|
||||
if (!active) return;
|
||||
active = false;
|
||||
if (!moved) return;
|
||||
if (input.hasPointerCapture?.(event.pointerId)) input.releasePointerCapture(event.pointerId);
|
||||
// The drag was one decision, so it closes as one entry in the history.
|
||||
apply(input.value, null);
|
||||
};
|
||||
input.addEventListener('pointerup', stop);
|
||||
input.addEventListener('pointercancel', stop);
|
||||
input.title = 'Zum Ändern seitwärts ziehen. Umschalt grob, Alt fein.';
|
||||
}
|
||||
|
||||
function element(tag, className, text) {
|
||||
const node = document.createElement(tag);
|
||||
if (className) node.className = className;
|
||||
@@ -49,10 +107,28 @@ function element(tag, className, text) {
|
||||
return node;
|
||||
}
|
||||
|
||||
export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, getDevice, getSelection, getHistory }) {
|
||||
const change = mutate => { getHistory().change(mutate); onChange(); };
|
||||
export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, getDevice, onDevice, getSelection, getHistory, getAreaKind = () => 'page' }) {
|
||||
/**
|
||||
* The merge key lets a drag collapse into a single undo entry instead of one per pixel.
|
||||
*
|
||||
* `keepPanel` is what makes dragging possible at all: a change normally re-renders this panel,
|
||||
* which replaces the very field the pointer is holding on to. The drag would end after its
|
||||
* first pixel. While scrubbing, the document and the preview are updated and the panel is left
|
||||
* standing; it is redrawn once, when the pointer is released.
|
||||
*/
|
||||
const change = (mutate, mergeKey, keepPanel = false) => {
|
||||
getHistory().change(mutate, mergeKey);
|
||||
onChange({ keepPanel });
|
||||
};
|
||||
// Whether the sides of a property move together. This has to outlive the panel: switching the
|
||||
// chain triggers a change, a change re-renders the panel, and a state kept in the button alone
|
||||
// would be gone by the time the next keystroke arrives.
|
||||
const linkedSides = new Map();
|
||||
// Lives as long as the editor session, so a look can travel between blocks and pages.
|
||||
let clipboard = null;
|
||||
let showAll = false, lastBlockId = null;
|
||||
const openGroups = new Map();
|
||||
const renderedGroups = new Map();
|
||||
|
||||
/** Replaces the styling of a block with a copy, or removes it when nothing is given. */
|
||||
const applyStyle = (doc, id, style) => {
|
||||
@@ -71,13 +147,54 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
}
|
||||
};
|
||||
|
||||
/** Short marks for the chips; the full device name is in the title and the accessible name. */
|
||||
const SHORT = { desktop: 'D', tablet: 'T', mobile: 'M' };
|
||||
|
||||
/** Does this device carry an own value for this property - as a plain value or per side? */
|
||||
const hasOwn = (block, deviceName, key, hover) => {
|
||||
const bucket = hover === 'scrolled' ? block?.style?.[deviceName]?.scrolled : hover ? block?.style?.[deviceName]?.hover : block?.style?.[deviceName];
|
||||
const value = bucket?.[key];
|
||||
if (value === undefined) return false;
|
||||
// A per-side property stores an object; an empty one is a leftover, not a value.
|
||||
return value && typeof value === 'object' ? Object.values(value).some(single => single !== '' && single !== undefined) : value !== '';
|
||||
};
|
||||
|
||||
/**
|
||||
* Three chips per option showing which devices carry an own value, and switching to one.
|
||||
*
|
||||
* Until now a single switch above the preview decided which device the whole panel edited,
|
||||
* so "is the heading smaller on the phone" could only be answered by switching and looking.
|
||||
* The chips answer it where the question comes up. Clicking one switches the preview as well:
|
||||
* editing a value for a device you are not looking at is editing blind.
|
||||
*/
|
||||
const deviceChips = (block, key, labelText, hover) => {
|
||||
const current = getDevice();
|
||||
const group = element('div', 'device-chips');
|
||||
group.setAttribute('role', 'group');
|
||||
group.setAttribute('aria-label', `Gerät für ${labelText}`);
|
||||
for (const [name, deviceLabel] of Object.entries(devices)) {
|
||||
const own = hasOwn(block, name, key, hover);
|
||||
const chip = element('button', 'device-chip' + (own ? ' device-chip-own' : ''), SHORT[name]);
|
||||
chip.type = 'button';
|
||||
chip.dataset.device = name;
|
||||
chip.setAttribute('aria-pressed', String(name === current));
|
||||
const state = own ? 'eigener Wert' : 'erbt';
|
||||
chip.title = `${deviceLabel} – ${state}`;
|
||||
chip.setAttribute('aria-label', `${labelText} für ${deviceLabel} bearbeiten – ${state}`);
|
||||
chip.addEventListener('click', () => { if (name !== current) onDevice(name); });
|
||||
group.append(chip);
|
||||
}
|
||||
return group;
|
||||
};
|
||||
|
||||
/** One labelled row with a reset button that appears as soon as this device has an own value. */
|
||||
const row = (id, labelText, control, { own, inherited, onReset }) => {
|
||||
const row = (id, labelText, control, { own, inherited, onReset, chips }) => {
|
||||
const wrap = element('div', 'design-row' + (own ? ' design-own' : ''));
|
||||
const head = element('div', 'design-row-head');
|
||||
const label = element('label', null, labelText);
|
||||
label.htmlFor = id;
|
||||
head.append(label);
|
||||
if (chips) head.append(chips);
|
||||
if (own) {
|
||||
const reset = element('button', 'text-button design-reset-one', '⟲');
|
||||
reset.type = 'button';
|
||||
@@ -91,33 +208,16 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
return wrap;
|
||||
};
|
||||
|
||||
const colorControl = (id, value, apply) => {
|
||||
const wrap = element('div', 'color-control');
|
||||
const picker = document.createElement('input');
|
||||
picker.type = 'color';
|
||||
picker.id = id;
|
||||
picker.value = /^#[0-9a-fA-F]{6}$/.test(value ?? '') ? value : '#000000';
|
||||
const text = document.createElement('input');
|
||||
text.type = 'text';
|
||||
text.className = 'color-text';
|
||||
text.value = value ?? '';
|
||||
text.placeholder = '#1a2b3c';
|
||||
text.setAttribute('aria-label', 'Farbwert oder Palettenname');
|
||||
const palette = document.createElement('select');
|
||||
palette.className = 'color-palette';
|
||||
palette.setAttribute('aria-label', 'Farbe aus der Palette');
|
||||
palette.append(new Option('Palette …', ''));
|
||||
for (const color of schema.colors) palette.append(new Option(`${color.name} (${color.value})`, `@${color.key}`));
|
||||
palette.value = (value ?? '').startsWith('@') ? value : '';
|
||||
const colorControl = (id, value, apply, label, inheritedValue = '') => createColorControl({
|
||||
id, value, label, inheritedValue, colors: schema.colors, onChange: apply,
|
||||
onComplete: () => {
|
||||
const focusedId = document.activeElement?.id;
|
||||
onChange();
|
||||
if (focusedId) document.getElementById(focusedId)?.focus();
|
||||
},
|
||||
});
|
||||
|
||||
picker.addEventListener('input', () => { text.value = picker.value; apply(picker.value); });
|
||||
text.addEventListener('change', () => apply(text.value.trim()));
|
||||
palette.addEventListener('change', () => { if (palette.value) { text.value = palette.value; apply(palette.value); } });
|
||||
wrap.append(picker, text, palette);
|
||||
return wrap;
|
||||
};
|
||||
|
||||
const lengthControl = (id, value, apply, placeholder) => {
|
||||
const lengthControl = (id, value, apply, placeholder, keywords = []) => {
|
||||
const wrap = element('div', 'length-control');
|
||||
const parts = splitLength(value);
|
||||
const number = document.createElement('input');
|
||||
@@ -130,13 +230,42 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
unit.setAttribute('aria-label', 'Einheit');
|
||||
for (const name of UNITS) unit.append(new Option(name, name));
|
||||
unit.value = parts.unit;
|
||||
const push = () => apply(number.value === '' ? '' : `${number.value}${unit.value}`);
|
||||
number.addEventListener('change', push);
|
||||
const push = (_value, mergeKey) => apply(number.value === '' ? '' : `${number.value}${unit.value}`, mergeKey);
|
||||
number.addEventListener('change', () => push());
|
||||
unit.addEventListener('change', () => { if (number.value !== '') push(); });
|
||||
makeScrubbable(number, { apply: push, step: 1, mergeKey: `scrub-${id}` });
|
||||
wrap.append(number, unit);
|
||||
if (keywords.length) {
|
||||
const mode = document.createElement('select');
|
||||
mode.className = 'length-mode';
|
||||
mode.setAttribute('aria-label', `${schema.properties[id.replace('style-', '')]?.label ?? 'Länge'} Vorgabe`);
|
||||
mode.append(new Option('Eigener Zahlenwert / geerbt', ''));
|
||||
for (const keyword of keywords) mode.append(new Option(keyword === 'none' ? 'Ohne Begrenzung' : 'Automatisch', keyword));
|
||||
mode.value = ['auto', 'none'].includes(value) ? value : '';
|
||||
number.hidden = unit.hidden = mode.value !== '';
|
||||
mode.addEventListener('change', () => apply(mode.value));
|
||||
wrap.prepend(mode);
|
||||
}
|
||||
return wrap;
|
||||
};
|
||||
|
||||
/**
|
||||
* The column shares of a row, "2 1" for two thirds and a third. Dragging the border between two
|
||||
* columns in the preview writes the same value; the field is for anyone who prefers numbers.
|
||||
*/
|
||||
const fractionsControl = (id, value, apply) => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.id = id;
|
||||
input.value = value ?? '';
|
||||
input.placeholder = 'gleich breit';
|
||||
input.pattern = String.raw`\d{1,3}(\.\d)?( \d{1,3}(\.\d)?){0,11}`;
|
||||
input.setAttribute('inputmode', 'decimal');
|
||||
input.title = 'Anteile je Spalte, durch Leerzeichen getrennt: 2 1 oder 25 50 25';
|
||||
input.addEventListener('change', () => { if (input.checkValidity()) apply(input.value.trim()); });
|
||||
return input;
|
||||
};
|
||||
|
||||
const numberControl = (id, value, property, apply) => {
|
||||
const wrap = element('div', 'number-control');
|
||||
const slider = document.createElement('input');
|
||||
@@ -153,6 +282,11 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
slider.addEventListener('input', () => { exact.value = slider.value; });
|
||||
slider.addEventListener('change', () => apply(slider.value));
|
||||
exact.addEventListener('change', () => { slider.value = exact.value; apply(exact.value); });
|
||||
makeScrubbable(exact, {
|
||||
apply: (value, mergeKey) => { slider.value = value; apply(value, mergeKey); },
|
||||
step: property.step,
|
||||
mergeKey: `scrub-${id}`,
|
||||
});
|
||||
wrap.append(slider, exact);
|
||||
return wrap;
|
||||
};
|
||||
@@ -177,35 +311,110 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
return select;
|
||||
};
|
||||
|
||||
/** Four inputs for padding, margin, border width and corners. */
|
||||
/**
|
||||
* Padding, margin, border width and corners as a box.
|
||||
*
|
||||
* Four stacked fields labelled "Oben, Rechts, Unten, Links" made you read to find out which
|
||||
* edge you were setting. In a box each field sits on the edge it controls, so the position is
|
||||
* the label. The chain in the middle keeps all four equal, which is what most of them are.
|
||||
*
|
||||
* The unit stays a single choice for the whole property, because four unit menus do not fit a
|
||||
* 240-pixel panel. Sides that already carry different units are shown as "gemischt" and are
|
||||
* left alone until a unit is actually picked - a stored 2rem is not silently turned into 2px.
|
||||
*/
|
||||
const sideControl = (key, block, deviceName, property) => {
|
||||
const grid = element('div', 'side-control');
|
||||
const parts = property.corners ? schema.corners : schema.sides;
|
||||
for (const [side, label] of Object.entries(parts)) {
|
||||
const cell = element('div', 'side-cell');
|
||||
const id = `style-${key}-${side}`;
|
||||
const caption = element('label', null, label);
|
||||
caption.htmlFor = id;
|
||||
const corners = Boolean(property.corners);
|
||||
const parts = corners ? schema.corners : schema.sides;
|
||||
const names = Object.keys(parts);
|
||||
const wrap = element('div', 'side-wrap');
|
||||
const box = element('div', 'side-box' + (corners ? ' side-box-corners' : ''));
|
||||
|
||||
// What is stored right now, per side, split into number and unit.
|
||||
const stored = Object.fromEntries(names.map(side => {
|
||||
const current = effectiveStyle(block, deviceName, key, side);
|
||||
const control = lengthControl(id, current.source === deviceName ? current.value : '', value => {
|
||||
change(doc => setStyle(doc, getSelection(), deviceName, key, value, { side }));
|
||||
}, current.value ? String(current.value) : 'erbt');
|
||||
if (current.value && current.source !== deviceName) control.classList.add('is-inherited');
|
||||
cell.append(caption, control);
|
||||
grid.append(cell);
|
||||
}
|
||||
const all = element('button', 'text-button side-all', 'Alle Seiten gleich setzen');
|
||||
all.type = 'button';
|
||||
all.addEventListener('click', () => {
|
||||
const first = grid.querySelector('input[type=number]');
|
||||
const unit = grid.querySelector('select');
|
||||
if (first?.value === '') return;
|
||||
const own = current.source === deviceName ? current.value : '';
|
||||
return [side, { own, inherited: current.value, parts: splitLength(own) }];
|
||||
}));
|
||||
const usedUnits = [...new Set(names.map(side => stored[side].own ? stored[side].parts.unit : null).filter(Boolean))];
|
||||
const commonUnit = 1 === usedUnits.length ? usedUnits[0] : '';
|
||||
|
||||
const unit = document.createElement('select');
|
||||
unit.id = `style-${key}-unit`;
|
||||
unit.className = 'side-unit-select';
|
||||
unit.setAttribute('aria-label', `Einheit für ${property.label}`);
|
||||
if (usedUnits.length > 1) unit.append(new Option('gemischt', ''));
|
||||
for (const name of UNITS) unit.append(new Option(name, name));
|
||||
unit.value = commonUnit || 'px';
|
||||
|
||||
const linked = element('button', 'side-link', '⛓');
|
||||
linked.type = 'button';
|
||||
// Off to begin with. It replaces the old "Alle Seiten gleich setzen" button, and that was a
|
||||
// deliberate act: typing into one field must not quietly rewrite the three others.
|
||||
linked.setAttribute('aria-pressed', String(linkedSides.get(key) ?? false));
|
||||
linked.title = 'Alle Seiten gemeinsam ändern';
|
||||
linked.setAttribute('aria-label', `Alle Seiten von ${property.label} gemeinsam ändern`);
|
||||
|
||||
/** The unit a side should be written with: its own, else the chosen one. */
|
||||
const unitFor = side => (stored[side].own ? stored[side].parts.unit : null) ?? (unit.value || 'px');
|
||||
|
||||
const write = (side, number, mergeKey) => {
|
||||
const targets = linkedSides.get(key) ? names : [side];
|
||||
change(doc => {
|
||||
for (const side of Object.keys(parts)) setStyle(doc, getSelection(), deviceName, key, `${first.value}${unit.value}`, { side });
|
||||
for (const target of targets) {
|
||||
setStyle(doc, getSelection(), deviceName, key, '' === number ? '' : `${number}${unitFor(target)}`, { side: target });
|
||||
}
|
||||
}, mergeKey, Boolean(mergeKey));
|
||||
};
|
||||
|
||||
for (const side of names) {
|
||||
const id = `style-${key}-${side}`;
|
||||
const field = document.createElement('input');
|
||||
field.type = 'number';
|
||||
field.step = 'any';
|
||||
field.id = id;
|
||||
field.className = 'side-field';
|
||||
field.value = stored[side].parts.number;
|
||||
field.placeholder = stored[side].inherited ? String(stored[side].inherited) : 'erbt';
|
||||
field.setAttribute('aria-label', `${property.label} ${parts[side]}`);
|
||||
if (stored[side].inherited && !stored[side].own) field.classList.add('is-inherited');
|
||||
field.addEventListener('change', () => write(side, field.value, null));
|
||||
makeScrubbable(field, { apply: (value, mergeKey) => write(side, value, mergeKey), step: 1, mergeKey: `scrub-${id}` });
|
||||
const cell = element('div', `side-cell side-${side}`);
|
||||
cell.append(field);
|
||||
box.append(cell);
|
||||
}
|
||||
box.append(linked);
|
||||
|
||||
linked.addEventListener('click', () => {
|
||||
const now = !linkedSides.get(key);
|
||||
linkedSides.set(key, now);
|
||||
linked.setAttribute('aria-pressed', String(now));
|
||||
if (!now) return;
|
||||
// Switching the chain on levels the sides - otherwise it would claim a state the box does
|
||||
// not show. The value taken is the first one that exists: starting from an empty top field
|
||||
// would wipe the sides that do have a value.
|
||||
const set = names.map(side => stored[side].parts.number).find(number => '' !== number);
|
||||
if (undefined === set) return;
|
||||
change(doc => {
|
||||
for (const target of names) setStyle(doc, getSelection(), deviceName, key, `${set}${unit.value || 'px'}`, { side: target });
|
||||
});
|
||||
});
|
||||
const wrap = element('div', 'side-wrap');
|
||||
wrap.append(grid, all);
|
||||
|
||||
unit.addEventListener('change', () => {
|
||||
if ('' === unit.value) return;
|
||||
change(doc => {
|
||||
for (const side of names) {
|
||||
const number = stored[side].own ? stored[side].parts.number : '';
|
||||
if ('' !== number) setStyle(doc, getSelection(), deviceName, key, `${number}${unit.value}`, { side });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const unitRow = element('div', 'side-unit');
|
||||
const unitLabel = element('label', null, 'Einheit');
|
||||
unitLabel.htmlFor = unit.id;
|
||||
unitRow.append(unitLabel, unit);
|
||||
wrap.append(box, unitRow);
|
||||
return wrap;
|
||||
};
|
||||
|
||||
@@ -239,15 +448,18 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
|
||||
const mediaControl = (key, value, apply) => {
|
||||
const wrap = element('div', 'media-control');
|
||||
const status = element('p', 'media-chosen', value ? `Bild gewählt (${String(value).slice(0, 8)}…)` : 'Kein Bild gewählt');
|
||||
const choose = element('button', 'secondary', value ? 'Anderes Bild wählen' : 'Bild aus der Mediathek');
|
||||
const hasImage = Boolean(value && value !== 'none');
|
||||
const preview = createMediaPreview({ mediaId: hasImage ? value : '' });
|
||||
const choose = element('button', 'secondary', hasImage ? 'Anderes Bild wählen' : 'Bild aus der Mediathek');
|
||||
choose.type = 'button';
|
||||
choose.id = `style-${key}`; choose.setAttribute('aria-label', choose.textContent);
|
||||
choose.addEventListener('click', () => openMedia({ onChoose: items => { if (items.length) apply(items[0].id); } }));
|
||||
wrap.append(status, choose);
|
||||
if (value) {
|
||||
wrap.append(preview.element, choose);
|
||||
if (hasImage) {
|
||||
const remove = element('button', 'text-button danger-text', 'Bild entfernen');
|
||||
remove.type = 'button';
|
||||
remove.addEventListener('click', () => apply(''));
|
||||
remove.title = 'Hintergrundbild für dieses Gerät entfernen. Die Datei bleibt in der Mediathek.';
|
||||
remove.addEventListener('click', () => { apply('none'); document.getElementById(choose.id)?.focus(); });
|
||||
wrap.append(remove);
|
||||
}
|
||||
return wrap;
|
||||
@@ -255,28 +467,70 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
|
||||
/** Renders the whole panel for one block into the given container. */
|
||||
return function render(panel) {
|
||||
// Read the previous nodes directly; a queued toggle event can be lost during a redraw.
|
||||
for (const [key, details] of renderedGroups) openGroups.set(key, details.open);
|
||||
renderedGroups.clear();
|
||||
const block = getBlock();
|
||||
const deviceName = getDevice();
|
||||
if (!block) return;
|
||||
if (lastBlockId !== block.id) { showAll = false; lastBlockId = block.id; }
|
||||
|
||||
const scope = element('p', 'design-scope');
|
||||
scope.textContent = `${devices[deviceName]} · ${deviceName === 'desktop' ? 'Basis für alle Geräte' : deviceName === 'tablet' ? 'bis 1024 px, gilt auch für Mobil' : 'bis 767 px'}`;
|
||||
panel.append(scope);
|
||||
panel.append(element('p', 'field-help', 'Leere Felder erben vom größeren Gerät und zuletzt vom Theme. Werte mit eigenem Wert sind hervorgehoben und lassen sich einzeln zurücksetzen.'));
|
||||
const all = element('button', 'secondary design-context-toggle', showAll ? 'Passende Eigenschaften anzeigen' : 'Alle Eigenschaften anzeigen');
|
||||
all.type = 'button'; all.setAttribute('aria-pressed', String(showAll));
|
||||
all.addEventListener('click', () => { showAll = !showAll; onChange(); panel.querySelector('.design-context-toggle')?.focus(); });
|
||||
panel.append(all);
|
||||
|
||||
for (const [group, groupLabel] of Object.entries(GROUPS)) {
|
||||
const properties = Object.entries(schema.properties).filter(([, p]) => p.group === group);
|
||||
// A filter over the properties. With more than ninety of them across eight collapsed groups,
|
||||
// "where is the border thickness" is a fair question - so it can be typed instead of hunted.
|
||||
const search = element('input', 'design-search');
|
||||
search.type = 'search';
|
||||
search.id = 'design-search';
|
||||
search.placeholder = 'Eigenschaft suchen, z. B. Rahmen …';
|
||||
const searchLabel = element('label', 'search-label', 'Eigenschaft suchen');
|
||||
searchLabel.htmlFor = search.id;
|
||||
const found = element('p', 'field-help');
|
||||
search.addEventListener('input', () => {
|
||||
const term = search.value.trim().toLowerCase();
|
||||
let matches = 0;
|
||||
for (const groupElement of panel.querySelectorAll('.design-group')) {
|
||||
let visible = 0;
|
||||
for (const entry of groupElement.querySelectorAll('.design-row, .side-wrap, .design-group-label')) {
|
||||
// A per-side control is a heading plus a block of fields; they travel together.
|
||||
const text = (entry.classList.contains('side-wrap') ? entry.previousElementSibling?.textContent : entry.textContent) ?? '';
|
||||
const hit = '' === term || text.toLowerCase().includes(term);
|
||||
entry.hidden = !hit;
|
||||
if (hit) visible++;
|
||||
}
|
||||
groupElement.hidden = '' !== term && 0 === visible;
|
||||
if (!groupElement.hidden && '' !== term) groupElement.open = true;
|
||||
matches += visible;
|
||||
}
|
||||
found.textContent = '' === term ? '' : (0 === matches ? 'Nichts gefunden. Unter „Erweitert“ gibt es außerdem eigenes CSS für dieses Element.' : `${matches} Treffer.`);
|
||||
});
|
||||
panel.append(searchLabel, search, found);
|
||||
|
||||
const priority = ['image', 'gallery', 'logos'].includes(block.type) ? ['image', 'size', 'spacing'] : isContainer(block) ? ['layout', 'size', 'spacing', 'background'] : ['spacer', 'divider', 'embed'].includes(block.type) ? ['size', 'spacing'] : ['text', 'size', 'spacing'];
|
||||
const orderedGroups = [...new Set([...priority, ...Object.keys(GROUPS)])];
|
||||
for (const group of orderedGroups) {
|
||||
const groupLabel = GROUPS[group];
|
||||
const properties = Object.entries(schema.properties).filter(([key, p]) => p.group === group && (showAll || relevantProperty(block, key, p)));
|
||||
if (properties.length === 0) continue;
|
||||
const details = element('details', 'design-group');
|
||||
if (group === 'text' || group === 'spacing') details.open = true;
|
||||
details.append(element('summary', null, groupLabel));
|
||||
const groupKey = `${block.type}:${group}`;
|
||||
details.open = openGroups.get(groupKey) ?? (group === priority[0] || group === 'spacing' || group === 'icon');
|
||||
renderedGroups.set(groupKey, details);
|
||||
details.append(element('summary', null, groupLabel)); details.dataset.group = group;
|
||||
const body = element('div', 'design-group-body');
|
||||
|
||||
for (const [key, property] of properties) {
|
||||
const id = `style-${key}`;
|
||||
const current = effectiveStyle(block, deviceName, key);
|
||||
const own = block.style?.[deviceName]?.[key] !== undefined;
|
||||
const apply = value => change(doc => setStyle(doc, getSelection(), deviceName, key, value));
|
||||
const apply = (value, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value), mergeKey, Boolean(mergeKey));
|
||||
const onReset = () => change(doc => setStyle(doc, getSelection(), deviceName, key, ''));
|
||||
const inheritedText = current.value !== undefined && !own
|
||||
? `Erbt ${describe(property, current.value)} von ${devices[current.source]}`
|
||||
@@ -284,25 +538,33 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
|
||||
let control;
|
||||
if (property.sides || property.corners) {
|
||||
body.append(element('p', 'design-group-label', property.label), sideControl(key, block, deviceName, property));
|
||||
const sideHead = element('div', 'design-row-head design-side-head');
|
||||
sideHead.append(element('p', 'design-group-label', property.label), deviceChips(block, key, property.label, false));
|
||||
body.append(sideHead, sideControl(key, block, deviceName, property));
|
||||
continue;
|
||||
}
|
||||
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply);
|
||||
else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt');
|
||||
if (property.type === 'color') control = colorControl(id, own ? current.value : '', apply, property.label, current.value || '');
|
||||
else if (property.type === 'length' || property.type === 'length-auto') control = lengthControl(id, own ? current.value : '', apply, current.value ? String(current.value) : 'erbt', property.type === 'length-auto' ? (key.startsWith('max') ? ['none'] : ['auto']) : []);
|
||||
else if (property.type === 'number') control = numberControl(id, own ? current.value : '', property, apply);
|
||||
else if (property.type === 'choice') control = choiceControl(id, own ? current.value : '', property, apply, inheritedText);
|
||||
else if (property.type === 'font') control = fontControl(id, own ? current.value : '', apply, inheritedText);
|
||||
else if (property.type === 'media') control = mediaControl(key, own ? current.value : '', apply);
|
||||
else if (property.type === 'media') control = mediaControl(key, current.value || '', apply);
|
||||
else if (property.type === 'fractions') control = fractionsControl(id, own ? current.value : '', apply);
|
||||
else continue;
|
||||
|
||||
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset }));
|
||||
body.append(row(id, property.label, control, { own, inherited: inheritedText, onReset, chips: deviceChips(block, key, property.label, false) }));
|
||||
|
||||
if (schema.hoverable.includes(key)) {
|
||||
const hoverId = `${id}-hover`;
|
||||
const hoverValue = hoverStyle(block, deviceName, key);
|
||||
const hoverApply = value => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { hover: true }));
|
||||
let hoverInherited = current.value || '';
|
||||
for (const name of Object.keys(devices)) {
|
||||
if (name === deviceName) break;
|
||||
hoverInherited = hoverStyle(block, name, key) ?? hoverInherited;
|
||||
}
|
||||
const hoverApply = (value, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { hover: true }), mergeKey, Boolean(mergeKey));
|
||||
const hoverControl = property.type === 'color'
|
||||
? colorControl(hoverId, hoverValue ?? '', hoverApply)
|
||||
? colorControl(hoverId, hoverValue ?? '', hoverApply, `${property.label} beim Überfahren`, hoverInherited)
|
||||
: property.type === 'number'
|
||||
? numberControl(hoverId, hoverValue ?? '', property, hoverApply)
|
||||
: lengthControl(hoverId, hoverValue ?? '', hoverApply, 'kein Hover');
|
||||
@@ -310,6 +572,28 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
own: hoverValue !== undefined,
|
||||
inherited: 'Kein eigener Hover-Wert',
|
||||
onReset: () => hoverApply(''),
|
||||
chips: deviceChips(block, key, `${property.label} beim Überfahren`, true),
|
||||
}));
|
||||
}
|
||||
|
||||
// The scrolled page - only in the header, the one area that changes with scrolling.
|
||||
if (getAreaKind() === 'header' && (schema.scrollable ?? []).includes(key)) {
|
||||
const scrolledId = `${id}-scrolled`;
|
||||
const scrolledValue = scrolledStyle(block, deviceName, key);
|
||||
let scrolledInherited = current.value || '';
|
||||
for (const name of Object.keys(devices)) {
|
||||
if (name === deviceName) break;
|
||||
scrolledInherited = scrolledStyle(block, name, key) ?? scrolledInherited;
|
||||
}
|
||||
const scrolledApply = (value, mergeKey) => change(doc => setStyle(doc, getSelection(), deviceName, key, value, { scrolled: true }), mergeKey, Boolean(mergeKey));
|
||||
const scrolledControl = property.type === 'color'
|
||||
? colorControl(scrolledId, scrolledValue ?? '', scrolledApply, `${property.label} beim Scrollen`, scrolledInherited)
|
||||
: numberControl(scrolledId, scrolledValue ?? '', property, scrolledApply);
|
||||
body.append(row(scrolledId, `${property.label} beim Scrollen`, scrolledControl, {
|
||||
own: scrolledValue !== undefined,
|
||||
inherited: 'Gilt, sobald die Seite um mehr als 40 px gescrollt ist. Vorher gilt der normale Wert - für einen transparenten Kopf dort #ffffff00 eintragen.',
|
||||
onReset: () => scrolledApply(''),
|
||||
chips: deviceChips(block, key, `${property.label} beim Scrollen`, 'scrolled'),
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -368,6 +652,7 @@ export function createDesignPanel({ schema, onChange, onSavePreset, getBlock, ge
|
||||
};
|
||||
|
||||
function describe(property, value) {
|
||||
if (property.type === 'media') return value === 'none' ? 'Kein Bild' : 'Bild aus der Mediathek';
|
||||
if (property.type === 'choice') return property.values[value] ?? value;
|
||||
return String(value);
|
||||
}
|
||||
|
||||
@@ -10,7 +10,8 @@
|
||||
"iconcard",
|
||||
"stat",
|
||||
"quote",
|
||||
"cta"
|
||||
"cta",
|
||||
"button"
|
||||
],
|
||||
"values": {
|
||||
"left": [
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
.kant .d-tablet-radius-small{border-radius:8px}
|
||||
.kant .d-tablet-radius-large{border-radius:24px}
|
||||
}
|
||||
@media(max-width:650px){
|
||||
@media(max-width:767px){
|
||||
.kant .d-mobile-align-left{text-align:left;margin-inline:0}
|
||||
.kant .d-mobile-align-center{text-align:center;margin-inline:auto}
|
||||
.kant .d-mobile-align-right{text-align:right;margin-inline:auto 0}
|
||||
|
||||
+408
-28
@@ -1,19 +1,103 @@
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt' };
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', embed: '⬓' };
|
||||
export const labels = { section: 'Sektion', row: 'Zeile', column: 'Spalte', heading: 'Überschrift', text: 'Text', image: 'Bild', button: 'Button', gallery: 'Galerie', postlist: 'Beitragsliste', postnav: 'Beitragsnavigation', iconcard: 'Symbolkarte', stat: 'Kennzahl', quote: 'Kundenstimme', logos: 'Logoleiste', cta: 'Aufruf-Band', contact: 'Kontaktdaten', divider: 'Trennlinie', spacer: 'Abstand', accordion: 'Akkordeon', accordionitem: 'Akkordeon-Eintrag', tabs: 'Tabs', tabitem: 'Tab', form: 'Formular', breadcrumb: 'Brotkrumen', menu: 'Navigation', partialref: 'Verknüpfte Sektion', embed: 'Externer Inhalt', icon: 'Symbol', progress: 'Fortschritt', slider: 'Slider', legal: 'Rechtszeile', social: 'Soziale Profile', html: 'Eigenes HTML', language: 'Sprachwahl' };
|
||||
export const icons = { section: '▣', row: '☰', column: '▥', heading: 'H', text: 'T', image: '▧', button: '↗', gallery: '▦', postlist: '☷', postnav: '⇄', iconcard: '◈', stat: '№', quote: '❝', logos: '⬚', cta: '◉', contact: '✆', divider: '—', spacer: '⇳', accordion: '⌵', accordionitem: '·', tabs: '▭', tabitem: '▫', form: '✉', breadcrumb: '⟩', menu: '☰', partialref: '⧉', legal: '§', embed: '⬓', icon: '◆', progress: '▰', slider: '⇄', social: '⚭', html: '</>', language: '文' };
|
||||
// A namespaced type belongs to a plugin this installation may not have. Such blocks stay in the
|
||||
// document, stay selectable and movable, but the editor never edits or fills them.
|
||||
/**
|
||||
* The toolbox, grouped the way the page is actually built. A flat list of thirty-one types said
|
||||
* nothing about which of them belong together or where they may go; the first group is the
|
||||
* scaffolding, the rest is what goes inside it.
|
||||
*/
|
||||
export const CATEGORIES = [
|
||||
{ name: 'Aufbau', hint: 'Sektion → Zeile → Spalte. Der Inhalt kommt in die Spalte.', types: ['section', 'row', 'column'] },
|
||||
{ name: 'Inhalt', types: ['heading', 'text', 'image', 'button', 'icon', 'divider', 'spacer'] },
|
||||
{ name: 'Bausteine', types: ['iconcard', 'stat', 'quote', 'cta', 'contact', 'social', 'logos', 'progress'] },
|
||||
{ name: 'Gruppen', types: ['gallery', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'] },
|
||||
{ name: 'Seite und Daten', types: ['postlist', 'postnav', 'breadcrumb', 'menu', 'language', 'form', 'legal', 'partialref', 'embed', 'html'] },
|
||||
];
|
||||
|
||||
/** How many columns a row can be added with. */
|
||||
export const ROW_COLUMNS = [2, 3, 4];
|
||||
|
||||
/**
|
||||
* Why a type cannot be added where the selection currently is. The toolbox says this instead of
|
||||
* simply greying a button out: a rule you are told is one you learn, a button that merely stops
|
||||
* working is one you keep pressing.
|
||||
*/
|
||||
export function whyNot(type) {
|
||||
if (REQUIRE[type]) return `${labels[type]}: gehört in ${REQUIRE[type].map(parent => labels[parent]).join(' oder ')}. Wähle dort zuerst ein Element aus.`;
|
||||
if (ROOT_NEEDS_CONTAINER.includes(type)) return `${labels[type]}: gehört in eine Sektion. Füge zuerst eine Sektion ein oder wähle eine aus.`;
|
||||
return `${labels[type]}: passt an dieser Stelle nicht. Wähle ein anderes Element aus.`;
|
||||
}
|
||||
|
||||
export const PLUGIN_TYPE = /^[a-z][a-z0-9]{1,30}\.[a-z][a-z0-9-]{1,40}$/;
|
||||
/**
|
||||
* Die Bausteine der aktiven Plugins, vom Server mit dem Dokument geliefert. Sie bekommen
|
||||
* Beschriftung, Symbol, Vorgaben und eine eigene Gruppe im Werkzeugkasten; was das Dokument
|
||||
* enthält, aber hier nicht steht, bleibt ein Baustein einer fehlenden Erweiterung.
|
||||
*/
|
||||
export const PLUGIN_BLOCKS = {};
|
||||
/** Die Seitenvorlagen der aktiven Plugins: id => {label, blocks}. */
|
||||
export const PLUGIN_PRESETS = {};
|
||||
export function registerPlugins(list, presets = []) {
|
||||
for (const key of Object.keys(PLUGIN_BLOCKS)) { delete PLUGIN_BLOCKS[key]; delete labels[key]; delete icons[key]; }
|
||||
for (const key of Object.keys(PLUGIN_PRESETS)) delete PLUGIN_PRESETS[key];
|
||||
for (const preset of presets || []) if (typeof preset?.id === 'string' && Array.isArray(preset.blocks)) PLUGIN_PRESETS[preset.id] = preset;
|
||||
const types = [];
|
||||
for (const definition of list || []) {
|
||||
if (!PLUGIN_TYPE.test(definition?.type || '')) continue;
|
||||
PLUGIN_BLOCKS[definition.type] = definition;
|
||||
labels[definition.type] = definition.label;
|
||||
icons[definition.type] = definition.icon || '⬡';
|
||||
types.push(definition.type);
|
||||
}
|
||||
const group = CATEGORIES.find(category => category.name === 'Erweiterungen');
|
||||
if (group) group.types = types;
|
||||
else if (types.length) CATEGORIES.push({ name: 'Erweiterungen', hint: 'Bausteine der eingeschalteten Plugins.', types });
|
||||
}
|
||||
export const pluginDefinition = block => PLUGIN_BLOCKS[block?.type] ?? null;
|
||||
export const isPluginBlock = block => typeof block?.type === 'string' && PLUGIN_TYPE.test(block.type);
|
||||
export const blockLabel = block => labels[block?.type] ?? (isPluginBlock(block) ? `Baustein ${block.type}` : 'Unbekannt');
|
||||
export const blockIcon = block => icons[block?.type] ?? '❔';
|
||||
export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem'].includes(block?.type);
|
||||
export const isContainer = block => ['section', 'row', 'column', 'gallery', 'logos', 'accordion', 'accordionitem', 'tabs', 'tabitem', 'slider'].includes(block?.type) || PLUGIN_BLOCKS[block?.type]?.container === true;
|
||||
// Nothing may be dropped into a plugin block: the core does not know its layout contract.
|
||||
// Containers that accept only one kind of child, mirrored by the server side validator.
|
||||
export const ONLY_CHILD = { gallery: 'image', logos: 'image', accordion: 'accordionitem', tabs: 'tabitem' };
|
||||
export const canContain = (parent, block) => isContainer(parent) && !isPluginBlock(parent)
|
||||
&& (ONLY_CHILD[parent.type] === undefined || ONLY_CHILD[parent.type] === block.type)
|
||||
&& (block.type !== 'accordionitem' || parent.type === 'accordion')
|
||||
&& (block.type !== 'tabitem' || parent.type === 'tabs');
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
// What may sit inside what.
|
||||
//
|
||||
// Three rules, the same three the Flatsome UX Builder uses, because they cover the cases and
|
||||
// because they are the ones people already have in their hands:
|
||||
//
|
||||
// ALLOW a container that takes only these children and nothing else
|
||||
// REQUIRE a block that exists only inside one of these parents
|
||||
// ROOT_NEEDS_CONTAINER
|
||||
// a block that may sit in any container but not loose at the top of a document
|
||||
//
|
||||
// Until now a row took anything - a heading could land directly in it, next to the columns -
|
||||
// and a column could stand anywhere. That is why the toolbox offered rows and columns out of
|
||||
// nowhere. The structure the editor has always described in its own hint text is
|
||||
// section -> row -> column -> content, and these rules are that sentence made binding.
|
||||
// ---------------------------------------------------------------------------------------------
|
||||
|
||||
/** Containers that take one kind of child and nothing else. */
|
||||
export const ALLOW = { row: ['column'], gallery: ['image'], logos: ['image'], accordion: ['accordionitem'], tabs: ['tabitem'] };
|
||||
/**
|
||||
* Blocks that exist only inside one of these parents. A column belongs to a row - and, as a
|
||||
* single one, directly to a section: that is how a button or a short text gets centred and
|
||||
* narrowed without a one-column row around it. Umut asked for exactly that.
|
||||
*/
|
||||
export const REQUIRE = { column: ['row', 'section'], accordionitem: ['accordion'], tabitem: ['tabs'] };
|
||||
/**
|
||||
* A row is not refused by any particular parent - a tab panel or an accordion entry may well
|
||||
* hold one - but it is not a thing a document starts with. At the top level a page begins with
|
||||
* a section, and that is where a row belongs.
|
||||
*/
|
||||
export const ROOT_NEEDS_CONTAINER = ['row'];
|
||||
|
||||
// Ein Plugin-Container nimmt auf, was sein Manifest erlaubt; ein Plugin ohne Definition nichts.
|
||||
const allowedIn = parent => PLUGIN_BLOCKS[parent?.type] ? (PLUGIN_BLOCKS[parent.type].children ?? undefined) : ALLOW[parent?.type];
|
||||
export const canContain = (parent, block) => isContainer(parent) && (!isPluginBlock(parent) || PLUGIN_BLOCKS[parent.type]?.container === true)
|
||||
&& (allowedIn(parent) === undefined || allowedIn(parent).includes(block?.type))
|
||||
&& (block?.type === undefined || REQUIRE[block.type] === undefined || REQUIRE[block.type].includes(parent.type));
|
||||
export const clone = value => structuredClone(value);
|
||||
|
||||
export function locate(blocks, id, parent = null) {
|
||||
@@ -29,6 +113,11 @@ export function locate(blocks, id, parent = null) {
|
||||
export function createBlock(type) {
|
||||
if (!Object.hasOwn(labels, type)) throw new Error('Unbekannter Block.');
|
||||
const block = { id: `b_${crypto.randomUUID().replaceAll('-', '')}`, type };
|
||||
if (PLUGIN_BLOCKS[type]) {
|
||||
block.props = Object.fromEntries(Object.entries(PLUGIN_BLOCKS[type].props || {}).map(([key, schema]) => [key, schema.default ?? '']));
|
||||
if (PLUGIN_BLOCKS[type].container) block.children = [];
|
||||
return block;
|
||||
}
|
||||
if (isContainer(block)) block.children = [];
|
||||
if (type === 'accordion') block.children = [createBlock('accordionitem')];
|
||||
// A single tab is not a tab group, and the script needs at least two to enhance anything.
|
||||
@@ -36,7 +125,13 @@ export function createBlock(type) {
|
||||
if (type === 'heading') block.props = { text: 'Deine neue Überschrift', level: 2 };
|
||||
if (type === 'text') block.props = { text: 'Hier beginnt dein neuer Inhalt.' };
|
||||
if (type === 'button') block.props = { text: 'Mehr erfahren', href: '/' };
|
||||
if (type === 'postlist') block.props = { count: 3, category: '', tag: '' };
|
||||
if (type === 'postlist') block.props = { count: 3, category: '', tag: '', layout: 'karten' };
|
||||
if (type === 'postnav') block.props = { previousLabel: 'Vorheriger Beitrag', nextLabel: 'Nächster Beitrag' };
|
||||
if (type === 'legal') block.props = { label: 'Rechtliches' };
|
||||
// Welche Sprachen es gibt, weiß die Website; der Block sagt nur, ob er Kürzel oder Namen zeigt.
|
||||
if (type === 'language') block.props = { display: 'code' };
|
||||
// Leer eingefuegt: nichts steht auf der Seite, bis jemand Code eintraegt.
|
||||
if (type === 'html') block.props = { html: '' };
|
||||
if (type === 'iconcard') block.props = { icon: 'check', title: 'Deine Leistung', text: 'Beschreibe hier kurz, worum es geht.', href: '' };
|
||||
if (type === 'stat') block.props = { value: '15+', label: 'Jahre Erfahrung' };
|
||||
if (type === 'quote') block.props = { text: 'Ein Satz, den eine Kundin oder ein Kunde über dich gesagt hat.', author: 'Name', role: '', rating: 5 };
|
||||
@@ -46,31 +141,111 @@ export function createBlock(type) {
|
||||
if (type === 'accordionitem') block.props = { title: 'Eine Frage', open: false };
|
||||
if (type === 'tabitem') block.props = { title: 'Neuer Tab' };
|
||||
if (type === 'form') block.props = { form: '' };
|
||||
if (type === 'menu') block.props = { slot: 'header' };
|
||||
if (type === 'menu') block.props = { slot: 'header', layout: 'horizontal' };
|
||||
if (type === 'partialref') block.props = { partial: '' };
|
||||
// An embed starts empty and blocked: no address, so nothing loads before someone enters one.
|
||||
if (type === 'embed') block.props = { group: 'external', title: '', provider: '', url: '', hint: '', height: 400 };
|
||||
if (type === 'icon') block.props = { icon: 'star', size: 48, decorative: true, label: '', href: '' };
|
||||
if (type === 'progress') block.props = { label: 'Fortschritt', value: 70, showValue: true };
|
||||
// Welche Profile es gibt, steht in den Einstellungen; der Block bringt nur die Darstellung mit.
|
||||
if (type === 'social') block.props = { size: 24, shape: 'plain', layout: 'horizontal', labels: false, networks: [], newTab: true };
|
||||
if (type === 'slider') block.props = { desktopVisible: 3, tabletVisible: 2, mobileVisible: 1, snap: true, dots: true, autoplay: 0 };
|
||||
if (type === 'image') block.props = { src: '/assets/image-placeholder.svg', alt: 'Abstrakte Landschaft als Platzhalter', decorative: false, width: 1000, height: 600 };
|
||||
return block;
|
||||
}
|
||||
|
||||
export function insertBlock(document, type, selected) {
|
||||
const block = createBlock(type);
|
||||
/**
|
||||
* Hebt ein geladenes Dokument auf die Fassung, die seine Bausteine und Eigenschaften verlangen.
|
||||
*
|
||||
* Normalerweise tut das der Editor in dem Moment, in dem jemand etwas einstellt. Ein Dokument,
|
||||
* das ein Skript geschrieben hat, kann aber eine Eigenschaft tragen, die seine Fassung noch nicht
|
||||
* kennt - der Kopfbereich von kant-it.de hatte die Hamburger-Umschaltung bei Fassung 19. Die
|
||||
* Website zeigt so etwas, der Server lehnt Vorschau und Speichern aber ab, und der Bereich war im
|
||||
* Editor nicht mehr zu bearbeiten. Nur Fassungen, die etwas hinzufügen, werden hier gehoben; die
|
||||
* Umstellung auf formatierten Text (12, 17) ändert die Bedeutung und bleibt eine bewusste Wahl.
|
||||
*/
|
||||
export function repairSchema(document) {
|
||||
const walk = blocks => {
|
||||
for (const block of blocks || []) {
|
||||
upgradeForType(document, block.type);
|
||||
const props = block.props || {};
|
||||
if ('caption' in props) upgradeDocument(document, 3);
|
||||
if ('click' in props || 'lightbox' in props) upgradeDocument(document, 19);
|
||||
if ('newTab' in props) upgradeDocument(document, 20);
|
||||
if (Object.values(block.style || {}).some(values => values?.backgroundImage === 'none')) upgradeDocument(document, 20);
|
||||
if (block.anchor) upgradeDocument(document, 21);
|
||||
if ('collapse' in props) upgradeDocument(document, 22);
|
||||
if ('faq' in props) upgradeDocument(document, 23);
|
||||
if (block.type === 'button' && ('icon' in props || 'iconPosition' in props)) upgradeDocument(document, 25);
|
||||
if (block.type === 'button' && ('iconMotion' in props || 'iconOnly' in props)) upgradeDocument(document, 26);
|
||||
walk(block.children);
|
||||
}
|
||||
};
|
||||
walk(document.blocks);
|
||||
if (document.translations) upgradeDocument(document, 27);
|
||||
return document;
|
||||
}
|
||||
|
||||
/** Raises the document schema to the version that knows this block type. */
|
||||
export function upgradeForType(document, type) {
|
||||
if (type === 'gallery') upgradeDocument(document, 3);
|
||||
if (type === 'postlist') upgradeDocument(document, 5);
|
||||
if (PLUGIN_BLOCKS[type]) upgradeDocument(document, 4);
|
||||
// Die Fassung, die der Server fuer die Eigenschaften verlangt, mit denen createBlock den
|
||||
// Baustein anlegt - nicht die, in der der Typ zuerst auftauchte. Sonst steht ein neuer
|
||||
// Baustein sofort mit 422 in der Vorschau und laesst sich nicht speichern.
|
||||
if (type === 'postlist') upgradeDocument(document, 18);
|
||||
if (type === 'postnav') upgradeDocument(document, 18);
|
||||
if (type === 'legal') upgradeDocument(document, 19);
|
||||
if (type === 'html') upgradeDocument(document, 24);
|
||||
if (type === 'language') upgradeDocument(document, 27);
|
||||
if (['iconcard', 'stat', 'quote', 'logos', 'cta', 'contact', 'divider', 'spacer', 'accordion', 'accordionitem'].includes(type)) upgradeDocument(document, 6);
|
||||
if (type === 'tabs' || type === 'tabitem') upgradeDocument(document, 7);
|
||||
if (type === 'form') upgradeDocument(document, 8);
|
||||
if (type === 'breadcrumb') upgradeDocument(document, 9);
|
||||
if (type === 'menu' || type === 'partialref') upgradeDocument(document, 11);
|
||||
if (type === 'menu') upgradeDocument(document, 16);
|
||||
if (type === 'partialref') upgradeDocument(document, 11);
|
||||
if (type === 'embed') upgradeDocument(document, 14);
|
||||
let target = locate(document.blocks, selected);
|
||||
if (target?.block.type === 'gallery' && type !== 'image') { target.siblings.splice(target.index + 1, 0, block); return block.id; }
|
||||
if (target?.parent?.type === 'gallery' && type !== 'image') target = locate(document.blocks, target.parent.id);
|
||||
if (!target) document.blocks.push(block);
|
||||
else if (canContain(target.block, block)) (target.block.children ??= []).push(block);
|
||||
else target.siblings.splice(target.index + 1, 0, block);
|
||||
return block.id;
|
||||
if (type === 'icon' || type === 'progress') upgradeDocument(document, 15);
|
||||
if (type === 'slider') upgradeDocument(document, 17);
|
||||
if (type === 'social') upgradeDocument(document, 23);
|
||||
}
|
||||
|
||||
/**
|
||||
* Where clicking a type in the toolbox would put it - without putting it anywhere.
|
||||
*
|
||||
* Asking and doing used to be the same step, so nothing could tell beforehand whether a type
|
||||
* fits at all. The toolbox needs exactly that answer to grey out what does not belong here, and
|
||||
* the answer has to be the one the click will actually follow, or the two drift apart.
|
||||
*
|
||||
* The search goes from the inside out: into the selection when it is a container that takes
|
||||
* this type, otherwise next to it, climbing until some ancestor accepts it, and finally the top
|
||||
* level. Returns null when the type cannot go anywhere from this selection.
|
||||
*
|
||||
* @return {{parentId: string|null, index: number}|null}
|
||||
*/
|
||||
export function insertionTarget(document, selected, type) {
|
||||
// A section starts a page area. Explicit insertion points and drag/drop can still nest it.
|
||||
if (type === 'section') {
|
||||
const top = blockPath(document.blocks, selected)[0];
|
||||
return { parentId: null, index: top ? document.blocks.findIndex(block => block.id === top.id) + 1 : document.blocks.length };
|
||||
}
|
||||
const probe = { type };
|
||||
let item = locate(document.blocks, selected);
|
||||
// Content belongs to a chosen entry. Do not silently place it outside the accordion.
|
||||
if (item?.block.type === 'accordion' && type !== 'accordionitem') return null;
|
||||
if (item && canContain(item.block, probe)) return { parentId: item.block.id, index: (item.block.children ?? []).length };
|
||||
while (item) {
|
||||
const parentId = item.parent?.id ?? null;
|
||||
if (dropList(document, parentId, probe)) return { parentId, index: item.index + 1 };
|
||||
item = item.parent ? locate(document.blocks, item.parent.id) : null;
|
||||
}
|
||||
return dropList(document, null, probe) ? { parentId: null, index: document.blocks.length } : null;
|
||||
}
|
||||
|
||||
export function insertBlock(document, type, selected) {
|
||||
const target = insertionTarget(document, selected, type);
|
||||
if (!target) return null;
|
||||
return insertBlockAt(document, type, target.parentId, target.index);
|
||||
}
|
||||
|
||||
export function duplicateBlock(document, selected) {
|
||||
@@ -79,6 +254,7 @@ export function duplicateBlock(document, selected) {
|
||||
const copy = clone(target.block);
|
||||
const rekey = block => {
|
||||
block.id = `b_${crypto.randomUUID().replaceAll('-', '')}`;
|
||||
delete block.anchor;
|
||||
if (block.type === 'heading' && block.props.level === 1) block.props.level = 2;
|
||||
(block.children || []).forEach(rekey);
|
||||
};
|
||||
@@ -93,7 +269,9 @@ export function moveBlock(document, selected, targetId, inside = false) {
|
||||
if (!source || !target || selected === targetId || locate(source.block.children || [], targetId)) return false;
|
||||
if (inside && !isContainer(target.block)) return false;
|
||||
if (inside && !canContain(target.block, source.block)) return false;
|
||||
if (!inside && target.parent && !canContain(target.parent, source.block)) return false;
|
||||
// Neben ein Element der obersten Ebene darf nur, was dort auch stehen darf - eine Spalte oder
|
||||
// ein Akkordeoneintrag nicht.
|
||||
if (!inside && !dropList(document, target.parent?.id ?? null, source.block)) return false;
|
||||
source.siblings.splice(source.index, 1);
|
||||
const destination = locate(document.blocks, targetId);
|
||||
if (inside) (destination.block.children ??= []).push(source.block);
|
||||
@@ -101,11 +279,94 @@ export function moveBlock(document, selected, targetId, inside = false) {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Where a block may land. `parentId` of null means the top level. Returns the list that would
|
||||
* hold the block, or null when the parent refuses this kind of child - a gallery takes images
|
||||
* and nothing else, an accordion takes entries.
|
||||
*/
|
||||
export function dropList(document, parentId, block) {
|
||||
if (parentId === null) {
|
||||
const type = block?.type;
|
||||
const loose = type !== undefined && (REQUIRE[type] !== undefined || ROOT_NEEDS_CONTAINER.includes(type));
|
||||
return loose ? null : document.blocks;
|
||||
}
|
||||
const parent = locate(document.blocks, parentId)?.block;
|
||||
return parent && canContain(parent, block) ? (parent.children ??= []) : null;
|
||||
}
|
||||
|
||||
/** Puts an already built block at an exact position, or refuses when the parent will not take it. */
|
||||
function place(document, block, parentId, index) {
|
||||
const list = dropList(document, parentId, block);
|
||||
if (!list) return null;
|
||||
list.splice(Math.max(0, Math.min(index, list.length)), 0, block);
|
||||
return block.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* Inserts a new block at an exact position instead of behind the selection.
|
||||
*
|
||||
* Erst pruefen, ob das Ziel den Baustein nimmt, dann die Fassung anheben: sonst bliebe bei einer
|
||||
* Ablehnung nur die Schemaaenderung uebrig - ein Verlaufsschritt, ein "ungespeichert", und nichts
|
||||
* zu sehen.
|
||||
*/
|
||||
export function insertBlockAt(document, type, parentId, index) {
|
||||
const block = createBlock(type);
|
||||
if (!dropList(document, parentId, block)) return null;
|
||||
upgradeForType(document, type);
|
||||
return place(document, block, parentId, index);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves an existing block to an exact position. Unlike moveBlock this keeps the index, so a
|
||||
* block dragged between two others lands between them and not at the end of the container.
|
||||
*/
|
||||
export function moveBlockTo(document, selected, parentId, index) {
|
||||
const source = locate(document.blocks, selected);
|
||||
if (!source) return false;
|
||||
// Dropping a container into itself or into its own child would detach the branch.
|
||||
if (parentId === selected || (parentId !== null && locate(source.block.children || [], parentId))) return false;
|
||||
if (!dropList(document, parentId, source.block)) return false;
|
||||
const from = source.siblings, fromIndex = source.index;
|
||||
from.splice(fromIndex, 1);
|
||||
// Re-read: removing the block may have shifted the target list or emptied a parent.
|
||||
const list = dropList(document, parentId, source.block);
|
||||
if (!list) { from.splice(fromIndex, 0, source.block); return false; }
|
||||
// The index still counts in the list as it was before the block was taken out, so the shift
|
||||
// has to be settled first and only then clamped - the other way round an index pointing past
|
||||
// the end is clamped to the old length and then pulled one step too far forward.
|
||||
let at = index;
|
||||
if (list === from && fromIndex < index) at -= 1;
|
||||
at = Math.max(0, Math.min(at, list.length));
|
||||
list.splice(at, 0, source.block);
|
||||
return true;
|
||||
}
|
||||
|
||||
export const devices = { desktop: 'Desktop', tablet: 'Tablet', mobile: 'Mobil' };
|
||||
|
||||
// Called only inside a history transaction: all other fields and IDs stay untouched.
|
||||
// Die höchste Fassung, die dieser Editor kennt. Sie stand bisher als Liste an zwei Stellen und
|
||||
// blieb bei 12 stehen, während Bausteine schon v13 bis v15 verlangten: in einem aktuellen Dokument
|
||||
// liess sich dann gar nichts mehr einfügen, und ein Symbol, ein Slider oder ein externer Inhalt
|
||||
// nirgends. Eine Zahl, an einer Stelle.
|
||||
export const SCHEMA_VERSION = 27;
|
||||
|
||||
export const VISIBILITY = { all: 'Auf allen Geräten', desktop: 'Nur Desktop', tablet: 'Nur Tablet', mobile: 'Nur Mobil', 'hide-desktop': 'Auf Desktop ausblenden', 'hide-tablet': 'Auf Tablet ausblenden', 'hide-mobile': 'Auf Mobil ausblenden', hidden: 'Überall ausblenden' };
|
||||
|
||||
export function setVisibility(document, id, value) {
|
||||
if (!Object.hasOwn(VISIBILITY, value)) throw new Error('Unbekannte Sichtbarkeit.');
|
||||
const block = locate(document.blocks, id)?.block;
|
||||
if (!block) return;
|
||||
if (value === 'all') {
|
||||
if (block.layout) delete block.layout.visibility;
|
||||
} else {
|
||||
upgradeDocument(document, 22);
|
||||
(block.layout ??= {}).visibility = value;
|
||||
}
|
||||
}
|
||||
const known = version => Number.isInteger(version) && version >= 1 && version <= SCHEMA_VERSION;
|
||||
|
||||
export function upgradeDocument(document, version = 2) {
|
||||
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(document.schemaVersion) || ![2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(version)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
if (!known(document.schemaVersion) || !known(version) || version < 2) throw new Error('Unbekanntes Dokumentschema.');
|
||||
document.schemaVersion = Math.max(document.schemaVersion, version);
|
||||
return document;
|
||||
}
|
||||
@@ -122,7 +383,7 @@ export function effectiveDesign(block, device, key) {
|
||||
}
|
||||
|
||||
export function setDesign(document, id, device, key, value) {
|
||||
if (![1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].includes(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
if (!known(document.schemaVersion)) throw new Error('Unbekanntes Dokumentschema.');
|
||||
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
|
||||
const block = locate(document.blocks, id)?.block;
|
||||
if (!block) return;
|
||||
@@ -158,17 +419,107 @@ export function mediaProps(media) {
|
||||
|
||||
export function insertMedia(document, selected, items) {
|
||||
const target = locate(document.blocks, selected)?.block;
|
||||
upgradeDocument(document, 3);
|
||||
if (target?.type === 'image') { target.props = mediaProps(items[0]); return target.id; }
|
||||
if (target?.type === 'image') {
|
||||
upgradeDocument(document, 3);
|
||||
target.props = { ...target.props, ...mediaProps(items[0]), caption: target.props.caption || '' };
|
||||
return target.id;
|
||||
}
|
||||
let parent = target;
|
||||
if (target?.type !== 'gallery') {
|
||||
const id = insertBlock(document, 'gallery', selected); parent = locate(document.blocks, id).block;
|
||||
// Ein Akkordeon nimmt nur Eintraege: dann gibt es keine Galerie und nichts zu tun.
|
||||
const id = insertBlock(document, 'gallery', selected);
|
||||
if (id === null) return null;
|
||||
parent = locate(document.blocks, id).block;
|
||||
}
|
||||
upgradeDocument(document, 3);
|
||||
for (const item of items) { const image = createBlock('image'); image.props = mediaProps(item); (parent.children ??= []).push(image); }
|
||||
return parent.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* A row with a given number of columns, inserted where the selection is.
|
||||
*
|
||||
* A row on its own looks like nothing, which is why "add a row" was hard to find: what people
|
||||
* want is a row that already has columns in it.
|
||||
*/
|
||||
/**
|
||||
* A full screen hero: edge to edge, as tall as the screen, content centred, with room for a
|
||||
* background image and a dark overlay so text stays readable on any photo.
|
||||
*
|
||||
* The parts all exist on their own - width "full", min-height in dvh, display flex, overlay - but
|
||||
* finding that combination is a research project. This is the one click.
|
||||
*/
|
||||
export function insertHero(document, selected) {
|
||||
const section = createBlock('section');
|
||||
section.layout = { tone: 'ink', width: 'full' };
|
||||
section.style = {
|
||||
desktop: {
|
||||
// dvh, not vh: on a phone 100vh is taller than the screen.
|
||||
minHeight: '100dvh', display: 'flex', alignItems: 'center',
|
||||
backgroundSize: 'cover', backgroundPosition: 'center', backgroundRepeat: 'no-repeat',
|
||||
overlayColor: '#000000', overlayOpacity: 0.4,
|
||||
padding: { top: '80px', right: '32px', bottom: '80px', left: '32px' },
|
||||
textAlign: 'center',
|
||||
},
|
||||
mobile: { minHeight: '80dvh', padding: { top: '56px', right: '20px', bottom: '56px', left: '20px' } },
|
||||
};
|
||||
// A hero at the top of a page carries its main heading - but a page has exactly one, and
|
||||
// inserting a second would produce a document the editor refuses to save.
|
||||
const hasMainHeading = blocks => blocks.some(block =>
|
||||
(block.type === 'heading' && block.props?.level === 1) || hasMainHeading(block.children ?? []));
|
||||
const heading = createBlock('heading');
|
||||
heading.props = { text: 'Deine Botschaft, ganzflächig.', level: hasMainHeading(document.blocks) ? 2 : 1 };
|
||||
const paragraph = createBlock('text');
|
||||
paragraph.props = { text: 'Ein Satz, der sagt, worum es geht. Wähle im Designbereich unter Hintergrund ein Bild und stelle die Deckkraft der Überlagerung so ein, dass der Text lesbar bleibt.' };
|
||||
const button = createBlock('button');
|
||||
section.children = [heading, paragraph, button];
|
||||
upgradeDocument(document, 10);
|
||||
const top = blockPath(document.blocks, selected)[0];
|
||||
const index = top ? document.blocks.findIndex(block => block.id === top.id) + 1 : document.blocks.length;
|
||||
document.blocks.splice(index, 0, section);
|
||||
return section.id;
|
||||
}
|
||||
|
||||
/** A row with its columns already in it. On its own an empty row is a box with nothing in it. */
|
||||
function buildRow(columns) {
|
||||
const count = Math.max(1, Math.min(6, Number(columns) || 2));
|
||||
const names = { 1: 'one', 2: 'two', 3: 'three', 4: 'four' };
|
||||
const row = createBlock('row');
|
||||
row.layout = { design: { desktop: { columns: names[count] ?? 'auto', gap: 'medium' }, mobile: { columns: 'one' } } };
|
||||
row.children = Array.from({ length: count }, () => createBlock('column'));
|
||||
return row;
|
||||
}
|
||||
|
||||
/** A row at an exact position, for a marked insertion point or a drop in the preview. */
|
||||
export function insertRowAt(document, columns, parentId, index) {
|
||||
const row = buildRow(columns);
|
||||
if (!dropList(document, parentId, row)) return null;
|
||||
upgradeDocument(document);
|
||||
return place(document, row, parentId, index);
|
||||
}
|
||||
|
||||
export function insertRow(document, columns, selected) {
|
||||
// Same placement rule as a single element, so the row does not land somewhere else than the
|
||||
// toolbox promised.
|
||||
const target = insertionTarget(document, selected, 'row');
|
||||
if (!target) return null;
|
||||
upgradeDocument(document);
|
||||
return place(document, buildRow(columns), target.parentId, target.index);
|
||||
}
|
||||
|
||||
/** Frische Kennungen für eine Vorlage aus einem Plugin - jedes Einfügen ist ein eigener Baustein. */
|
||||
const freshIds = blocks => blocks.map(block => ({ ...structuredClone(block), id: `b_${crypto.randomUUID().replaceAll('-', '')}`, ...(Array.isArray(block.children) ? { children: freshIds(block.children) } : {}) }));
|
||||
|
||||
export function insertPreset(document, name, selected) {
|
||||
if (name === 'hero') return insertHero(document, selected);
|
||||
if (PLUGIN_PRESETS[name]) {
|
||||
const blocks = freshIds(PLUGIN_PRESETS[name].blocks);
|
||||
const top = blockPath(document.blocks, selected)[0];
|
||||
const index = top ? document.blocks.findIndex(block => block.id === top.id) + 1 : document.blocks.length;
|
||||
document.blocks.splice(index, 0, ...blocks);
|
||||
upgradeDocument(document, SCHEMA_VERSION);
|
||||
return blocks[0].id;
|
||||
}
|
||||
if (!['intro', 'columns', 'cta'].includes(name)) throw new Error('Unbekannte Vorlage.');
|
||||
const section = createBlock('section');
|
||||
const heading = text => { const block = createBlock('heading'); block.props.text = text; return block; };
|
||||
@@ -202,6 +553,14 @@ export class EditorHistory {
|
||||
this.mergeKey = null;
|
||||
this.saved = JSON.stringify(document);
|
||||
}
|
||||
/** Uebernimmt die vom Server bereinigte Fassung als aktuellen Stand, der Verlauf bleibt. */
|
||||
adopt(document) {
|
||||
this.past.push(this.document);
|
||||
if (this.past.length > 100) this.past.shift();
|
||||
this.document = clone(document);
|
||||
this.future = [];
|
||||
this.mergeKey = null;
|
||||
}
|
||||
change(mutator, mergeKey = null) {
|
||||
const next = clone(this.document);
|
||||
const result = mutator(next);
|
||||
@@ -269,17 +628,24 @@ function prune(block) {
|
||||
* Sets one style value. An empty value removes it, which makes the block fall back to the wider
|
||||
* device and finally to the theme. Called inside a history transaction like every other change.
|
||||
*/
|
||||
export function setStyle(document, id, device, key, value, { side = null, hover = false } = {}) {
|
||||
export function scrolledStyle(block, device, key) {
|
||||
return block?.style?.[device]?.scrolled?.[key];
|
||||
}
|
||||
|
||||
export function setStyle(document, id, device, key, value, { side = null, hover = false, scrolled = false } = {}) {
|
||||
if (!Object.hasOwn(devices, device)) throw new Error('Unbekannte Vorschaugröße.');
|
||||
const block = locate(document.blocks, id)?.block;
|
||||
if (!block) return;
|
||||
const empty = value === '' || value === null || value === undefined;
|
||||
if (!empty) upgradeDocument(document, 10);
|
||||
if (key === 'backgroundImage' && value === 'none') upgradeDocument(document, 20);
|
||||
|
||||
block.style ??= {};
|
||||
block.style[device] ??= {};
|
||||
let target = block.style[device];
|
||||
if (hover) { target.hover ??= {}; target = target.hover; }
|
||||
// The scrolled state of the header: same shape as hover, applied once the page has moved.
|
||||
if (scrolled) { target.scrolled ??= {}; target = target.scrolled; }
|
||||
|
||||
if (side !== null) {
|
||||
if (empty) { if (target[key]) delete target[key][side]; }
|
||||
@@ -317,3 +683,17 @@ export function copyStyle(document, fromId, toId) {
|
||||
target.style = structuredClone(source.style);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** UI relevance only. Existing values remain editable, including on older documents. */
|
||||
export function relevantProperty(block, key, property) {
|
||||
if (Object.values(block.style ?? {}).some(values => values[key] !== undefined || values.hover?.[key] !== undefined || values.scrolled?.[key] !== undefined)) return true;
|
||||
const container = isContainer(block);
|
||||
if (property.group === 'slider') return block.type === 'slider';
|
||||
if (property.group === 'menu') return block.type === 'menu';
|
||||
if (property.group === 'icon') return block.type === 'button' && block.props?.icon !== 'none';
|
||||
if (property.group === 'image') return ['image', 'gallery', 'logos', 'postlist'].includes(block.type);
|
||||
if (property.group === 'text' || key === 'textShadow') return !['image', 'gallery', 'logos', 'spacer', 'divider', 'embed'].includes(block.type);
|
||||
if (key === 'columnWidths') return block.type === 'row';
|
||||
if (['flexDirection', 'flexWrap', 'justifyContent', 'alignItems', 'gridColumns', 'gap', 'rowGap', 'columnGap'].includes(key)) return container || ['button', 'menu', 'form', 'postlist', 'contact', 'social'].includes(block.type);
|
||||
return true;
|
||||
}
|
||||
+272
-159
@@ -1,165 +1,220 @@
|
||||
/* The editor and the media library. Only the editor and the media screen load this; the
|
||||
management screens get by with admin.css alone. Rules moved out of admin.css unchanged. */
|
||||
management screens get by with admin.css alone.
|
||||
|
||||
The layout follows the Flatsome UX Builder: a dark bar on top, one sidebar on the left that
|
||||
shows one thing at a time, and the page itself filling the rest. */
|
||||
/* Die Farbe fuer "hier steht ein eigener Wert". Dunkler als der Fokusring, damit weisse
|
||||
Schrift darauf die Kontrastschwelle sicher nimmt (5,6:1). */
|
||||
:root{--used:#9d4e0d}
|
||||
dialog{max-width:450px;width:calc(100% - 40px);padding:28px;background:var(--paper);border:1px solid var(--line);border-radius:12px;color:var(--ink);box-shadow:0 20px 80px #1b2a2433}
|
||||
dialog::backdrop{background:#172e2966}
|
||||
dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.editor-page-name h1{font-size:13px;font-weight:600;letter-spacing:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:0}
|
||||
.canvas-scroll:focus-visible{outline:3px solid #b55b12;outline-offset:-3px}
|
||||
.visually-hidden{position:absolute!important;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
||||
|
||||
.media-launch{margin-top:12px;white-space:normal}
|
||||
.media-dialog{width:calc(100% - 32px);max-width:1100px;max-height:calc(100dvh - 32px);overflow:auto}
|
||||
.media-heading{display:flex;justify-content:space-between;gap:20px;align-items:center}
|
||||
.media-heading h2{margin:0}
|
||||
.media-heading .eyebrow{margin-bottom:8px}
|
||||
.media-tools{display:grid;grid-template-columns:1fr 1fr;gap:24px}
|
||||
.media-tools label{margin-top:10px}
|
||||
.media-layout{display:grid;grid-template-columns:minmax(0,1fr) 270px;gap:24px}
|
||||
.media-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(145px,1fr));gap:12px;align-content:start;max-height:430px;overflow:auto;padding:4px}
|
||||
.media-card{border:2px solid var(--line);border-radius:9px;padding:7px;background:white;color:var(--ink);display:flex;flex-direction:column;gap:7px;text-align:left;min-width:0}
|
||||
.media-card[aria-pressed=true]{border-color:var(--accent);background:#edf4dc}
|
||||
.media-card img{width:100%;height:115px;object-fit:cover;border-radius:5px}
|
||||
.media-card strong{font-size:12px;overflow-wrap:anywhere}
|
||||
.media-card span{font-size:11px;color:var(--muted)}
|
||||
.media-details{padding-left:22px;border-left:1px solid var(--line)}
|
||||
.media-details button,.media-details a{margin-top:12px;display:block}
|
||||
.media-details input{font-size:13px}
|
||||
.media-footer{display:flex;justify-content:space-between;align-items:center;gap:20px;margin-top:20px;border-top:1px solid var(--line);padding-top:15px}
|
||||
.media-footer label{margin:0;font-size:12px}
|
||||
#media-error{color:#8b2f22;background:#fff0eb;padding:12px;border-radius:6px}
|
||||
#media-status{font-size:12px;margin-block:15px}
|
||||
.media-dialog> .panel-hint{margin-top:20px}
|
||||
.media-details>p{font-size:12px}
|
||||
@media(max-width:650px){.media-dialog{padding:18px}.media-tools,.media-layout{grid-template-columns:1fr}.media-details{padding:20px 0 0;border-left:0;border-top:1px solid var(--line)}.media-grid{max-height:280px;grid-template-columns:repeat(2,minmax(0,1fr))}.media-footer{align-items:stretch;flex-direction:column}.media-heading{gap:8px}}
|
||||
.icon-button,.block-actions button,.viewport-controls button{display:inline-flex;align-items:center;justify-content:center;gap:12px;border:1px solid var(--line);border-radius:7px;padding:11px 15px;text-decoration:none;font-size:12px;font-weight:600;background:white;color:var(--ink);white-space:nowrap;min-height:40px}
|
||||
.editor-message{padding:14px 18px;border-radius:7px;line-height:1.6;font-size:13px}
|
||||
.editor-body{overflow:hidden}
|
||||
#editor{height:100dvh;display:flex;flex-direction:column}
|
||||
.editor-header{display:flex;align-items:center;gap:20px;min-height:76px;padding:13px 20px;border-bottom:1px solid var(--line);background:white;z-index:1}
|
||||
.back-link{display:flex;gap:9px;text-decoration:none;font-size:12px;padding-right:20px;border-right:1px solid var(--line)}
|
||||
.editor-page-name{min-width:0;flex:1;display:grid;gap:5px}
|
||||
.editor-page-name strong{font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.editor-page-name>span{font-size:10px;color:var(--muted)}
|
||||
.history-actions,.save-actions{display:flex;align-items:center;gap:8px}
|
||||
.icon-button{font-size:21px;min-width:36px;padding:3px 9px}
|
||||
.save-actions>span{font-size:10px;color:var(--muted);max-width:130px;margin-right:8px}
|
||||
.editor-layout{display:grid;grid-template-columns:258px minmax(200px,1fr) 280px;flex:1;min-height:0}
|
||||
.left-panel,.right-panel{background:white;overflow:auto}
|
||||
.left-panel{border-right:1px solid var(--line)}
|
||||
.right-panel{border-left:1px solid var(--line)}
|
||||
.panel-section{padding:23px 18px;border-bottom:1px solid var(--line)}
|
||||
.panel-section .eyebrow{margin-bottom:10px}
|
||||
.panel-section h2{font-size:14px;margin-bottom:12px}
|
||||
.panel-hint{font-size:11px;color:var(--muted);line-height:1.6;margin-bottom:17px}
|
||||
.library{display:grid;grid-template-columns:1fr 1fr;gap:7px}
|
||||
.library button{min-height:64px;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:6px;border:1px solid var(--line);border-radius:7px;background:#fff;color:var(--ink);font-size:10px}
|
||||
.library button:hover:not(:disabled){border-color:var(--accent);background:#f3f7e8}
|
||||
.library button span{font-size:19px;line-height:1}
|
||||
.panel-title{display:flex;justify-content:space-between;align-items:center}
|
||||
.panel-title h2{margin-bottom:0}
|
||||
.panel-title+.panel-hint{margin-top:10px}
|
||||
|
||||
/* ---- shell ------------------------------------------------------------------------------- */
|
||||
html:has(>.editor-body){overflow:hidden;height:100%}
|
||||
.editor-body{overflow:hidden;height:100dvh}
|
||||
#editor{height:100dvh;display:flex;flex-direction:column;background:#e3e7e1}
|
||||
.editor-header{display:flex;align-items:center;gap:12px;height:52px;padding:0 12px;background:var(--ink);color:#fff;flex-shrink:0;z-index:5}
|
||||
.back-link{display:flex;gap:7px;align-items:center;color:#cfd9d3;text-decoration:none;font-size:12px;font-weight:600;padding:8px 14px 8px 4px;border-right:1px solid #ffffff22;white-space:nowrap}
|
||||
.back-link:hover{color:#fff}
|
||||
.editor-page-name{min-width:0;width:230px;display:grid;gap:1px}
|
||||
.editor-page-name h1{font-size:13px;font-weight:600;letter-spacing:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin:0;color:#fff}
|
||||
.editor-page-name>span{font-size:11px;color:#9fb1a7;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.icon-button{display:inline-flex;align-items:center;justify-content:center;width:34px;height:34px;min-height:0;padding:0;border:1px solid transparent;border-radius:8px;background:transparent;color:inherit;text-decoration:none;font-size:14px;line-height:1}
|
||||
.icon-button svg{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
|
||||
.editor-header .icon-button{color:#d5ded8}
|
||||
.editor-header .icon-button:hover:not(:disabled){background:#ffffff1a;color:#fff}
|
||||
.editor-header .icon-button[aria-pressed=true]{background:#fff;color:var(--ink)}
|
||||
.viewport-controls{display:flex;gap:2px;margin:0 auto;padding:3px;background:#ffffff14;border-radius:9px}
|
||||
.viewport-controls button{display:inline-flex;align-items:center;gap:7px;border:0;background:transparent;color:#c9d4cd;padding:0 11px;min-height:30px;border-radius:6px;font-size:12px;font-weight:600;white-space:nowrap}
|
||||
.viewport-controls button svg{width:16px;height:16px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
|
||||
.viewport-controls button:hover:not(:disabled){color:#fff}
|
||||
.editor-header .viewport-controls button[aria-pressed=true]{background:#fff;color:var(--ink)}
|
||||
.history-actions{display:flex;gap:2px}
|
||||
.save-actions{display:flex;align-items:center;gap:8px}
|
||||
.save-actions>span{font-size:11px;color:#9fb1a7;max-width:150px;text-align:right;line-height:1.3}
|
||||
.editor-header .secondary{background:transparent;color:#fff;border:1px solid #ffffff4d;font-size:12px;padding:0 13px;min-height:34px;white-space:nowrap}
|
||||
.editor-header .secondary:hover:not(:disabled){background:#ffffff1a;border-color:#ffffff99}
|
||||
.editor-header .primary{font-size:12px;padding:0 15px;min-height:34px;white-space:nowrap}
|
||||
.editor-message{border-radius:0;background:#fff0e9;color:#873e24;border-bottom:1px solid #e6c3b2;flex-shrink:0;padding:9px 16px;font-size:12px;line-height:1.6}
|
||||
.recovery-bar{display:flex;gap:12px;align-items:center;padding:10px 16px;border-bottom:1px solid var(--line);background:#fff;font-size:12px;flex-wrap:wrap}
|
||||
.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:25px}
|
||||
/* The single row is capped at the space left under the header, so the sidebar and the canvas
|
||||
scroll inside themselves instead of stretching the workspace. */
|
||||
.editor-layout{display:grid;grid-template-columns:330px minmax(0,1fr);grid-template-rows:minmax(0,1fr);flex:1;min-height:0}
|
||||
|
||||
/* ---- sidebar ----------------------------------------------------------------------------- */
|
||||
.sidebar{display:flex;flex-direction:column;min-width:0;min-height:0;overflow:hidden;background:#fff;border-right:1px solid var(--line)}
|
||||
.sidebar-head{flex:none;border-bottom:1px solid var(--line);background:#fff}
|
||||
.sidebar-nav{display:flex}
|
||||
.sidebar-nav [role=tab]{flex:1;border:0;border-bottom:2px solid transparent;background:transparent;color:var(--muted);font-size:12px;font-weight:700;letter-spacing:.2px;padding:0 6px;min-height:46px;border-radius:0}
|
||||
.sidebar-nav [role=tab]:hover:not(:disabled){color:var(--ink);background:#f7f9f5}
|
||||
.sidebar-nav [aria-selected=true]{color:var(--ink);border-bottom-color:var(--accent)}
|
||||
.sidebar-element{display:flex;align-items:center;gap:4px;padding:7px 8px 7px 6px;border-top:1px solid var(--line);background:#f7f9f5}
|
||||
.sidebar-element .icon-button{color:var(--ink);flex:none}
|
||||
.sidebar-element .icon-button:hover:not(:disabled){background:#eef2ea}
|
||||
.sidebar-element-name{flex:1;min-width:0;padding:0 4px}
|
||||
.sidebar-element-name h2{margin:0;font-size:14px;letter-spacing:0;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;line-height:1.3}
|
||||
.breadcrumbs{display:flex;flex-wrap:wrap;align-items:center;gap:1px;min-height:0;line-height:1.3}
|
||||
.breadcrumbs:empty{display:none}
|
||||
.crumb{border:0;background:none;padding:0;min-height:0;font-size:10.5px;color:var(--muted);cursor:pointer;white-space:nowrap}
|
||||
.crumb:hover{color:var(--accent);text-decoration:underline}
|
||||
.crumb+.crumb::before{content:'›';margin:0 4px;color:#b7c2bb}
|
||||
.property-tabs{display:flex;gap:4px;padding:0 8px 8px}
|
||||
.property-tabs button{flex:1;min-width:0;border:1px solid var(--line);background:#f5f7f2;color:var(--ink);border-radius:7px;padding:0 6px;min-height:32px;font-size:12px;font-weight:600}
|
||||
.property-tabs button:hover:not(:disabled){border-color:#b9c6bd}
|
||||
.property-tabs [aria-pressed=true]{background:var(--ink);color:#fff;border-color:var(--ink)}
|
||||
.sidebar-body{flex:1;min-height:0;overflow:auto;overflow-x:hidden;padding:14px 14px 28px;container-type:inline-size}
|
||||
.sidebar-view>:first-child{margin-top:0}
|
||||
.sidebar-foot{flex:none;display:flex;gap:6px;align-items:center;padding:10px 12px;border-top:1px solid var(--line);background:#fff}
|
||||
.sidebar-foot .icon-button{border-color:var(--line);color:var(--ink)}
|
||||
.sidebar-foot .icon-button:hover:not(:disabled){background:#eef2ea}
|
||||
.sidebar-foot .secondary{flex:1;font-size:12px;padding:0 8px;min-height:34px;white-space:nowrap}
|
||||
.sidebar-foot .danger-text{color:#943a2e}
|
||||
.sidebar-note{flex:none;font-size:11px;line-height:1.5;color:#873e24;background:#fff4ec;border-top:1px solid #e6c3b2;margin:0;padding:10px 14px}
|
||||
.panel-hint{font-size:11px;color:var(--muted);line-height:1.6;margin:0 0 12px}
|
||||
.panel-note{font-size:11px;color:var(--muted);line-height:1.7;padding:12px 0}
|
||||
.sidebar label{font-size:12px;margin:16px 0 6px}
|
||||
.sidebar input,.sidebar select,.sidebar textarea{font-size:12px;border-radius:7px;min-width:0;max-width:100%}
|
||||
.sidebar textarea{min-height:120px}
|
||||
.sidebar select{padding-inline:7px;text-overflow:ellipsis}
|
||||
.sidebar .field-help{font-size:11px;line-height:1.5}
|
||||
.sidebar .full-width{width:100%}
|
||||
.sidebar .secondary,.sidebar .primary{font-size:12px;min-height:36px}
|
||||
|
||||
/* ---- element library --------------------------------------------------------------------- */
|
||||
#library-search,#tree-search{font-size:12px;min-height:38px;padding:8px 11px;margin:0 0 4px}
|
||||
#library-found{margin:0 0 8px;min-height:0}
|
||||
#library-found:empty{display:none}
|
||||
.library-group+.library-group{margin-top:18px}
|
||||
.library-group-title{font-size:10px;font-weight:700;letter-spacing:1.1px;text-transform:uppercase;color:var(--muted);margin:16px 0 8px}
|
||||
.library-group:first-child .library-group-title{margin-top:8px}
|
||||
.library-group-hint{font-size:11px;line-height:1.5;color:var(--muted);margin:-4px 0 8px}
|
||||
.library-buttons{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:6px}
|
||||
.library-buttons button{display:flex;flex-direction:column;align-items:center;justify-content:center;gap:7px;min-height:74px;padding:10px 5px 9px;border:1px solid var(--line);border-radius:9px;background:#fbfcfa;color:var(--ink);font-size:11px;line-height:1.2;text-align:center;overflow-wrap:anywhere;min-width:0}
|
||||
.library-preview{display:grid;place-items:center;width:40px;height:27px;color:var(--accent)}
|
||||
.library-preview svg{width:40px;height:27px}
|
||||
.library-name{min-width:0}
|
||||
.library-buttons button:hover:not(:disabled){border-color:var(--accent);background:#fff;box-shadow:0 2px 10px #1b2a2414}
|
||||
.library-buttons button:focus-visible{outline:2px solid #b55b12;outline-offset:1px}
|
||||
.library-buttons button:disabled{opacity:.35;cursor:not-allowed}
|
||||
.library-buttons button:disabled .library-preview{color:var(--muted)}
|
||||
#library button[draggable=true]{cursor:grab}
|
||||
#library button[draggable=true]:active{cursor:grabbing}
|
||||
@container(max-width:260px){.library-buttons{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
.insert-hint{background:color-mix(in srgb, var(--ring,#b55b12) 9%, #fff);border:1px solid var(--ring,#b55b12);border-radius:8px;padding:9px 11px;font-size:11.5px;line-height:1.5;margin:0 0 12px}
|
||||
.insert-hint .text-button{margin-left:4px}
|
||||
#toolbox.awaiting-insert .library button[data-add]:not(:disabled){box-shadow:inset 0 0 0 1px var(--ring,#b55b12)}
|
||||
.preset-library{display:grid;gap:6px}
|
||||
.preset-library button{display:flex;gap:10px;text-align:left;align-items:center;min-height:40px;padding:8px 12px;border:1px solid var(--line);border-radius:8px;background:#fbfcfa;color:var(--ink);font-size:12px}
|
||||
.preset-library button:hover:not(:disabled){border-color:var(--accent);background:#fff}
|
||||
.preset-library span{font-size:16px;color:var(--accent)}
|
||||
|
||||
/* ---- structure --------------------------------------------------------------------------- */
|
||||
.tree-tools{display:flex;flex-wrap:wrap;gap:2px;margin:4px 0 8px}
|
||||
.tree-tools .text-button{font-size:11px;padding:4px 7px;min-height:26px}
|
||||
.structure-list{list-style:none;margin:0;padding:0}
|
||||
.structure-list .structure-list{padding-left:13px;border-left:1px solid #e5e9e3;margin-left:8px}
|
||||
.tree-item{display:flex;align-items:center;gap:7px;width:100%;text-align:left;border:1px solid transparent;background:none;color:#42584b;border-radius:5px;padding:9px 7px;font-size:11px;min-height:34px}
|
||||
.structure-list .structure-list{padding-left:12px;border-left:1px solid #e5e9e3;margin-left:9px}
|
||||
.structure-list li{position:relative}
|
||||
.tree-item{display:flex;align-items:center;gap:7px;width:100%;text-align:left;border:1px solid transparent;background:none;color:#42584b;border-radius:6px;padding:8px 7px 8px 24px;font-size:12px;min-height:34px}
|
||||
.tree-item span{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.tree-item:hover{background:#f3f6ef}
|
||||
.tree-item[aria-pressed=true]{background:#eaf1d5;border-color:#c5d89a;color:#203b34;font-weight:600}
|
||||
.tree-item[aria-pressed=true]{background:#eaf1d5;border-color:#c5d89a;color:#203b34;font-weight:600;padding-right:84px}
|
||||
.tree-icon{font-size:12px;min-width:15px}
|
||||
.tree-visibility{margin-left:auto;max-width:95px;font-size:10px;font-weight:400;line-height:1.3;color:#42584b;white-space:normal}
|
||||
.tree-toggle{position:absolute;top:5px;left:0;width:22px;height:25px;z-index:1;border:0;background:transparent;color:var(--ink);border-radius:4px;min-height:0;padding:0}
|
||||
.tree-edit{position:absolute;top:5px;right:4px;z-index:1;min-height:0;padding:4px 8px;border:1px solid #c5d89a;border-radius:6px;background:#fff;color:var(--accent);font-size:11px;font-weight:700}
|
||||
.tree-edit:hover{background:var(--accent);color:#fff}
|
||||
.drop-target{outline:2px dashed #315b49}
|
||||
.block-actions{display:flex;flex-wrap:wrap;gap:5px;border-top:1px solid var(--line);padding-top:13px;margin-top:16px}
|
||||
.block-actions button{font-size:10px;padding:7px 9px;min-height:33px}
|
||||
.block-actions .danger-text{color:#943a2e}
|
||||
.canvas-panel{min-width:0;display:flex;flex-direction:column;background:#e9ede7;overflow:hidden}
|
||||
.canvas-toolbar{display:flex;align-items:center;justify-content:space-between;gap:8px;padding:12px 20px;background:#f6f8f2;border-bottom:1px solid var(--line);min-height:59px}
|
||||
.canvas-label{font-size:9px;font-weight:700;letter-spacing:1.2px;color:#57655d}
|
||||
.viewport-controls{display:flex;gap:3px;background:#e8eee1;border-radius:7px;padding:3px}
|
||||
.viewport-controls button{padding:5px 9px;font-size:10px;min-height:28px;border:0;background:transparent}
|
||||
.viewport-controls [aria-pressed=true]{background:white;box-shadow:0 1px 3px #00000012}
|
||||
.canvas-toolbar>a{text-decoration:none;font-size:18px}
|
||||
.canvas-scroll{flex:1;min-height:0;overflow:auto;padding:22px;display:flex;justify-content:center;align-items:flex-start}
|
||||
#preview{background:white;border:0;box-shadow:0 3px 20px #203b3410;min-height:600px;height:100%;flex-shrink:0}
|
||||
.viewport-desktop{width:100%}
|
||||
.viewport-tablet{width:768px;max-width:100%}
|
||||
.viewport-mobile{width:390px;max-width:100%}
|
||||
.preview-status{font-size:10px;padding:9px 20px;color:#52614f;margin:0;min-height:30px;background:#f3f5ed}
|
||||
.right-panel label{font-size:11px}
|
||||
.right-panel input,.right-panel select,.right-panel textarea{font-size:12px}
|
||||
.right-panel textarea{min-height:150px}
|
||||
|
||||
.panel-note{font-size:11px;color:var(--muted);line-height:1.7;padding:20px}
|
||||
/* ---- canvas ------------------------------------------------------------------------------ */
|
||||
.canvas-panel{min-width:0;min-height:0;display:flex;flex-direction:column;background:#e3e7e1;overflow:hidden}
|
||||
.canvas-scroll{flex:1;min-height:0;overflow:auto;padding:16px}
|
||||
.canvas-scroll:focus-visible{outline:3px solid #b55b12;outline-offset:-3px}
|
||||
#preview-stage{position:relative;margin:0 auto;background:#fff;box-shadow:0 8px 30px #1b2a2418;border-radius:6px;overflow:hidden}
|
||||
#preview{position:absolute;top:0;left:0;border:0;background:#fff;transform-origin:top left;max-width:none;min-height:0}
|
||||
.preview-status{flex:none;font-size:11px;padding:6px 14px;color:#52614f;margin:0;background:#f5f7f2;border-top:1px solid var(--line);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.resizing-panels iframe{pointer-events:none}
|
||||
|
||||
/* ---- context menu ------------------------------------------------------------------------ */
|
||||
.context-menu{position:fixed;z-index:60;min-width:210px;padding:5px;background:#fff;border:1px solid var(--line);border-radius:9px;box-shadow:0 12px 40px #1b2a2433;display:grid}
|
||||
.context-menu button{border:0;background:transparent;text-align:left;padding:8px 11px;min-height:0;border-radius:6px;font-size:12px;color:var(--ink);white-space:nowrap}
|
||||
.context-menu button:hover,.context-menu button:focus-visible{background:#eef2ea;outline:0}
|
||||
.context-menu .danger-text{color:#943a2e}
|
||||
.context-menu .danger-text:hover{background:#fbeae6}
|
||||
|
||||
.editor-message{border-radius:0;background:#fff0e9;color:#873e24;border-bottom:1px solid #e6c3b2;flex-shrink:0;padding:9px 20px;font-size:12px}
|
||||
.recovery-bar{display:flex;gap:12px;align-items:center;padding:10px 20px;border-bottom:1px solid var(--line);background:#fff;font-size:12px;flex-wrap:wrap}
|
||||
.dialog-actions{display:flex;justify-content:flex-end;gap:10px;margin-top:25px}
|
||||
@media(max-width:1150px){.editor-layout{grid-template-columns:215px minmax(200px,1fr) 240px}.editor-header{gap:12px;padding-inline:14px}.save-actions>span{display:none}.panel-section{padding:18px 14px}.canvas-scroll{padding:12px}.canvas-label{display:none}}
|
||||
@media(max-width:850px){.editor-body{overflow:auto}#editor{height:auto;min-height:100dvh}.editor-header{flex-wrap:wrap}.editor-page-name{min-width:180px}.save-actions{width:100%;justify-content:flex-end}.save-actions>span{display:block;margin-right:auto}.editor-layout{display:flex;flex-wrap:wrap}.canvas-panel{order:0;width:100%;height:680px}.left-panel{order:1;width:45%;max-height:650px}.right-panel{order:2;width:55%;max-height:650px}.canvas-label{display:block}.editor-header .back-link{padding-right:12px}.editor-message{font-size:11px}.editor-layout .panel-hint{font-size:10px}}
|
||||
.left-panel{display:flex;flex-direction:column;overflow:hidden}
|
||||
.left-panel>.panel-section:first-child{max-height:45%;overflow:auto;flex-shrink:0}
|
||||
.structure-section{display:flex;flex-direction:column;flex:1;min-height:0}
|
||||
.structure-section #structure{overflow:auto;flex:1;min-height:90px;padding:3px}
|
||||
.structure-section .block-actions{flex-shrink:0;background:white}
|
||||
.structure-section .panel-hint{margin:8px 0}
|
||||
.search-label{font-size:11px;margin:12px 0 6px}
|
||||
#tree-search{font-size:11px;padding:9px;min-height:36px}
|
||||
.tree-tools{display:flex;flex-wrap:wrap;gap:3px;margin-top:5px}
|
||||
.tree-tools .text-button{font-size:10px;padding:5px}
|
||||
.structure-list li{position:relative}
|
||||
.tree-item{padding-left:23px}
|
||||
.tree-toggle{position:absolute;top:5px;left:0;width:22px;height:25px;z-index:1;border:0;background:transparent;color:var(--ink);border-radius:4px}
|
||||
.preset-heading{font-size:12px;margin:22px 0 8px}
|
||||
.preset-library{display:grid;gap:6px}
|
||||
.preset-library button{display:flex;gap:10px;text-align:left;align-items:center;min-height:40px;padding:9px 12px;border:1px solid var(--line);border-radius:7px;background:#f7f9f2;color:var(--ink);font-size:11px}
|
||||
.preset-library button:hover:not(:disabled){border-color:var(--accent)}
|
||||
.preset-library span{font-size:18px}
|
||||
.property-tabs{display:flex;background:#edf1e7;padding:4px;border-radius:8px;gap:4px}
|
||||
.property-tabs button{flex:1;min-width:0;border:0;background:transparent;color:var(--ink);border-radius:6px;padding:10px;font-size:12px;font-weight:600}
|
||||
.property-tabs [aria-pressed=true]{background:white;box-shadow:0 1px 3px #0001}
|
||||
.design-scope{font-size:12px;font-weight:600;line-height:1.6;margin:18px 0 8px;padding:10px;border-left:3px solid var(--accent);background:#f1f5e7}
|
||||
/* ---- properties -------------------------------------------------------------------------- */
|
||||
.accordion-entry-list{display:grid;gap:7px;margin:12px 0}
|
||||
.accordion-entry-choice{min-height:44px;padding:10px 12px;border:1px solid var(--line);border-radius:8px;background:#f7f9f3;color:var(--ink);font-size:12px;line-height:1.5;text-align:left;overflow-wrap:anywhere}
|
||||
.accordion-entry-choice:hover{border-color:var(--accent);background:#edf3e7}
|
||||
.design-scope{font-size:12px;font-weight:600;line-height:1.6;margin:0 0 8px;padding:9px 10px;border-left:3px solid var(--accent);background:#f1f5e7;border-radius:0 7px 7px 0}
|
||||
.design-reset{margin-top:24px;white-space:normal;width:100%}
|
||||
.breadcrumbs{display:flex;align-items:center;flex-wrap:wrap;gap:3px;min-height:35px;padding:3px 14px;background:#f6f8f2;border-top:1px solid var(--line)}
|
||||
.breadcrumbs .text-button{font-size:10px;white-space:normal}
|
||||
.breadcrumbs button+button:before{content:'›';color:var(--muted)}
|
||||
.breadcrumbs [aria-current=location]{font-weight:700;background:#e5edda}
|
||||
.canvas-scroll{display:block}
|
||||
.canvas-scroll #preview-stage{position:relative;margin:0 auto}
|
||||
#preview{position:absolute;top:0;left:0;transform-origin:top left;max-width:none;min-height:0}
|
||||
.right-panel select{padding-inline:7px;text-overflow:ellipsis}
|
||||
.right-panel .panel-section{min-width:0}
|
||||
@media(max-width:850px){.left-panel,.right-panel{height:650px}.left-panel>.panel-section:first-child{max-height:40%}.structure-section{padding:12px 7px}.tree-item{font-size:10px;gap:3px}.tree-icon{min-width:10px}.structure-list .structure-list{padding-left:5px;margin-left:4px}.block-actions button{padding:6px;white-space:normal}.property-tabs button{padding:9px 4px;font-size:11px}.canvas-scroll{padding:12px}.right-panel label{line-height:1.5}.tree-tools .text-button{white-space:normal}}
|
||||
.design-group{border:1px solid var(--line);border-radius:9px;margin-top:10px;background:#fff}
|
||||
.design-group>summary{cursor:pointer;padding:12px 14px;font-size:12px;font-weight:700;letter-spacing:.3px;list-style:none;display:flex;justify-content:space-between;align-items:center}
|
||||
.design-group>summary::after{content:'+';font-size:17px}
|
||||
.design-group{border:1px solid var(--line);border-radius:10px;margin-top:10px;background:#fff}
|
||||
.design-group>summary{cursor:pointer;padding:11px 14px;font-size:12px;font-weight:700;letter-spacing:.3px;list-style:none;display:flex;justify-content:space-between;align-items:center}
|
||||
.design-group>summary::-webkit-details-marker{display:none}
|
||||
.design-group>summary::after{content:'+';font-size:17px;color:var(--muted)}
|
||||
.design-group[open]>summary{border-bottom:1px solid var(--line)}
|
||||
.design-group[open]>summary::after{content:'–'}
|
||||
.design-group-body{padding:6px 14px 16px}
|
||||
.design-group-body{padding:4px 14px 16px}
|
||||
.design-group-body>label:first-child{margin-top:10px}
|
||||
.design-group-label{font-size:11px;font-weight:700;color:var(--muted);margin:18px 0 6px;letter-spacing:.3px}
|
||||
.design-row{margin-top:14px}
|
||||
.design-row-head{display:flex;justify-content:space-between;align-items:center;gap:8px}
|
||||
.design-row-head label{margin:0;font-size:11px}
|
||||
.design-own .design-row-head label{color:#2f5d4a;font-weight:700}
|
||||
.design-own .design-row-head label::before{content:'● ';color:var(--accent)}
|
||||
.design-row-head label{margin:0;font-size:11px;line-height:1.5;flex:1;min-width:0}
|
||||
/* Belegte Zeilen tragen dieselbe Farbe wie die belegten Geraetechips - eine Farbe, eine
|
||||
Bedeutung: hier steht ein eigener Wert und nicht der geerbte. */
|
||||
.design-own .design-row-head label{color:var(--used);font-weight:700}
|
||||
.design-own .design-row-head label::before{content:'● ';color:var(--used)}
|
||||
.design-reset-one{font-size:14px;padding:2px 6px;min-height:24px;line-height:1}
|
||||
.design-inherited{font-size:10px;color:var(--muted);margin:5px 0 0;line-height:1.45}
|
||||
.color-control{display:grid;grid-template-columns:38px 1fr;gap:6px;align-items:center}
|
||||
.color-control input[type=color]{padding:2px;min-height:38px;width:38px;border-radius:6px;cursor:pointer}
|
||||
.color-control .color-text{font-size:12px;min-height:38px}
|
||||
.color-control .color-palette{grid-column:1 / -1;font-size:11px;min-height:34px}
|
||||
.length-control{display:grid;grid-template-columns:1fr 74px;gap:6px}
|
||||
.length-control{display:grid;grid-template-columns:minmax(0,1fr) 74px;gap:6px}
|
||||
.length-control input,.length-control select{font-size:12px;min-height:38px}
|
||||
.length-control.is-inherited input{color:var(--muted)}
|
||||
.number-control{display:grid;grid-template-columns:1fr 74px;gap:8px;align-items:center}
|
||||
.length-mode{grid-column:1/-1}
|
||||
.number-control{display:grid;grid-template-columns:minmax(0,1fr) 74px;gap:8px;align-items:center}
|
||||
.number-control input[type=range]{width:100%;min-height:24px;padding:0;accent-color:#437754}
|
||||
.number-control input[type=number]{font-size:12px;min-height:38px}
|
||||
.side-wrap{margin-bottom:6px}
|
||||
.side-control{display:grid;grid-template-columns:1fr 1fr;gap:8px}
|
||||
.side-cell label{margin:0 0 4px;font-size:10px;color:var(--muted)}
|
||||
.side-all{font-size:10px;margin-top:8px;padding:4px 6px}
|
||||
.side-wrap{margin-bottom:10px}
|
||||
/* Each field sits on the edge it controls, so the position is the label. */
|
||||
.side-box{display:grid;grid-template-columns:1fr 1fr 1fr;grid-template-rows:auto auto auto;gap:4px;align-items:center;justify-items:center;padding:7px;border:1px dashed var(--line);border-radius:8px;background:#fbfcf8}
|
||||
.side-top{grid-area:1/2}.side-left{grid-area:2/1}.side-right{grid-area:2/3}.side-bottom{grid-area:3/2}
|
||||
.side-box-corners .side-topLeft{grid-area:1/1}.side-box-corners .side-topRight{grid-area:1/3}
|
||||
.side-box-corners .side-bottomLeft{grid-area:3/1}.side-box-corners .side-bottomRight{grid-area:3/3}
|
||||
.side-cell{width:100%}
|
||||
.side-field{width:100%;min-width:0;text-align:center;padding:5px 2px;font-size:11px;min-height:30px}
|
||||
.side-field.is-inherited{color:var(--muted)}
|
||||
.side-link{grid-area:2/2;width:26px;height:26px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:6px;background:#fff;font-size:12px;line-height:1;color:var(--muted);cursor:pointer}
|
||||
.side-link[aria-pressed=true]{border-color:var(--accent);background:#eef5e6;color:var(--accent)}
|
||||
.side-unit{display:flex;align-items:center;gap:7px;margin-top:7px}
|
||||
.side-unit label{margin:0;font-size:10px;color:var(--muted)}
|
||||
.side-unit-select{flex:1;min-width:0}
|
||||
/* The device chips on a single option: which devices carry an own value, and switch to one.
|
||||
|
||||
Zwei Zustaende, zwei Mittel. "Belegt" - dieses Geraet traegt einen eigenen Wert - ist die
|
||||
Auskunft, die man ueber die ganze Liste hinweg sucht, und bekommt deshalb die kraeftige Farbe.
|
||||
"Gerade bearbeitet" ist ein Rahmen, und zwar ein Rahmen INNEN: ein `outline` liegt ausserhalb
|
||||
des Kaestchens, laeuft bei 2 px Abstand unter den Nachbarn und wird von der seitlich
|
||||
beschnittenen Spalte gekappt - es sah aus wie ein abgeschnittener roter Kasten. */
|
||||
.device-chips{display:flex;flex:none;gap:6px;margin-left:auto}
|
||||
.device-chip{width:22px;height:22px;display:grid;place-items:center;padding:0;min-height:0;border:1px solid var(--line);border-radius:5px;background:#fff;color:var(--muted);font:700 10px/1 system-ui,sans-serif;cursor:pointer}
|
||||
.device-chip:hover{border-color:var(--ink)}
|
||||
.device-chip-own{background:var(--used);border-color:var(--used);color:#fff}
|
||||
.device-chip[aria-pressed=true]{border-width:2px;border-color:var(--ink)}
|
||||
.device-chip-own[aria-pressed=true]{border-color:var(--ink);box-shadow:inset 0 0 0 1px #fff}
|
||||
/* Der Fokusring bleibt innerhalb der Luecke zwischen zwei Chips - nichts liegt uebereinander. */
|
||||
.device-chip:focus-visible{outline-width:2px;outline-offset:0}
|
||||
.design-side-head{margin-bottom:5px}
|
||||
.design-side-head .design-group-label{margin:0}
|
||||
/* A number field that can be dragged sideways. The cursor is the only announcement it needs. */
|
||||
.scrubbable{cursor:ew-resize}
|
||||
.scrubbable:focus{cursor:text}
|
||||
.design-actions{display:grid;gap:8px;margin-top:22px}
|
||||
.design-actions button{width:100%}
|
||||
|
||||
|
||||
|
||||
|
||||
.design-context-toggle{margin:10px 0;width:100%;white-space:normal}
|
||||
#design-search{font-size:12px;min-height:36px;padding:9px}
|
||||
.search-label{font-size:11px;margin:12px 0 6px}
|
||||
.media-control{display:grid;gap:8px}
|
||||
.media-chosen{margin:0;font-size:11px;color:var(--muted)}
|
||||
.design-reuse{display:grid;gap:8px;margin-top:24px;padding-top:18px;border-top:1px solid var(--line)}
|
||||
@@ -172,8 +227,10 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.rt-button:hover{background:#eef2e7;border-color:var(--line)}
|
||||
.rt-toggle{margin-left:auto;font-size:10px;font-weight:700;letter-spacing:.5px}
|
||||
.rt-toggle[aria-pressed=true]{background:var(--ink);color:#fff}
|
||||
.rt-link{display:flex;gap:6px;padding:6px;border-bottom:1px solid var(--line);background:#fff6e2}
|
||||
.rt-link{display:flex;flex-wrap:wrap;align-items:center;gap:6px;padding:6px;border-bottom:1px solid var(--line);background:#fff6e2}
|
||||
.rt-link input{font-size:11px;min-height:30px}
|
||||
.rt-link>input{flex:1 0 100%;min-width:0}.rt-link .check-label{margin:0;font-size:12px}.rt-link input[type=checkbox]{min-height:0;width:auto}
|
||||
.selected-media-list{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:8px}.selected-media-list .selected-media-preview img{height:90px}.selected-media-list .text-button{font-size:11px;padding:6px}
|
||||
.rt-surface{min-height:120px;max-height:340px;overflow:auto;padding:11px 12px;font-size:13px;line-height:1.65}
|
||||
.rt-surface:focus-visible{outline:2px solid #b55b12;outline-offset:-2px}
|
||||
.rt-surface ul,.rt-surface ol{margin:0 0 8px;padding-left:20px}
|
||||
@@ -181,26 +238,82 @@ dialog p{font-size:14px;line-height:1.7;color:var(--muted)}
|
||||
.rt-source{min-height:150px;border:0;border-radius:0;font-family:ui-monospace,monospace;font-size:12px;line-height:1.6}
|
||||
.own-code{font-family:ui-monospace,monospace;font-size:12px;line-height:1.6;min-height:110px}
|
||||
.own-code[readonly]{background:#f4f5f1;color:var(--muted)}
|
||||
/* Bringing the editor chrome in line with the new administration: same radii, same restraint,
|
||||
and a few sizes that were simply too small to read comfortably. The layout is unchanged. */
|
||||
.editor-header{min-height:70px}
|
||||
.back-link{font-size:12px;font-weight:600;gap:7px;align-items:center}
|
||||
.editor-page-name h1{font-size:14px}
|
||||
.panel-section h2{font-size:13px;letter-spacing:0}
|
||||
.panel-section .eyebrow{font-size:9px;letter-spacing:1.4px}
|
||||
.library{gap:8px}
|
||||
.library button{min-height:68px;border-radius:9px;font-size:11px;gap:7px}
|
||||
.library button span{font-size:20px}
|
||||
.tree-item{font-size:12px;border-radius:6px}
|
||||
.right-panel label{font-size:12px}
|
||||
.right-panel input,.right-panel select,.right-panel textarea{font-size:12px;border-radius:7px}
|
||||
.property-tabs{padding:3px;border-radius:9px}
|
||||
.property-tabs button{border-radius:7px;font-size:12px}
|
||||
.viewport-controls{border-radius:8px}
|
||||
.viewport-controls button{border-radius:6px;font-size:11px}
|
||||
.design-group{border-radius:10px}
|
||||
.design-scope{border-radius:0 7px 7px 0}
|
||||
#preview{box-shadow:0 6px 28px #1b2a2414}
|
||||
.canvas-panel{background:#e7ebe5}
|
||||
.canvas-toolbar,.breadcrumbs,.preview-status{background:#f5f7f2}
|
||||
.icon-button{border-radius:8px}
|
||||
.image-width-control{padding:14px;margin-bottom:20px;border:1px solid var(--line);border-radius:10px;background:#f5f8f1}
|
||||
.image-width-control>label{margin-top:0}.image-width-inputs{display:grid;grid-template-columns:minmax(0,1fr) 76px;align-items:center;gap:12px}
|
||||
.image-width-inputs input[type=range]{width:100%;min-width:0;margin:0;padding:0;accent-color:var(--accent);min-height:36px}.image-width-control .field-help{margin:8px 0}.image-width-control .text-button{margin-top:4px}.image-width-error{color:#a62424}
|
||||
|
||||
/* --------------------------------------------------------------------------------------------
|
||||
The working layer above the preview.
|
||||
|
||||
It sits in the editor document, not in the preview, and carries the same scale transform as
|
||||
the frame - so a rectangle measured inside the frame can be used here without conversion. The
|
||||
layer itself lets the mouse through; only the controls on it catch a click, and each of them
|
||||
is counter-scaled from JavaScript so it stays finger-sized when the preview is shrunk.
|
||||
-------------------------------------------------------------------------------------------- */
|
||||
.canvas-overlay{position:absolute;top:0;left:0;transform-origin:top left;pointer-events:none;overflow:hidden;z-index:2}
|
||||
.ov-box{position:absolute;pointer-events:none}
|
||||
.ov-hover{outline:1px dashed #7c8a80;outline-offset:-1px}
|
||||
.ov-select{outline:2px solid var(--ring,#b55b12);outline-offset:-2px}
|
||||
.ov-label{position:absolute;left:0;top:0;transform-origin:left bottom;background:#5c6b62;color:#fff;font:600 10px/1 system-ui,sans-serif;letter-spacing:.3px;padding:4px 6px;border-radius:3px 3px 0 0;white-space:nowrap;translate:0 -100%}
|
||||
.ov-toolbar{position:absolute;display:flex;align-items:center;gap:2px;padding:3px;background:#243029;border-radius:7px;box-shadow:0 3px 12px #0000003d;pointer-events:auto}
|
||||
.ov-toolbar-name{color:#e7ece8;font:700 10px/1 system-ui,sans-serif;letter-spacing:.4px;padding:0 7px 0 5px;white-space:nowrap;max-width:150px;overflow:hidden;text-overflow:ellipsis;border-right:1px solid #ffffff24;margin-right:2px;align-self:stretch;display:flex;align-items:center}
|
||||
.ov-tool{width:26px;height:26px;display:grid;place-items:center;border:0;border-radius:5px;background:transparent;color:#e7ece8;font-size:13px;line-height:1;cursor:pointer;padding:0;min-height:0}
|
||||
.ov-tool:hover:not(:disabled){background:#3b4c42}
|
||||
.ov-tool:focus-visible{outline:2px solid #fff;outline-offset:-2px}
|
||||
.ov-tool-danger:hover:not(:disabled){background:#8d2f24}
|
||||
.ov-insert{position:absolute;display:grid;place-items:center;padding:0;min-height:0;border:2px solid #fff;border-radius:50%;background:var(--ring,#b55b12);color:#fff;font:700 14px/1 system-ui,sans-serif;cursor:pointer;box-shadow:0 2px 7px #0000003d;pointer-events:auto;opacity:.55;transition:opacity .12s}
|
||||
.ov-insert:hover,.ov-insert:focus-visible{opacity:1}
|
||||
.ov-drop{position:absolute;background:var(--ring,#b55b12);border-radius:2px;box-shadow:0 0 0 1px #fff8;pointer-events:none}
|
||||
.ov-drop-inside{background:color-mix(in srgb, var(--ring,#b55b12) 15%, transparent);border:2px dashed var(--ring,#b55b12);box-shadow:none}
|
||||
.ov-drop-label{position:absolute;pointer-events:none;background:#2c634a;color:#fff;border-radius:5px;padding:6px 9px;font:600 12px/1.4 system-ui,sans-serif;white-space:nowrap;box-shadow:0 2px 7px #0002}
|
||||
/* The border between two columns of the selected row: a grab area with a short bar in it. */
|
||||
.ov-resize{position:absolute;display:grid;place-items:center;padding:0;min-height:0;border:0;background:transparent;cursor:col-resize;pointer-events:auto;touch-action:none}
|
||||
.ov-resize::before{content:"";width:4px;height:40px;max-height:60%;border-radius:2px;background:var(--ring,#b55b12);box-shadow:0 0 0 1px #fff8;opacity:.55;transition:opacity .12s}
|
||||
.ov-resize:hover::before,.ov-resize:focus-visible::before,.canvas-overlay.is-resizing .ov-resize::before{opacity:1}
|
||||
.ov-resize:focus-visible{outline:2px solid var(--ring,#b55b12);outline-offset:-2px}
|
||||
.canvas-overlay.is-resizing .ov-hover,.canvas-overlay.is-resizing .ov-toolbar,.canvas-overlay.is-resizing .ov-insert{display:none}
|
||||
/* While something is being dragged the outlines would only add noise. */
|
||||
.canvas-overlay.is-dragging .ov-hover,.canvas-overlay.is-dragging .ov-toolbar,.canvas-overlay.is-dragging .ov-insert,.canvas-overlay.is-dragging .ov-resize{display:none}
|
||||
@media(prefers-reduced-motion:reduce){.ov-insert{transition:none;opacity:1}}
|
||||
|
||||
/* ---- narrow screens ---------------------------------------------------------------------- */
|
||||
@media(max-width:1100px){.editor-layout{grid-template-columns:290px minmax(0,1fr)}.editor-page-name{width:150px}.save-actions>span{display:none}.viewport-controls button span{display:none}.viewport-controls button{padding:0 9px}}
|
||||
@media(max-width:850px){
|
||||
.editor-body{overflow:auto}#editor{height:auto;min-height:100dvh}
|
||||
.editor-header{flex-wrap:wrap;height:auto;padding:8px 10px;gap:8px}.editor-page-name{flex:1;width:auto}.viewport-controls{order:6;margin:0}.save-actions{margin-left:auto}
|
||||
.editor-layout{display:flex;flex-direction:column}
|
||||
.canvas-panel{height:62vh;flex:none}.sidebar{height:62vh;flex:none;border-right:0;border-top:1px solid var(--line)}
|
||||
.library-buttons{grid-template-columns:repeat(4,minmax(0,1fr))}
|
||||
}
|
||||
/* Übersetzen (ADR 111). Der Umschalter sitzt neben der Vorschaugröße; im Übersetzungsmodus
|
||||
verschwindet alles, was den Aufbau ändert - Reiter, Blockaktionen, Werkzeuge der Vorschau. */
|
||||
.language-switch{display:flex;gap:2px;padding:3px;background:#ffffff14;border-radius:9px}
|
||||
.language-switch[hidden]{display:none}
|
||||
.language-switch button{border:0;background:transparent;color:#c9d4cd;padding:0 10px;min-height:30px;min-width:36px;border-radius:6px;font-size:12px;font-weight:700;letter-spacing:.04em}
|
||||
.language-switch button:hover:not(:disabled){color:#fff}
|
||||
.editor-header .language-switch button[aria-pressed=true]{background:#fff;color:var(--ink)}
|
||||
#editor[data-language] .language-switch button[aria-pressed=true]{background:#f5c451;color:#1d1d1b}
|
||||
#editor[data-language] .sidebar-nav,#editor[data-language] .sidebar-foot,#editor[data-language] .ov-toolbar,#editor[data-language] .ov-insert,#editor[data-language] .ov-resize,#editor[data-language] .ov-drop{display:none!important}
|
||||
.translate-head{display:grid;gap:8px;margin-bottom:16px}
|
||||
.translate-head h2{margin:0;font-size:16px}
|
||||
.translate-head h2:focus{outline:none}
|
||||
.translate-head progress{width:100%;height:8px;accent-color:var(--ink)}
|
||||
.translate-summary{margin:0;font-size:13px;font-weight:600}
|
||||
.translate-group{border-top:1px solid var(--line,#dfe5e1);padding:12px 0 4px;scroll-margin-top:12px}
|
||||
.translate-group.is-current{box-shadow:inset 3px 0 0 #f5c451;padding-left:10px}
|
||||
.translate-group h3{margin:0 0 8px;font-size:13px}
|
||||
.translate-jump{border:0;background:none;padding:0;font:inherit;font-weight:700;color:inherit;text-align:left;cursor:pointer}
|
||||
.translate-jump:hover{text-decoration:underline}
|
||||
.translate-field{display:grid;gap:5px;margin-bottom:14px}
|
||||
.translate-field-head{display:flex;justify-content:space-between;align-items:baseline;gap:8px}
|
||||
.translate-field-head label{margin:0}
|
||||
.translate-state{font-size:11px;font-weight:700;padding:2px 7px;border-radius:999px;background:#e8ece9;color:#384640;white-space:nowrap}
|
||||
.translate-field[data-state=missing] .translate-state{background:#fde9c8;color:#6b3f00}
|
||||
.translate-field[data-state=stale] .translate-state{background:#fbd5d0;color:#7a1d12}
|
||||
.translate-field[data-state=done] .translate-state{background:#d7eedf;color:#1c5233}
|
||||
.translate-original{margin:0;padding:6px 8px;border-radius:6px;background:#f2f4f3;color:#46514c;font-size:12.5px;line-height:1.45;white-space:pre-line;max-height:7.5em;overflow:auto}
|
||||
.translate-actions{display:flex;gap:12px;flex-wrap:wrap}
|
||||
.translate-slug{display:flex;align-items:center;gap:4px}
|
||||
.translate-slug input{flex:1;min-width:0}
|
||||
.translate-prefix{font-size:12px;color:#5b6862;white-space:nowrap}
|
||||
.translate-filter{margin-top:4px}
|
||||
+1274
-93
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,56 @@
|
||||
.block-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-block:24px}.block-gallery>.block-image{min-width:0;margin:0}.block-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover}.block-image figcaption{font-size:14px;line-height:1.5;padding:12px 0;color:inherit}.fx-shadow-none{box-shadow:none}.fx-shadow-soft{box-shadow:0 8px 24px #142e251f}.fx-shadow-strong{box-shadow:0 16px 40px #142e2540}.block-image{border-radius:12px}.block-image img{border-radius:inherit}.block-gallery:empty{min-height:32px}.fx-hover-zoom{overflow:hidden}.fx-hover-zoom img{transform-origin:center}.tone-ink figure{color:var(--paper)}
|
||||
.block-gallery{display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:24px;margin-block:24px}.block-gallery>.block-image{min-width:0;margin:0}.block-gallery img{width:100%;aspect-ratio:4/3;object-fit:cover}.block-image figcaption{font-size:14px;line-height:1.5;padding:12px 0;color:inherit}.fx-shadow-none{box-shadow:none}.fx-shadow-soft{box-shadow:0 8px 24px #142e251f}.fx-shadow-strong{box-shadow:0 16px 40px #142e2540}.block-image{border-radius:var(--k-image-radius,12px)}.block-image img{border-radius:inherit}.block-gallery:empty{min-height:32px}.fx-hover-zoom{overflow:hidden}.fx-hover-zoom img{transform-origin:center}.tone-ink figure{color:var(--paper)}
|
||||
@media(max-width:1024px){.block-gallery{grid-template-columns:repeat(2,minmax(0,1fr))}}
|
||||
@media(max-width:650px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}}
|
||||
@media(prefers-reduced-motion:no-preference){.fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)}.fx-entrance-fade{animation:kant-fade .55s ease-out backwards}.fx-entrance-rise{animation:kant-rise .55s ease-out backwards}@keyframes kant-fade{from{opacity:.35}to{opacity:1}}@keyframes kant-rise{from{opacity:.35;transform:translateY(16px)}to{opacity:1;transform:translateY(0)}}}
|
||||
@media(max-width:767px){.block-gallery{grid-template-columns:minmax(0,1fr);gap:16px}}
|
||||
.fx-hover-lift,.fx-hover-zoom img{transition:transform .25s ease}.fx-hover-lift:hover,.fx-hover-lift:focus-within{transform:translateY(-4px)}.fx-hover-zoom:hover img,.fx-hover-zoom:focus-within img{transform:scale(1.035)}
|
||||
|
||||
|
||||
/* Hover effects remain CSS; entrance effects are observed by motion.js. They play for everyone
|
||||
unless the website chooses to respect the browser preference - see the override at the end. */
|
||||
.fx-hover-sink { transition: transform .2s ease }
|
||||
.fx-hover-sink:hover { transform: translateY(3px) }
|
||||
.fx-hover-glow { transition: box-shadow .2s ease }
|
||||
.fx-hover-glow:hover { box-shadow: 0 0 0 4px color-mix(in srgb,var(--accent) 18%,transparent), 0 10px 30px color-mix(in srgb,var(--ink) 16%,transparent) }
|
||||
.fx-hover-tilt { transition: transform .2s ease }
|
||||
.fx-hover-tilt:hover { transform: rotate(-1.2deg) scale(1.01) }
|
||||
.fx-hover-grow-border { transition: box-shadow .2s ease }
|
||||
.fx-hover-grow-border:hover { box-shadow: inset 0 0 0 3px #2c6b4f }
|
||||
|
||||
/* Beitragskarten und Knöpfe, ergänzt am 13.09.2026.
|
||||
|
||||
Umut hat gefragt, wo der Zeigereffekt bleibt: eine Karte, die sich beim Zeigen nicht rührt,
|
||||
wirkt tot. Angehoben wird die ganze Karte, das Bild zieht leicht auf, und der Hinweis darüber
|
||||
blendet ein. Alles innerhalb der Bewegungsschranke - wer weniger Bewegung eingestellt hat,
|
||||
bekommt den Hinweis trotzdem, nur ohne Fahrt. */
|
||||
.post-card { transition: transform .28s ease, box-shadow .28s ease, border-color .28s ease }
|
||||
/* Eine Zeile in der kompakten Liste hebt sich nicht - sie ist keine Karte. */
|
||||
.post-list-kompakt .post-card:hover, .post-list-kompakt .post-card:focus-within { transform: none; box-shadow: none; border-color: var(--accent) }
|
||||
/* Wechselseitig: die Karte selbst bleibt stehen, nur das Bild zieht auf. */
|
||||
.post-list-wechsel .post-card:hover, .post-list-wechsel .post-card:focus-within { transform: none; box-shadow: none }
|
||||
.post-list-wechsel .post-card h3 a { transition: color .2s ease }
|
||||
.post-list-wechsel .post-card:hover h3 a, .post-list-wechsel .post-card:focus-within h3 a { color: var(--accent) }
|
||||
.post-card:hover, .post-card:focus-within {
|
||||
transform: translateY(-8px);
|
||||
box-shadow: 0 18px 50px -16px color-mix(in srgb, var(--ink) 28%, transparent);
|
||||
border-color: transparent;
|
||||
}
|
||||
.post-image img { transition: transform .5s ease }
|
||||
.post-card:hover .post-image img, .post-card:focus-within .post-image img { transform: scale(1.06) }
|
||||
.post-view { transition: opacity .3s ease }
|
||||
.post-nav-link { transition: transform .2s ease, border-color .2s ease }
|
||||
.post-nav-link:hover, .post-nav-link:focus-visible { transform: translateY(-3px); border-color: var(--accent) }
|
||||
|
||||
/* Der Knopf hebt sich beim Zeigen um zwei Pixel - dieselbe Geste wie auf der Karte. */
|
||||
.button { transition: transform .2s ease, background-color .2s ease, border-color .2s ease }
|
||||
.button:hover { transform: translateY(-2px) }
|
||||
|
||||
/* Die Website kann die Browservorgabe „Bewegung reduzieren" beachten (Einstellungen → Bewegung).
|
||||
Dann trägt <html> die Klasse motion-respect, und die Hover-Fahrten fallen weg - Farben und
|
||||
Schatten bleiben, nur nichts bewegt sich mehr. Ohne die Klasse spielt alles, wie bei Flatsome. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
html.motion-respect :is(.fx-hover-lift, .fx-hover-zoom img, .fx-hover-sink, .fx-hover-tilt, .post-card, .post-image img, .post-nav-link, .button) { transition: none }
|
||||
html.motion-respect :is(.fx-hover-lift, .fx-hover-sink, .fx-hover-tilt, .post-card, .post-nav-link, .button):hover,
|
||||
html.motion-respect :is(.fx-hover-lift, .post-card):focus-within,
|
||||
html.motion-respect .post-nav-link:focus-visible,
|
||||
html.motion-respect .fx-hover-zoom:hover img, html.motion-respect .fx-hover-zoom:focus-within img,
|
||||
html.motion-respect .post-card:hover .post-image img, html.motion-respect .post-card:focus-within .post-image img { transform: none }
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { openMedia } from './media.js';
|
||||
|
||||
/*
|
||||
* The site icon in the settings: an image from the media library (uploads happen right there in
|
||||
* the dialog), then a square cut-out chosen by dragging. The crop is stored as fractions -
|
||||
* left and top of the width and height, the edge of the shorter side - so it holds for every
|
||||
* variant of the image, and the server cuts the PNG files from it when the form is saved.
|
||||
*/
|
||||
const field = document.querySelector('[data-favicon-field]');
|
||||
if (field) setup(field);
|
||||
|
||||
function setup(field) {
|
||||
const idInput = field.querySelector('[name="faviconMediaId"]');
|
||||
const cropInput = field.querySelector('[name="faviconCrop"]');
|
||||
const choose = field.querySelector('[data-favicon-choose]');
|
||||
const remove = field.querySelector('[data-favicon-remove]');
|
||||
const current = field.querySelector('.favicon-current');
|
||||
const base = document.querySelector('meta[name="admin-base"]').content;
|
||||
|
||||
const editor = document.createElement('div'); editor.className = 'favicon-editor'; editor.hidden = true;
|
||||
editor.innerHTML = `<div class="favicon-stage"><img alt="" draggable="false"><div class="favicon-frame" tabindex="0" role="application" aria-label="Ausschnitt für das Symbol. Mit den Pfeiltasten verschieben, mit Umschalt in großen Schritten, mit Plus und Minus die Größe ändern."><span class="favicon-handle" aria-hidden="true"></span></div></div>
|
||||
<div class="favicon-side">
|
||||
<label for="favicon-size">Größe des Ausschnitts</label>
|
||||
<input id="favicon-size" type="range" min="2" max="100" step="1" value="100">
|
||||
<p class="field-help">Quadrat im Bild verschieben, an der Ecke oder mit dem Regler vergrößern und verkleinern.</p>
|
||||
<div class="favicon-samples" aria-label="Vorschau">
|
||||
<figure><canvas width="180" height="180" class="favicon-sample-large"></canvas><figcaption>Startbildschirm</figcaption></figure>
|
||||
<figure><canvas width="32" height="32" class="favicon-sample-small"></canvas><figcaption>Browser-Reiter</figcaption></figure>
|
||||
</div>
|
||||
<button type="button" class="text-button" data-favicon-center>Mittig ausrichten</button>
|
||||
</div>`;
|
||||
field.after(editor);
|
||||
const stage = editor.querySelector('.favicon-stage');
|
||||
const image = stage.querySelector('img');
|
||||
const frame = stage.querySelector('.favicon-frame');
|
||||
const size = editor.querySelector('#favicon-size');
|
||||
const large = editor.querySelector('.favicon-sample-large');
|
||||
const small = editor.querySelector('.favicon-sample-small');
|
||||
|
||||
let crop = { x: 0, y: 0, s: 1 };
|
||||
const clamp = (value, min, max) => Math.min(max, Math.max(min, value));
|
||||
const edge = () => crop.s * Math.min(image.naturalWidth, image.naturalHeight);
|
||||
|
||||
/** Keep the square inside the picture, then show it and write it into the form. */
|
||||
const apply = () => {
|
||||
const w = image.naturalWidth, h = image.naturalHeight; if (!w || !h) return;
|
||||
crop.s = clamp(crop.s, 0.02, 1);
|
||||
const e = edge();
|
||||
crop.x = clamp(crop.x, 0, (w - e) / w); crop.y = clamp(crop.y, 0, (h - e) / h);
|
||||
Object.assign(frame.style, { left: `${crop.x * 100}%`, top: `${crop.y * 100}%`, width: `${e / w * 100}%`, height: `${e / h * 100}%` });
|
||||
size.value = Math.round(crop.s * 100);
|
||||
cropInput.value = [crop.x, crop.y, crop.s].map(value => String(Math.round(value * 1e6) / 1e6)).join(',');
|
||||
for (const canvas of [large, small]) {
|
||||
const context = canvas.getContext('2d'); context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
context.imageSmoothingQuality = 'high';
|
||||
context.drawImage(image, crop.x * w, crop.y * h, e, e, 0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
};
|
||||
const center = () => {
|
||||
const w = image.naturalWidth, h = image.naturalHeight, e = edge();
|
||||
crop.x = (w - e) / 2 / w; crop.y = (h - e) / 2 / h; apply();
|
||||
};
|
||||
/** Resize around the middle of the square, so the slider and the keys do not drift it away. */
|
||||
const resize = s => {
|
||||
const w = image.naturalWidth, h = image.naturalHeight, before = edge();
|
||||
crop.s = clamp(s, 0.02, 1); const after = edge();
|
||||
crop.x += (before - after) / 2 / w; crop.y += (before - after) / 2 / h; apply();
|
||||
};
|
||||
|
||||
const load = (src, initial) => new Promise((resolve, reject) => {
|
||||
image.onload = () => { editor.hidden = false; crop = initial || { x: 0, y: 0, s: 1 }; if (initial) apply(); else center(); resolve(); };
|
||||
image.onerror = () => reject(new Error('Das Bild konnte nicht geladen werden.'));
|
||||
image.src = src;
|
||||
});
|
||||
const largest = item => item.variants.at(-1).src;
|
||||
|
||||
const showCurrent = label => {
|
||||
current.replaceChildren(); current.append(document.createTextNode(label));
|
||||
choose.textContent = idInput.value ? 'Anderes Bild wählen' : 'Bild wählen oder hochladen';
|
||||
remove.hidden = !idInput.value;
|
||||
};
|
||||
|
||||
choose.addEventListener('click', () => openMedia({ needsAlt: false, onChoose: async items => {
|
||||
if (!items.length) return;
|
||||
idInput.value = items[0].id; cropInput.value = '';
|
||||
showCurrent(`Neues Symbol aus „${items[0].title}“ – wird beim Speichern übernommen.`);
|
||||
try { await load(largest(items[0])); frame.focus({ preventScroll: true }); editor.scrollIntoView({ block: 'nearest' }); }
|
||||
catch (error) { showCurrent(error.message); }
|
||||
} }));
|
||||
remove.addEventListener('click', () => {
|
||||
idInput.value = ''; cropInput.value = ''; editor.hidden = true; image.removeAttribute('src');
|
||||
showCurrent('Mitgeliefertes Zeichen – wird beim Speichern übernommen.'); choose.focus();
|
||||
});
|
||||
editor.querySelector('[data-favicon-center]').addEventListener('click', center);
|
||||
size.addEventListener('input', () => resize(size.value / 100));
|
||||
|
||||
// Drag to move, drag the corner to resize. Pointer events cover mouse, pen and touch alike.
|
||||
let drag = null;
|
||||
frame.addEventListener('pointerdown', event => {
|
||||
event.preventDefault(); frame.setPointerCapture(event.pointerId); frame.focus({ preventScroll: true });
|
||||
drag = { mode: event.target.classList.contains('favicon-handle') ? 'resize' : 'move', startX: event.clientX, startY: event.clientY, crop: { ...crop }, rect: stage.getBoundingClientRect() };
|
||||
});
|
||||
frame.addEventListener('pointermove', event => {
|
||||
if (!drag) return;
|
||||
const w = image.naturalWidth, h = image.naturalHeight, { rect } = drag;
|
||||
const dx = (event.clientX - drag.startX) / rect.width, dy = (event.clientY - drag.startY) / rect.height;
|
||||
if (drag.mode === 'move') { crop.x = drag.crop.x + dx; crop.y = drag.crop.y + dy; }
|
||||
else {
|
||||
// The corner follows the larger of both movements, measured in image pixels.
|
||||
const grow = Math.max(dx * w, dy * h), shorter = Math.min(w, h);
|
||||
const room = Math.min(w - drag.crop.x * w, h - drag.crop.y * h);
|
||||
crop.x = drag.crop.x; crop.y = drag.crop.y;
|
||||
crop.s = clamp(drag.crop.s * shorter + grow, 0.02 * shorter, room) / shorter;
|
||||
}
|
||||
apply();
|
||||
});
|
||||
const stop = () => { drag = null; };
|
||||
frame.addEventListener('pointerup', stop); frame.addEventListener('pointercancel', stop);
|
||||
frame.addEventListener('keydown', event => {
|
||||
const step = event.shiftKey ? 0.1 : 0.01;
|
||||
const moves = { ArrowLeft: [-step, 0], ArrowRight: [step, 0], ArrowUp: [0, -step], ArrowDown: [0, step] };
|
||||
if (moves[event.key]) { event.preventDefault(); crop.x += moves[event.key][0]; crop.y += moves[event.key][1]; apply(); }
|
||||
else if (['+', '='].includes(event.key)) { event.preventDefault(); resize(crop.s + step); }
|
||||
else if (event.key === '-') { event.preventDefault(); resize(crop.s - step); }
|
||||
});
|
||||
|
||||
// An icon that is already set opens with its picture and its cut-out.
|
||||
if (/^[a-f0-9]{32}$/.test(idInput.value)) {
|
||||
fetch(`${base}/api/media/${idInput.value}`, { credentials: 'same-origin' })
|
||||
.then(response => { if (!response.ok || response.redirected) throw new Error(); return response.json(); })
|
||||
.then(({ item }) => {
|
||||
const parts = cropInput.value.split(',').map(Number);
|
||||
return load(largest(item), parts.length === 3 && parts.every(Number.isFinite) ? { x: parts[0], y: parts[1], s: parts[2] } : null);
|
||||
})
|
||||
.catch(() => {});
|
||||
}
|
||||
}
|
||||
File renamed without changes.
@@ -0,0 +1,38 @@
|
||||
# Schriften in diesem Ordner
|
||||
|
||||
Hier liegen die Schriftdateien, die diese Website **selbst ausliefert**. Nichts davon wird zur
|
||||
Laufzeit von einem fremden Server geholt: keine Verbindung zu `fonts.googleapis.com`, keine
|
||||
IP-Adresse eines Besuchers, die dabei an einen Dritten geht.
|
||||
|
||||
## Woher sie kommen
|
||||
|
||||
`php bin/console cms:fonts:fetch` (oder der Knopf „Schriften auf diesen Server holen" im
|
||||
Designbereich) lädt die im Design gewählten Schnitte einmalig von Google Fonts herunter, behält
|
||||
nur die lateinischen Zeichenbereiche und schreibt `fonts.css` mit den passenden
|
||||
`@font-face`-Regeln daneben.
|
||||
|
||||
## Lizenz
|
||||
|
||||
**Lato** steht unter der SIL Open Font License 1.1 – der vollständige Text liegt als
|
||||
`lato-OFL.txt` daneben, wie die Lizenz es verlangt. Copyright (c) 2010–2014 tyPoland Łukasz
|
||||
Dziedzic, Reserved Font Name „Lato". Die Lizenz erlaubt ausdrücklich, die Dateien mitzuliefern und
|
||||
von einem eigenen Server auszuliefern; verboten ist der Verkauf der Schrift für sich genommen und
|
||||
die Verwendung des reservierten Namens für eine geänderte Fassung.
|
||||
|
||||
**Cormorant Garamond** (Überschriften der Referenzwebsite „Ihr Malerservice Drews") steht ebenfalls
|
||||
unter der SIL Open Font License 1.1, Text in `cormorant-garamond-OFL.txt`. Copyright 2015 The
|
||||
Cormorant Project Authors (github.com/CatharsisFonts/Cormorant), Reserved Font Name „Cormorant".
|
||||
|
||||
**DM Sans** (Fließtext der Seite „Kantarci Markt") steht unter der SIL Open Font License 1.1, Text
|
||||
in `dm-sans-OFL.txt`. Copyright 2014 The DM Sans Project Authors (github.com/googlefonts/dm-fonts).
|
||||
DM Sans ist eine variable Schrift: `dm-sans-400-…`, `-500-…` und `-700-…` sind dieselbe Datei,
|
||||
`fonts.css` nennt nur die erste, mit dem Gewichtsbereich 400–700.
|
||||
|
||||
**Barlow** und **Barlow Condensed** (Fließtext und Überschriften der Seite „Hüfner Gartenbau“)
|
||||
stehen unter der SIL Open Font License 1.1, Text in `barlow-OFL.txt` (für beide Familien gleich).
|
||||
Copyright 2017 The Barlow Project Authors (github.com/jpt/barlow).
|
||||
|
||||
Wer hier andere Schriften ablegt, prüft deren Lizenz selbst: nicht jede Schrift, die Google Fonts
|
||||
ausliefert, steht unter der OFL – einige stehen unter der Apache-Lizenz 2.0, und eine gekaufte
|
||||
Schrift eines Schriftenhauses darf meist gar nicht weitergegeben werden. Die Lizenzdatei gehört in
|
||||
jedem Fall daneben.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2017 The Barlow Project Authors (https://github.com/jpt/barlow)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
http://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2015 the Cormorant Project Authors (github.com/CatharsisFonts/Cormorant)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
Copyright 2014 The DM Sans Project Authors (https://github.com/googlefonts/dm-fonts)
|
||||
|
||||
This Font Software is licensed under the SIL Open Font License, Version 1.1.
|
||||
This license is copied below, and is also available with a FAQ at:
|
||||
https://scripts.sil.org/OFL
|
||||
|
||||
|
||||
-----------------------------------------------------------
|
||||
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
|
||||
-----------------------------------------------------------
|
||||
|
||||
PREAMBLE
|
||||
The goals of the Open Font License (OFL) are to stimulate worldwide
|
||||
development of collaborative font projects, to support the font creation
|
||||
efforts of academic and linguistic communities, and to provide a free and
|
||||
open framework in which fonts may be shared and improved in partnership
|
||||
with others.
|
||||
|
||||
The OFL allows the licensed fonts to be used, studied, modified and
|
||||
redistributed freely as long as they are not sold by themselves. The
|
||||
fonts, including any derivative works, can be bundled, embedded,
|
||||
redistributed and/or sold with any software provided that any reserved
|
||||
names are not used by derivative works. The fonts and derivatives,
|
||||
however, cannot be released under any other type of license. The
|
||||
requirement for fonts to remain under this license does not apply
|
||||
to any document created using the fonts or their derivatives.
|
||||
|
||||
DEFINITIONS
|
||||
"Font Software" refers to the set of files released by the Copyright
|
||||
Holder(s) under this license and clearly marked as such. This may
|
||||
include source files, build scripts and documentation.
|
||||
|
||||
"Reserved Font Name" refers to any names specified as such after the
|
||||
copyright statement(s).
|
||||
|
||||
"Original Version" refers to the collection of Font Software components as
|
||||
distributed by the Copyright Holder(s).
|
||||
|
||||
"Modified Version" refers to any derivative made by adding to, deleting,
|
||||
or substituting -- in part or in whole -- any of the components of the
|
||||
Original Version, by changing formats or by porting the Font Software to a
|
||||
new environment.
|
||||
|
||||
"Author" refers to any designer, engineer, programmer, technical
|
||||
writer or other person who contributed to the Font Software.
|
||||
|
||||
PERMISSION & CONDITIONS
|
||||
Permission is hereby granted, free of charge, to any person obtaining
|
||||
a copy of the Font Software, to use, study, copy, merge, embed, modify,
|
||||
redistribute, and sell modified and unmodified copies of the Font
|
||||
Software, subject to the following conditions:
|
||||
|
||||
1) Neither the Font Software nor any of its individual components,
|
||||
in Original or Modified Versions, may be sold by itself.
|
||||
|
||||
2) Original or Modified Versions of the Font Software may be bundled,
|
||||
redistributed and/or sold with any software, provided that each copy
|
||||
contains the above copyright notice and this license. These can be
|
||||
included either as stand-alone text files, human-readable headers or
|
||||
in the appropriate machine-readable metadata fields within text or
|
||||
binary files as long as those fields can be easily viewed by the user.
|
||||
|
||||
3) No Modified Version of the Font Software may use the Reserved Font
|
||||
Name(s) unless explicit written permission is granted by the corresponding
|
||||
Copyright Holder. This restriction only applies to the primary font name as
|
||||
presented to the users.
|
||||
|
||||
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
|
||||
Software shall not be used to promote, endorse or advertise any
|
||||
Modified Version, except to acknowledge the contribution(s) of the
|
||||
Copyright Holder(s) and the Author(s) or with their explicit written
|
||||
permission.
|
||||
|
||||
5) The Font Software, modified or unmodified, in part or in whole,
|
||||
must be distributed entirely under this license, and must not be
|
||||
distributed under any other license. The requirement for fonts to
|
||||
remain under this license does not apply to any document created
|
||||
using the Font Software.
|
||||
|
||||
TERMINATION
|
||||
This license becomes null and void if any of the above conditions are
|
||||
not met.
|
||||
|
||||
DISCLAIMER
|
||||
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
||||
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
|
||||
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
|
||||
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
|
||||
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
||||
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
|
||||
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
||||
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
|
||||
OTHER DEALINGS IN THE FONT SOFTWARE.
|
||||
@@ -0,0 +1,40 @@
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Lato';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/lato-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Lato';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/lato-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:'Lato';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/lato-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-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Lato';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/lato-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Lato';font-style:normal;font-weight:900;font-display:swap;src:url(/assets/fonts/lato-900-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Lato';font-style:normal;font-weight:900;font-display:swap;src:url(/assets/fonts/lato-900-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:'Cormorant Garamond';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/cormorant-garamond-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-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Cormorant Garamond';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/cormorant-garamond-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Barlow';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/barlow-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Barlow';font-style:normal;font-weight:400;font-display:swap;src:url(/assets/fonts/barlow-400-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Barlow';font-style:normal;font-weight:600;font-display:swap;src:url(/assets/fonts/barlow-600-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Barlow';font-style:normal;font-weight:600;font-display:swap;src:url(/assets/fonts/barlow-600-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Barlow';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-700-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Barlow';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-condensed-700-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:700;font-display:swap;src:url(/assets/fonts/barlow-condensed-700-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:800;font-display:swap;src:url(/assets/fonts/barlow-condensed-800-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* latin */
|
||||
@font-face{font-family:'Barlow Condensed';font-style:normal;font-weight:800;font-display:swap;src:url(/assets/fonts/barlow-condensed-800-latin.woff2) format('woff2');unicode-range:U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD}
|
||||
/* latin-ext */
|
||||
@font-face{font-family:'DM Sans';font-style:normal;font-weight:400 700;font-display:swap;src:url(/assets/fonts/dm-sans-400-latin-ext.woff2) format('woff2');unicode-range:U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C4, U+2113, U+2C60-2C7F, U+A720-A7FF}
|
||||
/* 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}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 372 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user