From ce908200942792981b3885249f0e8e8646806ec1 Mon Sep 17 00:00:00 2001 From: samuelbiasgit Date: Tue, 8 Sep 2026 18:21:51 +0200 Subject: [PATCH] camera - (real) remote control, brandschutz - configurable course, dashboard - vbb licensed API (a real vbb license, how cool) --- Dockerfile | 2 + README.md | 2 + docker-compose.local.yml | 2 + docker-compose.yml | 4 + .../_central/layouts/admin_brandschutz.css | 543 ++++++ .../projects/_central/layouts/brandschutz.css | 99 +- .../_central/scripts/admin_brandschutz.js | 695 +++++++- .../projects/_central/scripts/brandschutz.js | 856 ++++----- .../projects/adminpanel/admin_brandschutz.php | 185 +- .../projects/brandschutz/brandschutz.php | 1530 ++--------------- .../brandschutz/util/brandschutz_course.php | 443 +++++ .../brandschutz/util/brandschutz_renderer.php | 130 ++ .../projects/camera_remote/api.php | 36 + .../projects/camera_remote/camera_remote.css | 104 ++ .../projects/camera_remote/camera_remote.js | 80 + .../projects/camera_remote/camera_remote.php | 88 + .../util/camera_remote_helpers.php | 120 ++ .../projects/dashboard/dashboard.js | 6 +- .../projects/dashboard/dashboard.php | 1 + web/intranet_repo/projects/dashboard/data.php | 2 +- .../projects/dashboard/stylo.css | 5 +- .../projects/dashboard/util/bvg.php | 53 - .../projects/dashboard/util/vbb.php | 116 ++ web/intranet_repo/projects/intranet/index.php | 7 + 24 files changed, 3044 insertions(+), 2065 deletions(-) create mode 100644 web/intranet_repo/projects/brandschutz/util/brandschutz_course.php create mode 100644 web/intranet_repo/projects/brandschutz/util/brandschutz_renderer.php create mode 100644 web/intranet_repo/projects/camera_remote/api.php create mode 100644 web/intranet_repo/projects/camera_remote/camera_remote.css create mode 100644 web/intranet_repo/projects/camera_remote/camera_remote.js create mode 100644 web/intranet_repo/projects/camera_remote/camera_remote.php create mode 100644 web/intranet_repo/projects/camera_remote/util/camera_remote_helpers.php delete mode 100644 web/intranet_repo/projects/dashboard/util/bvg.php create mode 100644 web/intranet_repo/projects/dashboard/util/vbb.php diff --git a/Dockerfile b/Dockerfile index 11318e5..f6d4d83 100644 --- a/Dockerfile +++ b/Dockerfile @@ -48,6 +48,7 @@ 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 \ @@ -56,6 +57,7 @@ RUN mkdir -p \ && 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 \ diff --git a/README.md b/README.md index 0f996df..f2183b8 100644 --- a/README.md +++ b/README.md @@ -326,6 +326,8 @@ SMS_GATEWAY_URL= HA_BASE_URL= HA_TOKEN= OPENWEATHER_API_KEY= +VBB_ACCESS_ID= +VBB_API_BASE_URL=https://vbb.demo.hafas.cloud/api/fahrinfo/latest # Contentpanel / Aushang-Formatüberführung OLLAMA_GATEWAY_URL=https://ollama.pelpino.de diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 8fcd39f..b546c27 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -27,6 +27,8 @@ services: HA_BASE_URL: "${HA_BASE_URL:-}" HA_TOKEN: "${HA_TOKEN:-}" OPENWEATHER_API_KEY: "${OPENWEATHER_API_KEY:-}" + VBB_ACCESS_ID: "${VBB_ACCESS_ID:-}" + VBB_API_BASE_URL: "${VBB_API_BASE_URL:-https://vbb.demo.hafas.cloud/api/fahrinfo/latest}" OLLAMA_GATEWAY_URL: "${OLLAMA_GATEWAY_URL:-https://ollama.pelpino.de}" OLLAMA_API_KEY: "${OLLAMA_API_KEY:-}" OLLAMA_AUTH_HEADER: "${OLLAMA_AUTH_HEADER:-Authorization}" diff --git a/docker-compose.yml b/docker-compose.yml index 93710dd..0d1eb73 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -23,6 +23,8 @@ 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} + VBB_API_BASE_URL: ${VBB_API_BASE_URL:?set VBB_API_BASE_URL} 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 +40,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 +118,7 @@ services: volumes: profile_pictures: + brandschutz_media: content_news: developer_work: seelsorge_data: diff --git a/web/intranet_repo/projects/_central/layouts/admin_brandschutz.css b/web/intranet_repo/projects/_central/layouts/admin_brandschutz.css index 9547e29..20074d7 100644 --- a/web/intranet_repo/projects/_central/layouts/admin_brandschutz.css +++ b/web/intranet_repo/projects/_central/layouts/admin_brandschutz.css @@ -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; } +} diff --git a/web/intranet_repo/projects/_central/layouts/brandschutz.css b/web/intranet_repo/projects/_central/layouts/brandschutz.css index ae04a9b..9685c39 100644 --- a/web/intranet_repo/projects/_central/layouts/brandschutz.css +++ b/web/intranet_repo/projects/_central/layouts/brandschutz.css @@ -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); +} diff --git a/web/intranet_repo/projects/_central/scripts/admin_brandschutz.js b/web/intranet_repo/projects/_central/scripts/admin_brandschutz.js index a14d40c..951eeb7 100644 --- a/web/intranet_repo/projects/_central/scripts/admin_brandschutz.js +++ b/web/intranet_repo/projects/_central/scripts/admin_brandschutz.js @@ -1,15 +1,680 @@ -function openBsModal(userId) { - var modal = document.getElementById('bsModal-' + userId); - if (modal) modal.style.display = 'flex'; -} -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'; - }); - } -}); +(function () { + 'use strict'; + + function openBsModal(userId) { + var modal = document.getElementById('bsModal-' + userId); + if (modal) modal.style.display = 'flex'; + } + function closeBsModal(userId) { + var modal = document.getElementById('bsModal-' + userId); + if (modal) modal.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(); +}()); diff --git a/web/intranet_repo/projects/_central/scripts/brandschutz.js b/web/intranet_repo/projects/_central/scripts/brandschutz.js index d26343d..2deec05 100644 --- a/web/intranet_repo/projects/_central/scripts/brandschutz.js +++ b/web/intranet_repo/projects/_central/scripts/brandschutz.js @@ -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); + function escapeHtml(value) { + return String(value).replace(/[&<>'"]/g, function (character) { + return {'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[character]; + }); } - 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); - }); -} - -/* ── 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 scrollToTop() { + window.scrollTo({top: 0, behavior: 'smooth'}); + var headerHeight = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 64; + 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'; } -} -function bsScrollToTop() { - 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'; -} + function pagesFor(chapterIndex) { + return chapters[chapterIndex] ? Array.from(chapters[chapterIndex].querySelectorAll(':scope > .bs-substep')) : []; + } -/* ── 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 updateProgress() { + document.querySelectorAll('#bsMainProgress .bs-progress-step').forEach(function (step, index) { + step.classList.toggle('active', index === currentChapter); + step.classList.toggle('done', index < currentChapter); + }); + document.querySelectorAll('#bsMainProgress .bs-progress-line').forEach(function (line, index) { + line.classList.toggle('done', index < currentChapter); + }); + } + + function currentPageElement() { + return pagesFor(currentChapter)[currentPage] || null; + } + + 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'); }); + }); + return assessmentsDone && checklistsDone; + } + + 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'; } - }); - 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'); - } - }); - - // 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'; + 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'); } } - 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 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; - } - }); - - 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; -} - -/* ══════════════════════════════════════ - 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 bsGetAllQuizIds() { - return bsQuizGates.reduce(function(ids, gate) { return ids.concat(gate.quizIds); }, []); -} - -function bsRenderQuizResult(quizId) { - if (quizId === 'fill2') { - return bsCheckFill(['fill2_1', 'fill2_2', 'fill2_3']); - } - if (quizId === 'match3') { - return bsCheckDropdownMatch(); + 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(); } - var messages = bsQuizMessages[quizId] || ['Richtig!', 'Falsch: Leider falsch. Die richtige Antwort ist markiert.']; - return bsCheckMultiGeneric(quizId, messages[0], messages[1]); -} + function scoredAssessments() { + return Array.from(document.querySelectorAll('.bs-assessment[data-score="true"]')); + } -function bsGetScoreStats() { - var allIds = bsGetAllQuizIds(); - var total = allIds.length; - 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++; - }); - var percent = total > 0 ? Math.round((correct / total) * 100) : 0; - return { correct: correct, wrong: wrong, open: open, total: total, percent: percent }; -} + function scoreStats() { + var assessments = scoredAssessments(); + var correct = 0; + var wrong = 0; + assessments.forEach(function (assessment) { + if (results[assessment.id] === true) correct++; + if (results[assessment.id] === false) wrong++; + }); + 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 = - '
Zwischenstand nach Säule ' + completedSection + '
' + - '
' + - '
' + - '
Max. erreichbar: ' + maxReachable + '% (mindestens 70% erforderlich)
' + - '
' + - '
' + + function scoreCards(stats) { + return '
' + '
' + stats.correct + '
Richtig
' + '
' + stats.wrong + '
Falsch
' + '
' + stats.open + '
Offen
' + - '
' + answered + '/' + stats.total + '
Gesamt
' + + '
' + (stats.correct + stats.wrong) + '/' + stats.total + '
Gesamt
' + '
'; - - 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 = ''; - - for (var i = 0; i < 6; i++) { - var el = document.getElementById('bsSection' + i); - if (el) el.style.display = 'none'; - } - scoreEl.style.display = ''; - bsScrollToTop(); -} - -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 = '
' + (passed ? 'Schulung bestanden!' : 'Schulung nicht bestanden') + '
'; - - html += '
' + - '
' + - '
' + stats.percent + '% richtig (' + stats.correct + ' von ' + stats.total + ' Fragen)
' + - '
'; - - html += '
' + - '
' + stats.correct + '
Richtig
' + - '
' + stats.wrong + '
Falsch
' + - '
'; - - if (passed) { - html += '
' + - 'Herzlichen Glückwunsch! 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.' + - '
'; - html += '
' + - '' + - '
'; - html += ''; - } else { - html += '
' + - '
Leider nicht bestanden
' + - 'Du hast ' + stats.percent + '% der Fragen richtig beantwortet. Zum Bestehen sind mindestens 70% erforderlich. ' + - 'Bitte wiederhole die Schulung, um alle Inhalte zu verinnerlichen.' + - '
'; - html += ''; + function continueAfterChapter() { + if (currentChapter < chapters.length - 1) showPage(currentChapter + 1, 0); + else showFinalResult(); } - 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; - 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); }; -} - -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); - - 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'); - } 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.'); + function finishChapter() { + var chapterAssessments = chapters[currentChapter].querySelectorAll('.bs-assessment[data-score="true"]'); + if (chapterAssessments.length === 0) { + continueAfterChapter(); + return; } + 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 = '
Zwischenstand nach Kapitel ' + (currentChapter + 1) + '
' + + '
' + + '
Max. erreichbar: ' + maxReachable + '% (mindestens ' + settings.passingScore + '% erforderlich)
' + scoreCards(stats); + scoreSection.querySelector('.bs-actions-left').innerHTML = ''; + scoreSection.querySelector('.bs-actions-right').innerHTML = ''; + 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(); } - bsUpdatePageGate(gate.nextBtnId, gate.quizIds); -} + function showFinalResult() { + var stats = scoreStats(); + var passed = stats.percent >= Number(settings.passingScore || 70); + var body = finalSection.querySelector('.bs-body'); + body.innerHTML = '
' + escapeHtml(passed ? settings.successTitle : settings.failureTitle) + '
' + + '
' + + '
' + stats.percent + '% richtig (' + stats.correct + ' von ' + stats.total + ' Fragen)
' + scoreCards(stats); + if (passed) { + body.innerHTML += '
' + settings.successText + '
' + + '
' + + ''; + 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 { + body.innerHTML += '
Nicht bestanden
' + settings.failureText + '
' + + ''; + document.getElementById('bsRestartBtn').addEventListener('click', restartTraining); + } + chapters.forEach(function (chapter) { chapter.hidden = true; }); + if (scoreSection) scoreSection.hidden = true; + finalSection.hidden = false; + scrollToTop(); + } + 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; - }); - 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 = ''; - input.classList.remove('correct', 'wrong'); - }); - container.querySelectorAll('.bs-quiz-feedback, .bs-feedback').forEach(function(fb) { - fb.classList.remove('show', 'correct', 'wrong'); - fb.textContent = ''; - }); -} + 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'); + } + }); + } 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'); + } + }); + } 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'); + } + }); + } + return correct; + } -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; + 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 { + 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(); + } - button.classList.toggle('bs-btn-locked', !allChecked); -} - -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'); + 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); } - - 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'); - } else { - finishBtn.classList.add('bs-btn-locked'); + 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); }); + }); } -} - -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; + 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); } - options.forEach(function(opt) { container.appendChild(opt); }); }); -})(); + + 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); +}()); diff --git a/web/intranet_repo/projects/adminpanel/admin_brandschutz.php b/web/intranet_repo/projects/adminpanel/admin_brandschutz.php index 478bc90..658a5ad 100644 --- a/web/intranet_repo/projects/adminpanel/admin_brandschutz.php +++ b/web/intranet_repo/projects/adminpanel/admin_brandschutz.php @@ -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,59 +36,93 @@ 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); + 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 ' + . 'INNER JOIN user_permissions up ON up.user_id = ugi.user_id ' + . 'WHERE ugi.user_id = ? AND up."Brandschutz" = 1' + ); + $stmtUser->execute([(int) $editUserId]); + $editedUser = $stmtUser->fetch(PDO::FETCH_ASSOC); + if (!$editedUser) { + 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); + brandschutz_log_admin_message( + $pdo, + $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.' + ); + $pdo->commit(); + } catch (Throwable $exception) { + 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.'); } - - $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 ' - . 'INNER JOIN user_permissions up ON up.user_id = ugi.user_id ' - . 'WHERE ugi.user_id = ? AND up."Brandschutz" = 1' - ); - $stmtUser->execute([(int) $editUserId]); - $editedUser = $stmtUser->fetch(PDO::FETCH_ASSOC); - if (!$editedUser) { - 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); - brandschutz_log_admin_message( - $pdo, - $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.' - ); - $pdo->commit(); - } catch (Throwable $exception) { - if ($pdo->inTransaction()) { - $pdo->rollBack(); - } - throw $exception; - } - - header('Location: admin_brandschutz.php?message=5', true, 303); - exit(); } +$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, ' . 'CASE WHEN ul.user_id IS NOT NULL THEN 1 ELSE 0 END AS IstRegistriert ' @@ -110,6 +147,12 @@ unset($bsUser); + @@ -126,6 +169,51 @@ unset($bsUser);
+ + + +
@@ -133,6 +221,7 @@ unset($bsUser); Brandschutz-Schulungen
Nutzer mit Brandschutz-Rolle
+
diff --git a/web/intranet_repo/projects/brandschutz/brandschutz.php b/web/intranet_repo/projects/brandschutz/brandschutz.php index c930ffa..a2d91bb 100644 --- a/web/intranet_repo/projects/brandschutz/brandschutz.php +++ b/web/intranet_repo/projects/brandschutz/brandschutz.php @@ -1,1377 +1,153 @@ -getMessage()); - } - - if (($_POST['action'] ?? '') !== 'complete_training') { - http_response_code(400); - exit('Ungültige Aktion.'); - } - - if ((int) ($groups['Brandschutz'] ?? 0) !== 1) { - http_response_code(403); - exit('Keine Berechtigung für diese Aktion.'); - } - - $userId = (int) $_SESSION['user_id']; - $today = brandschutz_today(); - $trainingDate = $today->format('Y-m-d'); - $trainingDateDisplay = $today->format('d.m.Y'); - $dueDateDisplay = $today->modify('+' . BRANDSCHUTZ_VALIDITY_DAYS . ' days')->format('d.m.Y'); - $userName = brandschutz_display_name(is_array($user) ? $user : []); - $senderMail = trim((string) ($user['mail'] ?? '')); - - $pdo->beginTransaction(); - try { - brandschutz_store_training_date($pdo, $userId, $trainingDate, true); - brandschutz_log_admin_message( - $pdo, - $userName, - $senderMail, - 'Brandschutzschulung absolviert: ' . $userName, - $userName . ' hat die Brandschutzunterweisung am ' . $trainingDateDisplay . ' erfolgreich absolviert.' - ); - $pdo->commit(); - } catch (Throwable $exception) { - if ($pdo->inTransaction()) { - $pdo->rollBack(); - } - throw $exception; - } - - if ($senderMail !== '') { - require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/send_mail.php'; - $mailSubject = 'Brandschutzunterweisung erfolgreich absolviert'; - $mailMessage = brandschutz_greeting(is_array($user) ? $user : []) - . ",\n\nSie haben die Brandschutzunterweisung am {$trainingDateDisplay} erfolgreich absolviert." - . "\n\nIhre nächste Unterweisung ist bis zum {$dueDateDisplay} fällig." - . "\n\nVielen Dank für Ihre Teilnahme!"; - - if (!send_intranet_mail($senderMail, $mailSubject, $mailMessage)) { - error_log('Brandschutz: Bestätigungsmail an ' . $senderMail . ' konnte nicht gesendet werden.'); - } - } - - header('Location: /intranet_repo/projects/intranet/index.php?message=8', true, 303); - exit(); -} -?> - - - - - - - Brandschutzunterweisung - - - - - - - -
- Brandschutzunterweisung -
- - - - - - - - -
-
- - -
-
-
0
- Einleitung -
-
-
-
1
- Brandverhütung -
-
-
-
2
- Brandfall -
-
-
-
3
- vBsU -
-
-
-
4
- Feuerlöscher -
-
-
-
5
- Gemeinde -
-
- -
-
-
Willkommen zur Brandschutzunterweisung
- -
- Brandschutz geht uns alle an. Diese Unterweisung vermittelt Ihnen das notwendige Wissen, um Brände zu vermeiden, - im Ernstfall richtig zu handeln und Ihre Aufgaben im Rahmen des Brandschutzes wahrzunehmen. - Die Schulung vermittelt die Inhalte der für uns geltenden Brandschutzordnung nach DIN 14096 und ist in fünf Säulen gegliedert. - Die Schulung dauert nur etwa 20 Minuten und kann bequem vom Handy oder PC aus durchgeführt werden. -
- -
-
-

- Unser Brandschutz steht auf fünf Säulen: Brände vermeiden, im Brandfall richtig handeln, - sicherheitsrelevante Aufgaben pflichtbewusst erledigen, Feuerlöscher sicher einsetzen und die besonderen Gegebenheiten - unserer Kirchengemeinde kennen. Jede Säule trägt dazu bei, Menschen zu schützen, Sachschäden zu verhindern - und im Ernstfall klare Orientierung zu geben. -

-
-
- 1 - Brände vermeiden -
-
- 2 - Im Brandfall richtig handeln -
-
- 3 - Aufgaben des vBsU -
-
- 4 - Feuerlöscher nutzen -
-
- 5 - Unsere Kircheneinrichtung kennen -
-
-
-
-
-
-
-
- -
-
-
- - - - - - - - - - - - - -
-
- - - - - - - - - - +getMessage()); + } + + if (($_POST['action'] ?? '') !== 'complete_training') { + http_response_code(400); + exit('Ungültige Aktion.'); + } + if ((int) ($groups['Brandschutz'] ?? 0) !== 1) { + http_response_code(403); + exit('Keine Berechtigung für diese Aktion.'); + } + + $userId = (int) $_SESSION['user_id']; + $today = brandschutz_today(); + $trainingDate = $today->format('Y-m-d'); + $trainingDateDisplay = $today->format('d.m.Y'); + $dueDateDisplay = $today->modify('+' . BRANDSCHUTZ_VALIDITY_DAYS . ' days')->format('d.m.Y'); + $userName = brandschutz_display_name(is_array($user) ? $user : []); + $senderMail = trim((string) ($user['mail'] ?? '')); + + $pdo->beginTransaction(); + try { + brandschutz_store_training_date($pdo, $userId, $trainingDate, true); + brandschutz_log_admin_message( + $pdo, + $userName, + $senderMail, + 'Brandschutzschulung absolviert: ' . $userName, + $userName . ' hat die Brandschutzunterweisung am ' . $trainingDateDisplay . ' erfolgreich absolviert.' + ); + $pdo->commit(); + } catch (Throwable $exception) { + if ($pdo->inTransaction()) $pdo->rollBack(); + throw $exception; + } + + if ($senderMail !== '') { + require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/ini/send_mail.php'; + $mailSubject = 'Brandschutzunterweisung erfolgreich absolviert'; + $mailMessage = brandschutz_greeting(is_array($user) ? $user : []) + . ",\n\nSie haben die Brandschutzunterweisung am {$trainingDateDisplay} erfolgreich absolviert." + . "\n\nIhre nächste Unterweisung ist bis zum {$dueDateDisplay} fällig." + . "\n\nVielen Dank für Ihre Teilnahme!"; + if (!send_intranet_mail($senderMail, $mailSubject, $mailMessage)) { + error_log('Brandschutz: Bestätigungsmail an ' . $senderMail . ' konnte nicht gesendet werden.'); + } + } + + header('Location: /intranet_repo/projects/intranet/index.php?message=8', true, 303); + exit(); +} + +$courseError = null; +$course = ['title' => 'Brandschutzunterweisung', 'settings' => [], 'chapters' => []]; +try { + $course = brandschutz_course_load($pdo)['config']; +} catch (Throwable $exception) { + error_log('Brandschutz-Konfiguration: ' . $exception->getMessage()); + $courseError = 'Die Schulung konnte momentan nicht geladen werden. Bitte versuchen Sie es später erneut.'; +} +$clientSettings = [ + 'passingScore' => (int) ($course['settings']['passing_score'] ?? 70), + 'maxAttempts' => (int) ($course['settings']['max_attempts'] ?? 3), + 'successTitle' => (string) ($course['settings']['success_title'] ?? 'Schulung bestanden!'), + 'successText' => brandschutz_course_sanitize_html($course['settings']['success_text'] ?? ''), + 'failureTitle' => (string) ($course['settings']['failure_title'] ?? 'Schulung nicht bestanden'), + 'failureText' => brandschutz_course_sanitize_html($course['settings']['failure_text'] ?? ''), + 'confirmationText' => brandschutz_course_sanitize_html($course['settings']['confirmation_text'] ?? ''), +]; +?> + + + + + + + + <?= brandschutz_h((string) $course['title']) ?> + + + + + +
+ +
+ + + + + + +
+ +
Schulung nicht verfügbar
+ +
Noch keine Inhalte
Für diese Schulung wurden noch keine Kapitel angelegt.
+ +
+ $chapter): ?> + 0): ?>
+
+
+ +
+ +
+ + $chapter): ?> +
0 ? ' hidden' : '' ?>> + 0 || count($course['chapters']) === 1): ?>
+ +
Leeres Kapitel
Dieses Kapitel enthält noch keine Seiten.
+
+
+ + $page): ?> +
0 ? ' hidden' : '' ?>> +
+
Diese Seite enthält noch keine Elemente.
+ +
+
+
+ + +
+ + + + + +
+ + + + diff --git a/web/intranet_repo/projects/brandschutz/util/brandschutz_course.php b/web/intranet_repo/projects/brandschutz/util/brandschutz_course.php new file mode 100644 index 0000000..2324e37 --- /dev/null +++ b/web/intranet_repo/projects/brandschutz/util/brandschutz_course.php @@ -0,0 +1,443 @@ +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( + '
' . $html . '
', + 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.'); + } +} diff --git a/web/intranet_repo/projects/brandschutz/util/brandschutz_renderer.php b/web/intranet_repo/projects/brandschutz/util/brandschutz_renderer.php new file mode 100644 index 0000000..de325db --- /dev/null +++ b/web/intranet_repo/projects/brandschutz/util/brandschutz_renderer.php @@ -0,0 +1,130 @@ +' . brandschutz_course_sanitize_html($element['content'] ?? '') . ''; + return; + } + if ($type === 'infobox' || $type === 'warnbox') { + $class = $type === 'warnbox' ? 'bs-warnbox' : 'bs-infobox'; + echo '
'; + if (($element['title'] ?? '') !== '') echo '
' . brandschutz_h((string) $element['title']) . '
'; + echo brandschutz_course_sanitize_html($element['content'] ?? '') . '
'; + 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 '
  • ' . brandschutz_course_sanitize_html($item) . '
  • '; + echo ''; + return; + } + if ($type === 'accordion') { + foreach ($element['items'] ?? [] as $item) { + echo '
    '; + echo '
    ' . brandschutz_course_sanitize_html($item['content'] ?? '') . '
    '; + } + return; + } + if ($type === 'checklist') { + echo '
      '; + foreach ($element['items'] ?? [] as $item) { + echo '
    • '; + } + echo '
    '; + return; + } + if ($type === 'scenario') { + echo '
    ' . brandschutz_h((string) ($element['title'] ?? '')) . '
    '; + echo '
    ' . brandschutz_course_sanitize_html($element['content'] ?? '') . '
    '; + return; + } + if ($type === 'multiple_choice') { + brandschutz_render_assessment_start($element, $id, 'multiple_choice'); + echo '
    '; + foreach ($element['answers'] ?? [] as $answer) { + echo ''; + } + echo '
    '; + brandschutz_render_assessment_end($id); + return; + } + if ($type === 'fill_blank') { + brandschutz_render_assessment_start($element, $id, 'fill_blank'); + echo '
    '; + $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 ''; + } else { + echo nl2br(brandschutz_h($part)); + } + } + echo '
    '; + brandschutz_render_assessment_end($id); + return; + } + if ($type === 'matching') { + brandschutz_render_assessment_start($element, $id, 'matching'); + echo '
    '; + foreach ($element['pairs'] ?? [] as $pair) { + echo '
    ' . brandschutz_h((string) ($pair['item'] ?? '')) . ''; + echo '
    '; + } + echo '
    '; + 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 '
    Bild nicht verfügbar
    Das hinterlegte Bild konnte nicht geladen werden.
    '; + return; + } + $url = BRANDSCHUTZ_MEDIA_URL . '/' . rawurlencode($file); + echo '
    ' . brandschutz_h((string) ($element['alt'] ?? '')) . ''; + if (($element['caption'] ?? '') !== '') echo '
    ' . brandschutz_h((string) $element['caption']) . '
    '; + echo '
    '; + } +} + +function brandschutz_render_assessment_start(array $element, string $escapedId, string $kind): void +{ + echo '
    '; + echo '
    ' . brandschutz_h((string) ($element['question'] ?? '')) . '
    '; +} + +function brandschutz_render_assessment_end(string $escapedId): void +{ + echo '
    '; + echo ''; + echo '
    '; +} diff --git a/web/intranet_repo/projects/camera_remote/api.php b/web/intranet_repo/projects/camera_remote/api.php new file mode 100644 index 0000000..5b27f44 --- /dev/null +++ b/web/intranet_repo/projects/camera_remote/api.php @@ -0,0 +1,36 @@ + 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.']); +} diff --git a/web/intranet_repo/projects/camera_remote/camera_remote.css b/web/intranet_repo/projects/camera_remote/camera_remote.css new file mode 100644 index 0000000..edd42cd --- /dev/null +++ b/web/intranet_repo/projects/camera_remote/camera_remote.css @@ -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); + } +} diff --git a/web/intranet_repo/projects/camera_remote/camera_remote.js b/web/intranet_repo/projects/camera_remote/camera_remote.js new file mode 100644 index 0000000..2520731 --- /dev/null +++ b/web/intranet_repo/projects/camera_remote/camera_remote.js @@ -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); }); +}()); diff --git a/web/intranet_repo/projects/camera_remote/camera_remote.php b/web/intranet_repo/projects/camera_remote/camera_remote.php new file mode 100644 index 0000000..dd823fa --- /dev/null +++ b/web/intranet_repo/projects/camera_remote/camera_remote.php @@ -0,0 +1,88 @@ + camera_remote_csrf_token(), + 'configured' => $configured, +]; +?> + + + + + + + + + + + + +
    + Kamera-Fernbedienung +
    + +
    +
    +
    +
    +
    +

    Kamera steuern

    +

    Wählen Sie eine Kameraposition oder schalten Sie die Kamera ein beziehungsweise aus.

    +
    +
    + > +
    +
    + + + + +
    +
    +

    Allgemein

    +
    + > + > +
    +
    + +
    +

    Altar

    +
    + > + > + > + > +
    +
    + +
    +

    Musik

    +
    + > + > + > + > +
    +
    +
    +
    +
    +
    + + + + diff --git a/web/intranet_repo/projects/camera_remote/util/camera_remote_helpers.php b/web/intranet_repo/projects/camera_remote/util/camera_remote_helpers.php new file mode 100644 index 0000000..69420d0 --- /dev/null +++ b/web/intranet_repo/projects/camera_remote/util/camera_remote_helpers.php @@ -0,0 +1,120 @@ + ['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']]); + } +} diff --git a/web/intranet_repo/projects/dashboard/dashboard.js b/web/intranet_repo/projects/dashboard/dashboard.js index a1f1967..ab6d7d7 100644 --- a/web/intranet_repo/projects/dashboard/dashboard.js +++ b/web/intranet_repo/projects/dashboard/dashboard.js @@ -425,7 +425,7 @@ 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; + 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) @@ -433,7 +433,7 @@ 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: 'pünktlich', ontime: 'pünktlich', + 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); @@ -445,7 +445,7 @@ 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 BVG-Antwort'); + 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()); diff --git a/web/intranet_repo/projects/dashboard/dashboard.php b/web/intranet_repo/projects/dashboard/dashboard.php index f2555c2..60f39b7 100644 --- a/web/intranet_repo/projects/dashboard/dashboard.php +++ b/web/intranet_repo/projects/dashboard/dashboard.php @@ -45,6 +45,7 @@ $config = require __DIR__ . '/config.php';

    Abfahrten: Eichborndamm/Antonienstr.

    + Fahrplandaten: VBB

    Abfahrten werden geladen …

    diff --git a/web/intranet_repo/projects/dashboard/data.php b/web/intranet_repo/projects/dashboard/data.php index abcfcd9..5855c57 100644 --- a/web/intranet_repo/projects/dashboard/data.php +++ b/web/intranet_repo/projects/dashboard/data.php @@ -29,7 +29,7 @@ try { $payload = ['outdoor' => dashboard_fetch_outdoor_weather()]; break; case 'departures_eich': - require_once __DIR__ . '/util/bvg.php'; + require_once __DIR__ . '/util/vbb.php'; $payload = dashboard_fetch_departures(); break; default: diff --git a/web/intranet_repo/projects/dashboard/stylo.css b/web/intranet_repo/projects/dashboard/stylo.css index 2774627..c8a773c 100644 --- a/web/intranet_repo/projects/dashboard/stylo.css +++ b/web/intranet_repo/projects/dashboard/stylo.css @@ -93,10 +93,11 @@ body.dashboard { #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(80 * 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 { --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-row.is-scheduled { --departure-color: var(--nak-darkgreen); } +.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; } diff --git a/web/intranet_repo/projects/dashboard/util/bvg.php b/web/intranet_repo/projects/dashboard/util/bvg.php deleted file mode 100644 index 5d24259..0000000 --- a/web/intranet_repo/projects/dashboard/util/bvg.php +++ /dev/null @@ -1,53 +0,0 @@ -= ($cache['retryAt'] ?? 0)) { - try { - $data = dashboard_http_json('https://v6.bvg.transport.rest/stops/' . DASHBOARD_BVG_STOP . '/departures?' - . 'duration=120&results=30&bus=true&suburban=false&subway=false&tram=false' - . '&ferry=false&express=false®ional=false&remarks=false&language=de&pretty=false'); - if (!isset($data['departures']) || !is_array($data['departures'])) { - throw new RuntimeException('Unerwartete BVG-Antwort.'); - } - $now = microtime(true); - $cache = ['departures' => array_values(array_filter($data['departures'], static fn(array $d): bool => - ($d['line']['product'] ?? '') === 'bus' - && in_array($d['stop']['id'] ?? '', [DASHBOARD_BVG_STOP, '900000' . substr(DASHBOARD_BVG_STOP, 3)], true))), - 'updatedAt' => (int) round($now * 1000), 'retryAt' => min($now + 12, (floor($now / 60) + 1) * 60), - 'unavailable' => false]; - } catch (Throwable $error) { - // Letzte Antwort behalten; auch bei 429/503 nicht sofort erneut anfragen. - error_log('Dashboard BVG: ' . $error->getMessage()); - $cache['unavailable'] = true; - $cache['retryAt'] = microtime(true) + 30; - } - 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('BVG-Cache konnte nicht gespeichert werden.'); - } - } - return $cache + ['departures' => [], 'updatedAt' => 0]; - } finally { - fclose($file); - } -} diff --git a/web/intranet_repo/projects/dashboard/util/vbb.php b/web/intranet_repo/projects/dashboard/util/vbb.php new file mode 100644 index 0000000..0df4263 --- /dev/null +++ b/web/intranet_repo/projects/dashboard/util/vbb.php @@ -0,0 +1,116 @@ + $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([ + 'id' => DASHBOARD_VBB_STOP_ID, + 'duration' => 120, + 'maxJourneys' => 30, + 'products' => DASHBOARD_VBB_BUS_PRODUCTS, + 'type' => 'DEP', + 'format' => 'json', + 'lang' => 'de', + ], '', '&', PHP_QUERY_RFC3986); + $baseUrl = rtrim(intranet_env('VBB_API_BASE_URL'), '/'); + $data = dashboard_http_json($baseUrl . '/departureBoard?' . $query, [ + 'Authorization: Bearer ' . intranet_env('VBB_ACCESS_ID'), + ]); + 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); + } +} diff --git a/web/intranet_repo/projects/intranet/index.php b/web/intranet_repo/projects/intranet/index.php index 75b7e0b..5d94d50 100644 --- a/web/intranet_repo/projects/intranet/index.php +++ b/web/intranet_repo/projects/intranet/index.php @@ -88,6 +88,13 @@
    Monatsplan
    + + + +