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
' +
'
' +
- '
' + 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 = '
< Zurück zu Säule ' + completedSection + ' ';
-
- 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 += '
Schulung abschließen ';
- } 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 += '
Schulung neu starten ';
+ 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 = '
< Zurück ';
+ scoreSection.querySelector('.bs-actions-right').innerHTML = '
Weiter > ';
+ 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 + '
' +
+ '
' + settings.confirmationText + '
' +
+ '
Schulung abschließen ';
+ 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 + '
' +
+ '
Schulung neu starten ';
+ 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);
+
+
+
+
+ = htmlspecialchars($editorError, ENT_QUOTES, 'UTF-8') ?>
+
+
+
+
+ Kapitel + Hinzufügen
+
+
+
+
+ Elemente + Baustein
+
+
+
+
+
+
+
+
+
×
+
Baustein bearbeiten
+
+
+ Abbrechen
+ Übernehmen
+
+
+
+
+ Schulungseditor öffnen
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
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
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
-
-
-
-
-
-
-
-
- Schulung starten >
-
-
-
-
-
-
-
-
-
Grundlagen der Brandverhütung
-
- Die beste Brandbekämpfung ist die Brandverhütung. Durch Ordnung, Sauberkeit und umsichtiges Verhalten lassen sich die meisten Brände vermeiden.
- Die folgenden Regeln gelten für alle Räumlichkeiten unserer Kirchengemeinde.
-
-
-
-
Grundregel
- Es besteht
Rauchverbot in allen Gebäudeteilen. Brennbare Abfälle dürfen nicht in Gebäuden gelagert werden.
-
-
-
- Ordnung und Sauberkeit : Fluchtwege, Treppen und Verkehrsflächen müssen stets freigehalten werden.
- Brennbare Abfälle müssen regelmäßig und ordnungsgemäß entsorgt werden. Keine Lagerung in Gebäuden.
- Brandbekämpfungseinrichtungen : Feuerlöscher, Brandmeldeeinrichtungen, Notausgänge, Hauptschalter und Absperrschieber müssen ständig freizugänglich und gekennzeichnet sein. Bei Mängeln sofort den Gemeindevorsteher informieren.
- Flucht- und Rettungswege dürfen niemals zugestellt oder versperrt werden. Sicherheitsschilder und Fluchtpläne dürfen nicht entfernt, verdeckt oder zugestellt werden.
- Brennbare Flüssigkeiten und Gase müssen getrennt und sicher gelagert werden (nur in zugelassenen Behältern).
-
-
-
-
- < Einleitung
-
-
- Weiter >
-
-
-
-
-
-
-
Sichere Nutzung elektrischer Geräte
-
- Elektrische Defekte zählen zu den häufigsten Brandursachen. Die folgenden Regeln sind strikt einzuhalten:
-
-
-
- Geräte abschalten : Alle elektrischen Geräte müssen nach Gebrauch abgeschaltet oder vom Netz getrennt werden.
- Stand-by-Betrieb : Geräte dürfen nur im Stand-by laufen, wenn sie unter Aufsicht stehen.
- Defekte Geräte : Beschädigte Kabel, Stecker oder Geräte sofort außer Betrieb nehmen und melden.
- Mehrfachsteckdosen nicht hintereinanderschalten. Maximale Belastung beachten.
- Private Elektrogeräte nur nach Genehmigung verwenden.
-
-
-
-
Tipp
- Beim Verlassen eines Raumes immer prüfen: Sind alle Geräte ausgeschaltet? Sind Kaffeemaschine, Wasserkocher und Heizlüfter vom Netz getrennt?
-
-
-
-
- < Zurück
-
-
- Weiter >
-
-
-
-
-
-
-
Kerzen und Dekoration
-
- Offenes Feuer ist eine der häufigsten Brandursachen in Versammlungsräumen. Für unsere Kirchengemeinde gelten besondere Regeln:
-
-
-
- Adventskränze und -gestecke nur mit elektrischen Kerzen erlaubt.
- Kerzen am Altar dürfen nur unter ständiger Aufsicht brennen. Löschgerät in Griffweite bereithalten.
- Dekoration nur aus schwerentflammbarem Material. Abstand zu Wärmequellen einhalten.
- Schweiß- und Trennschleifarbeiten nur mit schriftlicher Genehmigung und festgelegten Brandschutzmaßnahmen.
-
-
-
-
- < Zurück
-
-
- Weiter zum Quiz >
-
-
-
-
-
-
-
Testen Sie Ihr Wissen
-
-
-
1. Welche Aussage zur Lagerung brennbarer Materialien ist korrekt?
-
-
-
- ●
- Brennbare Abfälle dürfen kurzzeitig im Flur gelagert werden.
-
-
-
- ●
- Brennbare Flüssigkeiten und Gase müssen getrennt und in zugelassenen Behältern gelagert werden.
-
-
-
- ●
- Brennbare Materialien dürfen neben Feuerlöschern abgestellt werden.
-
-
-
-
- Antwort prüfen
-
-
-
-
-
2. Was gilt für Adventskränze in unseren Kirchenräumen?
-
-
-
- ●
- Echte Kerzen sind erlaubt, wenn jemand daneben steht.
-
-
-
- ●
- Nur elektrische Kerzen sind auf Adventskränzen und -gestecken erlaubt.
-
-
-
- ●
- Kerzen auf Adventskränzen sind generell verboten, auch elektrische.
-
-
-
-
- Antwort prüfen
-
-
-
-
-
3. Wann dürfen Schweiß- und Trennschleifarbeiten durchgeführt werden?
-
-
-
- ●
- Jederzeit, wenn ein Feuerlöscher in der Nähe steht.
-
-
-
- ●
- Nur mit schriftlicher Genehmigung und festgelegten Brandschutzmaßnahmen.
-
-
-
- ●
- Nur am Wochenende, wenn niemand im Gebäude ist.
-
-
-
-
- Antwort prüfen
-
-
-
-
-
- < Zurück
-
-
- Weiter zu Säule 2 >
-
-
-
-
-
-
-
-
-
-
Sofortmaßnahmen bei Brandentdeckung
-
- Wird ein Brand entdeckt, ist schnelles, aber besonnenes Handeln entscheidend.
- Panik ist der größte Feind, bleiben Sie ruhig und handeln Sie systematisch.
-
-
-
-
Oberste Regel
-
Ruhe bewahren! Überlegtes Handeln rettet Leben. Panik führt zu Fehlentscheidungen und gefährdet Sie und andere.
-
-
-
- Folgende Sofortmaßnahmen sind bei Brandentdeckung zu ergreifen und sollten bei Möglichkeit an mehrere Personen delegiert werden.
-
-
-
- A Alarmieren : Feuerwehr über Notruf 112 alarmieren.
- L Leute warnen : Alle Anwesenden informieren, laut und deutlich warnen.
- A Assistieren : Gefährdete und hilfebedürftige Personen unterstützen.
- R Räumen : Gebäude über die gekennzeichneten Fluchtwege verlassen, Aufzug nicht benutzen und Sammelstelle aufsuchen.
- M Möglichst löschen : Entstehungsbrand nur bekämpfen, wenn dies gefahrlos möglich ist; Feuerlöscher einsetzen.
-
-
-
-
-
- < Säule 1
-
-
- Weiter >
-
-
-
-
-
-
-
Der Notruf und die 5 W-Fragen
-
- Die Feuerwehr benötigt bei jedem Notruf klare Informationen. Merken Sie sich die 5 W-Fragen :
-
-
-
- Wo brennt es? (Adresse, Gebäudeteil, Stockwerk)
- Was brennt? (Art und Umfang des Brandes)
- Wie viele Personen sind betroffen/verletzt?
- Welche Verletzungen liegen vor?
- Warten auf Rückfragen der Leitstelle!
-
-
-
-
Unsere Adresse für den Notruf
-
Klixstraße 17, 13403 Berlin
- Diese Adresse findest du innen an der rechten Tür des Haupteingangs (Fluchttür).
-
-
-
-
- < Zurück
-
-
- Weiter >
-
-
-
-
-
-
-
Evakuierung und Flucht
-
- Im Brandfall ist die geordnete Evakuierung aller Personen oberstes Ziel.
-
-
-
- Fluchtweg nutzen : Immer die gekennzeichneten Fluchtwege verwenden. Aufzüge nicht benutzen!
- Türen schließen : Brandabschnittstüren und Rauchschutztüren schließen (nicht abschließen!).
- Sammelplatz aufsuchen : Am vereinbarten Sammelplatz einfinden und auf Vollzähligkeitsprüfung warten.
- Verrauchte Bereiche meiden : Bei Rauch: Gebückt gehen, Rauch steigt nach oben.
- Einweiser für Feuerwehr : Wenn möglich, eine Person zur Einweisung der Feuerwehr abstellen.
-
-
-
-
Warum Türen schließen?
-
Geschlossene Türen bremsen Feuer und Rauch und halten Fluchtwege länger nutzbar.
- Türen sollten deshalb geschlossen gehalten werden, aber nicht verkeilt oder blockiert werden!
-
-
-
-
Was tun, wenn der Fluchtweg versperrt ist?
-
- Wenn ein Fluchtweg durch Rauch oder Feuer versperrt ist: Gehen Sie in einen rauchfreien Raum,
- schließen Sie die Tür, dichten Sie Ritzen mit nassen Tüchern ab und machen Sie sich am Fenster
- bemerkbar. Warten Sie auf die Feuerwehr. Springen Sie niemals aus oberen Stockwerken!
-
-
-
-
- < Zurück
-
-
- Weiter zu den Übungen >
-
-
-
-
-
-
-
Szenario-Übung
-
-
-
-
-
- Szenario: Sie bemerken nach dem Gottesdienst Rauch im Treppenhaus. Mehrere Gemeindemitglieder,
- darunter zwei ältere Personen mit Gehilfe, befinden sich noch im ersten Stock. Was tun Sie?
-
-
-
- Welche Verhaltensweisen sind hier falsch ? Wählen Sie alle Antworten aus, die man nicht tun sollte.
-
-
-
-
- ●
- Schnell durch das verrauchte Treppenhaus laufen, um die älteren Personen herunterzubringen.
-
-
-
- ●
- Ruhe bewahren, 112 anrufen, alle warnen, mit den älteren Personen in einem rauchfreien Raum warten und sich am Fenster bemerkbar machen.
-
-
-
- ●
- Durch das verrauchte Treppenhaus gehen und versuchen, den Brand selbst zu löschen.
-
-
-
-
- Antwort prüfen
-
-
-
-
-
-
-
Ergänzen Sie die Lücken:
-
- Die Notrufnummer lautet .
- Die Adresse unserer Kirchengemeinde lautet
- mit Postleitzahl .
-
-
-
- Antwort prüfen
-
-
-
-
-
- < Zurück
-
-
- Weiter zu Säule 3 >
-
-
-
-
-
-
-
-
-
-
Wer ist der/die vBsU?
-
- Der/die verantwortliche Brandschutzunterwiesene (vBsU) ist die Person, die bei einer Veranstaltung
- in der Kirchengemeinde die Verantwortung für den Brandschutz übernimmt. Diese Rolle ist essenziell für die
- Sicherheit aller Anwesenden.
-
-
-
-
Kernaufgabe
- Der/die vBsU ist
30 Minuten vor Veranstaltungsbeginn anwesend und führt vor Ort eine Reihe von
- Prüfungen und Vorbereitungen durch, um die Sicherheit zu gewährleisten.
-
-
-
-
-
- Jedes volljährige Gemeindemitglied, das diese Brandschutzunterweisung erfolgreich absolviert hat und bereit ist,
- die Verantwortung zu übernehmen. Eine Liste der aktuellen vBsU liegt beim Gemeindevorsteher aus.
-
-
-
-
-
- < Säule 2
-
-
- Weiter >
-
-
-
-
-
-
-
Vor der Veranstaltung
-
- Der/die vBsU führt 30 Minuten vor Beginn folgende Prüfungen durch:
-
-
-
-
- ●
- Fluchtwege und Notausgänge frei und benutzbar?
-
-
- ●
- Feuerlöscher vorhanden und zugänglich?
-
-
- ●
- Notbeleuchtung funktionsfähig?
-
-
- ●
- Sammelplatz (Bürgersteig, Parkplatz) bekannt und zugänglich?
-
-
- ●
- Hilfebedürftige Personen identifiziert?
-
-
- ●
- Helfer eingeteilt (Evakuierung, Notruf, Einweisung)?
-
-
- ●
- Durchsagetext und Notrufadressen griffbereit?
-
-
-
-
-
- < Zurück
-
-
- Weiter >
-
-
-
-
-
-
-
Im Notfall
-
- Im Brandfall übernimmt der/die vBsU die Koordination aller Maßnahmen:
-
-
-
- Evakuierung einleiten : Durchsage vom Pult verlesen (rotes, laminiertes DIN-A4-Blatt).
- Notruf veranlassen : 112 anrufen oder delegieren.
- Helfer koordinieren : Aufgaben an vorher eingeteilte Helfer vergeben.
- Gebäude kontrollieren : Sicherstellen, dass alle Räume geräumt sind.
- Vollzähligkeitsprüfung : Am Sammelplatz die Anwesenheit prüfen.
- Feuerwehr einweisen : Informationen zu Brandort, vermissten Personen und Gefahren geben.
-
-
-
-
Durchsage im Notfall
- Der vorbereitete Text zur Räumung des Gebäudes wird vom Pult aus verlesen. So hören alle Anwesenden
- gleichzeitig die gleiche, klare Ansage. Der Text liegt als rotes, laminiertes DIN-A4-Blatt bereit.
-
-
-
-
- < Zurück
-
-
- Weiter zu den Übungen >
-
-
-
-
-
-
-
Zuordnungsübung und Quiz
-
-
Übung: Ordnen Sie jede Aufgabe der richtigen Phase zu.
-
-
-
- Fluchtwege prüfen
-
- -- Bitte wählen --
- Vor der Veranstaltung
- Im Notfall
-
-
-
- Evakuierungsdurchsage
-
- -- Bitte wählen --
- Vor der Veranstaltung
- Im Notfall
-
-
-
- Hilfebedürftige identifizieren
-
- -- Bitte wählen --
- Vor der Veranstaltung
- Im Notfall
-
-
-
- Vollzähligkeitsprüfung
-
- -- Bitte wählen --
- Vor der Veranstaltung
- Im Notfall
-
-
-
-
-
- Antwort prüfen
-
-
-
-
Wie lange vor Veranstaltungsbeginn muss der/die vBsU anwesend sein?
-
-
-
- ●
- 15 Minuten vorher
-
-
-
- ●
- 30 Minuten vorher
-
-
-
- ●
- 60 Minuten vorher
-
-
-
-
- Antwort prüfen
-
-
-
-
-
Was ist die erste Handlung des/der vBsU im Brandfall?
-
-
-
- ●
- Den Brand selbst löschen.
-
-
-
- ●
- Die Evakuierung einleiten und per Durchsage alle Anwesenden warnen.
-
-
-
- ●
- Warten, bis die Feuerwehr eintrifft.
-
-
-
-
- Antwort prüfen
-
-
-
-
-
- < Zurück
-
-
- Weiter zu Säule 4 >
-
-
-
-
-
-
-
-
-
-
Grundsätze der Brandbekämpfung
-
- Feuerlöscher sind für die Bekämpfung von Entstehungsbränden gedacht, also für kleine,
- gerade entstandene Brände. Bei einem ausgedehnten Brand ist sofortige Evakuierung Pflicht!
-
-
-
-
Wichtigster Grundsatz
-
Personenrettung geht IMMER vor Brandbekämpfung!
- Bringen Sie zuerst alle Personen in Sicherheit, bevor Sie versuchen, einen Brand zu löschen.
-
-
-
- Rückzugsweg freihalten : Löschen Sie immer mit dem Rücken zum Ausgang, damit Sie jederzeit fliehen können.
- Feuerlöscher erst am Brandherd aktivieren : Nicht schon auf dem Weg zum Brand auslösen (begrenzte Löschdauer: ca. 10-15 Sekunden).
- Windrichtung beachten : Immer mit dem Wind löschen, nicht gegen den Wind.
- Stoßweise löschen : Kurze, gezielte Stöße sind effektiver als Dauerbetrieb.
- Nicht in Flammen spritzen : An der Basis des Feuers ansetzen, von vorne nach hinten und von unten nach oben.
-
-
-
-
Wussten Sie?
- Ein handelsüblicher 6-kg-Pulverlöscher hat nur ca. 10-15 Sekunden Löschdauer. Daher: Erst am Brand aktivieren und gezielt einsetzen!
-
-
-
-
- < Säule 3
-
-
- Weiter >
-
-
-
-
-
-
-
Richtige Löschtechnik
-
Die richtige Anwendung entscheidet über den Löscherfolg:
-
-
-
- Wenn verfügbar, mehrere Feuerlöscher gleichzeitig (nicht nacheinander!) einsetzen.
- Die Löschwirkung vervielfacht sich dadurch.
-
-
-
-
-
- Von vorne nach hinten löschen. An der vorderen Kante des Feuers beginnen und
- das Löschmittel gleichmäßig über die Fläche verteilen.
-
-
-
-
-
- Von oben nach unten löschen. Der Schwerkraft folgen.
-
-
-
-
-
- < Zurück
-
-
- Weiter >
-
-
-
-
-
-
-
Besonderheiten und Gefahren
-
-
- Elektrobrände : Niemals mit Wasser löschen! CO2-Löscher oder Pulverlöscher verwenden. Gerät wenn möglich vom Netz trennen.
- Fettbrände : Niemals mit Wasser löschen (Fettexplosion)! Deckel drauf, Löschdecke oder speziellen Fettbrandlöscher verwenden.
- Nach dem Löschen : Brandstelle nicht verlassen, Rückzündung möglich. Feuerwehr informieren. Benutzten Feuerlöscher nicht wieder aufhängen, sondern sofort zur Neubefüllung melden.
-
-
-
-
Nach dem Löschen
- Einen benutzten Feuerlöscher immer sofort als benutzt kennzeichnen und die Neubefüllung veranlassen, auch wenn er nur kurz ausgelöst wurde.
-
-
-
-
- < Zurück
-
-
- Weiter zum Quiz >
-
-
-
-
-
-
-
Testen Sie Ihr Wissen
-
-
-
-
-
-
-
- Szenario: Sie entdecken einen kleinen Brand an einer Steckdosenleiste im Gemeindesaal.
- Die Steckdosenleiste steht unter Strom. In der Nähe befinden sich ein CO2-Löscher
- und ein Eimer Wasser. Was tun Sie?
-
-
-
-
-
-
-
- < Zurück
-
-
- Weiter zu Säule 5 >
-
-
-
-
-
-
-
-
-
-
Unsere Räumlichkeiten und Ausstattung
-
-
- In den vorangegangenen Säulen hast du die allgemeinen Grundlagen des Brandschutzes kennengelernt.
- Jetzt wird es konkret: Wir schauen uns an, wie der Brandschutz in unserer Kirchengemeinde
- organisiert ist, wo sich Feuerlöscher und Notleuchten befinden, wie die Räumung abläuft und
- welche Besonderheiten unsere Rettungswege haben.
-
-
-
- Unsere Gemeinde auf einen Blick:
- Unsere Kirchengemeinde fasst 180 bis 200 Personen . Wir verfügen über
- keine Brandmeldeanlage , umso wichtiger ist es, dass jeder die Abläufe
- im Brandfall kennt und weiß, wo sich die wichtigsten Hilfsmittel befinden.
-
-
-
- Standorte der Feuerlöscher:
-
-
- Vorraum am seitlichen Ausgang
- Treppenhaus 1. OG
- Hinterer Kirchenanbau neben der Toilette
-
-
-
- Wichtig: Die Adresse unserer Kirchengemeinde (für den Notruf) findest du
- innen an der rechten Tür des Haupteingangs (Fluchttür): Klixstraße 17, 13403 Berlin .
-
-
-
-
- < Säule 4
-
-
- Weiter >
-
-
-
-
-
-
-
Rettungswege und Gefahrenstellen
-
-
- Ausgänge aus dem Kirchenschiff:
-
-
- Haupteingang (Achtung: Stufen!)
- Hinterer Kirchenanbau (Achtung: Stufen!)
- Seitlicher Eingang (Achtung: Gegenverkehr von der Empore!)
-
-
-
- Potenzielle Gefahrenstellen auf den Rettungswegen:
-
-
- Stufen zwischen Kirchenschiff und Anbau
- Stufen vor dem Haupteingang
- Treppenhaus an der Empore (Gegenverkehr)
-
-
-
- Koordination im Brandfall: Die unmittelbaren Aktionen werden
- ausschließlich durch die zuständige vBsU koordiniert. Eine klare Zuständigkeit
- verhindert Chaos und widersprüchliche Anweisungen. An den potenziellen Gefahrendstellen sollte im Brandfall bei Möglichkeit eine BsU positioniert werden.
-
-
-
-
- < Zurück
-
-
- Weiter >
-
-
-
-
-
-
-
Quiz Teil 1
-
-
-
-
-
-
-
Verfügt unsere Kirchengemeinde über eine Brandmeldeanlage?
-
-
-
- ●
- Nein, wir haben keine Brandmeldeanlage.
-
-
-
- ●
- Ja, in jedem Raum sind Rauchmelder installiert.
-
-
-
- ●
- Ja, aber nur im Kirchenschiff.
-
-
-
-
- Antwort prüfen
-
-
-
-
-
Wo findest du die Adresse der Kirchengemeinde für den Notruf?
-
-
-
- ●
- Innen an der Haupteingangstür.
-
-
-
- ●
- Auf dem Altar.
-
-
-
- ●
- Im Treppenhaus 1. OG.
-
-
-
-
- Antwort prüfen
-
-
-
-
-
Wie viele Personen fasst unser Kirchenschiff?
-
-
-
- ●
- 180 bis 200 Personen.
-
-
-
- ●
- 200 bis 230 Personen.
-
-
-
- ●
- 150 bis 180 Personen.
-
-
-
-
- Antwort prüfen
-
-
-
-
-
- < Zurück
-
-
- Weiter >
-
-
-
-
-
-
-
Quiz Teil 2
-
-
-
-
-
Wer koordiniert im Brandfall die Aktionen?
-
-
-
- ●
- Die zuständige vBsU.
-
-
-
- ●
- Der Gemeindevorsteher.
-
-
-
- ●
- Anwesende BsU gemeinsam.
-
-
-
-
- Antwort prüfen
-
-
-
-
-
-
-
Wie wird die Räumung eingeleitet?
-
-
-
- ●
- Der vorbereitete Text wird vom Pult aus verlesen.
-
-
-
- ●
- Es gibt eine Alarmsirene.
-
-
-
- ●
- Die anwesenden BsU koordinieren die Räumung in ihrem erreichbaren Umfeld.
-
-
-
-
- Antwort prüfen
-
-
-
-
-
Wer übernimmt die weitere Erste Hilfe im Brandfall?
-
-
-
- ●
- Anwesende Ersthelfer*innen.
-
-
-
- ●
- Die vBsU.
-
-
-
- ●
- Niemand, wir warten auf den Rettungsdienst.
-
-
-
-
- Antwort prüfen
-
-
-
-
-
-
- < Zurück
-
-
- Weiter >
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+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
= brandschutz_h($courseError) ?>
+
+
Noch keine Inhalte
Für diese Schulung wurden noch keine Kapitel angelegt.
+
+
+ $chapter): ?>
+ 0): ?>
+
+
= $chapterIndex ?>
+
= brandschutz_h((string) $chapter['short_title']) ?>
+
+
+
+
+ $chapter): ?>
+
0 ? ' hidden' : '' ?>>
+ 0 || count($course['chapters']) === 1): ?>
+
+ Leeres Kapitel
Dieses Kapitel enthält noch keine Seiten.
+
+
+
+ $page): ?>
+ 0 ? ' hidden' : '' ?>>
+
= brandschutz_h((string) $page['title']) ?>
+
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 '' . $tag . '>';
+ 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 brandschutz_course_sanitize_html($item) . ' ';
+ }
+ echo ' ';
+ return;
+ }
+ if ($type === 'scenario') {
+ echo '';
+ 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_h((string) ($answer['text'] ?? '')) . ' ';
+ }
+ 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 '-- Bitte wählen -- ';
+ foreach ($element['choices'] ?? [] as $choice) {
+ echo '' . brandschutz_h((string) ($choice['label'] ?? '')) . ' ';
+ }
+ 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 ' ';
+ 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 'Antwort prüfen ';
+ 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 steuern
+
Wählen Sie eine Kameraposition oder schalten Sie die Kamera ein beziehungsweise aus.
+
+
+ >
+
+
+
+
+ >
+ = htmlspecialchars($statusText, ENT_QUOTES, 'UTF-8') ?>
+
+
+
+
+
+
+
+
+
+
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 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
+
+
+
+
+ Kamera
+
+