Author SHA1 Message Date
samuelbias 470bc61846 Merge pull request 'security: fixed critical PHP-RCE-Vulnerabilities CVE-2026-109618 and CVE-2026-109619' (#41) from security-patch-php into main
Reviewed-on: #41
2026-10-21 21:25:25 +02:00
samuelbias c38d283e78 security: fixed critical PHP-RCE-Vulnerabilities CVE-2026-109618 and CVE-2026-109619
References:

https://www.bsi.bund.de/SharedDocs/Cybersicherheitswarnungen/DE/2026/2026-297981-1032.pdf?__blob=publicationFile&v=3
https://www.php.net/archive/2026.php
2026-10-21 21:18:29 +02:00
samuelbias 48a18cf1ab Merge pull request 'dashboard - bugfix: VBB API Endpoint Authorization' (#40) from dev into main
Reviewed-on: #40
Reviewed-by: Leonie Fink <leoniefink12@gmail.com>
2026-09-20 06:50:12 +02:00
samuelbias 2893a06fe7 Merge pull request 'dashboard - Abfahrtinfo VBB API fix' (#39) from dashboard/feature/abfahrten into dev
Reviewed-on: #39
2026-09-20 06:47:27 +02:00
samuelbias 72d6df0102 dashboard - Abfahrtinfo VBB API fix 2026-09-20 06:45:43 +02:00
samuelbias b58eeea9c6 Merge pull request 'camera, brandschutz editor' (#38) from dev into main
Reviewed-on: #38
Reviewed-by: Leonie Fink <leoniefink12@gmail.com>
2026-09-20 06:25:30 +02:00
samuelbias 41269309e4 Merge pull request 'camera - (real) remote control, brandschutz - configurable course, dashboard - vbb licensed API (a real vbb license, how cool)' (#36) from feature/brandschutz-editor into dev
Reviewed-on: #36
2026-09-08 18:23:24 +02:00
samuelbias ce90820094 camera - (real) remote control, brandschutz - configurable course, dashboard - vbb licensed API (a real vbb license, how cool) 2026-09-08 18:21:51 +02:00
leoniefink 34fb9f50f7 Merge pull request 'dashboard - stronger but thin border around posters' (#34) from feature/dashboard-rewrite-v2 into main
Reviewed-on: #34
Reviewed-by: Leonie Fink <leoniefink12@gmail.com>
2026-09-06 05:53:01 +02:00
samuelbias 4ff034bbb2 Merge pull request 'dashboard - stronger but thin border around posters' (#35) from feature/dashboard-rewrite-v2 into dev
Reviewed-on: #35
2026-09-06 05:52:42 +02:00
samuelbias 3f98cf2663 dashboard - stronger but thin border around posters 2026-09-06 05:49:02 +02:00
samuelbias c01da71468 Merge pull request 'dashboard - thinner border around Aushänge, no loading animation if all Aushänge fit in one view' (#32) from feature/dashboard-rewrite-v2 into main
Reviewed-on: #32
Reviewed-by: Leonie Fink <leoniefink12@gmail.com>
2026-09-06 05:46:18 +02:00
samuelbias 718fbd74ad Merge pull request 'dashboard - thinner border around Aushänge, no loading animation if all Aushänge fit in one view' (#33) from feature/dashboard-rewrite-v2 into dev
Reviewed-on: #33
2026-09-06 05:43:40 +02:00
samuelbias 7d3f99c7b8 dashboard - thinner border around Aushänge, no loading animation if all Aushänge fit in one view 2026-09-06 05:42:27 +02:00
samuelbias b5f784c120 Merge pull request 'template formatting improved' (#31) from dev into main
Reviewed-on: #31
Reviewed-by: Leonie Fink <leoniefink12@gmail.com>
2026-09-06 05:24:46 +02:00
samuelbias 214a3eb910 Merge pull request 'contentpanel - template formatting improved' (#30) from feature/dashboard-rewrite-v2 into dev
Reviewed-on: #30
2026-09-06 05:21:27 +02:00
samuelbias 055b6d10c4 contentpanel - template formatting improved 2026-09-06 05:17:37 +02:00
samuelbias 91d5f3d422 Merge pull request 'redesign - new dashboard, new contentpanel template functionality, monatsplan fix' (#29) from dev into main
Reviewed-on: #29
Reviewed-by: Leonie Fink <leoniefink12@gmail.com>
2026-09-04 14:31:09 +02:00
samuelbias 8ba41eb71e Merge pull request 'contentpanel - test fix' (#28) from feature/dashboard-rewrite-v2 into dev
Reviewed-on: #28
2026-09-04 14:23:39 +02:00
samuelbias 2d88c64e48 contentpanel - test fix 2026-09-04 14:22:30 +02:00
samuelbias 5c7401f3bc Merge pull request 'Feature/dashboard rewrite v2' (#27) from feature/dashboard-rewrite-v2 into dev
Reviewed-on: #27
2026-09-04 13:27:47 +02:00
samuelbias 01b4be301f dashboard - whole redesign and rewrite 2026-09-04 13:15:08 +02:00
samuelbias 87fc43db87 dashboard - first draft of complete redesign 2026-09-03 08:19:00 +02:00
samuelbias 9abf97a922 Merge pull request 'contentpanel - Aushangworkflow um docx Vorlage erweitern' (#25) from feature/contentpanel/aushangsvorlage into dev
Reviewed-on: #25
2026-09-02 21:14:36 +02:00
samuelbias b5233bb6eb Merge pull request 'feature/developerpanel - linked dev instance and sso' (#21) from feature/developerpanel into main
Reviewed-on: #21
2026-08-29 22:58:45 +02:00
samuelbias 9c34e504ca Merge pull request 'feature/developerpanel - linked dev instance and sso' (#22) from feature/developerpanel into dev
Reviewed-on: #22
2026-08-29 22:56:58 +02:00
samuelbias ab58e13b88 Merge pull request 'bugfix/docker-compose - strato sync switch implemented' (#19) from bugfix/docker-compose into main
Reviewed-on: #19
2026-08-29 22:12:31 +02:00
samuelbias 9c3aef4a96 Merge pull request 'bugfix/docker-compose - strato sync switch implemented' (#20) from bugfix/docker-compose into dev
Reviewed-on: #20
2026-08-29 22:11:52 +02:00
samuelbias a238ba1a55 Merge pull request 'bugfix/docker-compose - strato sync switch implemented' (#17) from bugfix/docker-compose into main
Reviewed-on: #17
Reviewed-by: Leonie Fink <leoniefink12@gmail.com>
2026-08-29 21:57:11 +02:00
samuelbias e8305771f8 Merge pull request 'bugfix/docker-compose - strato sync switch implemented' (#18) from bugfix/docker-compose into dev
Reviewed-on: #18
2026-08-29 21:56:34 +02:00
samuelbias 6988aee875 Merge pull request 'bugfix/docker-compose - strato sync switch implemented' (#16) from bugfix/docker-compose into main
Reviewed-on: #16
2026-08-29 21:51:55 +02:00
samuelbias 2e33f2da38 Merge pull request 'bugfix/docker-compose - strato sync switch implemented' (#15) from bugfix/docker-compose into dev
Reviewed-on: #15
2026-08-29 21:51:03 +02:00
samuelbias 494bfa8884 Merge pull request 'ACHTUNG: Refactoring - Rewrite des Intranet Quellcodes' (#14) from dev into main
Reviewed-on: #14
Reviewed-by: Leonie Fink <leoniefink12@gmail.com>
2026-08-29 21:37:33 +02:00
samuelbias 37e7e4e367 Merge pull request 'monatsplan - ckalender read fixed, developerpanel - rss feed implemented' (#13) from bugfix/monatsplan into dev
Reviewed-on: #13
2026-08-29 21:28:07 +02:00
samuelbias 0314bced40 Merge pull request 'web - refactor core modules, harden authorization, simplify endpoints' (#11) from refactor/whole-web-application into dev
Reviewed-on: #11
2026-08-29 11:27:24 +02:00
markusvonhobe 3c56459cb8 Merge pull request 'project/developerpanel - all necessary websites linked' (#7) from feature/new-developerpanel into main
Reviewed-on: #7
Reviewed-by: Markus von Hobe <markus.vonhobe@gmail.com>
2026-08-28 23:23:43 +02:00
samuelbias 643d1a49d1 Merge pull request 'Feature/new developerpanel' (#6) from feature/new-developerpanel into dev
Reviewed-on: #6
2026-08-28 16:03:29 +02:00
36 changed files with 4042 additions and 2888 deletions
+1
View File
@@ -23,6 +23,7 @@ web/intranet_repo/projects/contentpanel/insertion/news/*
# Generierte Dashboard-Terminbilder
web/intranet_repo/projects/dashboard/month/*
web/intranet_repo/projects/dashboard/cache/
# Lokale Backup-Dateien
*.bak
+5 -1
View File
@@ -8,7 +8,7 @@ RUN npm run build \
&& test -s dist/app.js \
&& test -s dist/index.html
FROM php:8.3.33-apache-bookworm
FROM php:8.3.36-apache-bookworm
ENV TZ=Europe/Berlin \
PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
@@ -48,16 +48,20 @@ COPY docker/scheduler.py /opt/intranet/docker/scheduler.py
RUN mkdir -p \
/data/ckal-xlsx /data/pdf /data/state \
/var/www/html/intranet_repo/general_files/profile_pictures \
/var/www/html/intranet_repo/projects/brandschutz/uploads \
/var/www/html/intranet_repo/projects/contentpanel/insertion/news \
/var/www/html/intranet_repo/projects/developerpanel/work \
/var/www/html/intranet_repo/projects/seelsorge/data \
/var/www/html/intranet_repo/projects/dashboard/cache \
/var/www/html/intranet_repo/projects/dashboard/month \
&& chown -R www-data:www-data \
/data \
/var/www/html/intranet_repo/general_files/profile_pictures \
/var/www/html/intranet_repo/projects/brandschutz/uploads \
/var/www/html/intranet_repo/projects/contentpanel/insertion/news \
/var/www/html/intranet_repo/projects/developerpanel/work \
/var/www/html/intranet_repo/projects/seelsorge/data \
/var/www/html/intranet_repo/projects/dashboard/cache \
/var/www/html/intranet_repo/projects/dashboard/month \
&& chmod +x /opt/intranet/docker/scheduler.py /opt/intranet/worker/pdf_python/pdf_to_png.sh /opt/intranet/worker/contentpanel_aushang/process.py /opt/intranet/worker/contentpanel_aushang/betteroffice_render.py
+1
View File
@@ -326,6 +326,7 @@ SMS_GATEWAY_URL=
HA_BASE_URL=
HA_TOKEN=
OPENWEATHER_API_KEY=
VBB_ACCESS_ID=
# Contentpanel / Aushang-Formatüberführung
OLLAMA_GATEWAY_URL=https://ollama.pelpino.de
+1
View File
@@ -27,6 +27,7 @@ services:
HA_BASE_URL: "${HA_BASE_URL:-}"
HA_TOKEN: "${HA_TOKEN:-}"
OPENWEATHER_API_KEY: "${OPENWEATHER_API_KEY:-}"
VBB_ACCESS_ID: "${VBB_ACCESS_ID:-}"
OLLAMA_GATEWAY_URL: "${OLLAMA_GATEWAY_URL:-https://ollama.pelpino.de}"
OLLAMA_API_KEY: "${OLLAMA_API_KEY:-}"
OLLAMA_AUTH_HEADER: "${OLLAMA_AUTH_HEADER:-Authorization}"
+3
View File
@@ -23,6 +23,7 @@ services:
HA_BASE_URL: ${HA_BASE_URL:?set HA_BASE_URL}
HA_TOKEN: ${HA_TOKEN:?set HA_TOKEN}
OPENWEATHER_API_KEY: ${OPENWEATHER_API_KEY:?set OPENWEATHER_API_KEY}
VBB_ACCESS_ID: ${VBB_ACCESS_ID:?set VBB_ACCESS_ID}
OLLAMA_GATEWAY_URL: ${OLLAMA_GATEWAY_URL:-https://ollama.pelpino.de}
OLLAMA_API_KEY: ${OLLAMA_API_KEY:-}
OLLAMA_AUTH_HEADER: ${OLLAMA_AUTH_HEADER:-Authorization}
@@ -38,6 +39,7 @@ services:
CKAL_XLSX_DIR: /data/ckal-xlsx
volumes:
- profile_pictures:/var/www/html/intranet_repo/general_files/profile_pictures
- brandschutz_media:/var/www/html/intranet_repo/projects/brandschutz/uploads
- content_news:/var/www/html/intranet_repo/projects/contentpanel/insertion/news
- seelsorge_data:/var/www/html/intranet_repo/projects/seelsorge/data
- dashboard_month:/var/www/html/intranet_repo/projects/dashboard/month
@@ -115,6 +117,7 @@ services:
volumes:
profile_pictures:
brandschutz_media:
content_news:
developer_work:
seelsorge_data:
@@ -355,3 +355,546 @@
.admin-modal-actions { flex-direction: column-reverse; }
.admin-modal-actions button { width: 100%; }
}
/* Visueller Schulungseditor */
body.bs-editor-open {
overflow: hidden;
}
.bs-editor-shell [hidden],
.bs-editor-overlay[aria-hidden="true"],
.bs-course-editor-overlay[aria-hidden="true"] {
display: none !important;
}
.bs-course-editor-overlay {
z-index: 10010;
align-items: center;
justify-content: center;
padding: 12px;
box-sizing: border-box;
}
.bs-editor-shell {
width: min(1400px, calc(100vw - 24px));
max-height: calc(100dvh - 24px);
overflow: auto;
margin: 0;
border: 1px solid rgba(120, 140, 160, 0.22);
background: #fff;
box-shadow: 0 18px 42px rgba(31, 38, 46, 0.1);
}
.bs-course-editor-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: 1px solid var(--gray-300);
background: #fff;
color: var(--gray-800);
font-size: 1.3rem;
cursor: pointer;
}
.bs-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 18px 20px;
border-bottom: 1px solid var(--gray-200);
background: linear-gradient(135deg, hsl(25, 100%, 97%), #fff);
}
.bs-editor-header h1 {
margin: 0 0 3px;
font-size: 1.25rem;
color: var(--gray-900);
}
.bs-editor-header p {
margin: 0;
color: var(--gray-600);
font-size: var(--font-size-sm);
}
.bs-editor-header-actions,
.bs-editor-item-controls,
.bs-editor-modal-actions {
display: flex;
align-items: center;
gap: 7px;
}
.bs-editor-button,
.bs-editor-panel-title button,
.bs-editor-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 34px;
padding: 7px 11px;
border: 1px solid var(--gray-300);
background: #fff;
color: var(--gray-800);
font: 700 var(--font-size-xs)/1 var(--font-primary);
text-decoration: none;
cursor: pointer;
}
.bs-editor-button:hover,
.bs-editor-panel-title button:hover,
.bs-editor-icon-button:hover {
border-color: var(--nak-orange);
background: hsl(25, 100%, 97%);
}
.bs-editor-button--primary {
border-color: hsl(25, 90%, 42%);
background: var(--nak-orange);
color: #fff;
}
.bs-editor-button--primary:hover {
background: hsl(25, 95%, 40%);
color: #fff;
}
.bs-editor-button:disabled,
.bs-editor-panel-title button:disabled,
.bs-editor-icon-button:disabled {
opacity: 0.4;
cursor: default;
}
.bs-editor-grid {
display: grid;
grid-template-columns: minmax(220px, 0.8fr) minmax(220px, 0.8fr) minmax(340px, 1.6fr);
min-height: 460px;
}
.bs-editor-panel {
min-width: 0;
border-right: 1px solid var(--gray-200);
background: var(--gray-50);
}
.bs-editor-panel:last-child {
border-right: 0;
}
.bs-editor-panel-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 42px;
padding: 10px 12px;
border-bottom: 1px solid var(--gray-200);
background: #fff;
font-size: var(--font-size-sm);
font-weight: 850;
}
.bs-editor-list,
.bs-element-list {
display: grid;
align-content: start;
gap: 8px;
padding: 10px;
}
.bs-editor-list-item,
.bs-element-card {
border: 1px solid var(--gray-200);
background: #fff;
box-shadow: 0 3px 10px rgba(31, 38, 46, 0.05);
cursor: grab;
}
.bs-editor-list-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px;
}
.bs-editor-list-item.active {
border-color: var(--nak-orange);
box-shadow: 0 0 0 2px hsla(25, 90%, 50%, 0.12);
}
.bs-editor-list-item.dragging,
.bs-element-card.dragging {
opacity: 0.45;
}
.bs-editor-item-text {
display: grid;
gap: 3px;
min-width: 0;
}
.bs-editor-item-text strong,
.bs-editor-item-text span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bs-editor-item-text strong {
font-size: var(--font-size-sm);
color: var(--gray-900);
}
.bs-editor-item-text span {
font-size: 0.72rem;
color: var(--gray-600);
}
.bs-editor-icon-button {
width: 27px;
min-height: 27px;
padding: 3px;
}
.bs-editor-icon-button--danger {
color: hsl(0, 65%, 43%);
}
.bs-element-card {
padding: 12px;
}
.bs-element-card-heading,
.bs-editor-repeat-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.bs-element-type {
display: inline-flex;
padding: 4px 8px;
border: 1px solid hsl(25, 80%, 82%);
background: hsl(25, 100%, 96%);
color: hsl(25, 80%, 34%);
font-size: 0.7rem;
font-weight: 850;
text-transform: uppercase;
}
.bs-element-summary {
margin: 10px 0 0;
color: var(--gray-700);
font-size: var(--font-size-xs);
line-height: 1.5;
}
.bs-editor-empty {
padding: 30px 18px;
border: 1px dashed var(--gray-300);
background: rgba(255, 255, 255, 0.65);
color: var(--gray-600);
text-align: center;
}
.bs-editor-empty p {
margin: 7px 0 0;
font-size: var(--font-size-xs);
line-height: 1.5;
}
.bs-editor-notice {
margin: 12px 20px 0;
padding: 10px 12px;
border: 1px solid hsl(210, 35%, 82%);
background: hsl(210, 60%, 96%);
color: hsl(210, 55%, 32%);
font-size: var(--font-size-xs);
font-weight: 700;
}
.bs-editor-notice--success {
border-color: hsl(145, 42%, 76%);
background: hsl(145, 55%, 95%);
color: hsl(145, 55%, 28%);
}
.bs-editor-notice--error {
border-color: hsl(0, 55%, 80%);
background: hsl(0, 70%, 96%);
color: hsl(0, 60%, 38%);
}
.bs-editor-overlay {
z-index: 10020;
align-items: center;
justify-content: center;
}
.bs-editor-modal {
position: relative;
width: min(780px, calc(100vw - 24px));
max-height: calc(100dvh - 24px);
overflow: auto;
padding: 22px;
background: #fff;
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.35);
}
.bs-editor-modal h2 {
margin: 0 42px 18px 0;
font-size: 1.2rem;
}
.bs-editor-modal-close {
position: absolute;
top: 12px;
right: 12px;
width: 34px;
height: 34px;
border: 1px solid var(--gray-300);
background: #fff;
font-size: 1.35rem;
cursor: pointer;
}
#bsEditorModalBody {
display: grid;
gap: 13px;
}
.bs-editor-field {
display: grid;
gap: 5px;
min-width: 0;
font-size: var(--font-size-xs);
font-weight: 750;
color: var(--gray-700);
}
.bs-editor-field input,
.bs-editor-field textarea,
.bs-editor-field select,
.bs-repeat-row select {
box-sizing: border-box;
width: 100%;
min-height: 38px;
padding: 8px 10px;
border: 1px solid var(--gray-300);
background: #fff;
color: var(--gray-900);
font: 500 var(--font-size-sm)/1.45 var(--font-primary);
}
.bs-editor-field textarea {
min-height: 92px;
resize: vertical;
}
.bs-richtext {
overflow: hidden;
border: 1px solid var(--gray-300);
background: #fff;
}
.bs-richtext:focus-within {
border-color: var(--nak-orange);
box-shadow: 0 0 0 2px hsla(25, 95%, 48%, 0.14);
}
.bs-richtext-toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px;
border-bottom: 1px solid var(--gray-200);
background: var(--gray-50);
}
.bs-richtext-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 31px;
height: 30px;
padding: 0 8px;
border: 1px solid transparent;
background: transparent;
color: var(--gray-800);
font: 700 var(--font-size-sm)/1 var(--font-primary);
cursor: pointer;
}
.bs-richtext-button:nth-child(2) {
font-style: italic;
}
.bs-richtext-button:nth-child(3) {
text-decoration: underline;
}
.bs-richtext-button:hover,
.bs-richtext-button:focus-visible {
border-color: var(--gray-300);
background: #fff;
}
.bs-richtext-button--text {
width: auto;
}
.bs-richtext-editor {
min-height: 108px;
padding: 10px 12px;
background: #fff;
color: var(--gray-900);
font: 500 var(--font-size-sm)/1.55 var(--font-primary);
outline: none;
}
.bs-richtext-editor p {
margin: 0 0 0.75em;
}
.bs-richtext-editor p:last-child {
margin-bottom: 0;
}
.bs-richtext-editor a {
color: hsl(25, 90%, 38%);
text-decoration: underline;
}
.bs-repeat-row .bs-richtext-editor {
min-height: 72px;
}
.bs-editor-field small {
font-weight: 500;
color: var(--gray-500);
}
.bs-editor-check {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--font-size-xs);
font-weight: 700;
}
.bs-editor-check input {
width: 18px;
height: 18px;
}
.bs-editor-repeat-block {
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid var(--gray-200);
background: var(--gray-50);
}
.bs-repeat-list {
display: grid;
gap: 8px;
}
.bs-repeat-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)) auto;
align-items: end;
gap: 8px;
padding: 9px;
border: 1px solid var(--gray-200);
background: #fff;
}
.bs-editor-modal-actions {
justify-content: flex-end;
margin-top: 18px;
padding-top: 15px;
border-top: 1px solid var(--gray-200);
}
.bs-image-upload {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border: 1px solid var(--gray-200);
background: var(--gray-50);
}
.bs-media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 10px;
}
.bs-media-item {
position: relative;
min-height: 82px;
border: 2px solid var(--gray-200);
background: #fff;
color: var(--gray-700);
font: 700 var(--font-size-xs)/1.3 var(--font-primary);
cursor: pointer;
}
.bs-media-item.selected {
border-color: var(--nak-orange);
box-shadow: 0 0 0 3px hsla(25, 90%, 50%, 0.15);
}
.bs-media-item > button:first-child {
width: 100%;
height: 82px;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
}
.bs-media-item img {
display: block;
width: 100%;
height: 82px;
object-fit: cover;
}
.bs-media-delete {
position: absolute;
top: 3px;
right: 3px;
width: 25px;
height: 25px;
border: 0;
background: rgba(120, 0, 0, 0.85);
color: #fff;
cursor: pointer;
}
@media (max-width: 980px) {
.bs-editor-grid { grid-template-columns: 1fr 1fr; }
.bs-elements-panel { grid-column: 1 / -1; border-top: 1px solid var(--gray-200); }
}
@media (max-width: 680px) {
.bs-editor-shell { width: calc(100vw - 12px); max-height: calc(100dvh - 12px); }
.bs-course-editor-overlay { padding: 6px; }
.bs-editor-header { align-items: flex-start; flex-direction: column; }
.bs-editor-header-actions { flex-wrap: wrap; }
.bs-editor-grid { grid-template-columns: 1fr; }
.bs-elements-panel { grid-column: auto; }
.bs-editor-panel { border-right: 0; border-bottom: 1px solid var(--gray-200); }
.bs-repeat-row { grid-template-columns: 1fr; }
.bs-image-upload { align-items: stretch; flex-direction: column; }
}
@@ -528,11 +528,16 @@
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
box-sizing: border-box;
padding: 14px 18px;
border: 0;
background: var(--gray-50);
cursor: pointer;
font-family: inherit;
font-weight: 600;
font-size: var(--font-size-sm);
text-align: left;
color: var(--gray-800);
transition: background 150ms;
}
@@ -675,7 +680,9 @@
.bs-wizard { margin: 0; width: 100%; }
.bs-progress { padding: 14px 8px 10px; }
.bs-progress-step[data-section="0"],
.bs-progress-step[data-section="0"] + .bs-progress-line {
.bs-progress-step[data-section="0"] + .bs-progress-line,
.bs-progress-step[data-chapter-index="0"],
.bs-progress-step[data-chapter-index="0"] + .bs-progress-line {
display: none;
}
.bs-progress-label { font-size: 0.6rem; max-width: 48px; }
@@ -692,3 +699,93 @@
.bs-intro-pillar-number { margin-bottom: 0; flex-shrink: 0; }
.bs-score-grid { grid-template-columns: repeat(2, 1fr); }
}
/* Konfigurationsbasierter Renderer */
.bs-wizard [hidden] {
display: none !important;
}
.bs-progress {
overflow-x: auto;
}
.bs-checklist-item {
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
line-height: inherit;
text-align: left;
cursor: pointer;
}
.bs-image {
margin: 22px 0;
text-align: center;
}
.bs-image img {
display: block;
width: auto;
max-width: 100%;
max-height: 70vh;
margin: 0 auto;
object-fit: contain;
box-shadow: 0 8px 28px rgba(31, 38, 46, 0.14);
}
.bs-image figcaption {
margin-top: 8px;
color: var(--gray-600);
font-size: var(--font-size-xs);
}
.bs-fill-input {
min-width: 100px;
max-width: 220px;
margin: 3px 5px;
}
.bs-empty-state {
padding: 24px;
border: 1px dashed var(--gray-300);
color: var(--gray-600);
text-align: center;
}
.bs-result-message,
.bs-confirmation {
margin-top: 20px;
}
.bs-confirmation {
padding: 20px;
border: 1px solid var(--gray-200);
background: var(--gray-50);
}
.bs-confirmation label {
display: flex;
align-items: flex-start;
gap: 12px;
cursor: pointer;
}
.bs-confirmation input {
width: 20px;
height: 20px;
margin-top: 2px;
}
#bsFinishBtn,
#bsRestartBtn {
width: 100%;
margin-top: 24px;
padding: 16px;
font-size: var(--font-size-md);
}
@@ -1,3 +1,6 @@
(function () {
'use strict';
function openBsModal(userId) {
var modal = document.getElementById('bsModal-' + userId);
if (modal) modal.style.display = 'flex';
@@ -6,10 +9,672 @@ function closeBsModal(userId) {
var modal = document.getElementById('bsModal-' + userId);
if (modal) modal.style.display = 'none';
}
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
document.querySelectorAll('.standard_overlay[id^="bsModal-"]').forEach(function(m) {
m.style.display = 'none';
window.openBsModal = openBsModal;
window.closeBsModal = closeBsModal;
var dataElement = document.getElementById('bsEditorData');
if (!dataElement) return;
var editorData = JSON.parse(dataElement.textContent);
var config = editorData.config;
var images = editorData.images || [];
var revision = Number(editorData.revision || 1);
var currentChapter = 0;
var currentPage = 0;
var modalState = null;
var dirty = false;
var saveInFlight = false;
var saveQueued = false;
var dragged = null;
var typeLabels = {
text: 'Textblock', infobox: 'Infobox', warnbox: 'Warnbox', list: 'Liste', accordion: 'Akkordeon',
checklist: 'Checkliste', scenario: 'Szenario', multiple_choice: 'Multiple-Choice-Frage',
fill_blank: 'Lückentext', matching: 'Zuordnung', image: 'Bild'
};
var chapterList = document.getElementById('bsChapterList');
var pageList = document.getElementById('bsPageList');
var elementList = document.getElementById('bsElementList');
var modal = document.getElementById('bsEditorModal');
var modalTitle = document.getElementById('bsEditorModalTitle');
var modalBody = document.getElementById('bsEditorModalBody');
var status = document.getElementById('bsEditorStatus');
var courseEditorOverlay = document.getElementById('bsCourseEditorOverlay');
function id(prefix) {
return prefix + '-' + (window.crypto && crypto.randomUUID ? crypto.randomUUID() : Date.now() + '-' + Math.random().toString(16).slice(2));
}
function el(tag, className, text) {
var node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function button(label, action, className, title) {
var node = el('button', className || 'bs-editor-icon-button', label);
node.type = 'button';
if (title) node.title = title;
node.addEventListener('click', function (event) { event.stopPropagation(); action(); });
return node;
}
function markDirty() {
dirty = true;
showStatus('Die Änderung wird verarbeitet.', 'info');
saveCourse();
}
function showStatus(message, kind) {
status.textContent = message;
status.className = 'bs-editor-notice bs-editor-notice--' + (kind || 'info');
status.hidden = false;
}
async function saveCourse() {
if (saveInFlight) {
saveQueued = true;
return;
}
saveInFlight = true;
saveQueued = false;
var snapshot = JSON.stringify(config);
var saved = false;
var form = new FormData();
form.append('action', 'save_course');
form.append('csrf_token', editorData.csrfToken);
form.append('course_revision', String(revision));
form.append('course_config', snapshot);
try {
var response = await fetch('admin_brandschutz.php', {method: 'POST', body: form});
var result = await response.json();
if (!response.ok || !result.ok) throw new Error(result.error || 'Speichern fehlgeschlagen.');
revision = Number(result.revision);
saved = true;
if (JSON.stringify(config) === snapshot) {
dirty = false;
showStatus('Die Änderung wurde erfolgreich gespeichert.', 'success');
} else {
saveQueued = true;
}
} catch (error) {
dirty = true;
showStatus(error.message || 'Die Änderung konnte nicht gespeichert werden.', 'error');
} finally {
saveInFlight = false;
if (saved && saveQueued) saveCourse();
}
}
function selectedChapter() { return config.chapters[currentChapter] || null; }
function selectedPage() {
var chapter = selectedChapter();
return chapter && chapter.pages[currentPage] ? chapter.pages[currentPage] : null;
}
function move(array, from, to) {
if (from < 0 || to < 0 || from >= array.length || to >= array.length || from === to) return;
array.splice(to, 0, array.splice(from, 1)[0]);
markDirty();
}
function controls(kind, index, length, editAction, deleteAction) {
var group = el('div', 'bs-editor-item-controls');
group.append(button('✎', editAction, '', 'Bearbeiten'));
group.append(button('↑', function () { reorder(kind, index, index - 1); }, '', 'Nach oben'));
group.lastChild.disabled = index === 0;
group.append(button('↓', function () { reorder(kind, index, index + 1); }, '', 'Nach unten'));
group.lastChild.disabled = index === length - 1;
group.append(button('×', deleteAction, 'bs-editor-icon-button bs-editor-icon-button--danger', 'Löschen'));
return group;
}
function dragEvents(node, kind, index) {
node.draggable = true;
node.addEventListener('dragstart', function () { dragged = {kind: kind, index: index}; node.classList.add('dragging'); });
node.addEventListener('dragend', function () { dragged = null; node.classList.remove('dragging'); });
node.addEventListener('dragover', function (event) { if (dragged && dragged.kind === kind) event.preventDefault(); });
node.addEventListener('drop', function (event) {
event.preventDefault();
if (dragged && dragged.kind === kind) reorder(kind, dragged.index, index);
});
}
function reorder(kind, from, to) {
if (kind === 'chapter') {
move(config.chapters, from, to);
currentChapter = Math.max(0, Math.min(to, config.chapters.length - 1));
currentPage = 0;
} else if (kind === 'page') {
move(selectedChapter().pages, from, to);
currentPage = Math.max(0, Math.min(to, selectedChapter().pages.length - 1));
} else {
move(selectedPage().elements, from, to);
}
render();
}
function render() {
currentChapter = Math.max(0, Math.min(currentChapter, Math.max(0, config.chapters.length - 1)));
var chapter = selectedChapter();
if (chapter) currentPage = Math.max(0, Math.min(currentPage, Math.max(0, chapter.pages.length - 1)));
renderChapters();
renderPages();
renderElements();
}
function renderEmpty(container, title, text) {
container.replaceChildren();
var empty = el('div', 'bs-editor-empty');
empty.append(el('strong', '', title), el('p', '', text));
container.append(empty);
}
function renderChapters() {
chapterList.replaceChildren();
if (config.chapters.length === 0) {
renderEmpty(chapterList, 'Noch keine Kapitel', 'Lege das erste Kapitel an, um die Schulung aufzubauen.');
return;
}
config.chapters.forEach(function (chapter, index) {
var item = el('div', 'bs-editor-list-item' + (index === currentChapter ? ' active' : ''));
var text = el('div', 'bs-editor-item-text');
text.append(el('strong', '', chapter.short_title || chapter.title), el('span', '', chapter.title));
item.append(text, controls('chapter', index, config.chapters.length,
function () { openEntityEditor('chapter', index); },
function () {
if (!confirm('Kapitel „' + chapter.title + '“ einschließlich aller Seiten löschen?')) return;
config.chapters.splice(index, 1); currentChapter = Math.max(0, index - 1); currentPage = 0; markDirty(); render();
}));
item.addEventListener('click', function () { currentChapter = index; currentPage = 0; render(); });
dragEvents(item, 'chapter', index);
chapterList.append(item);
});
}
function renderPages() {
pageList.replaceChildren();
var chapter = selectedChapter();
if (!chapter) {
renderEmpty(pageList, 'Kein Kapitel ausgewählt', 'Lege zuerst ein Kapitel an.');
return;
}
if (chapter.pages.length === 0) {
renderEmpty(pageList, 'Leeres Kapitel', 'Füge diesem Kapitel eine Seite hinzu.');
return;
}
chapter.pages.forEach(function (page, index) {
var item = el('div', 'bs-editor-list-item' + (index === currentPage ? ' active' : ''));
var text = el('div', 'bs-editor-item-text');
text.append(el('strong', '', page.title), el('span', '', page.elements.length + (page.elements.length === 1 ? ' Element' : ' Elemente')));
item.append(text, controls('page', index, chapter.pages.length,
function () { openEntityEditor('page', index); },
function () {
if (!confirm('Seite „' + page.title + '“ einschließlich aller Elemente löschen?')) return;
chapter.pages.splice(index, 1); currentPage = Math.max(0, index - 1); markDirty(); render();
}));
item.addEventListener('click', function () { currentPage = index; render(); });
dragEvents(item, 'page', index);
pageList.append(item);
});
}
function elementSummary(element) {
if (element.question) return element.question;
if (element.title) return element.title;
if (element.content) return String(element.content).replace(/<[^>]*>/g, '').slice(0, 120);
if (element.items && element.items.length) return element.items.length + ' Einträge';
if (element.file) return element.caption || element.alt || element.file;
return 'Noch nicht ausgefüllt';
}
function renderElements() {
elementList.replaceChildren();
var page = selectedPage();
document.getElementById('bsElementPanelTitle').textContent = page ? 'Elemente · ' + page.title : 'Elemente';
document.getElementById('bsAddPage').disabled = !selectedChapter();
document.getElementById('bsAddElement').disabled = !page;
if (!page) {
renderEmpty(elementList, 'Keine Seite ausgewählt', selectedChapter() ? 'Füge dem Kapitel eine Seite hinzu.' : 'Lege zuerst Kapitel und Seite an.');
return;
}
if (page.elements.length === 0) {
renderEmpty(elementList, 'Leere Seite', 'Füge über „+ Baustein“ Inhalte oder interaktive Elemente hinzu.');
return;
}
page.elements.forEach(function (element, index) {
var card = el('article', 'bs-element-card');
var heading = el('div', 'bs-element-card-heading');
heading.append(el('span', 'bs-element-type', typeLabels[element.type] || element.type));
heading.append(controls('element', index, page.elements.length,
function () { openElementEditor(index, false); },
function () {
if (!confirm((typeLabels[element.type] || 'Element') + ' wirklich löschen?')) return;
page.elements.splice(index, 1); markDirty(); render();
}));
card.append(heading, el('p', 'bs-element-summary', elementSummary(element)));
dragEvents(card, 'element', index);
elementList.append(card);
});
}
function openModal(title, state) {
modalState = state;
modalTitle.textContent = title;
modal.style.display = 'flex';
modal.setAttribute('aria-hidden', 'false');
setTimeout(function () { var first = modalBody.querySelector('input, textarea, select, button'); if (first) first.focus(); }, 0);
}
function closeModal() {
modal.style.display = 'none';
modal.setAttribute('aria-hidden', 'true');
modalState = null;
}
function openCourseEditor() {
courseEditorOverlay.style.display = 'flex';
courseEditorOverlay.setAttribute('aria-hidden', 'false');
document.body.classList.add('bs-editor-open');
render();
document.getElementById('bsCloseCourseEditor').focus();
}
function closeCourseEditor() {
if (modal.style.display === 'flex') closeModal();
courseEditorOverlay.style.display = 'none';
courseEditorOverlay.setAttribute('aria-hidden', 'true');
document.body.classList.remove('bs-editor-open');
document.getElementById('bsOpenCourseEditor').focus();
}
function field(label, name, value, options) {
options = options || {};
var wrapper = el(options.richText ? 'div' : 'label', 'bs-editor-field');
wrapper.append(el('span', '', label));
if (options.richText) {
wrapper.append(richTextEditor(label, name, value));
if (options.hint) wrapper.append(el('small', '', options.hint));
return wrapper;
}
var input = document.createElement(options.multiline ? 'textarea' : 'input');
input.name = name;
if (!options.multiline) input.type = options.type || 'text';
if (options.placeholder) input.placeholder = options.placeholder;
if (options.required) input.required = true;
input.value = value === undefined || value === null ? '' : value;
wrapper.append(input);
if (options.hint) wrapper.append(el('small', '', options.hint));
return wrapper;
}
function richTextEditor(label, name, value) {
var box = el('div', 'bs-richtext');
var toolbar = el('div', 'bs-richtext-toolbar');
var editor = el('div', 'bs-richtext-editor');
editor.contentEditable = 'true';
editor.dataset.fieldName = name;
editor.setAttribute('role', 'textbox');
editor.setAttribute('aria-multiline', 'true');
editor.setAttribute('aria-label', label);
editor.innerHTML = value === undefined || value === null ? '' : value;
function commandButton(label, title, command, argument) {
var control = button(label, function () {
editor.focus();
document.execCommand(command, false, argument || null);
}, 'bs-richtext-button', title);
control.setAttribute('aria-label', title);
control.addEventListener('mousedown', function (event) { event.preventDefault(); });
return control;
}
var linkButton = button('Link', function () {
var selection = window.getSelection();
var range = selection && selection.rangeCount ? selection.getRangeAt(0).cloneRange() : null;
if (!range || range.collapsed || !editor.contains(range.commonAncestorContainer)) {
showStatus('Bitte zuerst den gewünschten Text markieren.', 'error');
return;
}
var selectedLink = Array.from(editor.querySelectorAll('a')).some(function (link) {
return range.intersectsNode(link);
});
editor.focus();
selection.removeAllRanges();
selection.addRange(range);
if (selectedLink) {
document.execCommand('unlink', false, null);
return;
}
var href = window.prompt('Linkziel (https://…, mailto:… oder /interner-pfad):', 'https://');
if (href === null) return;
href = href.trim();
if (!/^(https?:\/\/|mailto:|\/[^/])/i.test(href)) {
showStatus('Bitte ein gültiges Linkziel eingeben.', 'error');
return;
}
editor.focus();
selection.removeAllRanges();
selection.addRange(range);
document.execCommand('createLink', false, href);
}, 'bs-richtext-button bs-richtext-button--text', 'Link hinzufügen oder entfernen');
linkButton.setAttribute('aria-label', 'Link hinzufügen oder entfernen');
linkButton.addEventListener('mousedown', function (event) { event.preventDefault(); });
toolbar.append(
commandButton('B', 'Fett', 'bold'),
commandButton('I', 'Kursiv', 'italic'),
commandButton('U', 'Unterstrichen', 'underline'),
commandButton('¶', 'Absatz', 'formatBlock', 'p'),
linkButton,
commandButton('Tx', 'Formatierung entfernen', 'removeFormat')
);
editor.addEventListener('focus', function () { document.execCommand('defaultParagraphSeparator', false, 'p'); });
editor.addEventListener('paste', function (event) {
event.preventDefault();
var text = (event.clipboardData || window.clipboardData).getData('text/plain');
document.execCommand('insertText', false, text);
});
box.append(toolbar, editor);
return box;
}
function controlValue(control) {
if (control.classList.contains('bs-richtext-editor')) {
if (!control.textContent.trim()) return '';
var normalized = control.cloneNode(true);
Array.from(normalized.children).forEach(function (child) {
if (child.tagName === 'DIV') {
var paragraph = document.createElement('p');
while (child.firstChild) paragraph.append(child.firstChild);
child.replaceWith(paragraph);
}
});
return normalized.innerHTML.trim();
}
return control.type === 'checkbox' ? control.checked : control.value.trim();
}
function fieldControl(root, name) {
return root.querySelector('[name="' + name + '"], [data-field-name="' + name + '"]');
}
function checkbox(label, name, checked) {
var wrapper = el('label', 'bs-editor-check');
var input = document.createElement('input');
input.type = 'checkbox'; input.name = name; input.checked = !!checked;
wrapper.append(input, el('span', '', label));
return wrapper;
}
function openEntityEditor(kind, index, isNew) {
modalBody.replaceChildren();
if (kind === 'chapter') {
var chapter = isNew
? {id: id('chapter'), title: 'Neues Kapitel', short_title: 'Kapitel', pages: []}
: JSON.parse(JSON.stringify(config.chapters[index]));
modalBody.append(field('Kapitelname', 'title', chapter.title, {required: true}), field('Kurztitel in der Navigation', 'short_title', chapter.short_title, {required: true, hint: 'Kurz halten, damit die Fortschrittsanzeige auf Mobilgeräten lesbar bleibt.'}));
openModal(isNew ? 'Kapitel hinzufügen' : 'Kapitel bearbeiten', {kind: kind, index: index, isNew: !!isNew, entity: chapter});
} else {
var page = isNew
? {id: id('page'), title: 'Neue Seite', elements: []}
: JSON.parse(JSON.stringify(selectedChapter().pages[index]));
modalBody.append(field('Seitentitel', 'title', page.title, {required: true}));
openModal(isNew ? 'Seite hinzufügen' : 'Seite bearbeiten', {kind: kind, index: index, isNew: !!isNew, entity: page});
}
}
function defaultElement(type) {
var base = {id: id('element'), type: type};
if (type === 'text') base.content = '';
if (['infobox', 'warnbox', 'scenario'].includes(type)) Object.assign(base, {title: '', content: ''});
if (type === 'list') Object.assign(base, {ordered: false, items: ['Neuer Listeneintrag']});
if (type === 'checklist') base.items = ['Neuer Checklistenpunkt'];
if (type === 'accordion') base.items = [{title: 'Neuer Abschnitt', content: ''}];
if (type === 'multiple_choice') Object.assign(base, assessmentDefaults(), {answers: [{text: 'Richtige Antwort', correct: true}, {text: 'Falsche Antwort', correct: false}]});
if (type === 'fill_blank') Object.assign(base, assessmentDefaults(), {text: 'Ergänzen Sie {{1}}.', blanks: [{answers: ['Lösung']}]});
if (type === 'matching') Object.assign(base, assessmentDefaults(), {choices: [{value: 'a', label: 'Kategorie A'}, {value: 'b', label: 'Kategorie B'}], pairs: [{item: 'Neuer Begriff', correct: 'a'}]});
if (type === 'image') Object.assign(base, {file: '', alt: '', caption: ''});
return base;
}
function assessmentDefaults() {
return {question: 'Neue Frage', feedback_correct: 'Richtig!', feedback_incorrect: 'Leider nicht richtig.', score: true};
}
function addRepeater(container, values, columns) {
container.replaceChildren();
values.forEach(function (value, index) {
var row = el('div', 'bs-repeat-row');
columns.forEach(function (column) {
if (column.type === 'checkbox') {
var check = checkbox(column.label, column.name, value[column.key]);
check.querySelector('input').dataset.key = column.key;
row.append(check);
} else if (column.type === 'select') {
var selectLabel = el('label', 'bs-editor-field');
selectLabel.append(el('span', '', column.label));
var select = document.createElement('select'); select.dataset.key = column.key;
column.options().forEach(function (option) { var o = new Option(option.label, option.value); o.selected = option.value === value[column.key]; select.add(o); });
selectLabel.append(select); row.append(selectLabel);
} else {
var inputField = field(column.label, column.name, value[column.key] || '', {multiline: column.multiline, richText: column.richText});
inputField.querySelector('input,textarea,.bs-richtext-editor').dataset.key = column.key;
row.append(inputField);
}
});
row.append(button('×', function () {
var currentValues = readRepeater(container);
currentValues.splice(index, 1);
addRepeater(container, currentValues, columns);
}, 'bs-editor-icon-button bs-editor-icon-button--danger', 'Eintrag entfernen'));
container.append(row);
});
container._values = values;
container._columns = columns;
}
function readRepeater(container) {
return Array.from(container.querySelectorAll(':scope > .bs-repeat-row')).map(function (row) {
var value = {};
row.querySelectorAll('[data-key]').forEach(function (input) { value[input.dataset.key] = controlValue(input); });
return value;
});
}
function repeaterBlock(title, values, columns, emptyValue) {
var block = el('div', 'bs-editor-repeat-block');
var heading = el('div', 'bs-editor-repeat-title');
var container = el('div', 'bs-repeat-list');
heading.append(el('strong', '', title), button('+ Eintrag', function () {
values = readRepeater(container); values.push(Object.assign({}, emptyValue)); addRepeater(container, values, columns);
}, 'bs-editor-button'));
block.append(heading, container);
addRepeater(container, JSON.parse(JSON.stringify(values || [])), columns);
return block;
}
function renderElementFields(element, isNew) {
modalBody.replaceChildren();
if (isNew) {
var typeField = el('label', 'bs-editor-field');
typeField.append(el('span', '', 'Bausteintyp'));
var typeSelect = document.createElement('select'); typeSelect.name = 'type';
Object.keys(typeLabels).forEach(function (type) { typeSelect.add(new Option(typeLabels[type], type, false, type === element.type)); });
typeSelect.addEventListener('change', function () { modalState.element = defaultElement(typeSelect.value); renderElementFields(modalState.element, true); });
typeField.append(typeSelect); modalBody.append(typeField);
}
if (element.type === 'text') modalBody.append(field('Inhalt', 'content', element.content, {richText: true}));
if (['infobox', 'warnbox', 'scenario'].includes(element.type)) modalBody.append(field('Titel', 'title', element.title), field('Inhalt', 'content', element.content, {richText: true}));
if (element.type === 'list') modalBody.append(checkbox('Nummerierte Liste', 'ordered', element.ordered));
if (['list', 'checklist'].includes(element.type)) {
modalBody.append(repeaterBlock('Einträge', (element.items || []).map(function (item) { return {text: item}; }), [{key: 'text', label: 'Text', richText: true}], {text: 'Neuer Eintrag'}));
}
if (element.type === 'accordion') {
modalBody.append(repeaterBlock('Akkordeon-Abschnitte', element.items || [], [{key: 'title', label: 'Titel'}, {key: 'content', label: 'Inhalt', richText: true}], {title: 'Neuer Abschnitt', content: ''}));
}
if (['multiple_choice', 'fill_blank', 'matching'].includes(element.type)) {
modalBody.append(field('Frage / Arbeitsauftrag', 'question', element.question, {multiline: true, required: true}));
}
if (element.type === 'multiple_choice') {
modalBody.append(repeaterBlock('Antwortmöglichkeiten', element.answers || [], [{key: 'text', label: 'Antwort'}, {key: 'correct', label: 'Korrekt', type: 'checkbox'}], {text: 'Neue Antwort', correct: false}));
}
if (element.type === 'fill_blank') {
modalBody.append(field('Lückentext', 'text', element.text, {multiline: true, hint: 'Platzhalter fortlaufend als {{1}}, {{2}}, … einfügen.'}));
modalBody.append(repeaterBlock('Erwartete Lösungen', (element.blanks || []).map(function (blank) { return {answers: (blank.answers || []).join(' | ')}; }), [{key: 'answers', label: 'Gültige Lösungen (mit | trennen)'}], {answers: 'Lösung'}));
}
if (element.type === 'matching') {
modalBody.append(repeaterBlock('Kategorien', element.choices || [], [{key: 'value', label: 'Interner Wert'}, {key: 'label', label: 'Bezeichnung'}], {value: 'neu', label: 'Neue Kategorie'}));
var options = function () { return [{value: '', label: '-- wählen --'}].concat((modalState.element.choices || []).map(function (choice) { return {value: choice.value, label: choice.label}; })); };
modalBody.append(repeaterBlock('Zuordnungen', element.pairs || [], [{key: 'item', label: 'Begriff'}, {key: 'correct', label: 'Richtige Kategorie', type: 'select', options: options}], {item: 'Neuer Begriff', correct: ''}));
}
if (['multiple_choice', 'fill_blank', 'matching'].includes(element.type)) {
modalBody.append(field('Feedback bei korrekter Lösung', 'feedback_correct', element.feedback_correct, {multiline: true}), field('Feedback bei falscher Lösung', 'feedback_incorrect', element.feedback_incorrect, {multiline: true}), checkbox('Für die Abschlussauswertung werten', 'score', element.score));
}
if (element.type === 'image') renderImageFields(element);
}
function renderImageFields(element) {
modalBody.append(field('Alternativtext', 'alt', element.alt, {required: true, hint: 'Beschreibt den Bildinhalt für Screenreader.'}), field('Bildunterschrift (optional)', 'caption', element.caption));
var upload = el('div', 'bs-image-upload');
var input = document.createElement('input'); input.type = 'file'; input.accept = 'image/jpeg,image/png,image/webp,image/gif';
var uploadButton = button('Bild hochladen', function () { uploadImage(input); }, 'bs-editor-button bs-editor-button--primary');
upload.append(input, uploadButton);
modalBody.append(upload, el('div', 'bs-media-grid'));
renderMediaGrid(element);
}
function renderMediaGrid(element) {
var grid = modalBody.querySelector('.bs-media-grid');
if (!grid) return;
grid.replaceChildren();
var none = button('Kein Bild', function () { element.file = ''; renderMediaGrid(element); }, 'bs-media-item' + (!element.file ? ' selected' : ''));
grid.append(none);
images.forEach(function (image) {
var item = el('div', 'bs-media-item' + (element.file === image.file ? ' selected' : ''));
var choose = el('button'); choose.type = 'button';
var preview = document.createElement('img'); preview.src = image.url; preview.alt = image.file;
choose.append(preview); choose.addEventListener('click', function () { element.file = image.file; renderMediaGrid(element); });
var remove = button('×', function () { deleteImage(image.file); }, 'bs-media-delete', 'Bild dauerhaft löschen');
item.append(choose, remove); grid.append(item);
});
}
async function uploadImage(input) {
if (!input.files || !input.files[0]) { showStatus('Bitte zuerst eine Bilddatei auswählen.', 'error'); return; }
var form = new FormData(); form.append('action', 'upload_image'); form.append('csrf_token', editorData.csrfToken); form.append('image', input.files[0]);
try {
var response = await fetch('admin_brandschutz.php', {method: 'POST', body: form});
var result = await response.json();
if (!response.ok || !result.ok) throw new Error(result.error || 'Upload fehlgeschlagen.');
images.unshift(result.image); modalState.element.file = result.image.file; renderMediaGrid(modalState.element); showStatus('Bild wurde hochgeladen.', 'success');
} catch (error) { showStatus(error.message || 'Bild konnte nicht hochgeladen werden.', 'error'); }
}
async function deleteImage(file) {
if (!confirm('Dieses Bild dauerhaft aus der Medienbibliothek löschen?')) return;
var form = new FormData(); form.append('action', 'delete_image'); form.append('csrf_token', editorData.csrfToken); form.append('file', file);
try {
var response = await fetch('admin_brandschutz.php', {method: 'POST', body: form}); var result = await response.json();
if (!response.ok || !result.ok) throw new Error(result.error || 'Löschen fehlgeschlagen.');
images = images.filter(function (image) { return image.file !== file; });
if (modalState.element.file === file) modalState.element.file = '';
renderMediaGrid(modalState.element); showStatus('Bild wurde gelöscht.', 'success');
} catch (error) { showStatus(error.message || 'Bild konnte nicht gelöscht werden.', 'error'); }
}
function openElementEditor(index, isNew) {
var element = isNew ? defaultElement('text') : JSON.parse(JSON.stringify(selectedPage().elements[index]));
openModal(isNew ? 'Baustein hinzufügen' : 'Baustein bearbeiten', {kind: 'element', index: index, isNew: isNew, element: element});
renderElementFields(element, isNew);
}
function readElementForm(element) {
modalBody.querySelectorAll(':scope > .bs-editor-field input, :scope > .bs-editor-field textarea, :scope > .bs-editor-field .bs-richtext-editor').forEach(function (input) {
element[input.name || input.dataset.fieldName] = controlValue(input);
});
modalBody.querySelectorAll(':scope > .bs-editor-check input').forEach(function (input) { element[input.name] = input.checked; });
var repeaters = Array.from(modalBody.querySelectorAll('.bs-repeat-list'));
if (['list', 'checklist'].includes(element.type)) element.items = readRepeater(repeaters[0]).map(function (item) { return item.text; }).filter(Boolean);
if (element.type === 'accordion') element.items = readRepeater(repeaters[0]);
if (element.type === 'multiple_choice') element.answers = readRepeater(repeaters[0]);
if (element.type === 'fill_blank') element.blanks = readRepeater(repeaters[0]).map(function (blank) { return {answers: blank.answers.split('|').map(function (answer) { return answer.trim(); }).filter(Boolean)}; });
if (element.type === 'matching') {
element.choices = readRepeater(repeaters[0]);
element.pairs = readRepeater(repeaters[1]);
}
return element;
}
function saveModal() {
if (!modalState) return;
if (modalState.kind === 'chapter') {
var chapter = modalState.entity;
chapter.title = modalBody.querySelector('[name="title"]').value.trim() || 'Unbenanntes Kapitel';
chapter.short_title = modalBody.querySelector('[name="short_title"]').value.trim() || 'Kapitel';
if (modalState.isNew) {
config.chapters.push(chapter);
currentChapter = config.chapters.length - 1;
currentPage = 0;
} else {
config.chapters[modalState.index] = chapter;
}
} else if (modalState.kind === 'page') {
var page = modalState.entity;
page.title = modalBody.querySelector('[name="title"]').value.trim() || 'Unbenannte Seite';
if (modalState.isNew) {
selectedChapter().pages.push(page);
currentPage = selectedChapter().pages.length - 1;
} else {
selectedChapter().pages[modalState.index] = page;
}
} else if (modalState.kind === 'settings') {
config.title = modalBody.querySelector('[name="course_title"]').value.trim() || 'Brandschutzunterweisung';
config.settings.passing_score = Number(modalBody.querySelector('[name="passing_score"]').value) || 70;
config.settings.max_attempts = Number(modalBody.querySelector('[name="max_attempts"]').value) || 3;
['success_title', 'success_text', 'failure_title', 'failure_text', 'confirmation_text'].forEach(function (name) {
config.settings[name] = controlValue(fieldControl(modalBody, name));
});
} else if (modalState.kind === 'element') {
var element = readElementForm(modalState.element);
if (modalState.isNew) selectedPage().elements.push(element);
else selectedPage().elements[modalState.index] = element;
}
markDirty(); closeModal(); render();
}
function openSettings() {
modalBody.replaceChildren();
var settings = config.settings || (config.settings = {});
modalBody.append(field('Titel der Schulung', 'course_title', config.title), field('Bestehensgrenze in Prozent', 'passing_score', settings.passing_score || 70, {type: 'number'}), field('Maximale Versuche pro Frage', 'max_attempts', settings.max_attempts || 3, {type: 'number'}), field('Erfolgstitel', 'success_title', settings.success_title), field('Erfolgstext', 'success_text', settings.success_text, {richText: true}), field('Titel bei Nichtbestehen', 'failure_title', settings.failure_title), field('Text bei Nichtbestehen', 'failure_text', settings.failure_text, {richText: true}), field('Bestätigungstext', 'confirmation_text', settings.confirmation_text, {richText: true}));
openModal('Schulungseinstellungen', {kind: 'settings'});
}
document.getElementById('bsAddChapter').addEventListener('click', function () {
openEntityEditor('chapter', -1, true);
});
document.getElementById('bsAddPage').addEventListener('click', function () {
var chapter = selectedChapter(); if (!chapter) return;
openEntityEditor('page', -1, true);
});
document.getElementById('bsAddElement').addEventListener('click', function () { if (selectedPage()) openElementEditor(-1, true); });
document.getElementById('bsEditSettings').addEventListener('click', openSettings);
document.getElementById('bsOpenCourseEditor').addEventListener('click', openCourseEditor);
document.getElementById('bsCloseCourseEditor').addEventListener('click', closeCourseEditor);
document.getElementById('bsEditorModalSave').addEventListener('click', saveModal);
document.getElementById('bsEditorModalCancel').addEventListener('click', closeModal);
document.getElementById('bsEditorModalClose').addEventListener('click', closeModal);
modal.addEventListener('click', function (event) { if (event.target === modal) closeModal(); });
courseEditorOverlay.addEventListener('click', function (event) { if (event.target === courseEditorOverlay) closeCourseEditor(); });
document.addEventListener('keydown', function (event) {
if (event.key === 'Escape') {
if (modal.style.display === 'flex') closeModal();
else if (courseEditorOverlay.style.display === 'flex') closeCourseEditor();
else document.querySelectorAll('.standard_overlay[id^="bsModal-"]').forEach(function (overlay) { overlay.style.display = 'none'; });
}
});
window.addEventListener('beforeunload', function (event) { if (dirty || saveInFlight) { event.preventDefault(); event.returnValue = ''; } });
render();
}());
@@ -1,601 +1,327 @@
/* ══════════════════════════════════════
NAVIGATION
══════════════════════════════════════ */
var bsCurrentSection = 0;
var bsTotalSections = 6; // 0=intro, 1-5=pillars
var bsCurrentSub = {}; // track sub-steps per section
(function () {
'use strict';
// Init sub-step tracking
for (var i = 1; i <= 5; i++) bsCurrentSub[i] = 1;
var settingsElement = document.getElementById('bsCourseSettings');
var settings = settingsElement ? JSON.parse(settingsElement.textContent) : {
passingScore: 70,
maxAttempts: 3,
successTitle: 'Schulung bestanden!',
successText: '',
failureTitle: 'Schulung nicht bestanden',
failureText: '',
confirmationText: ''
};
var chapters = Array.from(document.querySelectorAll('.bs-section[data-chapter]'));
var scoreSection = document.getElementById('bsSectionScore');
var finalSection = document.getElementById('bsFinalResult');
var currentChapter = 0;
var currentPage = 0;
var attempts = {};
var results = {};
function bsGoToSection(section) {
var scoreEl = document.getElementById('bsSectionScore');
if (scoreEl) scoreEl.style.display = 'none';
// Hide all sections
for (var i = 0; i < bsTotalSections; i++) {
var el = document.getElementById('bsSection' + i);
if (el) el.style.display = 'none';
}
// Show target section
var target = document.getElementById('bsSection' + section);
if (target) target.style.display = '';
bsCurrentSection = section;
bsUpdateMainProgress(section);
// Reset to first sub-step when entering a section
if (section >= 1 && section <= 5) {
bsCurrentSub[section] = 1;
bsShowSubStep(section, 1);
}
bsScrollToTop();
}
function bsUpdateMainProgress(section) {
var steps = document.querySelectorAll('#bsMainProgress .bs-progress-step');
var lines = document.querySelectorAll('#bsMainProgress .bs-progress-line');
steps.forEach(function(el, idx) {
el.classList.remove('active', 'done');
if (idx === section) el.classList.add('active');
else if (idx < section) el.classList.add('done');
});
lines.forEach(function(el, idx) {
el.classList.toggle('done', idx < section);
function escapeHtml(value) {
return String(value).replace(/[&<>'"]/g, function (character) {
return {'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'}[character];
});
}
/* ── Sub-step navigation ── */
function bsSubStep(section, step) {
bsCurrentSub[section] = step;
bsShowSubStep(section, step);
bsScrollToTop();
}
function bsShowSubStep(section, step) {
// Count sub-steps
var maxSub = document.querySelectorAll('#bsSection' + section + ' .bs-substep').length;
for (var i = 1; i <= maxSub; i++) {
var el = document.getElementById('bsSub' + section + '_' + i);
if (el) el.style.display = (i === step) ? '' : 'none';
}
}
function bsScrollToTop() {
function scrollToTop() {
window.scrollTo({top: 0, behavior: 'smooth'});
var headerHeight = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 64;
var elemHeaderMenue = document.getElementById('header_menue');
var elemHeaderTitle = document.getElementById('header_title');
if (elemHeaderMenue) elemHeaderMenue.style.top = '0';
if (elemHeaderTitle) elemHeaderTitle.style.top = headerHeight + 'px';
var menu = document.getElementById('header_menue');
var title = document.getElementById('header_title');
if (menu) menu.style.top = '0';
if (title) title.style.top = headerHeight + 'px';
}
/* ── Dropdown Matching ── */
function bsCheckDropdownMatch() {
var container = document.getElementById('match3');
var rows = container.querySelectorAll('.bs-dropdown-match-row');
var correct = 0;
var total = rows.length;
rows.forEach(function(row) {
var select = row.querySelector('select');
row.classList.remove('correct', 'wrong');
if (select.value === select.dataset.correct) {
row.classList.add('correct');
correct++;
} else {
row.classList.add('wrong');
function pagesFor(chapterIndex) {
return chapters[chapterIndex] ? Array.from(chapters[chapterIndex].querySelectorAll(':scope > .bs-substep')) : [];
}
function updateProgress() {
document.querySelectorAll('#bsMainProgress .bs-progress-step').forEach(function (step, index) {
step.classList.toggle('active', index === currentChapter);
step.classList.toggle('done', index < currentChapter);
});
var fb = document.getElementById('match3_fb');
if (correct === total) {
fb.className = 'bs-quiz-feedback show correct';
fb.textContent = 'Richtig: Alle Zuordnungen korrekt! Gut gemacht!';
} else {
fb.className = 'bs-quiz-feedback show wrong';
fb.textContent = correct + ' von ' + total + ' richtig. Bitte überprüfen Sie Ihre Auswahl.';
}
return correct === total;
}
/* ══════════════════════════════════════
FILL-IN-THE-BLANK
══════════════════════════════════════ */
function bsCheckFill(ids) {
var correct = 0;
var total = ids.length;
ids.forEach(function(id) {
var input = document.getElementById(id);
var answer = input.dataset.answer.toLowerCase().trim();
var value = input.value.toLowerCase().trim();
input.classList.remove('correct', 'wrong');
if (value === answer) {
input.classList.add('correct');
correct++;
} else {
input.classList.add('wrong');
}
document.querySelectorAll('#bsMainProgress .bs-progress-line').forEach(function (line, index) {
line.classList.toggle('done', index < currentChapter);
});
// Find feedback element near the button
var fb = document.getElementById('fill2_fb');
if (fb) {
fb.classList.remove('correct', 'wrong');
fb.classList.add('show');
if (correct === total) {
fb.classList.add('correct');
fb.textContent = 'Richtig: Alle Lücken richtig ausgefüllt!';
} else {
fb.classList.add('wrong');
fb.textContent = correct + ' von ' + total + ' richtig. Korrekte Antworten: 112, Klixstraße 17, 13403';
}
}
return correct === total;
}
/* ══════════════════════════════════════
MULTI-SELECT CHECKBOX QUIZ
══════════════════════════════════════ */
function bsToggleCheckbox(optionEl) {
var cb = optionEl.querySelector('input[type="checkbox"]');
cb.checked = !cb.checked;
optionEl.classList.toggle('selected', cb.checked);
function currentPageElement() {
return pagesFor(currentChapter)[currentPage] || null;
}
function bsCheckMultiGeneric(quizId, correctMsg, wrongMsg) {
var quiz = document.getElementById(quizId);
var options = quiz.querySelectorAll('.bs-quiz-option');
var allCorrect = true;
options.forEach(function(opt) {
var cb = opt.querySelector('input[type="checkbox"]');
var isCorrect = cb.dataset.correct === 'true';
opt.classList.remove('correct', 'wrong');
if (isCorrect && cb.checked) {
opt.classList.add('correct');
} else if (isCorrect && !cb.checked) {
opt.classList.add('correct');
allCorrect = false;
} else if (!isCorrect && cb.checked) {
opt.classList.add('wrong');
allCorrect = false;
}
function pageComplete(page) {
if (!page) return true;
var assessments = Array.from(page.querySelectorAll('.bs-assessment'));
var assessmentsDone = assessments.every(function (assessment) { return assessment.dataset.complete === 'true'; });
var checklists = Array.from(page.querySelectorAll('[data-checklist="true"]'));
var checklistsDone = checklists.every(function (checklist) {
var items = Array.from(checklist.querySelectorAll('li'));
return items.length === 0 || items.every(function (item) { return item.classList.contains('checked'); });
});
var fb = document.getElementById(quizId + '_fb');
fb.classList.remove('correct', 'wrong');
fb.classList.add('show');
if (allCorrect) {
fb.classList.add('correct');
fb.textContent = correctMsg;
} else {
fb.classList.add('wrong');
fb.textContent = wrongMsg;
}
return allCorrect;
return assessmentsDone && checklistsDone;
}
/* ══════════════════════════════════════
INDIVIDUAL QUIZ GATES
══════════════════════════════════════ */
var bsQuizPassed = {};
var bsQuizAttempts = {};
var bsQuizCorrect = {}; // true = correct, false = nach drei Versuchen beendet
var bsQuizGates = [
{ section: 1, nextBtnId: 'bsNextS1Q', quizIds: ['quiz1_1', 'quiz1_2', 'quiz1_3'] },
{ section: 2, nextBtnId: 'bsNextS2Q', quizIds: ['scenario2_1', 'fill2'] },
{ section: 3, nextBtnId: 'bsNextS3Q', quizIds: ['match3', 'quiz3_1', 'quiz3_2'] },
{ section: 4, nextBtnId: 'bsNextS4Q', quizIds: ['tf4', 'scenario4_1'] },
{ section: 5, nextBtnId: 'bsNextS5P1', quizIds: ['bs5q1', 'bs5q2', 'bs5q3', 'bs5q4', 'bs5q5'] },
{ section: 5, nextBtnId: 'bsNextS5P2', quizIds: ['bs5q6', 'bs5q7', 'bs5q8', 'bs5q9', 'bs5q10'] }
];
var bsQuizMessages = {
scenario2_1: [
'Richtig! Nicht durch das verrauchte Treppenhaus gehen und keinen Löschversuch im Rauch unternehmen. Richtig ist: 112 anrufen, warnen, in einem rauchfreien Raum bleiben und sich am Fenster bemerkbar machen.',
'Falsch. Gesucht sind die Verhaltensweisen, die man nicht tun sollte: durch das verrauchte Treppenhaus laufen oder im verrauchten Bereich selbst löschen.'
],
tf4: [
'Richtig: Alle Antworten richtig! Ausgezeichnet!',
'Nicht ganz richtig. Die korrekten Antworten sind markiert.'
],
scenario4_1: [
'Richtig! Bei Elektrobränden niemals Wasser verwenden, Stromschlaggefahr! Der CO₂-Löscher ist die richtige Wahl.',
'Falsch. Bei Elektrobränden darf kein Wasser verwendet werden (Stromschlaggefahr). Ein CO₂-Löscher ist hier das richtige Löschmittel.'
],
bs5q1: [
'Richtig! Die Feuerlöscher befinden sich im Vorraum am seitlichen Ausgang, im Treppenhaus 1. OG und im hinteren Kirchenanbau neben der Toilette.',
'Falsch: Nicht ganz. Die korrekten Standorte sind: Vorraum am seitlichen Ausgang, Treppenhaus 1. OG und hinterer Kirchenanbau neben der Toilette.'
],
bs5q2: [
'Richtig! Alle drei Ausgänge stehen zur Verfügung: Haupteingang, hinterer Kirchenanbau und seitlicher Eingang. Achte auf die Stufen und den Gegenverkehr von der Empore!',
'Falsch: Nicht ganz. Die korrekten Rettungswege sind: Haupteingang (Stufen!), hinterer Kirchenanbau (Stufen!) und seitlicher Eingang (Emporenverkehr!). Fenster sind keine Rettungswege.'
],
bs5q3: [
'Richtig! Wir haben keine Brandmeldeanlage. Deshalb ist es besonders wichtig, dass alle Anwesenden wachsam sind und die Abläufe kennen.',
'Falsch. Wir haben keine Brandmeldeanlage, daher müssen wir uns umso mehr auf geschultes Verhalten und aufmerksame Personen verlassen.'
],
bs5q4: [
'Richtig! Die Adresse für den Notruf findest du innen an der rechten Tür des Haupteingangs. Merke dir diesen Ort!',
'Falsch. Die Adresse befindet sich innen an der rechten Tür des Haupteingangs (Fluchttür), nicht auf dem Altar oder im Treppenhaus.'
],
bs5q5: [
'Richtig! Unsere Kirchengemeinde fasst 180 bis 200 Personen.',
'Falsch. Unsere Kirchengemeinde fasst 180 bis 200 Personen, nicht mehr.'
],
bs5q6: [
'Richtig! Die Gefahrenstellen sind: Stufen zum Anbau, Stufen am Haupteingang und das Treppenhaus an der Empore (Gegenverkehr).',
'Falsch: Nicht ganz. Die Gefahrenstellen sind: Stufen zwischen Kirchenschiff und Anbau, Stufen vor dem Haupteingang und das Treppenhaus an der Empore. Die Rampe ist keine Gefahrenstelle.'
],
bs5q7: [
'Richtig! Die zuständige vBsU koordiniert, eine klare Zuständigkeit verhindert Chaos und widersprüchliche Anweisungen.',
'Falsch. Nur die zuständige vBsU koordiniert die Aktionen. Das verhindert widersprüchliche Anweisungen und sorgt für eine geordnete Evakuierung.'
],
bs5q8: [
'Richtig! Notleuchten befinden sich an sechs Standorten: Sakristei, unterm Altar, seitliche Klappen, Empore, Eltern-Kind-Raum und Vorraum.',
'Falsch: Nicht ganz. Die Notleuchten befinden sich an: Sakristei, unterm Altar, letzte seitliche Klappen im Kirchenschiff, Empore (Tisch), Eltern-Kind-Raum (Schrank unter TV) und „Diakonie-Schrank" im Vorraum.'
],
bs5q9: [
'Richtig! Der vorbereitete Text wird vom Pult aus verlesen, so erreicht die Information alle Anwesenden gleichzeitig und klar verständlich.',
'Falsch. Die Räumung wird eingeleitet, indem der vorbereitete Text „zur Räumung des Gebäudes" vom Pult aus verlesen wird (rotes, laminiertes DIN-A4-Blatt). So wird sichergestellt, dass alle die gleiche, klare Ansage hören.'
],
bs5q10: [
'Richtig! Anwesende Ersthelfer*innen übernehmen die weitere Erste Hilfe. Die vBsU koordiniert die Evakuierung, nicht die medizinische Versorgung.',
'Falsch. Für die weitere Erste Hilfe sind die anwesenden Ersthelfer*innen zuständig, nicht die vBsU. Für weitergehende Erste Hilfe ist eine gesonderte Schulung notwendig. Die erste Hilfeleistung ist natürlich von jedem erwünscht und gesetzlich vorgeschrieben.'
]
};
function bsGetQuizGate(quizId) {
return bsQuizGates.find(function(gate) {
return gate.quizIds.includes(quizId);
}) || null;
function updateNavigation() {
var page = currentPageElement();
if (!page) return;
var previous = page.querySelector('.bs-prev-page');
var next = page.querySelector('.bs-next-page');
if (previous) {
previous.hidden = currentChapter === 0 && currentPage === 0;
previous.textContent = currentPage > 0 ? '< Zurück' : '< Voriges Kapitel';
}
if (next) {
var lastPage = currentPage === pagesFor(currentChapter).length - 1;
var lastChapter = currentChapter === chapters.length - 1;
next.textContent = lastPage ? (lastChapter ? 'Zum Ergebnis >' : 'Weiter zum nächsten Kapitel >') : 'Weiter >';
next.classList.toggle('bs-btn-locked', !pageComplete(page));
next.setAttribute('aria-disabled', pageComplete(page) ? 'false' : 'true');
}
}
function bsGetAllQuizIds() {
return bsQuizGates.reduce(function(ids, gate) { return ids.concat(gate.quizIds); }, []);
function showPage(chapterIndex, pageIndex) {
if (!chapters[chapterIndex]) return;
currentChapter = chapterIndex;
var pages = pagesFor(chapterIndex);
currentPage = Math.max(0, Math.min(pageIndex, Math.max(0, pages.length - 1)));
chapters.forEach(function (chapter, index) { chapter.hidden = index !== currentChapter; });
pages.forEach(function (page, index) { page.hidden = index !== currentPage; });
if (scoreSection) scoreSection.hidden = true;
if (finalSection) finalSection.hidden = true;
updateProgress();
updateNavigation();
scrollToTop();
}
function bsRenderQuizResult(quizId) {
if (quizId === 'fill2') {
return bsCheckFill(['fill2_1', 'fill2_2', 'fill2_3']);
}
if (quizId === 'match3') {
return bsCheckDropdownMatch();
function scoredAssessments() {
return Array.from(document.querySelectorAll('.bs-assessment[data-score="true"]'));
}
var messages = bsQuizMessages[quizId] || ['Richtig!', 'Falsch: Leider falsch. Die richtige Antwort ist markiert.'];
return bsCheckMultiGeneric(quizId, messages[0], messages[1]);
}
function bsGetScoreStats() {
var allIds = bsGetAllQuizIds();
var total = allIds.length;
function scoreStats() {
var assessments = scoredAssessments();
var correct = 0;
var wrong = 0;
var open = 0;
allIds.forEach(function(id) {
if (bsQuizCorrect[id] === true) correct++;
else if (bsQuizCorrect[id] === false) wrong++;
else open++;
assessments.forEach(function (assessment) {
if (results[assessment.id] === true) correct++;
if (results[assessment.id] === false) wrong++;
});
var percent = total > 0 ? Math.round((correct / total) * 100) : 0;
return { correct: correct, wrong: wrong, open: open, total: total, percent: percent };
var total = assessments.length;
return {
correct: correct,
wrong: wrong,
open: total - correct - wrong,
total: total,
percent: total > 0 ? Math.round((correct / total) * 100) : 100
};
}
function bsShowSectionScore(completedSection, nextAction) {
var stats = bsGetScoreStats();
var answered = stats.correct + stats.wrong;
var maxReachable = stats.total > 0 ? 100 - Math.round((stats.wrong / stats.total) * 100) : 100;
var scoreEl = document.getElementById('bsSectionScore');
var body = scoreEl.querySelector('.bs-body');
var barColor = maxReachable >= 70 ? 'var(--bs-green)' : 'var(--bs-red)';
body.innerHTML =
'<div class="bs-chapter-title">Zwischenstand nach Säule ' + completedSection + '</div>' +
'<div class="bs-score-bar-wrap">' +
'<div class="bs-score-bar"><div class="bs-score-bar-fill" style="width:' + maxReachable + '%;background:' + barColor + '"></div></div>' +
'<div class="bs-score-bar-label">Max. erreichbar: ' + maxReachable + '% (mindestens 70% erforderlich)</div>' +
'</div>' +
'<div class="bs-score-grid">' +
function scoreCards(stats) {
return '<div class="bs-score-grid">' +
'<div class="bs-score-card bs-score-correct"><div class="bs-score-card-num">' + stats.correct + '</div><div class="bs-score-card-label">Richtig</div></div>' +
'<div class="bs-score-card bs-score-wrong"><div class="bs-score-card-num">' + stats.wrong + '</div><div class="bs-score-card-label">Falsch</div></div>' +
'<div class="bs-score-card bs-score-open"><div class="bs-score-card-num">' + stats.open + '</div><div class="bs-score-card-label">Offen</div></div>' +
'<div class="bs-score-card"><div class="bs-score-card-num">' + answered + '/' + stats.total + '</div><div class="bs-score-card-label">Gesamt</div></div>' +
'<div class="bs-score-card"><div class="bs-score-card-num">' + (stats.correct + stats.wrong) + '/' + stats.total + '</div><div class="bs-score-card-label">Gesamt</div></div>' +
'</div>';
var actionsRight = scoreEl.querySelector('.bs-actions-right');
actionsRight.innerHTML = '';
if (typeof nextAction === 'function') {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'bs-btn bs-btn-primary';
btn.textContent = completedSection < 5 ? 'Weiter zu Säule ' + (completedSection + 1) + ' >' : 'Zum Ergebnis >';
btn.onclick = nextAction;
actionsRight.appendChild(btn);
}
var actionsLeft = scoreEl.querySelector('.bs-actions-left');
actionsLeft.innerHTML = '<button type="button" class="bs-btn" onclick="bsGoToSection(' + completedSection + ')">< Zurück zu Säule ' + completedSection + '</button>';
for (var i = 0; i < 6; i++) {
var el = document.getElementById('bsSection' + i);
if (el) el.style.display = 'none';
}
scoreEl.style.display = '';
bsScrollToTop();
function continueAfterChapter() {
if (currentChapter < chapters.length - 1) showPage(currentChapter + 1, 0);
else showFinalResult();
}
function bsFinalResultAction() {
document.getElementById('bsSectionScore').style.display = 'none';
bsShowFinalResult();
document.getElementById('bsSection5').style.display = '';
bsCurrentSub[5] = 5;
bsShowSubStep(5, 5);
bsScrollToTop();
}
function bsShowFinalResult() {
var stats = bsGetScoreStats();
var passed = stats.percent >= 70;
var section = document.getElementById('bsSub5_5');
var body = section.querySelector('.bs-body');
var barColor = passed ? 'var(--bs-green)' : 'var(--bs-red)';
var html = '<div class="bs-chapter-title">' + (passed ? 'Schulung bestanden!' : 'Schulung nicht bestanden') + '</div>';
html += '<div class="bs-score-bar-wrap">' +
'<div class="bs-score-bar"><div class="bs-score-bar-fill" style="width:' + stats.percent + '%;background:' + barColor + '"></div></div>' +
'<div class="bs-score-bar-label">' + stats.percent + '% richtig (' + stats.correct + ' von ' + stats.total + ' Fragen)</div>' +
'</div>';
html += '<div class="bs-score-grid">' +
'<div class="bs-score-card bs-score-correct"><div class="bs-score-card-num">' + stats.correct + '</div><div class="bs-score-card-label">Richtig</div></div>' +
'<div class="bs-score-card bs-score-wrong"><div class="bs-score-card-num">' + stats.wrong + '</div><div class="bs-score-card-label">Falsch</div></div>' +
'</div>';
if (passed) {
html += '<div class="bs-successbox" style="margin-top:20px;">' +
'<strong>Herzlichen Glückwunsch!</strong> Du hast die Brandschutzunterweisung erfolgreich absolviert. ' +
'Du kennst jetzt die Grundlagen der Brandverhütung, weißt, wie du dich im Brandfall verhältst, ' +
'kennst die Aufgaben der vBsU, kannst einen Feuerlöscher richtig einsetzen und weißt, wo sich die wichtigsten ' +
'Einrichtungen in unserer Kirchengemeinde befinden.' +
'</div>';
html += '<div style="margin-top:24px;padding:20px;background:var(--gray-50);border:1px solid var(--gray-200);">' +
'<label style="display:flex;align-items:flex-start;gap:12px;cursor:pointer;">' +
'<input type="checkbox" id="bsConfirmCheckbox" onchange="bsToggleConfirm()" style="width:20px;height:20px;margin-top:2px;">' +
'<span style="font-size:var(--font-size-sm);color:var(--gray-800);line-height:1.6;">' +
'Ich bestätige, dass ich die Inhalte der <a href="/intranet_repo/projects/brandschutz/Brandschutzordnung.pdf" target="_blank" onclick="event.stopPropagation();">Brandschutzordnung</a> verstanden und verinnerlicht habe. ' +
'Ich bin bereit, die Aufgaben als vBsU zu übernehmen, sofern ich dazu eingeteilt werde.' +
'</span>' +
'</label>' +
'</div>';
html += '<button type="button" class="bs-btn bs-btn-primary bs-btn-locked" id="bsFinishBtn" onclick="bsFinishTraining()" style="margin-top:24px;width:100%;padding:16px;font-size:var(--font-size-md);">Schulung abschließen</button>';
} else {
html += '<div class="bs-warnbox" style="margin-top:20px;">' +
'<div class="bs-warnbox-title">Leider nicht bestanden</div>' +
'Du hast ' + stats.percent + '% der Fragen richtig beantwortet. Zum Bestehen sind mindestens 70% erforderlich. ' +
'Bitte wiederhole die Schulung, um alle Inhalte zu verinnerlichen.' +
'</div>';
html += '<button type="button" class="bs-btn bs-btn-primary" onclick="bsRestartTraining()" style="margin-top:24px;width:100%;padding:16px;font-size:var(--font-size-md);">Schulung neu starten</button>';
}
body.innerHTML = html;
}
function bsSetQuizButtonState(quizId, state) {
var btn = document.getElementById('bsCheck_' + quizId);
if (!btn) return;
btn.classList.remove('bs-btn-success', 'bs-btn-failed', 'bs-btn-check-disabled');
if (state === 'passed') {
btn.textContent = 'Richtig';
btn.classList.add('bs-btn-success', 'bs-btn-check-disabled');
btn.onclick = null;
function finishChapter() {
var chapterAssessments = chapters[currentChapter].querySelectorAll('.bs-assessment[data-score="true"]');
if (chapterAssessments.length === 0) {
continueAfterChapter();
return;
}
if (state === 'failed') {
btn.textContent = 'Falsch';
btn.classList.add('bs-btn-failed', 'bs-btn-check-disabled');
btn.onclick = null;
return;
}
btn.textContent = 'Antwort prüfen';
btn.onclick = function() { bsCheckIndividualQuiz(quizId); };
var stats = scoreStats();
var maxReachable = stats.total > 0 ? 100 - Math.round((stats.wrong / stats.total) * 100) : 100;
var body = scoreSection.querySelector('.bs-body');
body.innerHTML = '<div class="bs-chapter-title">Zwischenstand nach Kapitel ' + (currentChapter + 1) + '</div>' +
'<div class="bs-score-bar-wrap"><div class="bs-score-bar"><div class="bs-score-bar-fill" style="width:' + maxReachable + '%;background:' + (maxReachable >= settings.passingScore ? 'var(--bs-green)' : 'var(--bs-red)') + '"></div></div>' +
'<div class="bs-score-bar-label">Max. erreichbar: ' + maxReachable + '% (mindestens ' + settings.passingScore + '% erforderlich)</div></div>' + scoreCards(stats);
scoreSection.querySelector('.bs-actions-left').innerHTML = '<button type="button" class="bs-btn bs-score-back">&lt; Zurück</button>';
scoreSection.querySelector('.bs-actions-right').innerHTML = '<button type="button" class="bs-btn bs-btn-primary bs-score-next">Weiter &gt;</button>';
scoreSection.querySelector('.bs-score-back').addEventListener('click', function () { showPage(currentChapter, currentPage); });
scoreSection.querySelector('.bs-score-next').addEventListener('click', continueAfterChapter);
chapters.forEach(function (chapter) { chapter.hidden = true; });
scoreSection.hidden = false;
scrollToTop();
}
function bsUpdatePageGate(nextBtnId, quizIds) {
var nextBtn = document.getElementById(nextBtnId);
if (!nextBtn) return;
var allPassed = quizIds.every(function(id) { return !!bsQuizPassed[id]; });
nextBtn.classList.toggle('bs-btn-locked', !allPassed);
}
/* Validate quiz without marking options */
function bsValidateQuiz(quizId) {
var quiz = document.getElementById(quizId);
if (!quiz) return false;
// Checkbox-based quizzes
var cbOptions = quiz.querySelectorAll('.bs-quiz-option input[type="checkbox"]');
if (cbOptions.length > 0) {
var ok = true;
cbOptions.forEach(function(cb) {
var isCorrect = cb.dataset.correct === 'true';
if ((isCorrect && !cb.checked) || (!isCorrect && cb.checked)) ok = false;
});
return ok;
}
// Fill-in-the-blank
var fills = quiz.querySelectorAll('.bs-fill-input');
if (fills.length > 0) {
var ok = true;
fills.forEach(function(input) {
if (input.value.toLowerCase().trim() !== input.dataset.answer.toLowerCase().trim()) ok = false;
});
return ok;
}
// Dropdown matching
var selects = quiz.querySelectorAll('.bs-dropdown-match-select');
if (selects.length > 0) {
var ok = true;
selects.forEach(function(sel) {
if (sel.value !== sel.dataset.correct) ok = false;
});
return ok;
}
return false;
}
function bsCheckIndividualQuiz(quizId) {
var gate = bsGetQuizGate(quizId);
if (!gate) return;
if (!bsQuizAttempts[quizId]) bsQuizAttempts[quizId] = 0;
bsQuizAttempts[quizId]++;
var attempt = bsQuizAttempts[quizId];
var passed = bsValidateQuiz(quizId);
function showFinalResult() {
var stats = scoreStats();
var passed = stats.percent >= Number(settings.passingScore || 70);
var body = finalSection.querySelector('.bs-body');
body.innerHTML = '<div class="bs-chapter-title">' + escapeHtml(passed ? settings.successTitle : settings.failureTitle) + '</div>' +
'<div class="bs-score-bar-wrap"><div class="bs-score-bar"><div class="bs-score-bar-fill" style="width:' + stats.percent + '%;background:' + (passed ? 'var(--bs-green)' : 'var(--bs-red)') + '"></div></div>' +
'<div class="bs-score-bar-label">' + stats.percent + '% richtig (' + stats.correct + ' von ' + stats.total + ' Fragen)</div></div>' + scoreCards(stats);
if (passed) {
// Correct — show markings and lock
bsRenderQuizResult(quizId);
bsQuizPassed[quizId] = true;
bsQuizCorrect[quizId] = true;
bsSetQuizButtonState(quizId, 'passed');
document.getElementById(quizId).classList.add('bs-quiz-locked');
} else if (attempt >= 3) {
// Third wrong attempt — show solution, lock as failed
bsRenderQuizResult(quizId);
bsQuizPassed[quizId] = true; // done (failed but completed)
bsQuizCorrect[quizId] = false;
bsSetQuizButtonState(quizId, 'failed');
document.getElementById(quizId).classList.add('bs-quiz-locked');
body.innerHTML += '<div class="bs-successbox bs-result-message">' + settings.successText + '</div>' +
'<div class="bs-confirmation"><label><input type="checkbox" id="bsConfirmCheckbox"><span>' + settings.confirmationText + '</span></label></div>' +
'<button type="button" class="bs-btn bs-btn-primary bs-btn-locked" id="bsFinishBtn">Schulung abschließen</button>';
var confirm = document.getElementById('bsConfirmCheckbox');
var finish = document.getElementById('bsFinishBtn');
confirm.addEventListener('change', function () { finish.classList.toggle('bs-btn-locked', !confirm.checked); });
finish.addEventListener('click', function () {
if (confirm.checked) document.getElementById('bsCompleteForm').submit();
});
} else {
// Wrong but retries left — show hint, keep selections
var remaining = 3 - attempt;
var fb = document.getElementById(quizId + '_fb');
if (fb) {
fb.classList.remove('correct', 'wrong');
fb.classList.add('show', 'wrong');
fb.textContent = 'Leider falsch. Du hast noch ' + remaining + (remaining === 1 ? ' Versuch.' : ' Versuche.');
body.innerHTML += '<div class="bs-warnbox bs-result-message"><div class="bs-warnbox-title">Nicht bestanden</div>' + settings.failureText + '</div>' +
'<button type="button" class="bs-btn bs-btn-primary" id="bsRestartBtn">Schulung neu starten</button>';
document.getElementById('bsRestartBtn').addEventListener('click', restartTraining);
}
chapters.forEach(function (chapter) { chapter.hidden = true; });
if (scoreSection) scoreSection.hidden = true;
finalSection.hidden = false;
scrollToTop();
}
bsUpdatePageGate(gate.nextBtnId, gate.quizIds);
function normalizeAnswer(value) {
return String(value).trim().toLocaleLowerCase('de-DE').replace(/\s+/g, ' ');
}
function bsResetQuizContainer(containerId) {
var container = document.getElementById(containerId);
if (!container) return;
container.querySelectorAll('input[type="checkbox"]').forEach(function(input) {
input.checked = false;
function validateAssessment(assessment, mark) {
var kind = assessment.dataset.assessmentKind;
var correct = true;
if (kind === 'multiple_choice') {
assessment.querySelectorAll('.bs-quiz-option').forEach(function (option) {
var input = option.querySelector('input');
var expected = input.dataset.correct === 'true';
if (input.checked !== expected) correct = false;
if (mark) {
option.classList.remove('correct', 'wrong');
if (expected) option.classList.add('correct');
else if (input.checked) option.classList.add('wrong');
}
});
container.querySelectorAll('.bs-quiz-option').forEach(function(opt) {
opt.classList.remove('correct', 'wrong', 'selected');
});
container.querySelectorAll('select').forEach(function(select) {
select.selectedIndex = 0;
});
container.querySelectorAll('.bs-dropdown-match-row').forEach(function(row) {
row.classList.remove('correct', 'wrong');
});
container.querySelectorAll('input[type="text"]').forEach(function(input) {
input.value = '';
} else if (kind === 'fill_blank') {
assessment.querySelectorAll('.bs-fill-input').forEach(function (input) {
var answers = JSON.parse(input.dataset.answers || '[]').map(normalizeAnswer);
var matches = answers.includes(normalizeAnswer(input.value));
if (!matches) correct = false;
if (mark) {
input.classList.remove('correct', 'wrong');
input.classList.add(matches ? 'correct' : 'wrong');
}
});
container.querySelectorAll('.bs-quiz-feedback, .bs-feedback').forEach(function(fb) {
fb.classList.remove('show', 'correct', 'wrong');
fb.textContent = '';
} else if (kind === 'matching') {
assessment.querySelectorAll('.bs-dropdown-match-row').forEach(function (row) {
var select = row.querySelector('select');
var matches = select.value === select.dataset.correct;
if (!matches) correct = false;
if (mark) {
row.classList.remove('correct', 'wrong');
row.classList.add(matches ? 'correct' : 'wrong');
}
});
}
function bsUpdateChecklistNext(checklistId, buttonId) {
const checklist = document.getElementById(checklistId);
const button = document.getElementById(buttonId);
if (!checklist || !button) return;
const items = checklist.querySelectorAll('li');
const checkedItems = checklist.querySelectorAll('li.checked');
const allChecked = items.length > 0 && items.length === checkedItems.length;
button.classList.toggle('bs-btn-locked', !allChecked);
return correct;
}
function bsRestartTraining() {
bsQuizPassed = {};
bsQuizAttempts = {};
bsQuizCorrect = {};
bsGetAllQuizIds().forEach(function(id) {
bsResetQuizContainer(id);
bsSetQuizButtonState(id, 'default');
var quiz = document.getElementById(id);
if (quiz) quiz.classList.remove('bs-quiz-locked');
});
['bsNextS1Q', 'bsNextS2Q', 'bsNextS3Q', 'bsNextS4Q', 'bsNextS5P1', 'bsNextS5P2'].forEach(function(id) {
var btn = document.getElementById(id);
if (btn) btn.classList.add('bs-btn-locked');
});
var checklist = document.getElementById('bsChecklist3_2');
if (checklist) {
checklist.querySelectorAll('li.checked').forEach(function(li) { li.classList.remove('checked'); });
var clBtn = document.getElementById('bsNextSub3_2');
if (clBtn) clBtn.classList.add('bs-btn-locked');
}
bsGoToSection(0);
}
/* ══════════════════════════════════════
ABSCHLUSS, BESTÄTIGUNG
══════════════════════════════════════ */
function bsToggleConfirm() {
var checkbox = document.getElementById('bsConfirmCheckbox');
var finishBtn = document.getElementById('bsFinishBtn');
if (checkbox.checked) {
finishBtn.classList.remove('bs-btn-locked');
function checkAssessment(assessment) {
if (!assessment || assessment.dataset.complete === 'true') return;
var id = assessment.id;
attempts[id] = (attempts[id] || 0) + 1;
var correct = validateAssessment(assessment, false);
var exhausted = attempts[id] >= Number(settings.maxAttempts || 3);
var feedback = assessment.querySelector('.bs-quiz-feedback');
if (correct || exhausted) {
validateAssessment(assessment, true);
assessment.dataset.complete = 'true';
assessment.classList.add('bs-quiz-locked');
assessment.querySelectorAll('input, select').forEach(function (control) { control.disabled = true; });
if (assessment.dataset.score === 'true') results[id] = correct;
feedback.textContent = correct ? assessment.dataset.feedbackCorrect : assessment.dataset.feedbackIncorrect;
feedback.className = 'bs-quiz-feedback show ' + (correct ? 'correct' : 'wrong');
var button = assessment.querySelector('.bs-check-assessment');
button.textContent = correct ? 'Richtig' : 'Falsch';
button.classList.add(correct ? 'bs-btn-success' : 'bs-btn-failed', 'bs-btn-check-disabled');
button.disabled = true;
} else {
finishBtn.classList.add('bs-btn-locked');
var remaining = Number(settings.maxAttempts || 3) - attempts[id];
feedback.textContent = 'Leider falsch. Du hast noch ' + remaining + (remaining === 1 ? ' Versuch.' : ' Versuche.');
feedback.className = 'bs-quiz-feedback show wrong';
}
updateNavigation();
}
function bsFinishTraining() {
var checkbox = document.getElementById('bsConfirmCheckbox');
var form = document.getElementById('bsCompleteForm');
if (!checkbox || !checkbox.checked || !form) return;
form.submit();
}
/* ══════════════════════════════════════
SHUFFLE QUIZ OPTIONS ON LOAD
══════════════════════════════════════ */
(function() {
var quizBlocks = document.querySelectorAll('.bs-quiz-options');
quizBlocks.forEach(function(container) {
var options = Array.from(container.querySelectorAll(':scope > .bs-quiz-option'));
if (options.length < 2) return;
// Fisher-Yates shuffle
for (var i = options.length - 1; i > 0; i--) {
var j = Math.floor(Math.random() * (i + 1));
var temp = options[i];
options[i] = options[j];
options[j] = temp;
}
options.forEach(function(opt) { container.appendChild(opt); });
function restartTraining() {
attempts = {};
results = {};
document.querySelectorAll('.bs-assessment').forEach(function (assessment) {
delete assessment.dataset.complete;
assessment.classList.remove('bs-quiz-locked');
assessment.querySelectorAll('input').forEach(function (input) { input.checked = false; input.value = input.type === 'text' ? '' : input.value; input.disabled = false; input.classList.remove('correct', 'wrong'); });
assessment.querySelectorAll('select').forEach(function (select) { select.selectedIndex = 0; select.disabled = false; });
assessment.querySelectorAll('.correct, .wrong, .selected').forEach(function (element) { element.classList.remove('correct', 'wrong', 'selected'); });
var feedback = assessment.querySelector('.bs-quiz-feedback');
feedback.className = 'bs-quiz-feedback';
feedback.textContent = '';
var button = assessment.querySelector('.bs-check-assessment');
button.textContent = 'Antwort prüfen';
button.disabled = false;
button.classList.remove('bs-btn-success', 'bs-btn-failed', 'bs-btn-check-disabled');
});
})();
document.querySelectorAll('.bs-checklist li').forEach(function (item) { item.classList.remove('checked'); });
showPage(0, 0);
}
function shuffleAnswerOptions() {
document.querySelectorAll('.bs-quiz-options').forEach(function (container) {
var options = Array.from(container.querySelectorAll(':scope > .bs-quiz-option'));
for (var index = options.length - 1; index > 0; index--) {
var other = Math.floor(Math.random() * (index + 1));
var temporary = options[index];
options[index] = options[other];
options[other] = temporary;
}
options.forEach(function (option) { container.appendChild(option); });
});
}
document.addEventListener('click', function (event) {
var accordion = event.target.closest('.bs-accordion-header');
if (accordion) {
var container = accordion.closest('.bs-accordion');
var open = container.classList.toggle('open');
accordion.setAttribute('aria-expanded', open ? 'true' : 'false');
return;
}
var checklistItem = event.target.closest('.bs-checklist-item');
if (checklistItem) {
checklistItem.closest('li').classList.toggle('checked');
updateNavigation();
return;
}
var check = event.target.closest('.bs-check-assessment');
if (check) {
checkAssessment(document.getElementById(check.dataset.assessmentId));
return;
}
if (event.target.closest('.bs-next-page')) {
var page = currentPageElement();
if (!pageComplete(page)) return;
if (currentPage < pagesFor(currentChapter).length - 1) showPage(currentChapter, currentPage + 1);
else finishChapter();
return;
}
if (event.target.closest('.bs-prev-page')) {
if (currentPage > 0) showPage(currentChapter, currentPage - 1);
else if (currentChapter > 0) showPage(currentChapter - 1, pagesFor(currentChapter - 1).length - 1);
}
});
document.addEventListener('change', function (event) {
if (event.target.matches('.bs-quiz-option input[type="checkbox"]')) {
event.target.closest('.bs-quiz-option').classList.toggle('selected', event.target.checked);
}
});
shuffleAnswerOptions();
if (chapters.length > 0) showPage(0, 0);
}());
@@ -4,6 +4,7 @@
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/initialise.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/projects/brandschutz/util/brandschutz_helpers.php';
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/projects/brandschutz/util/brandschutz_course.php';
if ((int) ($groups['Admin'] ?? 0) !== 1) {
header("Location: {$g_httpHomePath}");
@@ -25,6 +26,8 @@ function getProfileImageUrl_bs(array $row, string $g_profilePicturePath): string
return '/intranet_repo/general_files/profile_pictures/default.jpg';
}
$editorError = null;
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
try {
brandschutz_verify_csrf($_POST['csrf_token'] ?? null);
@@ -33,21 +36,48 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
exit($exception->getMessage());
}
if (($_POST['action'] ?? '') !== 'update_training_date') {
$action = (string) ($_POST['action'] ?? '');
if (in_array($action, ['upload_image', 'delete_image'], true)) {
header('Content-Type: application/json; charset=UTF-8');
try {
$courseState = brandschutz_course_load($pdo);
if ($action === 'upload_image') {
$image = brandschutz_media_upload($_FILES['image'] ?? []);
echo json_encode(['ok' => true, 'image' => $image], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
} else {
brandschutz_media_delete((string) ($_POST['file'] ?? ''), $courseState['config']);
echo json_encode(['ok' => true], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
} catch (Throwable $exception) {
http_response_code(400);
exit('Ungültige Aktion.');
echo json_encode(['ok' => false, 'error' => $exception->getMessage()], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
exit();
}
if ($action === 'save_course') {
header('Content-Type: application/json; charset=UTF-8');
try {
$postedConfig = json_decode((string) ($_POST['course_config'] ?? ''), true, 512, JSON_THROW_ON_ERROR);
$expectedRevision = filter_var($_POST['course_revision'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
if ($expectedRevision === false) throw new InvalidArgumentException('Ungültiger Bearbeitungsstand.');
$newRevision = brandschutz_course_save($pdo, $postedConfig, (int) $expectedRevision, (int) $_SESSION['user_id']);
echo json_encode(['ok' => true, 'revision' => $newRevision], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
} catch (Throwable $exception) {
http_response_code($exception instanceof RuntimeException ? 409 : 400);
$message = $exception instanceof JsonException ? 'Die Editor-Daten konnten nicht verarbeitet werden.' : $exception->getMessage();
echo json_encode(['ok' => false, 'error' => $message], JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
}
exit();
} elseif ($action === 'update_training_date') {
$editUserId = filter_var($_POST['bs_user_id'] ?? null, FILTER_VALIDATE_INT, ['options' => ['min_range' => 1]]);
$newDate = trim((string) ($_POST['bs_new_date'] ?? ''));
if ($editUserId === false || !brandschutz_is_valid_date($newDate)) {
http_response_code(400);
exit('Ungültige Eingaben.');
}
$stmtUser = $pdo->prepare(
'SELECT ugi.first_name, ugi.last_name, ugi.mail '
. 'FROM user_general_information ugi '
'SELECT ugi.first_name, ugi.last_name, ugi.mail FROM user_general_information ugi '
. 'INNER JOIN user_permissions up ON up.user_id = ugi.user_id '
. 'WHERE ugi.user_id = ? AND up."Brandschutz" = 1'
);
@@ -57,12 +87,10 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
http_response_code(404);
exit('Brandschutz-Nutzer nicht gefunden.');
}
$newStatus = brandschutz_training_status($newDate);
$resetCounters = (int) $newStatus['days_until_due'] >= 0;
$adminName = brandschutz_display_name(is_array($user) ? $user : [], 'Ein Administrator');
$editedName = brandschutz_display_name($editedUser);
$pdo->beginTransaction();
try {
brandschutz_store_training_date($pdo, (int) $editUserId, $newDate, $resetCounters);
@@ -71,20 +99,29 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$adminName,
(string) ($editedUser['mail'] ?? ''),
'Brandschutz-Schulungsdatum angepasst: ' . $editedName,
$adminName . ' hat das Schulungsdatum von ' . $editedName . ' manuell auf den '
. (new DateTimeImmutable($newDate))->format('d.m.Y') . ' gesetzt.'
$adminName . ' hat das Schulungsdatum von ' . $editedName . ' manuell auf den ' . (new DateTimeImmutable($newDate))->format('d.m.Y') . ' gesetzt.'
);
$pdo->commit();
} catch (Throwable $exception) {
if ($pdo->inTransaction()) {
$pdo->rollBack();
}
if ($pdo->inTransaction()) $pdo->rollBack();
throw $exception;
}
header('Location: admin_brandschutz.php?message=5', true, 303);
exit();
} elseif ($action !== 'save_course') {
http_response_code(400);
exit('Ungültige Aktion.');
}
}
$courseState = ['config' => ['title' => 'Brandschutzunterweisung', 'settings' => [], 'chapters' => []], 'revision' => 1, 'updated_at' => null];
try {
$courseState = brandschutz_course_load($pdo);
} catch (Throwable $exception) {
$editorError = $editorError ?? ('Die Schulungskonfiguration konnte nicht geladen werden: ' . $exception->getMessage());
}
$courseConfig = $courseState['config'];
$courseImages = brandschutz_media_list();
$sql = 'SELECT ugi.user_id, ugi.first_name, ugi.last_name, ugi.mail, '
. 'bt.last_training_date, bt.reminder_count, bt.admin_reminder_count, '
@@ -110,6 +147,12 @@ unset($bsUser);
<head>
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/html_head.php'; ?>
<link rel="stylesheet" href="/intranet_repo/projects/_central/layouts/admin_brandschutz.css">
<script id="bsEditorData" type="application/json"><?= json_encode([
'config' => $courseConfig,
'revision' => (int) $courseState['revision'],
'csrfToken' => $csrfToken,
'images' => $courseImages,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) ?></script>
<script src="/intranet_repo/projects/_central/scripts/admin_brandschutz.js" defer></script>
</head>
@@ -126,6 +169,51 @@ unset($bsUser);
<div class="content" id="fullsize">
<?php $adminActiveTab = 'brandschutz'; require __DIR__.'/_tabs.php'; ?>
<div class="standard_overlay bs-course-editor-overlay" id="bsCourseEditorOverlay" aria-hidden="true">
<section class="bs-editor-shell" id="bs-course-editor" role="dialog" aria-modal="true" aria-labelledby="bsCourseEditorTitle">
<div class="bs-editor-header">
<div>
<h1 id="bsCourseEditorTitle">Schulungseditor</h1>
<p>Kapitel, Seiten, Inhalte und Prüfungsfragen zentral verwalten.</p>
</div>
<div class="bs-editor-header-actions">
<button type="button" class="bs-editor-button" id="bsEditSettings">Einstellungen</button>
<button type="button" class="bs-course-editor-close" id="bsCloseCourseEditor" aria-label="Schulungseditor schließen">×</button>
</div>
</div>
<?php if ($editorError !== null): ?><div class="bs-editor-notice bs-editor-notice--error"><?= htmlspecialchars($editorError, ENT_QUOTES, 'UTF-8') ?></div><?php endif; ?>
<div class="bs-editor-notice" id="bsEditorStatus" role="status" aria-live="polite" hidden></div>
<div class="bs-editor-grid">
<aside class="bs-editor-panel">
<div class="bs-editor-panel-title"><span>Kapitel</span><button type="button" id="bsAddChapter" aria-label="Kapitel hinzufügen">+ Hinzufügen</button></div>
<div class="bs-editor-list" id="bsChapterList"></div>
</aside>
<aside class="bs-editor-panel">
<div class="bs-editor-panel-title"><span>Seiten</span><button type="button" id="bsAddPage" aria-label="Seite hinzufügen">+ Hinzufügen</button></div>
<div class="bs-editor-list" id="bsPageList"></div>
</aside>
<main class="bs-editor-panel bs-elements-panel">
<div class="bs-editor-panel-title"><span id="bsElementPanelTitle">Elemente</span><button type="button" id="bsAddElement">+ Baustein</button></div>
<div class="bs-element-list" id="bsElementList"></div>
</main>
</div>
</section>
</div>
<div class="standard_overlay bs-editor-overlay" id="bsEditorModal" aria-hidden="true">
<div class="bs-editor-modal" role="dialog" aria-modal="true" aria-labelledby="bsEditorModalTitle">
<button type="button" class="bs-editor-modal-close" id="bsEditorModalClose" aria-label="Schließen">×</button>
<h2 id="bsEditorModalTitle">Baustein bearbeiten</h2>
<div id="bsEditorModalBody"></div>
<div class="bs-editor-modal-actions">
<button type="button" class="bs-editor-button" id="bsEditorModalCancel">Abbrechen</button>
<button type="button" class="bs-editor-button bs-editor-button--primary" id="bsEditorModalSave">Übernehmen</button>
</div>
</div>
</div>
<div class="admin-users-shell">
<div class="admin-table-card">
<div class="admin-list-header">
@@ -133,6 +221,7 @@ unset($bsUser);
<span class="admin-list-title">Brandschutz-Schulungen</span>
<div class="admin-list-hint"><?= count($bsUsers) ?> Nutzer mit Brandschutz-Rolle</div>
</div>
<button type="button" class="bs-editor-button bs-editor-button--primary" id="bsOpenCourseEditor">Schulungseditor öffnen</button>
</div>
<table class="admin-user-table" id="bsTable">
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,443 @@
<?php
declare(strict_types=1);
const BRANDSCHUTZ_COURSE_SCHEMA_VERSION = 1;
const BRANDSCHUTZ_MEDIA_MAX_BYTES = 8388608;
const BRANDSCHUTZ_MEDIA_URL = '/intranet_repo/projects/brandschutz/uploads';
function brandschutz_media_directory(): string
{
return dirname(__DIR__) . '/uploads';
}
function brandschutz_course_ensure_storage(PDO $pdo): void
{
$pdo->exec(
'CREATE TABLE IF NOT EXISTS brandschutz_course_config ('
. 'id SMALLINT PRIMARY KEY CHECK (id = 1), '
. 'config JSONB NOT NULL, '
. 'revision INTEGER NOT NULL DEFAULT 1, '
. 'updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), '
. 'updated_by BIGINT NULL)'
);
$statement = $pdo->query('SELECT 1 FROM brandschutz_course_config WHERE id = 1');
if ($statement !== false && $statement->fetchColumn() !== false) {
return;
}
$initialConfig = brandschutz_course_initial_config();
$insert = $pdo->prepare(
'INSERT INTO brandschutz_course_config (id, config, revision) '
. 'VALUES (1, CAST(? AS JSONB), 1) ON CONFLICT (id) DO NOTHING'
);
$insert->execute([json_encode($initialConfig, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)]);
}
function brandschutz_course_initial_config(): array
{
return brandschutz_course_normalize([
'version' => BRANDSCHUTZ_COURSE_SCHEMA_VERSION,
'title' => 'Brandschutzunterweisung',
'settings' => [],
'chapters' => [],
]);
}
/** @return array{config: array, revision: int, updated_at: ?string} */
function brandschutz_course_load(PDO $pdo): array
{
brandschutz_course_ensure_storage($pdo);
$statement = $pdo->query('SELECT config, revision, updated_at FROM brandschutz_course_config WHERE id = 1');
$row = $statement !== false ? $statement->fetch(PDO::FETCH_ASSOC) : false;
if (!$row) {
throw new RuntimeException('Die Schulungskonfiguration konnte nicht geladen werden.');
}
try {
$config = json_decode((string) $row['config'], true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new RuntimeException('Die gespeicherte Schulungskonfiguration ist beschädigt.', 0, $exception);
}
return [
'config' => brandschutz_course_normalize($config),
'revision' => (int) $row['revision'],
'updated_at' => isset($row['updated_at']) ? (string) $row['updated_at'] : null,
];
}
function brandschutz_course_save(PDO $pdo, array $config, int $expectedRevision, int $userId): int
{
if ($expectedRevision < 1) {
throw new InvalidArgumentException('Ungültiger Bearbeitungsstand. Bitte laden Sie die Seite neu.');
}
$normalized = brandschutz_course_normalize($config);
$json = json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$statement = $pdo->prepare(
'UPDATE brandschutz_course_config SET config = CAST(? AS JSONB), '
. 'revision = revision + 1, updated_at = NOW(), updated_by = ? '
. 'WHERE id = 1 AND revision = ? RETURNING revision'
);
$statement->execute([$json, $userId > 0 ? $userId : null, $expectedRevision]);
$revision = $statement->fetchColumn();
if ($revision === false) {
throw new RuntimeException('Die Schulung wurde zwischenzeitlich geändert. Bitte laden Sie die Seite neu und prüfen Sie Ihre Änderungen.');
}
return (int) $revision;
}
function brandschutz_course_string(mixed $value, int $maxLength, string $fallback = ''): string
{
$string = trim(is_scalar($value) ? (string) $value : '');
if (mb_strlen($string, 'UTF-8') > $maxLength) {
throw new InvalidArgumentException('Ein Inhalt überschreitet die maximal erlaubte Länge.');
}
return $string !== '' ? $string : $fallback;
}
function brandschutz_course_id(mixed $value, string $prefix, array &$knownIds): string
{
$id = is_string($value) ? trim($value) : '';
if (!preg_match('/^[a-zA-Z0-9_-]{1,80}$/', $id)) {
$id = $prefix . '-' . bin2hex(random_bytes(8));
}
if (isset($knownIds[$id])) {
throw new InvalidArgumentException('Die Konfiguration enthält doppelte interne IDs.');
}
$knownIds[$id] = true;
return $id;
}
function brandschutz_course_sanitize_html(mixed $value): string
{
$html = brandschutz_course_string($value, 30000);
if ($html === '') {
return '';
}
$document = new DOMDocument('1.0', 'UTF-8');
$previous = libxml_use_internal_errors(true);
$document->loadHTML(
'<?xml encoding="UTF-8"><div id="brandschutz-sanitize-root">' . $html . '</div>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
libxml_use_internal_errors($previous);
$root = $document->getElementById('brandschutz-sanitize-root');
if (!$root instanceof DOMElement) {
return htmlspecialchars(strip_tags($html), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
brandschutz_course_sanitize_children($root);
$output = '';
foreach ($root->childNodes as $child) {
$output .= $document->saveHTML($child);
}
return trim($output);
}
function brandschutz_course_sanitize_children(DOMNode $parent): void
{
$allowed = ['a', 'br', 'em', 'i', 'p', 'strong', 'b', 'u'];
for ($node = $parent->firstChild; $node !== null;) {
$next = $node->nextSibling;
if ($node instanceof DOMComment) {
$parent->removeChild($node);
} elseif ($node instanceof DOMElement) {
brandschutz_course_sanitize_children($node);
$tag = strtolower($node->tagName);
if (!in_array($tag, $allowed, true)) {
while ($node->firstChild !== null) {
$parent->insertBefore($node->firstChild, $node);
}
$parent->removeChild($node);
} else {
$href = $tag === 'a' ? trim($node->getAttribute('href')) : '';
while ($node->attributes->length > 0) {
$node->removeAttributeNode($node->attributes->item(0));
}
if ($tag === 'a' && brandschutz_course_safe_link($href)) {
$node->setAttribute('href', $href);
$node->setAttribute('target', '_blank');
$node->setAttribute('rel', 'noopener noreferrer');
} elseif ($tag === 'a') {
while ($node->firstChild !== null) {
$parent->insertBefore($node->firstChild, $node);
}
$parent->removeChild($node);
}
}
}
$node = $next;
}
}
function brandschutz_course_safe_link(string $href): bool
{
if ($href === '' || str_starts_with($href, '//')) {
return false;
}
if (str_starts_with($href, '/')) {
return !str_contains($href, "\n") && !str_contains($href, "\r");
}
$scheme = strtolower((string) parse_url($href, PHP_URL_SCHEME));
return in_array($scheme, ['http', 'https', 'mailto'], true);
}
function brandschutz_course_plain_list(mixed $value, int $limit = 100): array
{
if (!is_array($value) || count($value) > $limit) {
if (is_array($value) && count($value) > $limit) {
throw new InvalidArgumentException('Ein Element enthält zu viele Einträge.');
}
return [];
}
$result = [];
foreach ($value as $item) {
$text = brandschutz_course_sanitize_html($item);
if ($text !== '') {
$result[] = $text;
}
}
return $result;
}
function brandschutz_course_normalize(mixed $input): array
{
if (!is_array($input)) {
throw new InvalidArgumentException('Die Schulungskonfiguration muss ein Objekt sein.');
}
$knownIds = [];
$chaptersInput = $input['chapters'] ?? [];
if (!is_array($chaptersInput) || count($chaptersInput) > 50) {
throw new InvalidArgumentException('Die Schulung darf höchstens 50 Kapitel enthalten.');
}
$settingsInput = is_array($input['settings'] ?? null) ? $input['settings'] : [];
$passingScore = filter_var($settingsInput['passing_score'] ?? 70, FILTER_VALIDATE_INT);
$maxAttempts = filter_var($settingsInput['max_attempts'] ?? 3, FILTER_VALIDATE_INT);
$config = [
'version' => BRANDSCHUTZ_COURSE_SCHEMA_VERSION,
'title' => brandschutz_course_string($input['title'] ?? '', 160, 'Brandschutzunterweisung'),
'settings' => [
'passing_score' => $passingScore !== false ? max(1, min(100, (int) $passingScore)) : 70,
'max_attempts' => $maxAttempts !== false ? max(1, min(10, (int) $maxAttempts)) : 3,
'success_title' => brandschutz_course_string($settingsInput['success_title'] ?? '', 200, 'Schulung bestanden!'),
'success_text' => brandschutz_course_sanitize_html($settingsInput['success_text'] ?? ''),
'failure_title' => brandschutz_course_string($settingsInput['failure_title'] ?? '', 200, 'Schulung nicht bestanden'),
'failure_text' => brandschutz_course_sanitize_html($settingsInput['failure_text'] ?? ''),
'confirmation_text' => brandschutz_course_sanitize_html($settingsInput['confirmation_text'] ?? ''),
],
'chapters' => [],
];
$pageCount = 0;
$elementCount = 0;
foreach ($chaptersInput as $chapterInput) {
if (!is_array($chapterInput)) continue;
$pagesInput = is_array($chapterInput['pages'] ?? null) ? $chapterInput['pages'] : [];
$chapter = [
'id' => brandschutz_course_id($chapterInput['id'] ?? '', 'chapter', $knownIds),
'title' => brandschutz_course_string($chapterInput['title'] ?? '', 200, 'Unbenanntes Kapitel'),
'short_title' => brandschutz_course_string($chapterInput['short_title'] ?? '', 80, 'Kapitel'),
'pages' => [],
];
foreach ($pagesInput as $pageInput) {
if (!is_array($pageInput)) continue;
$pageCount++;
if ($pageCount > 250) throw new InvalidArgumentException('Die Schulung darf höchstens 250 Seiten enthalten.');
$elementsInput = is_array($pageInput['elements'] ?? null) ? $pageInput['elements'] : [];
$page = [
'id' => brandschutz_course_id($pageInput['id'] ?? '', 'page', $knownIds),
'title' => brandschutz_course_string($pageInput['title'] ?? '', 200, 'Unbenannte Seite'),
'elements' => [],
];
foreach ($elementsInput as $elementInput) {
if (!is_array($elementInput)) continue;
$elementCount++;
if ($elementCount > 2000) throw new InvalidArgumentException('Die Schulung darf höchstens 2.000 Elemente enthalten.');
$page['elements'][] = brandschutz_course_normalize_element($elementInput, $knownIds);
}
$chapter['pages'][] = $page;
}
$config['chapters'][] = $chapter;
}
return $config;
}
function brandschutz_course_normalize_element(array $input, array &$knownIds): array
{
$types = ['text', 'infobox', 'warnbox', 'list', 'accordion', 'checklist', 'scenario', 'multiple_choice', 'fill_blank', 'matching', 'image'];
$type = is_string($input['type'] ?? null) ? $input['type'] : '';
if (!in_array($type, $types, true)) {
throw new InvalidArgumentException('Die Konfiguration enthält einen unbekannten Elementtyp.');
}
$base = ['id' => brandschutz_course_id($input['id'] ?? '', 'element', $knownIds), 'type' => $type];
if ($type === 'text') {
$base['content'] = brandschutz_course_sanitize_html($input['content'] ?? '');
} elseif (in_array($type, ['infobox', 'warnbox', 'scenario'], true)) {
$base['title'] = brandschutz_course_string($input['title'] ?? '', 200);
$base['content'] = brandschutz_course_sanitize_html($input['content'] ?? '');
} elseif (in_array($type, ['list', 'checklist'], true)) {
if ($type === 'list') $base['ordered'] = !empty($input['ordered']);
$base['items'] = brandschutz_course_plain_list($input['items'] ?? []);
} elseif ($type === 'accordion') {
$base['items'] = [];
$items = is_array($input['items'] ?? null) ? $input['items'] : [];
if (count($items) > 100) throw new InvalidArgumentException('Ein Akkordeon enthält zu viele Einträge.');
foreach ($items as $item) {
if (!is_array($item)) continue;
$base['items'][] = [
'title' => brandschutz_course_string($item['title'] ?? '', 200, 'Abschnitt'),
'content' => brandschutz_course_sanitize_html($item['content'] ?? ''),
];
}
} elseif ($type === 'multiple_choice') {
$base += brandschutz_course_assessment_fields($input);
$base['answers'] = [];
$answers = is_array($input['answers'] ?? null) ? $input['answers'] : [];
if (count($answers) > 50) throw new InvalidArgumentException('Eine Frage enthält zu viele Antworten.');
foreach ($answers as $answer) {
if (!is_array($answer)) continue;
$text = brandschutz_course_string($answer['text'] ?? '', 1000);
if ($text !== '') $base['answers'][] = ['text' => $text, 'correct' => !empty($answer['correct'])];
}
if ($base['answers'] === [] || !array_filter($base['answers'], static fn(array $answer): bool => $answer['correct'])) {
throw new InvalidArgumentException('Jede Multiple-Choice-Frage benötigt Antworten und mindestens eine korrekte Lösung.');
}
} elseif ($type === 'fill_blank') {
$base += brandschutz_course_assessment_fields($input);
$base['text'] = brandschutz_course_string($input['text'] ?? '', 10000);
$base['blanks'] = [];
$blanks = is_array($input['blanks'] ?? null) ? $input['blanks'] : [];
if (count($blanks) > 50) throw new InvalidArgumentException('Ein Lückentext enthält zu viele Lücken.');
foreach ($blanks as $blank) {
if (!is_array($blank)) continue;
$answers = [];
foreach (($blank['answers'] ?? []) as $answer) {
$text = brandschutz_course_string($answer, 300);
if ($text !== '') $answers[] = $text;
}
if ($answers !== []) $base['blanks'][] = ['answers' => array_values(array_unique($answers))];
}
if ($base['blanks'] === []) throw new InvalidArgumentException('Ein Lückentext benötigt mindestens eine erwartete Lösung.');
foreach (array_keys($base['blanks']) as $index) {
if (!str_contains($base['text'], '{{' . ($index + 1) . '}}')) {
throw new InvalidArgumentException('Der Lückentext enthält nicht für jede Lösung einen Platzhalter wie {{1}}.');
}
}
} elseif ($type === 'matching') {
$base += brandschutz_course_assessment_fields($input);
$base['choices'] = [];
foreach ((is_array($input['choices'] ?? null) ? $input['choices'] : []) as $choice) {
if (!is_array($choice)) continue;
$value = brandschutz_course_string($choice['value'] ?? '', 80);
$label = brandschutz_course_string($choice['label'] ?? '', 300);
if ($value !== '' && $label !== '') $base['choices'][] = ['value' => $value, 'label' => $label];
}
$validChoices = array_column($base['choices'], 'value');
$base['pairs'] = [];
foreach ((is_array($input['pairs'] ?? null) ? $input['pairs'] : []) as $pair) {
if (!is_array($pair)) continue;
$item = brandschutz_course_string($pair['item'] ?? '', 500);
$correct = brandschutz_course_string($pair['correct'] ?? '', 80);
if ($item !== '' && in_array($correct, $validChoices, true)) $base['pairs'][] = ['item' => $item, 'correct' => $correct];
}
if ($base['choices'] === [] || $base['pairs'] === []) throw new InvalidArgumentException('Eine Zuordnung benötigt Auswahlmöglichkeiten und Aufgaben.');
} elseif ($type === 'image') {
$file = basename(brandschutz_course_string($input['file'] ?? '', 255));
if ($file !== '' && !preg_match('/^[a-f0-9]{32}\.(?:jpg|jpeg|png|webp|gif)$/i', $file)) {
throw new InvalidArgumentException('Ein Bildelement verweist auf eine ungültige Datei.');
}
$base['file'] = $file;
$base['alt'] = brandschutz_course_string($input['alt'] ?? '', 500);
$base['caption'] = brandschutz_course_string($input['caption'] ?? '', 1000);
}
return $base;
}
function brandschutz_course_assessment_fields(array $input): array
{
return [
'question' => brandschutz_course_string($input['question'] ?? '', 2000, 'Frage'),
'feedback_correct' => brandschutz_course_string($input['feedback_correct'] ?? '', 3000, 'Richtig!'),
'feedback_incorrect' => brandschutz_course_string($input['feedback_incorrect'] ?? '', 3000, 'Leider nicht richtig.'),
'score' => !array_key_exists('score', $input) || !empty($input['score']),
];
}
function brandschutz_media_list(): array
{
$directory = brandschutz_media_directory();
if (!is_dir($directory)) return [];
$files = [];
foreach (scandir($directory) ?: [] as $file) {
if (!preg_match('/^[a-f0-9]{32}\.(?:jpg|jpeg|png|webp|gif)$/i', $file)) continue;
$path = $directory . DIRECTORY_SEPARATOR . $file;
if (!is_file($path)) continue;
$files[] = ['file' => $file, 'url' => BRANDSCHUTZ_MEDIA_URL . '/' . rawurlencode($file), 'size' => filesize($path) ?: 0, 'modified' => filemtime($path) ?: 0];
}
usort($files, static fn(array $a, array $b): int => $b['modified'] <=> $a['modified']);
return $files;
}
function brandschutz_media_upload(array $upload): array
{
if (($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file((string) ($upload['tmp_name'] ?? ''))) {
throw new InvalidArgumentException('Das Bild konnte nicht hochgeladen werden.');
}
$size = (int) ($upload['size'] ?? 0);
if ($size <= 0 || $size > BRANDSCHUTZ_MEDIA_MAX_BYTES) {
throw new InvalidArgumentException('Das Bild darf höchstens 8 MB groß sein.');
}
$temporaryPath = (string) $upload['tmp_name'];
$imageInfo = @getimagesize($temporaryPath);
$mime = $imageInfo['mime'] ?? '';
$extensions = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif'];
if (!isset($extensions[$mime])) {
throw new InvalidArgumentException('Erlaubt sind JPEG-, PNG-, WebP- und GIF-Bilder.');
}
if (($imageInfo[0] ?? 0) > 8000 || ($imageInfo[1] ?? 0) > 8000) {
throw new InvalidArgumentException('Das Bild darf höchstens 8.000 × 8.000 Pixel groß sein.');
}
$directory = brandschutz_media_directory();
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
throw new RuntimeException('Der Bildspeicher ist nicht verfügbar.');
}
$filename = bin2hex(random_bytes(16)) . '.' . $extensions[$mime];
if (!move_uploaded_file($temporaryPath, $directory . DIRECTORY_SEPARATOR . $filename)) {
throw new RuntimeException('Das Bild konnte nicht gespeichert werden.');
}
return ['file' => $filename, 'url' => BRANDSCHUTZ_MEDIA_URL . '/' . rawurlencode($filename), 'size' => $size, 'modified' => time()];
}
function brandschutz_course_uses_image(array $config, string $filename): bool
{
foreach ($config['chapters'] ?? [] as $chapter) {
foreach ($chapter['pages'] ?? [] as $page) {
foreach ($page['elements'] ?? [] as $element) {
if (($element['type'] ?? '') === 'image' && ($element['file'] ?? '') === $filename) return true;
}
}
}
return false;
}
function brandschutz_media_delete(string $filename, array $config): void
{
$filename = basename($filename);
if (!preg_match('/^[a-f0-9]{32}\.(?:jpg|jpeg|png|webp|gif)$/i', $filename)) {
throw new InvalidArgumentException('Ungültige Bilddatei.');
}
if (brandschutz_course_uses_image($config, $filename)) {
throw new InvalidArgumentException('Das Bild wird noch in der Schulung verwendet und kann nicht gelöscht werden.');
}
$path = brandschutz_media_directory() . DIRECTORY_SEPARATOR . $filename;
if (is_file($path) && !unlink($path)) {
throw new RuntimeException('Das Bild konnte nicht gelöscht werden.');
}
}
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/brandschutz_course.php';
function brandschutz_h(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function brandschutz_render_element(array $element): void
{
$type = (string) ($element['type'] ?? '');
$id = brandschutz_h((string) ($element['id'] ?? ''));
if ($type === 'text') {
echo '<div class="bs-text">' . brandschutz_course_sanitize_html($element['content'] ?? '') . '</div>';
return;
}
if ($type === 'infobox' || $type === 'warnbox') {
$class = $type === 'warnbox' ? 'bs-warnbox' : 'bs-infobox';
echo '<div class="' . $class . '">';
if (($element['title'] ?? '') !== '') echo '<div class="' . $class . '-title">' . brandschutz_h((string) $element['title']) . '</div>';
echo brandschutz_course_sanitize_html($element['content'] ?? '') . '</div>';
return;
}
if ($type === 'list') {
$tag = !empty($element['ordered']) ? 'ol' : 'ul';
$class = $tag === 'ol' ? 'bs-list-numbered' : 'bs-list';
echo '<' . $tag . ' class="' . $class . '">';
foreach ($element['items'] ?? [] as $item) echo '<li>' . brandschutz_course_sanitize_html($item) . '</li>';
echo '</' . $tag . '>';
return;
}
if ($type === 'accordion') {
foreach ($element['items'] ?? [] as $item) {
echo '<div class="bs-accordion"><button type="button" class="bs-accordion-header" aria-expanded="false">';
echo '<span>' . brandschutz_h((string) ($item['title'] ?? '')) . '</span><span class="bs-accordion-arrow" aria-hidden="true">▼</span></button>';
echo '<div class="bs-accordion-content">' . brandschutz_course_sanitize_html($item['content'] ?? '') . '</div></div>';
}
return;
}
if ($type === 'checklist') {
echo '<ul class="bs-checklist" data-checklist="true">';
foreach ($element['items'] ?? [] as $item) {
echo '<li><button type="button" class="bs-checklist-item"><span class="bs-check-box" aria-hidden="true">●</span><span>';
echo brandschutz_course_sanitize_html($item) . '</span></button></li>';
}
echo '</ul>';
return;
}
if ($type === 'scenario') {
echo '<div class="bs-scenario"><div class="bs-scenario-header">' . brandschutz_h((string) ($element['title'] ?? '')) . '</div>';
echo '<div class="bs-scenario-body"><div class="bs-scenario-text">' . brandschutz_course_sanitize_html($element['content'] ?? '') . '</div></div></div>';
return;
}
if ($type === 'multiple_choice') {
brandschutz_render_assessment_start($element, $id, 'multiple_choice');
echo '<div class="bs-quiz-options">';
foreach ($element['answers'] ?? [] as $answer) {
echo '<label class="bs-quiz-option"><input type="checkbox" data-correct="' . (!empty($answer['correct']) ? 'true' : 'false') . '">';
echo '<span class="bs-quiz-indicator" aria-hidden="true">●</span><span>' . brandschutz_h((string) ($answer['text'] ?? '')) . '</span></label>';
}
echo '</div>';
brandschutz_render_assessment_end($id);
return;
}
if ($type === 'fill_blank') {
brandschutz_render_assessment_start($element, $id, 'fill_blank');
echo '<div class="bs-text bs-fill-text">';
$parts = preg_split('/(\{\{\d+\}\})/', (string) ($element['text'] ?? ''), -1, PREG_SPLIT_DELIM_CAPTURE) ?: [];
foreach ($parts as $part) {
if (preg_match('/^\{\{(\d+)\}\}$/', $part, $match)) {
$index = (int) $match[1] - 1;
$answers = $element['blanks'][$index]['answers'] ?? [];
$answerJson = json_encode(array_values($answers), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
echo '<input type="text" class="bs-fill-input" autocomplete="off" aria-label="Lücke ' . ($index + 1) . '" data-answers="' . brandschutz_h($answerJson) . '">';
} else {
echo nl2br(brandschutz_h($part));
}
}
echo '</div>';
brandschutz_render_assessment_end($id);
return;
}
if ($type === 'matching') {
brandschutz_render_assessment_start($element, $id, 'matching');
echo '<div class="bs-dropdown-match">';
foreach ($element['pairs'] ?? [] as $pair) {
echo '<div class="bs-dropdown-match-row"><span class="bs-dropdown-match-label">' . brandschutz_h((string) ($pair['item'] ?? '')) . '</span>';
echo '<select class="bs-dropdown-match-select" data-correct="' . brandschutz_h((string) ($pair['correct'] ?? '')) . '"><option value="">-- Bitte wählen --</option>';
foreach ($element['choices'] ?? [] as $choice) {
echo '<option value="' . brandschutz_h((string) ($choice['value'] ?? '')) . '">' . brandschutz_h((string) ($choice['label'] ?? '')) . '</option>';
}
echo '</select></div>';
}
echo '</div>';
brandschutz_render_assessment_end($id);
return;
}
if ($type === 'image') {
$file = (string) ($element['file'] ?? '');
if ($file === '') return;
$path = brandschutz_media_directory() . DIRECTORY_SEPARATOR . $file;
if (!is_file($path)) {
echo '<div class="bs-warnbox"><div class="bs-warnbox-title">Bild nicht verfügbar</div>Das hinterlegte Bild konnte nicht geladen werden.</div>';
return;
}
$url = BRANDSCHUTZ_MEDIA_URL . '/' . rawurlencode($file);
echo '<figure class="bs-image"><img src="' . brandschutz_h($url) . '" alt="' . brandschutz_h((string) ($element['alt'] ?? '')) . '" loading="lazy">';
if (($element['caption'] ?? '') !== '') echo '<figcaption>' . brandschutz_h((string) $element['caption']) . '</figcaption>';
echo '</figure>';
}
}
function brandschutz_render_assessment_start(array $element, string $escapedId, string $kind): void
{
echo '<div class="bs-quiz bs-assessment" id="' . $escapedId . '" data-assessment-kind="' . $kind . '" data-score="' . (!empty($element['score']) ? 'true' : 'false') . '"';
echo ' data-feedback-correct="' . brandschutz_h((string) ($element['feedback_correct'] ?? 'Richtig!')) . '"';
echo ' data-feedback-incorrect="' . brandschutz_h((string) ($element['feedback_incorrect'] ?? 'Leider nicht richtig.')) . '">';
echo '<div class="bs-quiz-question">' . brandschutz_h((string) ($element['question'] ?? '')) . '</div>';
}
function brandschutz_render_assessment_end(string $escapedId): void
{
echo '<div class="bs-quiz-feedback" aria-live="polite"></div><div class="bs-quiz-btn-group">';
echo '<button type="button" class="bs-btn bs-btn-primary bs-check-assessment" data-assessment-id="' . $escapedId . '">Antwort prüfen</button>';
echo '</div></div>';
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/initialise.php';
require_once __DIR__ . '/util/camera_remote_helpers.php';
header('Content-Type: application/json; charset=utf-8');
function camera_remote_response(int $status, array $payload): never
{
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
camera_remote_response(405, ['ok' => false, 'error' => 'Methode nicht erlaubt.']);
}
if (!camera_remote_is_authorized(is_array($groups ?? null) ? $groups : [])) {
camera_remote_response(403, ['ok' => false, 'error' => 'Keine Berechtigung für die Kamera-Fernbedienung.']);
}
try {
camera_remote_verify_csrf($_POST['csrf_token'] ?? null);
$action = is_string($_POST['action'] ?? null) ? $_POST['action'] : '';
camera_remote_run($action);
camera_remote_response(200, ['ok' => true]);
} catch (InvalidArgumentException $exception) {
camera_remote_response(400, ['ok' => false, 'error' => $exception->getMessage()]);
} catch (RuntimeException $exception) {
camera_remote_response(503, ['ok' => false, 'error' => $exception->getMessage()]);
} catch (Throwable $exception) {
error_log('Camera Remote: ' . $exception->getMessage());
camera_remote_response(500, ['ok' => false, 'error' => 'Der Kamerabefehl konnte nicht ausgeführt werden.']);
}
@@ -0,0 +1,104 @@
.camera-remote-layout {
max-width: 1100px;
}
.camera-remote-main {
min-height: 0;
}
.camera-remote-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xl);
margin-bottom: var(--space-lg);
}
.camera-remote-heading h1 {
margin-bottom: var(--space-sm);
}
.camera-remote-heading p {
margin: 0;
color: var(--gray-600);
}
.camera-power-control {
min-width: 180px;
}
.camera-remote .camera-power-control .button {
margin: 0;
}
.camera-remote .mainpage_notice {
margin-bottom: var(--space-lg);
}
.camera-sections {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-md);
}
.camera-remote-panel {
min-height: 0;
padding: var(--space-lg);
}
.camera-remote-panel h2 {
margin-bottom: var(--space-md);
font-size: var(--font-size-lg);
}
.camera-presets {
display: grid;
gap: var(--space-sm);
}
.camera-remote .camera-presets .button_fullwidth {
margin: 0;
}
.camera-remote .camera-presets .camera-preset-active {
background: var(--theme-hover);
box-shadow: inset 5px 0 var(--nak-blue), var(--shadow-md);
}
.camera-remote .button_fullwidth:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@media (max-width: 768px) {
#base.camera-remote {
padding: 0;
}
.camera-remote-heading {
align-items: flex-start;
}
.camera-sections {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.camera-remote-layout {
padding: var(--space-md);
}
.camera-remote-heading {
flex-direction: column;
gap: var(--space-md);
}
.camera-power-control {
width: 100%;
}
.camera-remote-panel {
padding: var(--space-md);
}
}
@@ -0,0 +1,80 @@
(function () {
'use strict';
var configElement = document.getElementById('cameraRemoteConfig');
if (!configElement) return;
var config = JSON.parse(configElement.textContent);
var presets = Array.from(document.querySelectorAll('.camera-presets [data-camera-action]'));
var power = document.querySelector('[data-camera-action="power"]');
var status = document.getElementById('cameraRemoteStatus');
var busy = false;
presets.forEach(function (preset) { preset.setAttribute('aria-pressed', 'false'); });
function showError(message) {
status.textContent = message;
status.hidden = false;
}
function clearError() {
status.textContent = '';
status.hidden = true;
}
function setBusy(active) {
busy = active;
presets.forEach(function (preset) { preset.disabled = active || !config.configured; });
power.disabled = active || !config.configured;
}
function selectPreset(selected) {
presets.forEach(function (preset) {
var active = preset === selected;
preset.classList.toggle('camera-preset-active', active);
preset.setAttribute('aria-pressed', String(active));
});
}
async function request(action) {
var form = new FormData();
form.append('csrf_token', config.csrfToken);
form.append('action', action);
var response = await fetch('/intranet_repo/projects/camera_remote/api.php', {
method: 'POST',
body: form,
headers: {'Accept': 'application/json'},
});
var result;
try {
result = await response.json();
} catch (parseError) {
throw new Error('Der Server hat unerwartet geantwortet. Bitte laden Sie die Seite neu.');
}
if (!response.ok || !result.ok) throw new Error(result.error || 'Der Kamerabefehl ist fehlgeschlagen.');
}
async function runAction(control) {
if (busy || !config.configured) return;
clearError();
if (control === power) selectPreset(null);
setBusy(true);
try {
await request(control.dataset.cameraAction);
if (control !== power) {
selectPreset(control);
}
} catch (error) {
showError(error.message || 'Der Kamerabefehl konnte nicht ausgeführt werden.');
} finally {
setBusy(false);
}
}
presets.forEach(function (preset) {
preset.addEventListener('click', function () { runAction(preset); });
});
power.addEventListener('click', function () { runAction(power); });
}());
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/initialise.php';
require_once __DIR__ . '/util/camera_remote_helpers.php';
if (!camera_remote_is_authorized(is_array($groups ?? null) ? $groups : [])) {
http_response_code(403);
exit('Keine Berechtigung für die Kamera-Fernbedienung.');
}
$configured = camera_remote_is_configured();
$clientConfig = [
'csrfToken' => camera_remote_csrf_token(),
'configured' => $configured,
];
?>
<!DOCTYPE html>
<html lang="de" dir="ltr">
<head>
<?php require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/projects/_central/php/html_head.php'; ?>
<link rel="stylesheet" href="/intranet_repo/projects/camera_remote/camera_remote.css?v=<?= (int) filemtime(__DIR__ . '/camera_remote.css') ?>">
<script id="cameraRemoteConfig" type="application/json"><?= json_encode($clientConfig, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) ?></script>
<script src="/intranet_repo/projects/camera_remote/camera_remote.js?v=<?= (int) filemtime(__DIR__ . '/camera_remote.js') ?>" defer></script>
</head>
<body class="theme-admin">
<?php require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/projects/_central/php/header.php'; ?>
<?php include $g_phpSnippetsPath . 'overlay_message.php'; ?>
<div class="header_title ht_admin text_white" id="header_title">
<span class="header_title_text">Kamera-Fernbedienung</span>
</div>
<main class="content camera-remote" id="base">
<div class="content_flexcontainer camera-remote-layout">
<section class="content_panel camera-remote-main" aria-labelledby="cameraRemoteTitle">
<div class="camera-remote-heading">
<div>
<h1 id="cameraRemoteTitle">Kamera steuern</h1>
<p>Wählen Sie eine Kameraposition oder schalten Sie die Kamera ein beziehungsweise aus.</p>
</div>
<div class="camera-power-control">
<input class="button" type="button" data-camera-action="power" value="Kamera ein/aus"<?= $configured ? '' : ' disabled' ?>>
</div>
</div>
<?php $statusText = $configured ? '' : 'Home Assistant ist noch nicht konfiguriert. Bitte HA_BASE_URL und HA_TOKEN in der Umgebung hinterlegen.'; ?>
<div class="mainpage_notice mainpage_notice--error" id="cameraRemoteStatus" role="alert"<?= $configured ? ' hidden' : '' ?>>
<?= htmlspecialchars($statusText, ENT_QUOTES, 'UTF-8') ?>
</div>
<div class="camera-sections" aria-label="Kamerapositionen">
<section class="content_panel camera-remote-panel" aria-labelledby="cameraGeneralTitle">
<h2 id="cameraGeneralTitle">Allgemein</h2>
<div class="camera-presets">
<input class="button_fullwidth" type="button" data-camera-action="total" value="Totale"<?= $configured ? '' : ' disabled' ?>>
<input class="button_fullwidth" type="button" data-camera-action="lectern" value="Pult"<?= $configured ? '' : ' disabled' ?>>
</div>
</section>
<section class="content_panel camera-remote-panel" aria-labelledby="cameraAltarTitle">
<h2 id="cameraAltarTitle">Altar</h2>
<div class="camera-presets">
<input class="button_fullwidth" type="button" data-camera-action="altar" value="Altar"<?= $configured ? '' : ' disabled' ?>>
<input class="button_fullwidth" type="button" data-camera-action="chancel" value="Altarraum"<?= $configured ? '' : ' disabled' ?>>
<input class="button_fullwidth" type="button" data-camera-action="before_altar" value="Vor dem Altar"<?= $configured ? '' : ' disabled' ?>>
<input class="button_fullwidth" type="button" data-camera-action="flowers" value="Blumenschmuck"<?= $configured ? '' : ' disabled' ?>>
</div>
</section>
<section class="content_panel camera-remote-panel" aria-labelledby="cameraMusicTitle">
<h2 id="cameraMusicTitle">Musik</h2>
<div class="camera-presets">
<input class="button_fullwidth" type="button" data-camera-action="choir" value="Chor"<?= $configured ? '' : ' disabled' ?>>
<input class="button_fullwidth" type="button" data-camera-action="piano" value="Klavier"<?= $configured ? '' : ' disabled' ?>>
<input class="button_fullwidth" type="button" data-camera-action="organ" value="Orgel"<?= $configured ? '' : ' disabled' ?>>
<input class="button_fullwidth" type="button" data-camera-action="orchestra" value="Orchester/Kinderchor"<?= $configured ? '' : ' disabled' ?>>
</div>
</section>
</div>
</section>
</div>
</main>
<?php require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/projects/_central/php/footer.php'; ?>
</body>
</html>
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
const CAMERA_REMOTE_ACTIONS = [
'power' => ['entity_id' => 'script.kamera_on_off', 'calls' => 1],
'total' => ['entity_id' => 'script.kamera_totale', 'calls' => 2],
'lectern' => ['entity_id' => 'script.kamera_pult', 'calls' => 2],
'altar' => ['entity_id' => 'script.kamera_altar', 'calls' => 2],
'chancel' => ['entity_id' => 'script.kamera_altarraum', 'calls' => 2],
'before_altar' => ['entity_id' => 'script.kamera_vor_dem_altar', 'calls' => 2],
'flowers' => ['entity_id' => 'script.kamera_blumenschmuck', 'calls' => 2],
'choir' => ['entity_id' => 'script.kamera_chor', 'calls' => 2],
'piano' => ['entity_id' => 'script.kamera_klavier', 'calls' => 2],
'organ' => ['entity_id' => 'script.kamera_orgel', 'calls' => 2],
'orchestra' => ['entity_id' => 'script.kamera_orchester_kinderchor', 'calls' => 2],
];
function camera_remote_is_authorized(array $groups): bool
{
return !empty($groups['Technik']) || !empty($groups['Admin']);
}
function camera_remote_csrf_token(): string
{
return $_SESSION['camera_remote_csrf_token'] ??= bin2hex(random_bytes(32));
}
function camera_remote_verify_csrf(mixed $token): void
{
$sessionToken = $_SESSION['camera_remote_csrf_token'] ?? '';
if (!is_string($token) || !is_string($sessionToken) || $sessionToken === '' || !hash_equals($sessionToken, $token)) {
throw new InvalidArgumentException('Ungültige Anfrage. Bitte laden Sie die Seite neu.');
}
}
/** @return array{base_url: string, token: string} */
function camera_remote_config(): array
{
$baseUrl = rtrim(trim((string) getenv('HA_BASE_URL')), '/');
$token = trim((string) getenv('HA_TOKEN'));
$scheme = strtolower((string) parse_url($baseUrl, PHP_URL_SCHEME));
$host = parse_url($baseUrl, PHP_URL_HOST);
if ($baseUrl === '' || $token === '') {
throw new RuntimeException('Home Assistant ist noch nicht konfiguriert.');
}
if (!in_array($scheme, ['http', 'https'], true) || !is_string($host) || $host === '') {
throw new RuntimeException('Die Home-Assistant-Adresse ist ungültig.');
}
return ['base_url' => $baseUrl, 'token' => $token];
}
function camera_remote_is_configured(): bool
{
try {
camera_remote_config();
return true;
} catch (RuntimeException) {
return false;
}
}
function camera_remote_request(string $path, ?array $payload = null): void
{
$config = camera_remote_config();
$handle = curl_init($config['base_url'] . $path);
if ($handle === false) {
throw new RuntimeException('Die Verbindung zu Home Assistant konnte nicht vorbereitet werden.');
}
$options = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $config['token'],
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 8,
CURLOPT_FOLLOWLOCATION => false,
];
if ($payload !== null) {
$options[CURLOPT_POST] = true;
$options[CURLOPT_POSTFIELDS] = json_encode($payload, JSON_THROW_ON_ERROR);
}
curl_setopt_array($handle, $options);
try {
$response = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
$error = curl_error($handle);
} finally {
curl_close($handle);
}
if ($response === false || $status < 200 || $status >= 300) {
error_log('Camera Remote: Home Assistant antwortete mit ' . ($error !== '' ? $error : 'HTTP ' . $status));
throw new RuntimeException('Home Assistant konnte den Kamerabefehl nicht ausführen.');
}
try {
json_decode($response, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new RuntimeException('Home Assistant hat unerwartet geantwortet.', 0, $exception);
}
}
function camera_remote_run(string $action): void
{
$command = CAMERA_REMOTE_ACTIONS[$action] ?? null;
if (!is_array($command)) {
throw new InvalidArgumentException('Unbekannter Kamerabefehl.');
}
for ($call = 0; $call < $command['calls']; $call++) {
camera_remote_request('/api/services/script/turn_on', ['entity_id' => $command['entity_id']]);
}
}
@@ -9,7 +9,9 @@ if (!in_array($generalPermission, [1, '1', true, 't', 'true'], true)) {
exit();
}
$today = date('Y-m-d');
contentpanel_cleanup_expired_news();
contentpanel_cleanup_expired_announcements($pdo, $today);
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$action = trim((string) ($_POST['action'] ?? ''));
@@ -52,7 +54,6 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') {
$flash = $_SESSION['contentpanel_flash'] ?? null;
unset($_SESSION['contentpanel_flash']);
$today = date('Y-m-d');
$services = contentpanel_fetch_services($pdo, $today);
$announcements = contentpanel_fetch_announcements($pdo, $today);
$newsItems = contentpanel_list_news();
@@ -0,0 +1,13 @@
<?php
declare(strict_types=1);
function contentpanel_cleanup_expired_announcements(PDO $pdo, string $today): void
{
// Das Enddatum ist einschließlich gültig; ohne Enddatum zählt das Startdatum.
// Datensätze ohne beide Datumswerte werden nicht als abgelaufen gelöscht.
$statement = $pdo->prepare(
'DELETE FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) < ?'
);
$statement->execute([$today]);
}
@@ -2,6 +2,7 @@
require_once __DIR__ . '/news.php';
require_once __DIR__ . '/news_queue.php';
require_once __DIR__ . '/announcements.php';
function contentpanel_html($value): string
{
@@ -294,6 +295,6 @@ function contentpanel_fetch_announcements(PDO $pdo, string $today): array
return contentpanel_query($pdo,
'SELECT id, ansage_titel AS title, ansage_untertitel AS subtitle, ansage_datum_von AS date,
ansage_datum_bis AS end_date, ansage_uhrzeit AS time, ansage_ort AS location
FROM ansagen WHERE ansage_datum_von >= ? OR ansage_datum_bis >= ? ORDER BY ansage_datum_von',
[$today, $today])->fetchAll(PDO::FETCH_ASSOC);
FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) >= ? ORDER BY ansage_datum_von',
[$today])->fetchAll(PDO::FETCH_ASSOC);
}
@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
// Zentrale Anzeige- und Lesbarkeitswerte, ohne Build-Schritt.
return [
'POSTER_MIN_RENDER_HEIGHT_VH' => 39.61298125,
'POSTER_MIN_RENDER_HEIGHT_OFFSET_PX' => 2.85866875,
'POSTER_MAX_VISIBLE_PAGES' => 12,
'POSTER_MIN_DWELL_TIME_S' => 12,
'CONTENT_MIN_CONTRAST_RATIO' => 4.5,
// Ohne Aushänge gibt es keinen Durchlauf; dann weiterhin regelmäßig prüfen.
'EMPTY_REFRESH_MS' => 30000,
'REQUEST_TIMEOUT_MS' => 8000,
];
+518 -211
View File
@@ -1,236 +1,543 @@
(() => {
'use strict';
const CONTENT_REFRESH_MS = 30_000;
const INDOOR_REFRESH_MS = 10_000;
const OUTDOOR_REFRESH_MS = 30_000;
const VERTICAL_SPEED_PX_PER_SECOND = 7;
const HORIZONTAL_SPEED_PX_PER_SECOND = 10;
const config = JSON.parse(document.getElementById('dashboard-config').textContent);
const root = document.body;
const WEATHER_ICONS = {
cloudy: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><defs><filter id="weather-blur" x="-20%" y="-20%" width="140%" height="140%"><feGaussianBlur in="SourceAlpha" stdDeviation="2.5"/><feOffset dx="0" dy="2" result="off"/><feComponentTransfer><feFuncA type="linear" slope="0.15"/></feComponentTransfer><feMerge><feMergeNode/><feMergeNode in="SourceGraphic"/></feMerge></filter></defs><g filter="url(#weather-blur)"><path d="M22 46h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 20 29a9 9 0 0 0 2 17z" fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M18 46h22a9 9 0 0 0 0-18c-.7 0-1.4.1-2 .2A11 11 0 0 0 17 31a8 8 0 0 0 1 15z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round" opacity="0.95"/></g></svg>`,
sun: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"><circle cx="32" cy="32" r="10" fill="#FFD34D" stroke="#fff"/><path d="M32 6v8M32 50v8M6 32h8M50 32h8M13.5 13.5l5.7 5.7M44.8 44.8l5.7 5.7M50.5 13.5l-5.7 5.7M19.2 44.8l-5.7 5.7"/></g></svg>`,
moon: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><path d="M41 8a22 22 0 1 0 15 35A18 18 0 0 1 41 8z" fill="#DDE7FF" stroke="#fff" stroke-width="2" stroke-linejoin="round"/></svg>`,
cloudy_day: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><g transform="translate(4,0)"><circle cx="22" cy="22" r="9" fill="#FFD34D" stroke="#fff" stroke-width="2"/><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round"><path d="M22 6v6M22 32v6M6 22h6M32 22h6M10.5 10.5l4.2 4.2M29.3 29.3l4.2 4.2M33.5 10.5l-4.2 4.2M14.7 29.3l-4.2 4.2"/></g></g><g transform="translate(8,10)"><path d="M18 36h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 16 19a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M22 36h22a9 9 0 0 0 0-18c-.7 0-1.4.1-2 .2A11 11 0 0 0 21 21a8 8 0 0 0 1 15z" fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round" opacity="0.95"/></g></g></svg>`,
cloudy_night: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M38 10a16 16 0 1 0 11 26A13 13 0 0 1 38 10z" fill="#DDE7FF" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><g transform="translate(10,18)"><path d="M14 28h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 12 11a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M18 28h22a9 9 0 0 0 0-18c-.7 0-1.4.1-2 .2A11 11 0 0 0 17 13a8 8 0 0 0 1 15z" fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round" opacity="0.95"/></g></g></svg>`,
rain_shower: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M20 36h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 18 19a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><g stroke="#fff" stroke-width="2" stroke-linecap="round"><path d="M24 42l-3 6"/><path d="M34 42l-3 6"/><path d="M44 42l-3 6"/></g></g></svg>`,
rain: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M20 34h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 18 17a9 9 0 0 0 2 17z" fill="#57A0EE" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><g stroke="#fff" stroke-width="2" stroke-linecap="round"><path d="M22 40l-3 8"/><path d="M32 40l-3 8"/><path d="M42 40l-3 8"/><path d="M52 40l-3 8"/></g></g></svg>`,
thunder: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M18 34h28a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 16 17a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><path d="M32 36l-6 12h7l-3 10 12-16h-7l3-6z" fill="#FFD34D" stroke="#fff" stroke-width="2" stroke-linejoin="round"/></g></svg>`,
snowy: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g><path d="M20 34h26a10 10 0 0 0 0-20c-.8 0-1.6.1-2.4.3A13 13 0 0 0 18 17a9 9 0 0 0 2 17z" fill="#91C0F8" stroke="#fff" stroke-width="2" stroke-linejoin="round"/><g fill="#fff"><circle cx="24" cy="44" r="2"/><circle cx="34" cy="44" r="2"/><circle cx="44" cy="44" r="2"/><circle cx="29" cy="52" r="2"/><circle cx="39" cy="52" r="2"/></g></g></svg>`,
mist: `<svg xmlns="http://www.w3.org/2000/svg" width="49" height="49" viewBox="6 0 50 50" aria-hidden="true"><g fill="none" stroke="#fff" stroke-width="2" stroke-linecap="round" opacity="0.95"><path d="M14 24h36"/><path d="M10 32h44"/><path d="M14 40h36"/><path d="M18 48h28"/></g></svg>`,
};
function setText(selector, value) {
const element = document.querySelector(selector);
if (element) {
element.textContent = value ?? '';
}
function node(tag, className, text) {
const element = document.createElement(tag);
element.className = className;
if (text !== undefined) element.textContent = text;
return element;
}
function updateClock() {
const now = new Date();
const clock = document.getElementById('uhrzeit');
if (clock) {
clock.textContent = `${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
}
function empty(stage, text) {
stage.replaceChildren(node('p', 'empty-state', text));
}
function renderAnnouncements(announcements) {
const container = document.querySelector('.news-scroll');
if (!container || !Array.isArray(announcements)) {
return;
}
container.replaceChildren();
announcements.forEach((announcement) => {
const box = document.createElement('div');
box.className = 'news-box';
const date = document.createElement('div');
date.className = 'news-date';
date.append(document.createTextNode(announcement.date_label || ''));
if (announcement.end_label) {
date.append(document.createElement('br'), document.createTextNode(announcement.end_label));
}
const body = document.createElement('div');
body.append(document.createTextNode(announcement.title || ''));
if (announcement.subtitle) {
const subtitle = document.createElement('div');
subtitle.className = 'news-subtitle';
subtitle.textContent = announcement.subtitle;
body.append(subtitle);
}
if (announcement.meta) {
const meta = document.createElement('div');
meta.className = 'news-subtitle';
const italic = document.createElement('i');
italic.textContent = announcement.meta;
meta.append(italic);
body.append(meta);
}
box.append(date, body);
container.append(box);
});
}
function renderPosters(posters) {
const container = document.querySelector('.aushang-flex');
if (!container || !Array.isArray(posters)) {
return;
}
container.replaceChildren();
if (posters.length === 0) {
const empty = document.createElement('p');
empty.style.textAlign = 'left';
empty.textContent = 'Keine Aushänge vorgeplant.';
container.append(empty);
return;
}
posters.forEach((poster) => {
const image = document.createElement('img');
image.src = poster.url;
image.alt = poster.alt || 'Aushang';
image.style.height = '95%';
image.addEventListener('load', () => scheduleScrollingSetup(0), { once: true });
container.append(image);
});
}
function renderMonthImages(images) {
if (!Array.isArray(images)) {
return;
}
images.forEach((image) => {
const element = document.querySelector(`[data-month-page="${image.page}"]`);
if (element && image.url && element.getAttribute('src') !== image.url) {
element.src = image.url;
}
});
}
function renderIndoor(indoor) {
if (indoor) {
setText('#temperature_txt', `${indoor.value}${indoor.unit || '°C'}`);
}
}
function renderOutdoor(outdoor) {
const weather = document.getElementById('wetter');
if (!weather || !outdoor) {
return;
}
const icon = WEATHER_ICONS[outdoor.icon] || WEATHER_ICONS.cloudy;
weather.replaceChildren();
const iconContainer = document.createElement('div');
iconContainer.innerHTML = icon;
const temperature = document.createElement('div');
temperature.className = 'text-outdoor';
temperature.textContent = `${outdoor.temperature}°C`;
weather.append(iconContainer, temperature);
}
function renderContent(data) {
setText('[data-dashboard-date]', data.date_label);
if (data.service) {
setText('.bible', data.service.bibelwort);
setText('.verse', data.service.bibelvers);
setText('.GB', data.service.eingang_nummer);
setText('.GB-title', data.service.eingang_titel);
}
renderAnnouncements(data.announcements);
renderPosters(data.posters);
renderMonthImages(data.month_images);
scheduleScrollingSetup();
}
function startPolling(resource, interval, render) {
const refresh = async () => {
// Timeout und HTTP-Prüfung gelten für Inhalte und Bilder gleichermaßen.
async function request(url, read, options = {}) {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), config.REQUEST_TIMEOUT_MS);
try {
const response = await fetch(`data.php?resource=${encodeURIComponent(resource)}`, { cache: 'no-store' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const data = await response.json();
if (data.error) {
throw new Error(data.error);
}
render(data);
} catch (error) {
console.error(`Dashboard-Aktualisierung (${resource}) fehlgeschlagen:`, error);
const response = await fetch(url, { ...options, signal: controller.signal });
if (!response.ok) throw new Error('HTTP ' + response.status);
return await read(response);
} finally {
window.setTimeout(refresh, interval);
clearTimeout(timeout);
}
};
refresh();
}
function applyAnimation(element, distance, speed, animationName) {
element.style.animation = 'none';
if (distance <= 0) {
const imageSize = item => item.crop || item;
const aspectRatio = pages => pages.reduce((sum, item) =>
sum + imageSize(item).width / imageSize(item).height, 0);
const layoutKey = layout => [layout.width, layout.height, layout.groups.map(group => group.length)].join('/');
const dayFormatter = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Europe/Berlin', year: 'numeric', month: '2-digit', day: '2-digit',
});
function berlinDay(date = new Date()) {
const parts = Object.fromEntries(dayFormatter.formatToParts(date).map(part => [part.type, part.value]));
return parts.year + '-' + parts.month + '-' + parts.day;
}
let currentDay = berlinDay();
function groupDocuments(pages) {
const entries = new Map();
for (const item of pages) {
const key = item.document ?? item.url;
if (!entries.has(key)) entries.set(key, []);
entries.get(key).push(item);
}
return [...entries.values()].map(pages => pages.sort((a, b) => a.page - b.page));
}
class ImageGallery {
constructor(id, message, rotate = false) {
Object.assign(this, {
message, rotate, items: [], current: [], images: new Map(),
index: 0, generation: 0, signature: '', layoutKey: '', failed: false, pending: false,
});
const panel = document.getElementById(id);
this.stage = panel.querySelector('.image-stage');
this.progress = panel.querySelector('.poster-progress');
new ResizeObserver(() => this.resize()).observe(this.stage);
}
layout() {
// Keine Ganzzahlrundung durch clientHeight: Original-Bildhöhe exakt erhalten.
const bounds = this.stage.getBoundingClientRect();
const style = getComputedStyle(this.stage);
const width = bounds.width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
const height = bounds.height - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
const gap = parseFloat(style.columnGap);
const frame = this.rotate ? parseFloat(style.getPropertyValue('--poster-frame-width')) : 0;
if (!this.rotate) return { groups: [this.items], width, height, gap, frame };
const minimum = innerHeight * config.POSTER_MIN_RENDER_HEIGHT_VH / 100
- config.POSTER_MIN_RENDER_HEIGHT_OFFSET_PX;
const occupied = pages => minimum * aspectRatio(pages) + groupDocuments(pages).length * (2 * frame + gap);
const fits = pages => pages.length <= config.POSTER_MAX_VISIBLE_PAGES
&& occupied(pages) <= width + gap;
// Nur Aushänge aufteilen, die selbst nicht in eine Ansicht passen.
const chunks = [];
for (const pages of groupDocuments(this.items)) {
let chunk = [];
for (const item of pages) {
if (chunk.length && !fits([...chunk, item])) {
chunks.push(chunk);
chunk = [];
}
chunk.push(item);
}
if (chunk.length) chunks.push(chunk);
}
// Große Dokumente zuerst, kleinere in die Lücken: Datum ist nachrangig.
chunks.sort((a, b) => occupied(b) - occupied(a));
const groups = [];
for (const chunk of chunks) {
const group = groups.find(group => fits([...group, ...chunk]));
if (group) group.push(...chunk);
else groups.push([...chunk]);
}
// Zuerst vollständige Dokumente aus der vorigen Ansicht übernehmen,
// verbleibende Plätze nötigenfalls mit einzelnen Seiten auffüllen.
const primaryGroups = groups.map(group => [...group]);
for (let index = 0; groups.length > 1 && index < groups.length; index++) {
const current = groups[index];
const previous = primaryGroups[(index + groups.length - 1) % groups.length];
const available = previous.filter(item =>
!current.some(entry => entry.url === item.url));
for (const pages of groupDocuments(available).reverse()) {
if (fits([...current, ...pages])) current.unshift(...pages);
}
for (const item of [...available].reverse()) {
if (!current.some(entry => entry.url === item.url)
&& fits([...current, item])) current.unshift(item);
}
groups[index] = groupDocuments(current).flat();
}
return { groups, width, height, gap, frame };
}
loadImage(item) {
if (!this.images.has(item.url)) {
let localUrl;
this.images.set(item.url, request(item.url, async response => {
localUrl = URL.createObjectURL(await response.blob());
const image = new Image();
image.alt = item.alt;
image.dataset.source = item.url;
image.src = localUrl;
await image.decode();
if (!this.rotate) {
const canvas = document.createElement('canvas');
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext('2d');
context.drawImage(image, 0, 0);
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
let left = canvas.width, top = canvas.height, right = 0, bottom = 0;
for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) {
if (!pixels[(y * canvas.width + x) * 4 + 3]) continue;
left = Math.min(left, x); top = Math.min(top, y);
right = Math.max(right, x + 1); bottom = Math.max(bottom, y + 1);
}
if (right) Object.defineProperty(item, 'crop', {
value: { left, top, width: right - left, height: bottom - top }, configurable: true,
});
}
return image;
}).catch(() => {
if (localUrl) URL.revokeObjectURL(localUrl);
this.images.delete(item.url);
return null;
}));
}
return this.images.get(item.url);
}
pruneImages() {
for (const [url, pending] of this.images) {
if (this.items.some(item => item.url === url) || this.current.some(item => item.url === url)) continue;
this.images.delete(url);
pending.then(image => { if (image) URL.revokeObjectURL(image.src); });
}
}
update(items, message = this.message) {
const signature = JSON.stringify([items, message]);
if (signature === this.signature && !this.failed) return;
this.signature = signature;
this.message = message;
this.items = items;
// Nur neue Bildversionen laden; unveränderte Blob-URLs wiederverwenden.
const loading = items.map(item => this.loadImage(item));
if (!items.length) {
this.generation++;
clearTimeout(this.timer);
this.current = [];
this.pending = false;
empty(this.stage, message);
this.pruneImages();
this.schedule();
return;
}
const variable = animationName === 'scroll-h' ? '--scroll-distance-h' : '--scroll-distance-v';
element.style.setProperty(variable, `-${distance}px`);
element.style.animationName = animationName;
element.style.animationDuration = `${Math.max(1, distance / speed)}s`;
element.style.animationTimingFunction = 'linear';
element.style.animationIterationCount = 'infinite';
// Nur abgelaufene Seiten sofort entfernen; sonst bleiben vorhandene
// Bilder bis zum vollständig geladenen Ersatz sichtbar.
if (this.rotate && this.current.some(item => item.expires < currentDay)) {
this.current = [];
empty(this.stage, this.message);
}
if (this.rotate && this.current.length) {
this.pending = true;
return;
}
return this.rotate ? this.show(0) : Promise.all(loading).then(() => this.show(0));
}
function setupScrolling() {
const posterContainer = document.querySelector('.aushang-flex');
if (posterContainer) {
const images = Array.from(posterContainer.querySelectorAll('img'));
const totalWidth = images.reduce((sum, image) => {
const styles = window.getComputedStyle(image);
return sum + image.offsetWidth + (parseFloat(styles.marginRight) || 0);
}, 0);
applyAnimation(
posterContainer,
Math.max(0, totalWidth - posterContainer.offsetWidth),
HORIZONTAL_SPEED_PX_PER_SECOND,
'scroll-h'
);
resize() {
if (this.items.length && layoutKey(this.layout()) !== this.layoutKey) this.show(this.index);
}
const newsContainer = document.querySelector('.news-container');
const news = document.querySelector('.news-scroll');
if (newsContainer && news) {
applyAnimation(
news,
Math.max(0, news.scrollHeight - newsContainer.offsetHeight),
VERTICAL_SPEED_PX_PER_SECOND,
'scroll-v'
);
schedule() {
clearTimeout(this.timer);
if (!this.rotate) return;
const duration = this.items.length ? config.POSTER_MIN_DWELL_TIME_S * 1000 : config.EMPTY_REFRESH_MS;
const changing = this.items.length && this.layout().groups.length > 1;
this.progressAnimation?.cancel();
this.progress.toggleAttribute('hidden', !changing);
if (changing) {
this.progressAnimation = this.progress.querySelector('.poster-progress-value').animate(
[{ strokeDashoffset: 100 }, { strokeDashoffset: 0 }],
{ duration, easing: 'linear', fill: 'forwards' });
}
this.timer = setTimeout(async () => {
if (this.items.length && !this.pending && this.index + 1 < this.layout().groups.length) {
return this.show(this.index + 1);
}
// Texte und Bildbestand nach einem vollständigen Durchlauf prüfen.
await this.onCycle?.();
if (this.items.length && (this.pending || this.failed || this.index !== 0)) this.show(0);
else this.schedule();
}, duration);
}
async show(index) {
if (!this.items.length) return;
this.pending = false;
const layout = this.layout();
this.layoutKey = layoutKey(layout);
this.index = index % layout.groups.length;
const batch = layout.groups[this.index];
const documents = this.rotate ? groupDocuments(batch) : [batch];
const renderHeight = Math.min(layout.height - 2 * layout.frame,
(layout.width - (documents.length - 1) * layout.gap - documents.length * 2 * layout.frame) / aspectRatio(batch));
const monthScale = this.rotate ? 0 : Math.min(layout.height / Math.max(...batch.map(item => imageSize(item).height)),
(layout.width - (batch.length - 1) * layout.gap) / batch.reduce((sum, item) => sum + imageSize(item).width, 0));
const generation = ++this.generation;
clearTimeout(this.timer);
const sheet = node('div', 'image-sheet');
let failures = 0;
// Gemeinsam gerahmte Seiten laden unabhängig; erst die fertige Ansicht einblenden.
await Promise.all(documents.flatMap(pages => {
const wrapper = this.rotate ? node('div', 'poster-document') : sheet;
if (this.rotate) sheet.append(wrapper);
return pages.map(async item => {
const cell = node('div', 'image-cell');
const size = imageSize(item);
cell.style.height = ((this.rotate ? renderHeight : size.height * monthScale)) + 'px';
cell.style.width = ((this.rotate ? renderHeight * item.width / item.height : size.width * monthScale)) + 'px';
wrapper.append(cell);
const copy = (await this.loadImage(item))?.cloneNode();
if (copy) {
try {
await copy.decode();
if (item.crop) {
cell.classList.add('is-cropped');
Object.assign(copy.style, {
width: item.width / size.width * 100 + '%',
height: item.height / size.height * 100 + '%',
left: -size.left / size.width * 100 + '%',
top: -size.top / size.height * 100 + '%',
});
}
cell.append(copy);
return;
} catch { /* Die übrigen Seiten bleiben unabhängig darstellbar. */ }
}
failures++;
empty(cell, 'Seite zurzeit nicht verfügbar.');
});
}));
if (generation !== this.generation) return;
if (!batch.every(item => this.items.some(entry => entry.url === item.url))) return this.show(0);
this.failed = failures > 0;
// Einen vorhandenen Monatsplan bei defektem Ersatz nicht wegblenden.
if (!this.rotate && failures && this.stage.querySelector('img')) return;
this.current = batch;
this.stage.replaceChildren(sheet);
this.pruneImages();
if (failures === batch.length) empty(this.stage, 'Bilder zurzeit nicht verfügbar.');
this.schedule(); // Mindeststandzeit erst nach vollständigem Laden der Gruppe.
}
}
let scrollingTimer = null;
function scheduleScrollingSetup(delay = 80) {
if (scrollingTimer !== null) {
window.clearTimeout(scrollingTimer);
}
scrollingTimer = window.setTimeout(setupScrolling, delay);
// Eine Textseite enthält ausschließlich DOM-Text, niemals HTML aus der Datenbank.
function paragraphs(className, entries) {
const container = node('div', className);
for (const [name, text] of entries) if (text) container.append(node('p', name, text));
return container;
}
updateClock();
window.setInterval(updateClock, 1000);
window.addEventListener('load', () => scheduleScrollingSetup(50));
window.addEventListener('resize', () => scheduleScrollingSetup(100));
startPolling('content', CONTENT_REFRESH_MS, renderContent);
startPolling('indoor', INDOOR_REFRESH_MS, (data) => renderIndoor(data.indoor));
startPolling('outdoor', OUTDOOR_REFRESH_MS, (data) => renderOutdoor(data.outdoor));
function announcementList(items) {
const list = node('ul', 'text-page announcement-list');
for (const item of items) {
const entry = node('li', 'announcement-entry');
entry.append(paragraphs('announcement-when', [
['announcement-date', item.date_label || 'Ohne Datum'],
['announcement-end', item.end_label],
]), paragraphs('announcement-content', [
['announcement-title', item.title],
['announcement-subtitle', item.subtitle],
['announcement-meta', [item.time ? item.time + ' Uhr' : '', item.place].filter(Boolean).join(', ')],
]));
list.append(entry);
}
return list;
}
class FixedText {
constructor(id, render = entries => paragraphs('text-page', entries)) {
this.stage = document.getElementById(id).querySelector('.text-stage');
this.render = render;
this.signature = '';
this.loaded = false;
new ResizeObserver(() => this.rebuild()).observe(this.stage);
}
update(documents, message) {
this.loaded = true;
const signature = JSON.stringify([documents, message]);
if (signature === this.signature) return;
this.signature = signature;
if (documents.length) this.stage.replaceChildren(this.render(documents));
else empty(this.stage, message);
this.rebuild();
}
unavailable(message) {
// Auch ein erfolgreich gelesener Leerzustand bleibt bei Abruffehlern stehen.
if (this.loaded) return;
empty(this.stage, message);
this.rebuild();
}
rebuild() {
const page = this.stage.querySelector('.text-page');
this.stage.classList.toggle('is-overflowing', Boolean(page && this.stage.clientHeight
&& page.getBoundingClientRect().height > this.stage.clientHeight + 1));
}
}
const month = new ImageGallery('month-panel', 'Kein Monatsplan vorhanden.');
const posters = new ImageGallery('poster-panel', 'Keine aktuellen Aushänge.', true);
const announcements = new FixedText('announcements-panel', announcementList);
const bible = new FixedText('bible-panel');
let imageData = null;
function layoutImages() {
if (!imageData) return;
// Monatsplan dauerhaft separat: keine Vermischung mit der Aushangrotation.
const months = imageData.month_images;
if (months.length) root.style.setProperty('--month-aspect',
months.reduce((sum, item) => sum + item.width / item.height, 0));
return Promise.all([
month.update(months),
posters.update(imageData.posters.filter(item => item.expires >= currentDay)),
]);
}
function contentLoaded(data) {
if (data.date !== currentDay) return contentUnavailable();
if (data.errors.includes('announcements')) announcements.unavailable('Ansagen zurzeit nicht verfügbar.');
else announcements.update(data.announcements, 'Es sind keine Ansagen eingetragen.');
const service = data.service;
const blocks = service ? [
['bible-reference', service.reference || 'Bibelstelle nicht eingetragen.'],
['bible-verse', service.text || 'Bibelvers nicht eingetragen.'],
] : [];
if (data.errors.includes('service')) bible.unavailable('Bibelwort zurzeit nicht verfügbar.');
else bible.update(blocks, 'Für heute ist kein Bibelwort eingetragen.');
}
function contentUnavailable() {
announcements.unavailable('Ansagen zurzeit nicht verfügbar.');
bible.unavailable('Bibelwort zurzeit nicht verfügbar.');
}
function imagesUnavailable() {
if (!imageData) {
month.update([], 'Monatsplan zurzeit nicht verfügbar.');
posters.update([], 'Aushänge zurzeit nicht verfügbar.');
}
}
// Unabhängige Hintergrundabrufe, ohne Ladezustand und ohne doppelte Anfragen.
function createRefresh(resource, onData, onError) {
let inFlight = null;
return () => inFlight ??= request('data.php?resource=' + resource,
async response => onData(await response.json()), { cache: 'no-store' })
.catch(onError).finally(() => { inFlight = null; });
}
const timeFormat = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin', hour: '2-digit', minute: '2-digit',
});
const dateFormat = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin', day: '2-digit', month: 'long', year: 'numeric',
});
const clock = document.getElementById('clock');
const date = document.getElementById('date');
function fitClock() {
const width = clock.getBoundingClientRect().width;
const target = date.getBoundingClientRect().width;
if (width && Math.abs(width - target) > 1.5) {
clock.style.fontSize = parseFloat(getComputedStyle(clock).fontSize) * target / width + 'px';
}
}
new ResizeObserver(fitClock).observe(date);
const departures = new FixedText('departures-panel', rows => {
const list = node('ul', 'text-page departures-list');
for (const item of rows) {
const row = node('li', 'departure-row is-' + item.state);
row.title = item.detail;
row.append(node('span', 'departure-line', item.line), node('span', 'departure-direction', item.direction),
node('span', 'departure-remaining', item.remaining), node('span', 'departure-status', item.status));
list.append(row);
}
return list;
});
const departureData = { departures: [], updatedAt: 0 };
let nextDepartureRequest = 0;
function renderDepartures(now) {
const age = now - departureData.updatedAt;
// Der globale Feed-Zeitstempel kann älter sein; entscheidend sind die
// Prognosen der einzelnen Fahrten und das Alter unseres letzten Abrufs.
const fresh = age < 300000 && !departureData.unavailable;
const rows = (age < 7200000 ? departureData.departures : [])
.map(d => ({ ...d, timestamp: Date.parse(d.when || d.plannedWhen) }))
.filter(d => Number.isFinite(d.timestamp) && d.timestamp >= now)
.sort((a, b) => a.timestamp - b.timestamp).slice(0, 5).map(d => {
const live = fresh && Number.isFinite(d.delay) && Boolean(d.when);
const state = !fresh ? 'stale' : d.cancelled ? 'cancelled' : !live ? 'scheduled'
: d.delay > 0 ? 'delayed' : d.delay < 0 ? 'early' : 'ontime';
const status = { stale: 'veraltet', cancelled: 'entfällt', scheduled: 'Fahrplaninfo', ontime: 'pünktlich',
delayed: '+' + Math.ceil(d.delay / 60) + ' Min.', early: '−' + Math.ceil(-d.delay / 60) + ' Min.' }[state];
const minutes = Math.ceil((d.timestamp - now) / 60000);
const planned = Date.parse(d.plannedWhen);
return { state, status, line: d.line?.name || 'Bus', direction: d.direction || 'Richtung unbekannt',
remaining: d.cancelled ? '' : minutes ? minutes + ' Min' : 'jetzt',
detail: (Number.isFinite(planned) ? 'Geplant ' + timeFormat.format(planned) + ' · ' : '')
+ (d.direction || '') + ' · ' + status };
});
departures.update(rows, fresh ? 'Keine Busabfahrten in den nächsten 120 Minuten.' : 'Live-Abfahrten derzeit nicht verfügbar.');
}
const refreshDepartures = createRefresh('departures_eich', data => {
if (!Array.isArray(data.departures) || !Number.isFinite(data.updatedAt)) throw new Error('Ungültige VBB-Antwort');
Object.assign(departureData, data);
nextDepartureRequest = Number(data.retryAt) * 1000 || 0;
renderDepartures(Date.now());
}, () => {
departureData.unavailable = true;
nextDepartureRequest = Date.now() + 30000;
renderDepartures(Date.now());
});
function tick() {
const now = new Date();
const nextDay = berlinDay(now);
clock.textContent = timeFormat.format(now);
clock.dateTime = now.toISOString();
date.textContent = dateFormat.format(now);
date.dateTime = nextDay;
fitClock();
renderDepartures(now.getTime());
if (!document.hidden && now.getTime() >= nextDepartureRequest) {
nextDepartureRequest = now.getTime() + 1000;
refreshDepartures();
}
if (nextDay !== currentDay) {
currentDay = nextDay;
// Texte erst mit einer erfolgreichen Antwort austauschen; abgelaufene
// Aushänge weiterhin sofort entfernen.
layoutImages();
refreshAll();
}
}
const refreshContent = createRefresh('content', contentLoaded, contentUnavailable);
const refreshImages = createRefresh('images', data => {
imageData = data;
return layoutImages();
}, imagesUnavailable);
const temperatureFormat = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 1 });
const refreshTemperatures = [
['indoor', data => [data.indoor.value, data.indoor.unit]],
['outdoor', data => [data.outdoor.temperature, '°C']],
].map(([id, read]) => {
const output = document.getElementById(id);
return createRefresh(id, data => {
const [value, unit] = read(data);
if (!Number.isFinite(value)) throw new Error('Ungültige Temperatur');
const text = temperatureFormat.format(value) + ' ' + unit;
if (output.textContent !== text) output.textContent = text;
output.classList.remove('is-unavailable');
}, () => {
if (output.classList.contains('is-unavailable')) output.textContent = 'Nicht verfügbar';
});
});
function refreshAll() {
// Langsame Temperatursensoren dürfen den Aushangswechsel nicht verzögern.
refreshTemperatures.forEach(refresh => refresh());
return Promise.all([refreshContent(), refreshImages()]);
}
posters.onCycle = refreshAll;
refreshAll();
tick();
setInterval(tick, 1000);
document.fonts.ready.then(() => {
fitClock();
announcements.rebuild();
bible.rebuild();
// Ein Mindestkontrast wird auch bei später geänderten zentralen Tokens geprüft.
const style = getComputedStyle(root);
const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const context = canvas.getContext('2d', { willReadFrequently: true });
function luminance(value) {
context.fillStyle = value;
context.fillRect(0, 0, 1, 1);
const channels = Array.from(context.getImageData(0, 0, 1, 1).data).slice(0, 3);
return channels.map(channel => {
const c = channel / 255;
return c <= 0.04045 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4;
}).reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index], 0);
}
const background = Math.min(...Array.from(document.querySelectorAll(
'.dashboard-panel, .dashboard-overview, .panel-heading, .announcement-entry'),
panel => luminance(getComputedStyle(panel).backgroundColor)));
for (const [property, token] of [['--dashboard-text', '--nak-black'], ['--dashboard-meta', '--gray-700']]) {
const contrast = (background + 0.05) / (luminance(style.getPropertyValue(token)) + 0.05);
if (contrast < config.CONTENT_MIN_CONTRAST_RATIO) root.style.setProperty(property, 'var(--nak-black)');
}
});
document.addEventListener('visibilitychange', () => {
if (!document.hidden) {
tick();
// Nach Rückkehr bekommt die sichtbare Seite erneut ihre volle Lesezeit.
posters.schedule();
}
});
})();
@@ -2,139 +2,71 @@
declare(strict_types=1);
date_default_timezone_set('Europe/Berlin');
require_once __DIR__ . '/util/dashboard_helpers.php';
$pdo = require __DIR__ . '/../../general_files/ini/database.php';
$content = dashboard_fetch_content($pdo, dashboard_now());
$pdo = null;
$service = $content['service'];
$announcements = $content['announcements'];
$posters = $content['posters'];
$monthImages = $content['month_images'];
$stylesheetVersion = (int) filemtime(__DIR__ . '/stylo.css');
$scriptVersion = (int) filemtime(__DIR__ . '/dashboard.js');
// Die Anzeigefläche muss auch ohne Datenbank oder externe APIs starten können.
$config = require __DIR__ . '/config.php';
?>
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>NAK Reinickendorf</title>
<link rel="icon" type="image/svg+xml" href="/intranet_repo/projects/_central/img/nak-logo.svg">
<link rel="shortcut icon" href="/intranet_repo/projects/_central/img/favicon.ico">
<link rel="stylesheet" href="stylo.css?v=<?= $stylesheetVersion ?>">
<link href="https://fonts.googleapis.com/css2?family=SUSE:wght@400;700&display=swap" rel="stylesheet">
<script src="dashboard.js?v=<?= $scriptVersion ?>" defer></script>
<meta name="description" content="Aktuelle Informationen der Neuapostolischen Kirche Berlin-Reinickendorf.">
<title>Gemeindeinformationen · NAK Berlin-Reinickendorf</title>
<link rel="icon" type="image/svg+xml" href="../_central/img/nak-logo.svg">
<link rel="stylesheet" href="../_central/layouts/nak-design.css">
<link rel="stylesheet" href="stylo.css?v=<?= (int) filemtime(__DIR__ . '/stylo.css') ?>">
<script id="dashboard-config" type="application/json"><?= json_encode($config, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) ?></script>
<script src="dashboard.js?v=<?= (int) filemtime(__DIR__ . '/dashboard.js') ?>" defer></script>
</head>
<body>
<div class="content-board">
<div class="content-column-right">
<div class="content-column-right-upper-box">
<div class="content-column-right-upper-box-right">
<div class="content-item-time">
<div class="time"><div id="uhrzeit"></div></div>
<div class="date" style="text-align: center" data-dashboard-date><?= dashboard_html($content['date_label']) ?></div>
<body class="dashboard">
<main class="dashboard-main" aria-label="Gemeindeinformationen">
<div class="dashboard-column announcements-column">
<section class="dashboard-overview" aria-label="Datum, Uhrzeit und Temperaturen">
<div class="dashboard-clock">
<time id="clock" aria-label="Uhrzeit Europe/Berlin">–:–</time>
<time id="date">Datum wird geladen</time>
</div>
<dl class="temperatures">
<div><dt>Innen</dt><dd id="indoor" class="is-unavailable">Wird geladen …</dd></div>
<div><dt>Außen</dt><dd id="outdoor" class="is-unavailable">Wird geladen …</dd></div>
</dl>
</section>
<section class="dashboard-panel text-panel" id="announcements-panel" aria-labelledby="announcements-title">
<div class="panel-heading"><h1 id="announcements-title">Ansagen</h1></div>
<div class="text-stage"><p class="empty-state">Ansagen werden geladen …</p></div>
</section>
</div>
<div class="content-item-daily">
<div class="header-sticky-note"><div>Heutige Ansagen</div></div>
<div class="news-container">
<div class="news-scroll">
<?php foreach ($announcements as $announcement): ?>
<div class="news-box">
<div class="news-date">
<?= dashboard_html($announcement['date_label']) ?>
<?php if ($announcement['end_label'] !== ''): ?>
<br><?= dashboard_html($announcement['end_label']) ?>
<?php endif; ?>
</div>
<div>
<?= dashboard_html($announcement['title']) ?>
<?php if ($announcement['subtitle'] !== ''): ?>
<div class="news-subtitle"><?= dashboard_html($announcement['subtitle']) ?></div>
<?php endif; ?>
<?php if ($announcement['meta'] !== ''): ?>
<div class="news-subtitle"><i><?= dashboard_html($announcement['meta']) ?></i></div>
<?php endif; ?>
</div>
</div>
<?php endforeach; ?>
</div>
</div>
<div class="dashboard-column bible-column">
<section class="dashboard-panel text-panel bible-panel" id="bible-panel" aria-labelledby="bible-title">
<div class="panel-heading"><h2 id="bible-title">Bibelwort für heute</h2></div>
<div class="text-stage"><p class="empty-state">Bibelwort wird geladen …</p></div>
</section>
<section class="dashboard-panel text-panel" id="departures-panel" aria-labelledby="departures-title">
<div class="panel-heading">
<h2 id="departures-title">Abfahrten: Eichborndamm/Antonienstr.</h2>
<a class="departures-source" href="https://www.vbb.de/" target="_blank" rel="noopener">Fahrplandaten: VBB</a>
</div>
<div class="text-stage"><p class="empty-state">Abfahrten werden geladen …</p></div>
</section>
</div>
<div class="content-column-right-upper-box-left">
<div class="content-item-middle-top">
<div class="header-sticky-note"><div>Bibelwort</div></div>
<div class="bible"><?= dashboard_html($service['bibelwort']) ?></div>
<div class="verse"><?= dashboard_html($service['bibelvers']) ?></div>
</div>
<section class="dashboard-panel image-panel" id="month-panel" aria-labelledby="month-title">
<div class="panel-heading"><h2 id="month-title">Monatsplan</h2></div>
<div class="image-stage"><p class="empty-state">Monatsplan wird geladen …</p></div>
</section>
<div class="content-column-double-item">
<div class="content-item">
<div class="header-sticky-note"><div>Eingangslied</div></div>
<div class="GB"><?= dashboard_html($service['eingang_nummer']) ?></div>
<div class="GB-title"><?= dashboard_html($service['eingang_titel']) ?></div>
</div>
<div class="content-item">
<div class="header-sticky-note"><div>Temperatur</div></div>
<div class="temperature">
<div class="container"><div id="wetter"></div></div>
<div class="container">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-5 -5 60 60" width="64px" height="64px" baseProfile="basic" aria-hidden="true">
<polygon fill="#66e1ff" points="46,22.11 46,24.42 43,26.15 43,34.24 25,44.63 14,38.28 14,35.97 12,34.81 10,35.97 7,34.24 7,20.38 4,20.96 4,18.65 22,8.26 34,5.95"/>
<polygon fill="#4292a6" points="25,30.774 28,34.815 43,26.155 43,34.238 25,44.63"/>
<polygon fill="#ff5757" points="16,18.649 4,20.959 4,18.649 16,16.34 28,32.506 28,34.815"/>
<polygon fill="#4292a6" points="34,8.257 22,10.566 22,8.257 34,5.947 46,22.113 46,24.423"/>
<polygon fill="#e64e4e" points="4,18.649 22,8.257 34,5.947 16,16.34"/>
<polygon fill="#cc4545" points="28,32.506 46,22.113 34,5.947 16,16.34"/>
<polygon fill="#b33d3d" points="28,32.506 46,22.113 46,24.423 28,34.815"/>
<polygon fill="#4292a6" points="17,34.238 22,37.124 22,31.351 17,28.464"/>
<polygon fill="#4292a6" points="10,35.97 12,34.815 12,27.887 10,26.732"/>
<polygon fill="#4ad8ff" points="19,33.083 22,34.815 22,31.351 19,29.619"/>
<polygon fill="#52b1cc" points="17,34.238 22,37.124 22,34.815 19,33.083"/>
<polygon fill="#ff5757" points="12,34.815 14,35.97 14,29.041 12,27.887"/>
<section class="dashboard-panel image-panel" id="poster-panel" aria-labelledby="poster-title">
<div class="panel-heading">
<h2 id="poster-title">Aushänge</h2>
<svg class="poster-progress" viewBox="0 0 24 24" aria-hidden="true" hidden>
<circle class="poster-progress-track" cx="12" cy="12" r="10" />
<circle class="poster-progress-value" cx="12" cy="12" r="10" pathLength="100" />
</svg>
<div class="text-indoor" id="temperature_txt"></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="content-item-bottom-box">
<div class="header-sticky-note"><div>Aushänge</div></div>
<div class="aushang-flex">
<?php if ($posters === []): ?>
<p style="text-align: left">Keine Aushänge vorgeplant.</p>
<?php else: ?>
<?php foreach ($posters as $poster): ?>
<img src="<?= dashboard_html($poster['url']) ?>" alt="<?= dashboard_html($poster['alt']) ?>" height="95%">
<?php endforeach; ?>
<?php endif; ?>
</div>
</div>
</div>
<div class="content-column-left">
<div class="content-item-video">
<div class="video-container" aria-label="Leer"></div>
</div>
<div class="content-item-month">
<div class="header-sticky-note"><div>Monatsplan</div></div>
<div class="content-item-month-cell">
<?php foreach ($monthImages as $image): ?>
<img src="<?= dashboard_html($image['url']) ?>" data-month-page="<?= (int) $image['page'] ?>" alt="Monatsplan Seite <?= (int) $image['page'] ?>">
<?php endforeach; ?>
</div>
</div>
</div>
</div>
<div class="image-stage"><p class="empty-state">Aushänge werden geladen …</p></div>
</section>
</main>
<noscript>Bitte JavaScript aktivieren, damit Uhrzeit, Inhalte und Seitenwechsel angezeigt werden.</noscript>
</body>
</html>
+10 -3
View File
@@ -9,7 +9,6 @@ date_default_timezone_set('Europe/Berlin');
require_once __DIR__ . '/util/dashboard_helpers.php';
$resource = (string) ($_GET['resource'] ?? 'content');
$pdo = null;
try {
switch ($resource) {
@@ -17,12 +16,22 @@ try {
$pdo = require __DIR__ . '/../../general_files/ini/database.php';
$payload = dashboard_fetch_content($pdo, dashboard_now());
break;
case 'images':
$payload = [
'posters' => dashboard_list_posters(dashboard_now()),
'month_images' => dashboard_month_images(),
];
break;
case 'indoor':
$payload = ['indoor' => dashboard_fetch_indoor_temperature()];
break;
case 'outdoor':
$payload = ['outdoor' => dashboard_fetch_outdoor_weather()];
break;
case 'departures_eich':
require_once __DIR__ . '/util/vbb.php';
$payload = dashboard_fetch_departures();
break;
default:
http_response_code(400);
$payload = ['error' => 'Unbekannte Dashboard-Ressource.'];
@@ -33,6 +42,4 @@ try {
http_response_code(502);
error_log('Dashboard data endpoint (' . $resource . '): ' . $exception->getMessage());
echo json_encode(['error' => 'Dashboard-Daten konnten nicht geladen werden.'], JSON_UNESCAPED_UNICODE);
} finally {
$pdo = null;
}
+116 -330
View File
@@ -1,340 +1,126 @@
/* Gesamtes Seiten-Layout */
body {
margin: 0;
/* Bildgrößen aus dem Original; Schrift und Farben aus den zentralen CD-Tokens. */
body.dashboard {
--dashboard-text: var(--nak-black);
--dashboard-meta: var(--gray-700);
--panel-surface: #fff;
--dashboard-unit: clamp(1px, 0.052083333vw, 2px);
--panel-heading-height: calc(31 * var(--dashboard-unit));
/* Original: Board 97vh minus 7px Innenabstand, Monatsfeld 56%, Bildbereich 94%. */
--original-board-height: calc(97vh - 7px);
--month-image-height: calc(var(--original-board-height) * 0.56 * 0.94);
--month-aspect: 1.414;
--announcement-font: var(--bible-font);
--announcement-detail-font: calc(20 * var(--dashboard-unit));
--announcement-meta-font: calc(15 * var(--dashboard-unit));
--announcement-leading: 1.35;
--bible-font: calc(23 * var(--dashboard-unit));
display: block;
height: 100vh;
display: flex;
justify-content: center;
align-items: center;
font-family: 'SUSE', sans-serif;
font-size: 20px;
background-image: url('cloudy.jpg');
background-size: cover;
background-repeat: no-repeat;
background-position: center center;
height: 100dvh;
min-height: 0;
padding: 0;
color: var(--dashboard-text);
background: var(--nak-blue-light) url('cloudy.jpg') center / cover no-repeat;
overflow: hidden;
}
/* Content Board Layout */
.content-board {
background-color: #a8a6a7a7;
width: 99%;
height: 97%;
box-sizing: border-box;
display: flex;
justify-content: space-around;
align-items: center;
border-radius: 0;
padding: 0 6px 7px 3px;
}
.content-column-left {
width: 40%;
.dashboard [hidden] { display: none !important; }
.dashboard-main {
display: grid;
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.05fr) calc(var(--month-image-height) * var(--month-aspect));
grid-template-rows: calc(var(--month-image-height) + var(--panel-heading-height)) minmax(0, 1fr);
grid-template-areas: "announcements bible month" "posters posters posters";
gap: var(--space-sm);
height: 100%;
box-sizing: border-box;
min-height: 0;
padding: var(--space-sm);
}
.content-column-right {
width: 58%;
height: 100%;
box-sizing: border-box;
}
.temperature {
display: flex;
justify-content: space-around;
align-items: center;
padding: 5px 0 0 0;
height: 70%;
}
.verse {
text-align: justify;
color: rgb(67, 67, 67);
padding: 5px 15px 5px 15px;
}
.date {
color: rgb(67, 67, 67);
font-weight: bold;
}
.content-column-right-upper-box-left {
width: 60%;
height: 100%;
box-sizing: border-box;
}
.content-column-right-upper-box-right {
width: 39%;
height: 100%;
box-sizing: border-box;
}
.content-column-right-upper-box {
width: 100%;
height: 51%;
box-sizing: border-box;
display: flex;
justify-content: space-between;
align-items: center;
}
.content-item-time {
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #ffffffab;
width: 100%;
height: 30%;
margin: 15px 0 15px 0;
box-sizing: border-box;
}
.content-item-news {
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #ffffffab;
width: 100%;
height: 63%;
margin: 15px 0 15px 0;
box-sizing: border-box;
}
.content-item-video {
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #cbc5bc;
width: 100%;
height: 40%;
margin: 15px 0 15px 0;
box-sizing: border-box;
}
.content-item-middle-top {
position: relative;
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #cbc5bc;
width: 100%;
height: 60%;
margin: 15px 0 15px 0;
box-sizing: border-box;
overflow: hidden;
}
/* Pseudoelement für den animierten border-bottom Effekt */
.content-item-middle-top::after {
#month-panel { grid-area: month; --panel-accent: var(--nak-purple); }
#poster-panel { grid-area: posters; --panel-accent: var(--nak-orange); --poster-frame-width: 1px; }
#announcements-panel { --panel-accent: var(--nak-teal); }
.dashboard-column { display: grid; gap: var(--space-sm); min-height: 0; min-width: 0; }
.announcements-column { grid-area: announcements; grid-template-rows: auto minmax(0, 1fr); }
.bible-column { grid-area: bible; grid-template-rows: minmax(0, 1fr) auto; }
.dashboard-panel { min-width: 0; min-height: 0; background: var(--panel-surface); }
.dashboard-overview { display: flex; align-items: flex-end; justify-content: space-between; gap: calc(8 * var(--dashboard-unit)); padding: calc(16 * var(--dashboard-unit)) calc(12 * var(--dashboard-unit)) calc(8 * var(--dashboard-unit)); background: var(--panel-surface); box-shadow: inset 0 calc(3 * var(--dashboard-unit)) var(--nak-blue); }
.dashboard-clock { display: grid; flex: 0 0 auto; justify-items: start; gap: calc(4 * var(--dashboard-unit)); }
#clock, #date { width: max-content; }
#clock { font-size: calc(64 * var(--dashboard-unit)); line-height: 0.9; font-weight: 500; font-variant-numeric: tabular-nums; }
#date { font-size: var(--announcement-meta-font); white-space: nowrap; }
.temperatures { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); min-width: 0; gap: calc(8 * var(--dashboard-unit)); margin: 0; }
.temperatures > div { min-width: 0; }
.temperatures dt { font-size: var(--announcement-meta-font); color: var(--dashboard-meta); }
.temperatures dd { margin: 0; font-size: var(--announcement-detail-font); line-height: var(--announcement-leading); font-weight: 500; font-variant-numeric: tabular-nums; }
.temperatures .is-unavailable { font-size: calc(12 * var(--dashboard-unit)); font-weight: 300; color: var(--dashboard-meta); }
.image-panel, .text-panel { display: grid; grid-template-rows: var(--panel-heading-height) minmax(0, 1fr); }
.panel-heading { display: flex; align-items: center; padding: calc(3 * var(--dashboard-unit)) calc(8 * var(--dashboard-unit)) 0; background: var(--panel-surface); background: color-mix(in srgb, var(--panel-accent) 12%, white); box-shadow: inset 0 calc(3 * var(--dashboard-unit)) var(--panel-accent); }
.panel-heading h1, .panel-heading h2 { margin: 0; color: var(--dashboard-text); font-size: calc(20 * var(--dashboard-unit)); line-height: 1.1; font-weight: 500; }
.poster-progress { width: calc(18 * var(--dashboard-unit)); height: calc(18 * var(--dashboard-unit)); margin-left: auto; flex: 0 0 auto; fill: none; stroke-width: 2; }
.poster-progress-track { stroke: var(--gray-300); }
.poster-progress-value { stroke: var(--panel-accent); stroke-dasharray: 100; stroke-dashoffset: 100; transform: rotate(-90deg); transform-origin: center; }
.image-stage { min-height: 0; min-width: 0; padding: 0; column-gap: var(--space-sm); }
#month-panel .image-stage,
#poster-panel .image-stage { padding: calc(4 * var(--dashboard-unit)); }
.image-sheet { display: flex; justify-content: center; align-items: center; gap: var(--space-sm); width: 100%; height: 100%; min-height: 0; }
#month-panel .image-sheet { align-items: flex-start; gap: calc(4 * var(--dashboard-unit)); }
.image-cell { margin: 0; min-height: 0; min-width: 0; flex: 0 0 auto; }
.poster-document { display: flex; flex: 0 0 auto; gap: 0; background: #fff; border: var(--poster-frame-width) solid var(--gray-500); }
.image-cell img { display: block; width: 100%; height: 100%; object-fit: contain; }
.image-cell.is-cropped { position: relative; overflow: hidden; }
.image-cell.is-cropped img { position: absolute; max-width: none; }
.image-cell .empty-state { padding: var(--space-sm); font-size: var(--announcement-font); }
.text-stage { min-height: 0; margin: calc(6 * var(--dashboard-unit)) calc(8 * var(--dashboard-unit)); overflow: hidden; position: relative; }
#announcements-panel .text-stage { margin: 0; }
#announcements-panel .text-stage > .empty-state { padding: 0 calc(10 * var(--dashboard-unit)); }
.text-page { color: var(--dashboard-text); }
.text-page p { margin: 0 0 var(--space-xs); white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--announcement-font); line-height: var(--announcement-leading); }
.text-page p:last-child { margin-bottom: 0; }
.text-page .announcement-title { font-size: var(--announcement-detail-font); }
.text-page .announcement-title, .text-page .bible-reference { font-weight: 500; }
.text-page .announcement-subtitle { font-size: var(--announcement-detail-font); color: var(--dashboard-meta); }
.text-page .announcement-meta { font-size: var(--announcement-meta-font); color: var(--dashboard-meta); }
.announcement-list { max-width: none; margin: 0; padding: 0; list-style: none; }
/* Originale Zeilenaufteilung: Datum links, Inhalt mit Zeit/Ort rechts. */
.announcement-entry { display: grid; grid-template-columns: calc(100 * var(--dashboard-unit)) minmax(0, 1fr); gap: calc(12 * var(--dashboard-unit)); padding: calc(6 * var(--dashboard-unit)) calc(10 * var(--dashboard-unit)); border-bottom: 1px solid var(--gray-300); background: var(--panel-surface); }
.announcement-entry:nth-child(even) { background: var(--gray-50); }
.announcement-content { min-width: 0; }
.announcement-entry p { margin: 0; }
.announcement-content .announcement-meta { font-style: italic; }
.announcement-when { min-width: 0; font-weight: 300; color: var(--dashboard-meta); font-variant-numeric: tabular-nums; }
.announcement-when p { margin-bottom: 0; font-size: var(--announcement-detail-font); }
.bible-panel { --panel-accent: var(--nak-yellow); }
.bible-panel .text-stage { margin-inline: calc(24 * var(--dashboard-unit)); margin-top: calc(12 * var(--dashboard-unit)); }
.bible-panel .text-page { max-width: none; }
#departures-panel { --panel-accent: var(--nak-darkblue); height: calc(161 * var(--dashboard-unit)); }
#departures-panel .text-stage { margin: 0; }
#departures-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: calc(18 * var(--dashboard-unit)); }
.departures-source { margin-left: auto; color: var(--dashboard-meta); font-size: calc(10 * var(--dashboard-unit)); white-space: nowrap; }
.departures-list { max-width: none; margin: 0; padding: 0; list-style: none; }
.departure-row { --departure-color: var(--gray-500); display: grid; grid-template-columns: calc(38 * var(--dashboard-unit)) minmax(0, 1fr) calc(52 * var(--dashboard-unit)) calc(100 * var(--dashboard-unit)); align-items: center; gap: calc(6 * var(--dashboard-unit)); height: calc(26 * var(--dashboard-unit)); padding: 0 calc(5 * var(--dashboard-unit)) 0 calc(8 * var(--dashboard-unit)); border-right: calc(6 * var(--dashboard-unit)) solid var(--departure-color); background: var(--panel-surface); font-size: var(--announcement-meta-font); line-height: 1.2; }
.departure-row:nth-child(even) { background: var(--gray-50); }
.departure-row.is-ontime { --departure-color: var(--nak-darkgreen); }
.departure-row.is-delayed { --departure-color: var(--nak-orange); }
.departure-row.is-early, .departure-row.is-cancelled { --departure-color: var(--nak-red); }
.departure-line { font-weight: 500; }
.departure-direction { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.departure-remaining, .departure-status { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
#departures-panel .empty-state { padding: calc(8 * var(--dashboard-unit)); font-size: var(--announcement-meta-font); }
.text-stage.is-overflowing::after {
content: "";
position: absolute;
bottom: 0;
left: 0;
width: 100%;
height: 15px;
background: linear-gradient(to bottom, transparent, #cbc5bc);
inset: auto 0 0;
height: calc(24 * var(--dashboard-unit));
background: linear-gradient(to bottom, transparent, var(--panel-surface));
pointer-events: none;
}
/* Monatsplan: jetzt mit weißem Hintergrund */
.content-item-month {
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #cbc5bc7d;
width: 100%;
height: 56%;
margin: 15px 0 15px 0;
box-sizing: border-box;
overflow: hidden;
.empty-state { margin: 0; font-size: var(--announcement-font); line-height: var(--announcement-leading); color: var(--dashboard-meta); }
.image-stage > .empty-state { padding: var(--space-md); }
.dashboard noscript { position: fixed; bottom: 0; padding: var(--space-lg); background: #fff; color: var(--nak-red); }
@media (max-width: 999px), (max-height: 599px) {
body.dashboard { height: auto; min-height: 100vh; overflow: visible; }
.dashboard-main {
height: auto;
grid-template-columns: minmax(0, 1fr);
grid-template-areas: "announcements" "bible" "month" "posters";
grid-template-rows: 520px 520px 65vh 55vh;
}
.content-item-month-cell {
display: flex;
background-color: #ffffffe5;
justify-content: center; /* Zentriere die Bilder horizontal */
height: 94%;
align-items: center; /* Zentriere die Bilder vertikal */
}
.content-item-month-cell img {
max-width: 100%; /* Verhindert, dass das Bild größer als der Container wird */
max-height: 100%; /* Verhindert Verzerrungen, indem es die maximale Höhe auf die Containerhöhe beschränkt */
object-fit: contain; /* Stellt sicher, dass das Bild im Container bleibt, ohne gestreckt zu werden */
}
.content-item-daily {
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #edebe8;
width: 100%;
height: 63%;
margin: 15px 0 15px 0;
box-sizing: border-box;
overflow: hidden;
}
.header-sticky-note {
font-size: 25px;
display: flex;
flex-direction: column;
align-items: center;
height: 20px;
width: 100%;
padding: 5px 0 15px 0;
border-bottom: 2px solid #38312430;
background-color: #cbc5bc;
border-radius: 0;
}
.time {
font-size: 80px;
text-align: center;
padding: 8px 0 0 0;
}
.bible {
text-align: center;
font-size: 60px;
padding: 10px 0 10px 0;
border-bottom: 2px solid #38312456;
background-color: #edebe8;
}
.GB {
text-align: center;
font-size: 60px;
padding: 5px 0 0 0;
}
.GB-title {
text-align: center;
color: rgb(67, 67, 67);
}
.content-column-double-item {
width: 100%;
height: 33%;
margin: 15px 0 15px 0;
box-sizing: border-box;
display: flex;
justify-content: space-between;
align-items: center;
}
/* Elemente innerhalb des Content Boards */
.content-item {
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #edebe8;
height: 100%;
width: 49%;
}
.content-item-bottom-box {
border-radius: 0;
box-shadow: 5px 5px 2px rgba(0, 0, 0, 0.16);
background-color: #edebe8;
margin: 6px 0 0 0;
height: 47.5%;
width: 100%;
overflow: hidden;
}
.video-container {
border-radius: 0;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
border: 5px solid #cbc5bc;
box-sizing: border-box;
display: flex;
justify-content: center;
align-items: center;
}
.video-container video {
width: 100%;
height: 100%;
transform: scale(1.2);
object-fit: cover;
border-radius: 0;
}
.container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 40%;
background-color: #38312431;
border-radius: 0;
border: 2px solid #38312440;
}
.text-outdoor {
font-weight: bold;
padding: 9px 0 0 0;
margin: 0 0 15px 0;
}
.text-indoor {
font-weight: bold;
margin: 0 0 15px 0;
}
.news-box {
display: flex;
justify-content: flex-start;
padding: 5px 15px 5px 15px;
border-bottom: 2px solid #38312456;
background-color: #edebe8;
word-wrap: break-word;
}
.news-date {
font-weight: bold;
margin: 0 10px 0 0;
}
.news-subtitle {
color: rgba(67, 67, 67, 0.657);
}
.news-container {
height: 290px;
overflow: hidden;
position: relative;
}
.news-scroll {
display: flex;
flex-direction: column;
animation-name: scroll-v;
animation-timing-function: linear;
animation-iteration-count: infinite;
--scroll-distance-v: 0px;
}
@keyframes scroll-v {
0% { transform: translateY(0); }
100% { transform: translateY(var(--scroll-distance-v)); }
}
.aushang-flex img {
border-radius: 0;
margin: 0 10px 0 0;
border: 2px solid #38312440;
box-shadow: 4px 4px 2px rgba(0, 0, 0, 0.08);
}
.aushang-flex {
padding: 0 5px 0 5px;
display: flex;
justify-content: space-evenly;
align-items: center;
height: 90.5%;
border-radius: 0;
animation-name: scroll-h;
animation-timing-function: linear;
animation-iteration-count: infinite;
--scroll-distance-h: 0px;
}
@keyframes scroll-h {
0% { transform: translateX(0); }
100% { transform: translateX(var(--scroll-distance-h)); }
}
@@ -1,12 +1,15 @@
<?php
require_once __DIR__ . '/../../contentpanel/insertion/util/contentpanel_helpers.php';
declare(strict_types=1);
require_once __DIR__ . '/../../contentpanel/insertion/util/news.php';
require_once __DIR__ . '/../../contentpanel/insertion/util/announcements.php';
require_once __DIR__ . '/../../../general_files/ini/runtime_env.php';
const DASHBOARD_TIMEZONE = 'Europe/Berlin';
const DASHBOARD_HA_ENTITY_ID = 'sensor.temp_temperatur';
const DASHBOARD_OPENWEATHER_LATITUDE = '52.571291';
const DASHBOARD_OPENWEATHER_LONGITUDE = '13.310422';
const DASHBOARD_OPENWEATHER_LATITUDE = '52.571524';
const DASHBOARD_OPENWEATHER_LONGITUDE = '13.312494';
const DASHBOARD_NEWS_URL = '/intranet_repo/projects/contentpanel/insertion/news/png/';
function dashboard_now(): DateTimeImmutable
@@ -14,141 +17,151 @@ function dashboard_now(): DateTimeImmutable
return new DateTimeImmutable('now', new DateTimeZone(DASHBOARD_TIMEZONE));
}
function dashboard_html($value): string
{
return htmlspecialchars((string) $value, ENT_QUOTES, 'UTF-8');
}
function dashboard_fetch_content(PDO $pdo, DateTimeImmutable $now): array
{
$now = $now->setTimezone(new DateTimeZone(DASHBOARD_TIMEZONE));
$today = $now->format('Y-m-d');
$payload = ['date' => $today, 'service' => null, 'announcements' => [], 'errors' => []];
$serviceStatement = $pdo->prepare(
'SELECT bibelwort, bibelvers, eingang_nummer, eingang_titel
FROM gottesdienst
WHERE bibeldatum = ?'
// Unabhängige Abfragen: ein fehlerhafter Bereich blockiert den anderen nicht.
try {
$statement = $pdo->prepare(
'SELECT bibelwort, bibelvers FROM gottesdienst
WHERE bibeldatum = ? ORDER BY id ASC LIMIT 1'
);
$serviceStatement->execute([$today]);
$service = $serviceStatement->fetch(PDO::FETCH_ASSOC) ?: [];
$announcementStatement = $pdo->prepare(
'SELECT ansage_datum_von, ansage_datum_bis, ansage_uhrzeit, ansage_ort, ansage_titel, ansage_untertitel
FROM ansagen
WHERE ansage_datum_von >= ? OR ansage_datum_bis >= ?
ORDER BY ansage_datum_von ASC'
);
$announcementStatement->execute([$today, $today]);
$announcements = $announcementStatement->fetchAll(PDO::FETCH_ASSOC);
foreach ($announcements as &$announcement) {
$announcement = dashboard_prepare_announcement($announcement, $now);
}
unset($announcement);
contentpanel_cleanup_expired_news();
return [
'date_label' => dashboard_date_label($now),
'service' => [
'bibelwort' => (string) ($service['bibelwort'] ?? 'Bibelwort nicht eingetragen'),
'bibelvers' => (string) ($service['bibelvers'] ?? 'Bibelvers nicht gefunden.'),
'eingang_nummer' => (string) ($service['eingang_nummer'] ?? 'N/A'),
'eingang_titel' => (string) ($service['eingang_titel'] ?? 'Eingangslied nicht gefunden'),
],
'announcements' => $announcements,
'posters' => dashboard_list_posters(),
'month_images' => dashboard_month_images(),
$statement->execute([$today]);
$service = $statement->fetch(PDO::FETCH_ASSOC);
if ($service) {
// Leitgedanken-Import: bibelwort = Referenz, bibelvers = eigentlicher Text.
$payload['service'] = [
'reference' => trim((string) ($service['bibelwort'] ?? '')),
'text' => trim((string) ($service['bibelvers'] ?? '')),
];
}
} catch (Throwable $exception) {
error_log('Dashboard Bibelwort: ' . $exception->getMessage());
$payload['errors'][] = 'service';
}
function dashboard_date_label(DateTimeImmutable $date): string
try {
$statement = $pdo->prepare(
'SELECT ansage_datum_von, ansage_datum_bis, ansage_uhrzeit, ansage_ort,
ansage_titel, ansage_untertitel
FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) >= ?
ORDER BY ansage_datum_von ASC NULLS LAST, ansage_uhrzeit ASC NULLS LAST, id ASC'
);
contentpanel_cleanup_expired_announcements($pdo, $today);
$statement->execute([$today]);
foreach ($statement->fetchAll(PDO::FETCH_ASSOC) as $row) {
$payload['announcements'][] = dashboard_prepare_announcement($row, $now);
}
} catch (Throwable $exception) {
error_log('Dashboard Ansagen: ' . $exception->getMessage());
$payload['errors'][] = 'announcements';
}
return $payload;
}
function dashboard_date(string $raw): ?DateTimeImmutable
{
static $months = [
1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
];
return sprintf('%02d. %s', (int) $date->format('d'), $months[(int) $date->format('n')]);
$date = DateTimeImmutable::createFromFormat('!Y-m-d', $raw, new DateTimeZone(DASHBOARD_TIMEZONE));
return $date && $date->format('Y-m-d') === $raw ? $date : null;
}
function dashboard_prepare_announcement(array $announcement, DateTimeImmutable $now): array
{
$startRaw = substr((string) ($announcement['ansage_datum_von'] ?? ''), 0, 10);
$endRaw = substr((string) ($announcement['ansage_datum_bis'] ?? ''), 0, 10);
$timeRaw = substr((string) ($announcement['ansage_uhrzeit'] ?? ''), 0, 5);
$startDate = DateTimeImmutable::createFromFormat('!Y-m-d', $startRaw, $now->getTimezone());
$tomorrow = $now->setTime(0, 0)->modify('+1 day');
if ($startDate && $startDate->format('Y-m-d') === $now->format('Y-m-d')) {
$dateLabel = 'Heute';
} elseif ($startDate && $startDate->format('Y-m-d') === $tomorrow->format('Y-m-d')) {
$dateLabel = 'Morgen';
} elseif ($startDate) {
static $weekdays = [1 => 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
$dateLabel = $weekdays[(int) $startDate->format('N')] . ' ' . $startDate->format('d.m.');
$now = $now->setTimezone(new DateTimeZone(DASHBOARD_TIMEZONE));
$start = substr((string) ($announcement['ansage_datum_von'] ?? ''), 0, 10);
$end = substr((string) ($announcement['ansage_datum_bis'] ?? ''), 0, 10);
$today = $now->format('Y-m-d');
$startDate = dashboard_date($start);
$endDate = dashboard_date($end);
if ($start === $today) {
$label = 'Heute';
} elseif ($start === $now->modify('+1 day')->format('Y-m-d')) {
$label = 'Morgen';
} else {
$dateLabel = $startRaw;
$days = [1 => 'Mo', 'Di', 'Mi', 'Do', 'Fr', 'Sa', 'So'];
$label = $startDate ? $days[(int) $startDate->format('N')] . ' ' . $startDate->format('d.m.') : '';
}
$endLabel = '';
if ($endRaw !== '') {
$endDate = DateTimeImmutable::createFromFormat('!Y-m-d', $endRaw, $now->getTimezone());
$endLabel = $endDate ? 'bis ' . $endDate->format('d.m.') : 'bis ' . $endRaw;
}
$meta = [];
if ($timeRaw !== '') {
$meta[] = $timeRaw . ' Uhr';
}
$endLabel = $endDate && $end !== $start ? 'bis ' . $endDate->format('d.m.') : '';
$time = substr((string) ($announcement['ansage_uhrzeit'] ?? ''), 0, 5);
$place = trim((string) ($announcement['ansage_ort'] ?? ''));
if ($place !== '') {
$meta[] = $place;
}
return [
'date_label' => $dateLabel,
'date_label' => $label,
'end_label' => $endLabel,
'title' => (string) ($announcement['ansage_titel'] ?? ''),
'subtitle' => (string) ($announcement['ansage_untertitel'] ?? ''),
'meta' => implode(', ', $meta),
'time' => $time,
'place' => $place,
'title' => trim((string) ($announcement['ansage_titel'] ?? '')),
'subtitle' => trim((string) ($announcement['ansage_untertitel'] ?? '')),
];
}
function dashboard_list_posters(): array
function dashboard_image(string $path, string $url): ?array
{
$files = glob(CONTENTPANEL_NEWS_PNG_DIRECTORY . '/*.png') ?: [];
natsort($files);
if (!is_file($path)) {
return null;
}
$size = @getimagesize($path);
if (!$size || $size[0] < 1 || $size[1] < 1) {
return null;
}
// Neu erzeugte, aber identische PNGs dürfen keinen sichtbaren Austausch auslösen.
$version = @hash_file('sha256', $path);
if ($version === false) {
return null;
}
return [
'url' => $url . '?v=' . $version,
'width' => $size[0],
'height' => $size[1],
];
}
function dashboard_list_posters(DateTimeImmutable $now, string $directory = CONTENTPANEL_NEWS_PNG_DIRECTORY): array
{
$today = $now->setTimezone(new DateTimeZone(DASHBOARD_TIMEZONE))->format('Y-m-d');
$files = glob($directory . '/*.png') ?: [];
natsort($files);
$posters = [];
foreach ($files as $file) {
if (!is_file($file)) {
$name = basename($file);
if (!preg_match('/^(.+)_page_([1-9][0-9]*)\\.png$/D', $name, $match)
|| !contentpanel_is_valid_news_file_base($match[1])) {
continue;
}
$basename = basename($file);
$posters[] = [
'url' => DASHBOARD_NEWS_URL . rawurlencode($basename) . '?v=' . (int) filemtime($file),
'alt' => 'Aushang',
// Dieselbe inklusive Ablauffrist wie im Contentpanel. Nur filtern, nicht löschen.
$expiry = substr($match[1], 0, 10);
if (!dashboard_date($expiry) || $expiry < $today) {
continue;
}
$image = dashboard_image($file, DASHBOARD_NEWS_URL . rawurlencode($name));
if ($image) {
$title = str_replace('_', ' ', substr($match[1], 11));
$posters[] = $image + [
'document' => $match[1],
'expires' => $expiry, 'page' => (int) $match[2],
'alt' => $title . ' · Seite ' . (int) $match[2],
];
}
return array_values($posters);
}
return $posters;
}
function dashboard_month_images(): array
function dashboard_month_images(?string $directory = null): array
{
$directory ??= __DIR__ . '/../month';
$images = [];
foreach ([1, 2] as $page) {
$filename = 'terminuebersicht-' . $page . '.png';
$path = __DIR__ . '/../month/' . $filename;
$version = is_file($path) ? (int) filemtime($path) : 0;
$images[] = [
'page' => $page,
'url' => 'month/' . $filename . '?v=' . $version,
];
$image = dashboard_image($directory . '/' . $filename, 'month/' . $filename);
if ($image) {
$images[] = $image + ['alt' => 'Monatsplan · Seite ' . $page];
}
}
return $images;
}
@@ -159,17 +172,12 @@ function dashboard_fetch_indoor_temperature(): array
$url = $baseUrl . '/api/states/' . rawurlencode(DASHBOARD_HA_ENTITY_ID);
$data = dashboard_http_json($url, ['Authorization: Bearer ' . $token]);
if (!array_key_exists('state', $data)) {
if (!isset($data['state']) || !is_numeric($data['state'])) {
throw new RuntimeException('Unerwartete Home-Assistant-Antwort.');
}
$value = $data['state'];
if (is_numeric($value)) {
$value = round((float) $value, 1);
}
return [
'value' => $value,
'value' => (int) ceil((float) $data['state'] - 0.4),
'unit' => (string) ($data['attributes']['unit_of_measurement'] ?? '°C'),
];
}
@@ -186,7 +194,6 @@ function dashboard_fetch_outdoor_weather(): array
$data = dashboard_http_json('https://api.openweathermap.org/data/2.5/weather?' . $query);
$temperature = $data['main']['temp'] ?? null;
$icon = (string) ($data['weather'][0]['icon'] ?? '');
if (!is_numeric($temperature)) {
throw new RuntimeException('Unerwartete OpenWeather-Antwort.');
@@ -194,27 +201,9 @@ function dashboard_fetch_outdoor_weather(): array
return [
'temperature' => (int) round((float) $temperature),
'icon' => dashboard_weather_kind($icon),
];
}
function dashboard_weather_kind(string $icon): string
{
return match ($icon) {
'04d', '04n', '03d', '03n' => 'cloudy',
'02d' => 'cloudy_day',
'02n' => 'cloudy_night',
'01d' => 'sun',
'01n' => 'moon',
'09d', '09n', '10n' => 'rain_shower',
'10d' => 'rain',
'11d', '11n' => 'thunder',
'13d', '13n' => 'snowy',
'50d', '50n' => 'mist',
default => 'cloudy',
};
}
function dashboard_http_json(string $url, array $headers = []): array
{
$handle = curl_init($url);
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
const DASHBOARD_VBB_STOP_ID = '900086103';
const DASHBOARD_VBB_BUS_PRODUCTS = 8;
const DASHBOARD_VBB_API_URL = 'https://fahrinfo.vbb.de/restproxy/latest/departureBoard';
function dashboard_vbb_datetime(array $departure, bool $realtime = false): ?DateTimeImmutable
{
$time = trim((string) ($departure[$realtime ? 'rtTime' : 'time'] ?? ''));
if ($time === '') {
return null;
}
$date = trim((string) ($departure[$realtime ? 'rtDate' : 'date'] ?? $departure['date'] ?? ''));
$value = DateTimeImmutable::createFromFormat(
'!Y-m-d H:i:s',
$date . ' ' . $time,
new DateTimeZone(DASHBOARD_TIMEZONE)
);
return $value ?: null;
}
function dashboard_vbb_normalize_departure(array $departure): ?array
{
$product = $departure['ProductAtStop'] ?? ($departure['Product'][0] ?? []);
if (!is_array($product) || stripos((string) ($product['catOut'] ?? $product['catOutL'] ?? ''), 'bus') === false) {
return null;
}
$planned = dashboard_vbb_datetime($departure);
if (!$planned) {
return null;
}
$actual = dashboard_vbb_datetime($departure, true);
$status = strtoupper(trim((string) ($departure['JourneyStatus'] ?? '')));
$cancelled = filter_var($departure['cancelled'] ?? false, FILTER_VALIDATE_BOOLEAN) || $status === 'C';
$line = trim((string) ($product['displayNumber'] ?? ''));
$line = $line !== '' ? $line : trim((string) ($product['line'] ?? $product['name'] ?? $departure['name'] ?? 'Bus'));
return [
'plannedWhen' => $planned->format(DATE_ATOM),
'when' => $actual?->format(DATE_ATOM),
'delay' => $actual ? $actual->getTimestamp() - $planned->getTimestamp() : null,
'cancelled' => $cancelled,
'line' => ['name' => $line],
'direction' => trim((string) ($departure['direction'] ?? '')),
];
}
function dashboard_fetch_departures(): array
{
$directory = __DIR__ . '/../cache';
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory)) {
throw new RuntimeException('VBB-Cache nicht beschreibbar.');
}
$file = fopen($directory . '/vbb-' . DASHBOARD_VBB_STOP_ID . '.json', 'c+');
if (!$file) {
throw new RuntimeException('VBB-Cache nicht verfügbar.');
}
try {
if (!flock($file, LOCK_EX)) {
throw new RuntimeException('VBB-Cache konnte nicht gesperrt werden.');
}
$cache = json_decode(stream_get_contents($file), true) ?: [];
if (microtime(true) >= ($cache['retryAt'] ?? 0)) {
try {
$query = http_build_query([
'accessId' => intranet_env('VBB_ACCESS_ID'),
'id' => DASHBOARD_VBB_STOP_ID,
'duration' => 120,
'maxJourneys' => 30,
'products' => DASHBOARD_VBB_BUS_PRODUCTS,
'rtMode' => 'SERVER_DEFAULT',
'type' => 'DEP',
'format' => 'json',
'lang' => 'de',
], '', '&', PHP_QUERY_RFC3986);
$data = dashboard_http_json(DASHBOARD_VBB_API_URL . '?' . $query);
if (!isset($data['Departure']) || !is_array($data['Departure'])) {
throw new RuntimeException('Unerwartete VBB-Antwort.');
}
$departures = [];
foreach ($data['Departure'] as $departure) {
if (is_array($departure) && ($normalized = dashboard_vbb_normalize_departure($departure))) {
$departures[] = $normalized;
}
}
$now = microtime(true);
$cache = [
'departures' => array_slice($departures, 0, 30),
'updatedAt' => (int) round($now * 1000),
'retryAt' => $now + 30,
'unavailable' => false,
];
} catch (Throwable $error) {
// Eine letzte erfolgreiche Antwort behalten und die API nach Fehlern nicht belasten.
error_log('Dashboard VBB: ' . $error->getMessage());
$cache['unavailable'] = true;
$cache['retryAt'] = microtime(true) + 60;
}
rewind($file);
$json = json_encode($cache, JSON_UNESCAPED_UNICODE | JSON_THROW_ON_ERROR);
if (!ftruncate($file, 0) || fwrite($file, $json) !== strlen($json) || !fflush($file)) {
throw new RuntimeException('VBB-Cache konnte nicht gespeichert werden.');
}
}
return $cache + ['departures' => [], 'updatedAt' => 0];
} finally {
fclose($file);
}
}
@@ -89,6 +89,13 @@
<div class="text">Monatsplan</div>
</button>
<?php if (!empty($groups["Technik"]) || !empty($groups["Admin"])) { ?>
<button class="button_mainpage unselectable bg_black" onclick="window.location.href='/intranet_repo/projects/camera_remote/camera_remote.php'">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32"><path d="M17 10.5V7c0-1.1-.9-2-2-2H5C3.9 5 3 5.9 3 7v10c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2v-3.5l4 4v-11l-4 4z"/></svg>
<div class="text">Kamera</div>
</button>
<?php } ?>
<?php if ($groups["Amtsgeschwister"] == 1) { ?>
<button class="button_mainpage unselectable bg_purple" onclick="window.location.href='/intranet_repo/projects/seelsorge/seelsorge.php'">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32"><path d="M16 11c1.66 0 2.99-1.34 2.99-3S17.66 5 16 5c-1.66 0-3 1.34-3 3s1.34 3 3 3zm-8 0c1.66 0 2.99-1.34 2.99-3S9.66 5 8 5C6.34 5 5 6.34 5 8s1.34 3 3 3zm0 2c-2.33 0-7 1.17-7 3.5V19h14v-2.5c0-2.33-4.67-3.5-7-3.5zm8 0c-.29 0-.62.02-.97.05 1.16.84 1.97 1.97 1.97 3.45V19h6v-2.5c0-2.33-4.67-3.5-7-3.5z"/></svg>
+34 -4
View File
@@ -53,10 +53,37 @@ def _set_text(paragraph, text):
node.appendChild(document.createTextNode(part))
anchor.parentNode.insertBefore(node, anchor)
anchor.parentNode.removeChild(anchor)
# Preserve verse/signature lines without stretching them to the full width.
if '\n' in text:
for alignment in paragraph.getElementsByTagNameNS(W, 'jc'):
alignment.setAttributeNS(W, 'w:val', 'left')
paragraph.getElementsByTagNameNS(W, 'jc')[0].setAttributeNS(W, 'w:val', 'left')
def _items(text):
lines = [line.strip() for line in text.replace('\r\n', '\n').replace('\r', '\n').split('\n') if line.strip()]
bullets = [line[1:].lstrip() if line.startswith('•') and line[1:].strip() else None for line in lines]
if not any(bullets):
return [(text, None)]
return [(bullet, i + 1 < len(lines) and bullets[i + 1] is not None) if bullet else (line, None)
for i, (line, bullet) in enumerate(zip(lines, bullets))]
def _format_bullet(paragraph, compact):
properties = paragraph.getElementsByTagNameNS(W, 'pPr')[0]
anchor = properties.getElementsByTagNameNS(W, 'jc')[0]
document = paragraph.ownerDocument
tabs = document.createElementNS(W, 'w:tabs')
tab = document.createElementNS(W, 'w:tab')
tab.setAttributeNS(W, 'w:val', 'left')
tab.setAttributeNS(W, 'w:pos', '480')
tabs.appendChild(tab)
properties.insertBefore(tabs, anchor)
if compact:
spacing = document.createElementNS(W, 'w:spacing')
spacing.setAttributeNS(W, 'w:after', '0')
properties.insertBefore(spacing, anchor)
indent = document.createElementNS(W, 'w:ind')
indent.setAttributeNS(W, 'w:left', '480')
indent.setAttributeNS(W, 'w:hanging', '360')
properties.insertBefore(indent, anchor)
def fill_template(template: Path, output: Path, title: str, date_label: str, paragraphs: list[str]) -> None:
@@ -72,12 +99,15 @@ def fill_template(template: Path, output: Path, title: str, date_label: str, par
used = {p.getAttributeNS(W14, 'paraId') for p in document.getElementsByTagNameNS(W, 'p')}
index = 1
for text in paragraphs:
for value, compact in _items(text):
paragraph = body.cloneNode(deep=True)
while f'{index:08X}' in used:
index += 1
paragraph.setAttributeNS(W14, 'w14:paraId', f'{index:08X}')
used.add(f'{index:08X}')
_set_text(paragraph, text)
_set_text(paragraph, f'•\t{value}' if compact is not None else value)
if compact is not None:
_format_bullet(paragraph, compact)
body.parentNode.insertBefore(paragraph, body)
body.parentNode.removeChild(body)
output.parent.mkdir(parents=True, exist_ok=True)
@@ -151,6 +151,30 @@ class PipelineTests(unittest.TestCase):
self.assertEqual([t.text for t in paragraph.findall('.//w:t', ns)], ['Von Herzen', 'Eure Gastgeber'])
self.assertEqual(paragraph.find('w:pPr/w:jc', ns).get('{' + ns['w'] + '}val'), 'left')
def test_bullet_lines_become_hanging_list_paragraphs(self):
with tempfile.TemporaryDirectory() as temp:
target = Path(temp) / 'bullets.docx'
fill_template(TEMPLATE, target, 'Einladung', _date_label('2026-09-04'), [
'Einleitung.',
'SEPTEMBER\n•05.09.2026: Erster Stichpunkt.\n• Zweiter Stichpunkt.\nOKTOBER\n• Dritter Stichpunkt.',
'Abschluss.',
])
with zipfile.ZipFile(target) as archive:
document = ET.fromstring(archive.read('word/document.xml'))
ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
q = lambda name: '{' + ns['w'] + '}' + name
bullets = [paragraph for paragraph in document.findall('.//w:p', ns)
if ''.join(paragraph.itertext()).startswith('•')]
self.assertEqual(len(bullets), 3)
self.assertTrue({'SEPTEMBER', 'OKTOBER'} <= {''.join(p.itertext()) for p in document.findall('.//w:p', ns)})
for paragraph in bullets:
self.assertIsNotNone(paragraph.find('w:r/w:tab', ns))
self.assertEqual(paragraph.find('w:pPr/w:tabs/w:tab', ns).get(q('pos')), '480')
indent = paragraph.find('w:pPr/w:ind', ns)
self.assertEqual((indent.get(q('left')), indent.get(q('hanging'))), ('480', '360'))
self.assertEqual(bullets[0].find('w:pPr/w:spacing', ns).get(q('after')), '0')
self.assertIsNone(bullets[1].find('w:pPr/w:spacing', ns))
def test_template_fill_creates_multiple_paragraphs(self):
with tempfile.TemporaryDirectory() as temp:
target = Path(temp) / "filled.docx"
@@ -35,7 +35,9 @@ class RenderingTests(unittest.TestCase):
paragraphs = [
("Dies ist ein langer Testabsatz zur Prüfung von Schriftgröße, Zeilenumbruch und Seitenaufteilung. " * 12).strip()
for _ in range(3)
] + ["Erste Gedichtzeile\nZweite Gedichtzeile\n\nVon Herzen\nEure Gastgeber",
] + ["• Ein längerer Stichpunkt, dessen zweite Zeile unter dem Text und nicht unter dem Aufzählungszeichen beginnt.\n"
"• Ein zweiter Stichpunkt bestätigt den gleichmäßigen Abstand der Liste.",
"Erste Gedichtzeile\nZweite Gedichtzeile\n\nVon Herzen\nEure Gastgeber",
"Abschlussmarker: Dieser letzte Absatz muss vollständig auf der letzten Seite erscheinen."]
fill_template(TEMPLATE, cls.document,
"Verabschiedung von Diakon Georg aus der Gemeinde Reinickendorf",
@@ -134,6 +134,19 @@ class WebPreviewTests(unittest.TestCase):
self.assertIn('Ungültige Anfrage', self.page.locator('.sb-alert-error').inner_text())
self.assertTrue(all((self.news / f'2030-12-31-Mehrseitig_page_{n}.png').is_file() for n in range(1, 11)))
def test_page_cleans_expired_announcements_on_each_authorized_request(self):
log = self.root / 'cleanup.jsonl'
before = len(log.read_text().splitlines())
self.page.reload()
cleanup = [json.loads(line) for line in log.read_text().splitlines()]
self.assertEqual(len(cleanup), before + 1)
sql, params = cleanup[-1]
self.assertEqual(sql, 'DELETE FROM ansagen WHERE COALESCE(ansage_datum_bis, ansage_datum_von) < ?')
self.assertEqual(len(params), 1)
self.assertRegex(params[0], r'^\d{4}-\d{2}-\d{2}$')
self.page.request.get(self.url + '?deny=1', max_redirects=0)
self.assertEqual(len(log.read_text().splitlines()), len(cleanup))
def test_forms_preserve_fields_validation_and_bound_sql_parameters(self):
for tab, action, fields in (
('gottesdienst', 'create_service', {'bibeldatum': '2030-12-31', 'bibelwort': 'Joh 3,16', 'bibelvers': 'Text & "Zitat"'}),
@@ -159,6 +172,7 @@ class WebPreviewTests(unittest.TestCase):
def test_original_upload_and_delete_through_real_page(self):
self.page.locator('#news_date').fill('2030-12-31')
self.page.locator('#news_name').fill('Uploadtest')
self.page.locator('[name="format_mode"][value="original"]').check()
self.page.locator('#fileToUpload').set_input_files(self.pdf)
self.page.get_by_role('button', name='Datei hochladen').click()
self.page.wait_for_selector('.sb-alert', timeout=60000)
@@ -202,9 +216,9 @@ class WebPreviewTests(unittest.TestCase):
self.page.get_by_role('button', name='Datei hochladen').click()
self.page.wait_for_selector('.sb-alert')
self.assertEqual(states[-1], ['block', True])
self.assertIn('erfolgreich hochgeladen', self.page.locator('.sb-alert').inner_text())
self.assertIn('Hintergrund', self.page.locator('.sb-alert').inner_text())
self.assertIn('E-Mail', self.page.locator('.sb-alert').inner_text())
message = self.page.locator('.sb-alert').inner_text()
self.assertIn('Hintergrund', message)
self.assertIn('E-Mail', message)
self.assertFalse(list(self.news.glob('*' + title + '*')))
self.assertTrue(self.page.get_by_role('button', name='Datei hochladen').is_enabled())
self.assertEqual(self.page.locator('.sb-progress, .sb-processing-note').count(), 0)
@@ -9,7 +9,9 @@ class TestStatement extends PDOStatement
public function execute(?array $params = null): bool
{
if (!str_starts_with($this->sql, 'SELECT')) {
file_put_contents($_SERVER['DOCUMENT_ROOT'] . '/writes.jsonl', json_encode([$this->sql, $params]) . "\n", FILE_APPEND);
$log = str_contains($this->sql, 'WHERE COALESCE(ansage_datum_bis, ansage_datum_von) < ?')
? '/cleanup.jsonl' : '/writes.jsonl';
file_put_contents($_SERVER['DOCUMENT_ROOT'] . $log, json_encode([$this->sql, $params]) . "\n", FILE_APPEND);
}
return true;
}
+14 -13
View File
@@ -61,12 +61,11 @@ def has_style(value, declaration):
def event_rows(cell):
blocks = cell.find_all("div", class_="calEventBox")
titles = cell.find_all(style=lambda value: has_style(value, "margin-right:.2em"))
for index, title in enumerate(titles):
time_text, place = "", "--"
block = blocks[index] if index < len(blocks) else None
for title in titles:
time_text = ""
block = title.find_parent("div", class_="calEventBox")
details = block and block.find(
style=lambda value: has_style(value, "font-weight:normal")
)
@@ -74,9 +73,10 @@ def event_rows(cell):
parts = [part.strip() for part in details.stripped_strings if part.strip()]
if parts and ":" in parts[0]:
time_text = parts.pop(0)
if parts:
place = parts[0].lstrip(", ") or "--"
yield time_text, title.get_text(strip=True), place
# Nur das Ortsfeld auswerten, nicht Trennzeichen, Datumsbereiche oder Notizen.
location = block and block.find(attrs={"name": "show_place"})
place = location.get_text(" ", strip=True).lstrip(", ") if location else ""
yield time_text, title.get_text(strip=True), "" if place == "--" else place
def next_month(month):
@@ -207,6 +207,10 @@ def add_month_section(pdf, cells, month, now, today=None, planning_note=False):
name = MONTHS[month.month - 1]
pdf.set_font("helvetica", "B", 16)
pdf.cell(160, 7, pdf_safe(f"Terminübersicht: {name.upper()} {month.year}"), 0, 1)
logo = os.path.join(os.path.dirname(__file__), "nak_logo.png")
if not os.path.isfile(logo):
raise RuntimeError(f"Logo fehlt: {logo}")
pdf.image(logo, 188, pdf.get_y() - 7.5, 11)
pdf.set_font("helvetica", "", 10)
subtitle = (
f"Die Termine für {name} befinden sich noch in Planung und können sich ändern."
@@ -230,13 +234,10 @@ def create_pdf(cells, output_path, now=None):
pdf.set_auto_page_break(auto=True, margin=15)
pdf.add_page()
logo = os.path.join(os.path.dirname(__file__), "nak_logo.png")
if not os.path.isfile(logo):
raise RuntimeError(f"Logo fehlt: {logo}")
pdf.image(logo, 188, 9.5, 11)
for index, month in enumerate(months):
if index:
if index and pdf.page_no() == 1:
pdf.add_page()
elif index:
pdf.cell(0, 6, " ", 0, 1)
add_month_section(
pdf, cells, month, now,