24 changed files with 3044 additions and 2065 deletions
+2
View File
@@ -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 \
+2
View File
@@ -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
+2
View File
@@ -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}"
+4
View File
@@ -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:
@@ -355,3 +355,546 @@
.admin-modal-actions { flex-direction: column-reverse; }
.admin-modal-actions button { width: 100%; }
}
/* Visueller Schulungseditor */
body.bs-editor-open {
overflow: hidden;
}
.bs-editor-shell [hidden],
.bs-editor-overlay[aria-hidden="true"],
.bs-course-editor-overlay[aria-hidden="true"] {
display: none !important;
}
.bs-course-editor-overlay {
z-index: 10010;
align-items: center;
justify-content: center;
padding: 12px;
box-sizing: border-box;
}
.bs-editor-shell {
width: min(1400px, calc(100vw - 24px));
max-height: calc(100dvh - 24px);
overflow: auto;
margin: 0;
border: 1px solid rgba(120, 140, 160, 0.22);
background: #fff;
box-shadow: 0 18px 42px rgba(31, 38, 46, 0.1);
}
.bs-course-editor-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
padding: 0;
border: 1px solid var(--gray-300);
background: #fff;
color: var(--gray-800);
font-size: 1.3rem;
cursor: pointer;
}
.bs-editor-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
padding: 18px 20px;
border-bottom: 1px solid var(--gray-200);
background: linear-gradient(135deg, hsl(25, 100%, 97%), #fff);
}
.bs-editor-header h1 {
margin: 0 0 3px;
font-size: 1.25rem;
color: var(--gray-900);
}
.bs-editor-header p {
margin: 0;
color: var(--gray-600);
font-size: var(--font-size-sm);
}
.bs-editor-header-actions,
.bs-editor-item-controls,
.bs-editor-modal-actions {
display: flex;
align-items: center;
gap: 7px;
}
.bs-editor-button,
.bs-editor-panel-title button,
.bs-editor-icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 34px;
padding: 7px 11px;
border: 1px solid var(--gray-300);
background: #fff;
color: var(--gray-800);
font: 700 var(--font-size-xs)/1 var(--font-primary);
text-decoration: none;
cursor: pointer;
}
.bs-editor-button:hover,
.bs-editor-panel-title button:hover,
.bs-editor-icon-button:hover {
border-color: var(--nak-orange);
background: hsl(25, 100%, 97%);
}
.bs-editor-button--primary {
border-color: hsl(25, 90%, 42%);
background: var(--nak-orange);
color: #fff;
}
.bs-editor-button--primary:hover {
background: hsl(25, 95%, 40%);
color: #fff;
}
.bs-editor-button:disabled,
.bs-editor-panel-title button:disabled,
.bs-editor-icon-button:disabled {
opacity: 0.4;
cursor: default;
}
.bs-editor-grid {
display: grid;
grid-template-columns: minmax(220px, 0.8fr) minmax(220px, 0.8fr) minmax(340px, 1.6fr);
min-height: 460px;
}
.bs-editor-panel {
min-width: 0;
border-right: 1px solid var(--gray-200);
background: var(--gray-50);
}
.bs-editor-panel:last-child {
border-right: 0;
}
.bs-editor-panel-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
min-height: 42px;
padding: 10px 12px;
border-bottom: 1px solid var(--gray-200);
background: #fff;
font-size: var(--font-size-sm);
font-weight: 850;
}
.bs-editor-list,
.bs-element-list {
display: grid;
align-content: start;
gap: 8px;
padding: 10px;
}
.bs-editor-list-item,
.bs-element-card {
border: 1px solid var(--gray-200);
background: #fff;
box-shadow: 0 3px 10px rgba(31, 38, 46, 0.05);
cursor: grab;
}
.bs-editor-list-item {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 10px;
}
.bs-editor-list-item.active {
border-color: var(--nak-orange);
box-shadow: 0 0 0 2px hsla(25, 90%, 50%, 0.12);
}
.bs-editor-list-item.dragging,
.bs-element-card.dragging {
opacity: 0.45;
}
.bs-editor-item-text {
display: grid;
gap: 3px;
min-width: 0;
}
.bs-editor-item-text strong,
.bs-editor-item-text span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.bs-editor-item-text strong {
font-size: var(--font-size-sm);
color: var(--gray-900);
}
.bs-editor-item-text span {
font-size: 0.72rem;
color: var(--gray-600);
}
.bs-editor-icon-button {
width: 27px;
min-height: 27px;
padding: 3px;
}
.bs-editor-icon-button--danger {
color: hsl(0, 65%, 43%);
}
.bs-element-card {
padding: 12px;
}
.bs-element-card-heading,
.bs-editor-repeat-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.bs-element-type {
display: inline-flex;
padding: 4px 8px;
border: 1px solid hsl(25, 80%, 82%);
background: hsl(25, 100%, 96%);
color: hsl(25, 80%, 34%);
font-size: 0.7rem;
font-weight: 850;
text-transform: uppercase;
}
.bs-element-summary {
margin: 10px 0 0;
color: var(--gray-700);
font-size: var(--font-size-xs);
line-height: 1.5;
}
.bs-editor-empty {
padding: 30px 18px;
border: 1px dashed var(--gray-300);
background: rgba(255, 255, 255, 0.65);
color: var(--gray-600);
text-align: center;
}
.bs-editor-empty p {
margin: 7px 0 0;
font-size: var(--font-size-xs);
line-height: 1.5;
}
.bs-editor-notice {
margin: 12px 20px 0;
padding: 10px 12px;
border: 1px solid hsl(210, 35%, 82%);
background: hsl(210, 60%, 96%);
color: hsl(210, 55%, 32%);
font-size: var(--font-size-xs);
font-weight: 700;
}
.bs-editor-notice--success {
border-color: hsl(145, 42%, 76%);
background: hsl(145, 55%, 95%);
color: hsl(145, 55%, 28%);
}
.bs-editor-notice--error {
border-color: hsl(0, 55%, 80%);
background: hsl(0, 70%, 96%);
color: hsl(0, 60%, 38%);
}
.bs-editor-overlay {
z-index: 10020;
align-items: center;
justify-content: center;
}
.bs-editor-modal {
position: relative;
width: min(780px, calc(100vw - 24px));
max-height: calc(100dvh - 24px);
overflow: auto;
padding: 22px;
background: #fff;
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.35);
}
.bs-editor-modal h2 {
margin: 0 42px 18px 0;
font-size: 1.2rem;
}
.bs-editor-modal-close {
position: absolute;
top: 12px;
right: 12px;
width: 34px;
height: 34px;
border: 1px solid var(--gray-300);
background: #fff;
font-size: 1.35rem;
cursor: pointer;
}
#bsEditorModalBody {
display: grid;
gap: 13px;
}
.bs-editor-field {
display: grid;
gap: 5px;
min-width: 0;
font-size: var(--font-size-xs);
font-weight: 750;
color: var(--gray-700);
}
.bs-editor-field input,
.bs-editor-field textarea,
.bs-editor-field select,
.bs-repeat-row select {
box-sizing: border-box;
width: 100%;
min-height: 38px;
padding: 8px 10px;
border: 1px solid var(--gray-300);
background: #fff;
color: var(--gray-900);
font: 500 var(--font-size-sm)/1.45 var(--font-primary);
}
.bs-editor-field textarea {
min-height: 92px;
resize: vertical;
}
.bs-richtext {
overflow: hidden;
border: 1px solid var(--gray-300);
background: #fff;
}
.bs-richtext:focus-within {
border-color: var(--nak-orange);
box-shadow: 0 0 0 2px hsla(25, 95%, 48%, 0.14);
}
.bs-richtext-toolbar {
display: flex;
flex-wrap: wrap;
gap: 4px;
padding: 6px;
border-bottom: 1px solid var(--gray-200);
background: var(--gray-50);
}
.bs-richtext-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 31px;
height: 30px;
padding: 0 8px;
border: 1px solid transparent;
background: transparent;
color: var(--gray-800);
font: 700 var(--font-size-sm)/1 var(--font-primary);
cursor: pointer;
}
.bs-richtext-button:nth-child(2) {
font-style: italic;
}
.bs-richtext-button:nth-child(3) {
text-decoration: underline;
}
.bs-richtext-button:hover,
.bs-richtext-button:focus-visible {
border-color: var(--gray-300);
background: #fff;
}
.bs-richtext-button--text {
width: auto;
}
.bs-richtext-editor {
min-height: 108px;
padding: 10px 12px;
background: #fff;
color: var(--gray-900);
font: 500 var(--font-size-sm)/1.55 var(--font-primary);
outline: none;
}
.bs-richtext-editor p {
margin: 0 0 0.75em;
}
.bs-richtext-editor p:last-child {
margin-bottom: 0;
}
.bs-richtext-editor a {
color: hsl(25, 90%, 38%);
text-decoration: underline;
}
.bs-repeat-row .bs-richtext-editor {
min-height: 72px;
}
.bs-editor-field small {
font-weight: 500;
color: var(--gray-500);
}
.bs-editor-check {
display: flex;
align-items: center;
gap: 8px;
font-size: var(--font-size-xs);
font-weight: 700;
}
.bs-editor-check input {
width: 18px;
height: 18px;
}
.bs-editor-repeat-block {
display: grid;
gap: 8px;
padding: 12px;
border: 1px solid var(--gray-200);
background: var(--gray-50);
}
.bs-repeat-list {
display: grid;
gap: 8px;
}
.bs-repeat-row {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)) auto;
align-items: end;
gap: 8px;
padding: 9px;
border: 1px solid var(--gray-200);
background: #fff;
}
.bs-editor-modal-actions {
justify-content: flex-end;
margin-top: 18px;
padding-top: 15px;
border-top: 1px solid var(--gray-200);
}
.bs-image-upload {
display: flex;
align-items: center;
gap: 10px;
padding: 12px;
border: 1px solid var(--gray-200);
background: var(--gray-50);
}
.bs-media-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(110px, 1fr));
gap: 10px;
}
.bs-media-item {
position: relative;
min-height: 82px;
border: 2px solid var(--gray-200);
background: #fff;
color: var(--gray-700);
font: 700 var(--font-size-xs)/1.3 var(--font-primary);
cursor: pointer;
}
.bs-media-item.selected {
border-color: var(--nak-orange);
box-shadow: 0 0 0 3px hsla(25, 90%, 50%, 0.15);
}
.bs-media-item > button:first-child {
width: 100%;
height: 82px;
padding: 0;
border: 0;
background: transparent;
cursor: pointer;
}
.bs-media-item img {
display: block;
width: 100%;
height: 82px;
object-fit: cover;
}
.bs-media-delete {
position: absolute;
top: 3px;
right: 3px;
width: 25px;
height: 25px;
border: 0;
background: rgba(120, 0, 0, 0.85);
color: #fff;
cursor: pointer;
}
@media (max-width: 980px) {
.bs-editor-grid { grid-template-columns: 1fr 1fr; }
.bs-elements-panel { grid-column: 1 / -1; border-top: 1px solid var(--gray-200); }
}
@media (max-width: 680px) {
.bs-editor-shell { width: calc(100vw - 12px); max-height: calc(100dvh - 12px); }
.bs-course-editor-overlay { padding: 6px; }
.bs-editor-header { align-items: flex-start; flex-direction: column; }
.bs-editor-header-actions { flex-wrap: wrap; }
.bs-editor-grid { grid-template-columns: 1fr; }
.bs-elements-panel { grid-column: auto; }
.bs-editor-panel { border-right: 0; border-bottom: 1px solid var(--gray-200); }
.bs-repeat-row { grid-template-columns: 1fr; }
.bs-image-upload { align-items: stretch; flex-direction: column; }
}
@@ -528,11 +528,16 @@
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
box-sizing: border-box;
padding: 14px 18px;
border: 0;
background: var(--gray-50);
cursor: pointer;
font-family: inherit;
font-weight: 600;
font-size: var(--font-size-sm);
text-align: left;
color: var(--gray-800);
transition: background 150ms;
}
@@ -675,7 +680,9 @@
.bs-wizard { margin: 0; width: 100%; }
.bs-progress { padding: 14px 8px 10px; }
.bs-progress-step[data-section="0"],
.bs-progress-step[data-section="0"] + .bs-progress-line {
.bs-progress-step[data-section="0"] + .bs-progress-line,
.bs-progress-step[data-chapter-index="0"],
.bs-progress-step[data-chapter-index="0"] + .bs-progress-line {
display: none;
}
.bs-progress-label { font-size: 0.6rem; max-width: 48px; }
@@ -692,3 +699,93 @@
.bs-intro-pillar-number { margin-bottom: 0; flex-shrink: 0; }
.bs-score-grid { grid-template-columns: repeat(2, 1fr); }
}
/* Konfigurationsbasierter Renderer */
.bs-wizard [hidden] {
display: none !important;
}
.bs-progress {
overflow-x: auto;
}
.bs-checklist-item {
display: flex;
align-items: flex-start;
gap: 12px;
width: 100%;
padding: 0;
border: 0;
background: transparent;
color: inherit;
font: inherit;
line-height: inherit;
text-align: left;
cursor: pointer;
}
.bs-image {
margin: 22px 0;
text-align: center;
}
.bs-image img {
display: block;
width: auto;
max-width: 100%;
max-height: 70vh;
margin: 0 auto;
object-fit: contain;
box-shadow: 0 8px 28px rgba(31, 38, 46, 0.14);
}
.bs-image figcaption {
margin-top: 8px;
color: var(--gray-600);
font-size: var(--font-size-xs);
}
.bs-fill-input {
min-width: 100px;
max-width: 220px;
margin: 3px 5px;
}
.bs-empty-state {
padding: 24px;
border: 1px dashed var(--gray-300);
color: var(--gray-600);
text-align: center;
}
.bs-result-message,
.bs-confirmation {
margin-top: 20px;
}
.bs-confirmation {
padding: 20px;
border: 1px solid var(--gray-200);
background: var(--gray-50);
}
.bs-confirmation label {
display: flex;
align-items: flex-start;
gap: 12px;
cursor: pointer;
}
.bs-confirmation input {
width: 20px;
height: 20px;
margin-top: 2px;
}
#bsFinishBtn,
#bsRestartBtn {
width: 100%;
margin-top: 24px;
padding: 16px;
font-size: var(--font-size-md);
}
@@ -1,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();
}());
@@ -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 {'&': '&amp;', '<': '&lt;', '>': '&gt;', "'": '&#39;', '"': '&quot;'}[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 =
'<div class="bs-chapter-title">Zwischenstand nach Säule ' + completedSection + '</div>' +
'<div class="bs-score-bar-wrap">' +
'<div class="bs-score-bar"><div class="bs-score-bar-fill" style="width:' + maxReachable + '%;background:' + barColor + '"></div></div>' +
'<div class="bs-score-bar-label">Max. erreichbar: ' + maxReachable + '% (mindestens 70% erforderlich)</div>' +
'</div>' +
'<div class="bs-score-grid">' +
function scoreCards(stats) {
return '<div class="bs-score-grid">' +
'<div class="bs-score-card bs-score-correct"><div class="bs-score-card-num">' + stats.correct + '</div><div class="bs-score-card-label">Richtig</div></div>' +
'<div class="bs-score-card bs-score-wrong"><div class="bs-score-card-num">' + stats.wrong + '</div><div class="bs-score-card-label">Falsch</div></div>' +
'<div class="bs-score-card bs-score-open"><div class="bs-score-card-num">' + stats.open + '</div><div class="bs-score-card-label">Offen</div></div>' +
'<div class="bs-score-card"><div class="bs-score-card-num">' + answered + '/' + stats.total + '</div><div class="bs-score-card-label">Gesamt</div></div>' +
'<div class="bs-score-card"><div class="bs-score-card-num">' + (stats.correct + stats.wrong) + '/' + stats.total + '</div><div class="bs-score-card-label">Gesamt</div></div>' +
'</div>';
var actionsRight = scoreEl.querySelector('.bs-actions-right');
actionsRight.innerHTML = '';
if (typeof nextAction === 'function') {
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'bs-btn bs-btn-primary';
btn.textContent = completedSection < 5 ? 'Weiter zu Säule ' + (completedSection + 1) + ' >' : 'Zum Ergebnis >';
btn.onclick = nextAction;
actionsRight.appendChild(btn);
}
var actionsLeft = scoreEl.querySelector('.bs-actions-left');
actionsLeft.innerHTML = '<button type="button" class="bs-btn" onclick="bsGoToSection(' + completedSection + ')">< Zurück zu Säule ' + completedSection + '</button>';
for (var i = 0; i < 6; i++) {
var el = document.getElementById('bsSection' + i);
if (el) el.style.display = 'none';
}
scoreEl.style.display = '';
bsScrollToTop();
}
function bsFinalResultAction() {
document.getElementById('bsSectionScore').style.display = 'none';
bsShowFinalResult();
document.getElementById('bsSection5').style.display = '';
bsCurrentSub[5] = 5;
bsShowSubStep(5, 5);
bsScrollToTop();
}
function bsShowFinalResult() {
var stats = bsGetScoreStats();
var passed = stats.percent >= 70;
var section = document.getElementById('bsSub5_5');
var body = section.querySelector('.bs-body');
var barColor = passed ? 'var(--bs-green)' : 'var(--bs-red)';
var html = '<div class="bs-chapter-title">' + (passed ? 'Schulung bestanden!' : 'Schulung nicht bestanden') + '</div>';
html += '<div class="bs-score-bar-wrap">' +
'<div class="bs-score-bar"><div class="bs-score-bar-fill" style="width:' + stats.percent + '%;background:' + barColor + '"></div></div>' +
'<div class="bs-score-bar-label">' + stats.percent + '% richtig (' + stats.correct + ' von ' + stats.total + ' Fragen)</div>' +
'</div>';
html += '<div class="bs-score-grid">' +
'<div class="bs-score-card bs-score-correct"><div class="bs-score-card-num">' + stats.correct + '</div><div class="bs-score-card-label">Richtig</div></div>' +
'<div class="bs-score-card bs-score-wrong"><div class="bs-score-card-num">' + stats.wrong + '</div><div class="bs-score-card-label">Falsch</div></div>' +
'</div>';
if (passed) {
html += '<div class="bs-successbox" style="margin-top:20px;">' +
'<strong>Herzlichen Glückwunsch!</strong> Du hast die Brandschutzunterweisung erfolgreich absolviert. ' +
'Du kennst jetzt die Grundlagen der Brandverhütung, weißt, wie du dich im Brandfall verhältst, ' +
'kennst die Aufgaben der vBsU, kannst einen Feuerlöscher richtig einsetzen und weißt, wo sich die wichtigsten ' +
'Einrichtungen in unserer Kirchengemeinde befinden.' +
'</div>';
html += '<div style="margin-top:24px;padding:20px;background:var(--gray-50);border:1px solid var(--gray-200);">' +
'<label style="display:flex;align-items:flex-start;gap:12px;cursor:pointer;">' +
'<input type="checkbox" id="bsConfirmCheckbox" onchange="bsToggleConfirm()" style="width:20px;height:20px;margin-top:2px;">' +
'<span style="font-size:var(--font-size-sm);color:var(--gray-800);line-height:1.6;">' +
'Ich bestätige, dass ich die Inhalte der <a href="/intranet_repo/projects/brandschutz/Brandschutzordnung.pdf" target="_blank" onclick="event.stopPropagation();">Brandschutzordnung</a> verstanden und verinnerlicht habe. ' +
'Ich bin bereit, die Aufgaben als vBsU zu übernehmen, sofern ich dazu eingeteilt werde.' +
'</span>' +
'</label>' +
'</div>';
html += '<button type="button" class="bs-btn bs-btn-primary bs-btn-locked" id="bsFinishBtn" onclick="bsFinishTraining()" style="margin-top:24px;width:100%;padding:16px;font-size:var(--font-size-md);">Schulung abschließen</button>';
} else {
html += '<div class="bs-warnbox" style="margin-top:20px;">' +
'<div class="bs-warnbox-title">Leider nicht bestanden</div>' +
'Du hast ' + stats.percent + '% der Fragen richtig beantwortet. Zum Bestehen sind mindestens 70% erforderlich. ' +
'Bitte wiederhole die Schulung, um alle Inhalte zu verinnerlichen.' +
'</div>';
html += '<button type="button" class="bs-btn bs-btn-primary" onclick="bsRestartTraining()" style="margin-top:24px;width:100%;padding:16px;font-size:var(--font-size-md);">Schulung neu starten</button>';
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 = '<div class="bs-chapter-title">Zwischenstand nach Kapitel ' + (currentChapter + 1) + '</div>' +
'<div class="bs-score-bar-wrap"><div class="bs-score-bar"><div class="bs-score-bar-fill" style="width:' + maxReachable + '%;background:' + (maxReachable >= settings.passingScore ? 'var(--bs-green)' : 'var(--bs-red)') + '"></div></div>' +
'<div class="bs-score-bar-label">Max. erreichbar: ' + maxReachable + '% (mindestens ' + settings.passingScore + '% erforderlich)</div></div>' + scoreCards(stats);
scoreSection.querySelector('.bs-actions-left').innerHTML = '<button type="button" class="bs-btn bs-score-back">&lt; Zurück</button>';
scoreSection.querySelector('.bs-actions-right').innerHTML = '<button type="button" class="bs-btn bs-btn-primary bs-score-next">Weiter &gt;</button>';
scoreSection.querySelector('.bs-score-back').addEventListener('click', function () { showPage(currentChapter, currentPage); });
scoreSection.querySelector('.bs-score-next').addEventListener('click', continueAfterChapter);
chapters.forEach(function (chapter) { chapter.hidden = true; });
scoreSection.hidden = false;
scrollToTop();
}
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 = '<div class="bs-chapter-title">' + escapeHtml(passed ? settings.successTitle : settings.failureTitle) + '</div>' +
'<div class="bs-score-bar-wrap"><div class="bs-score-bar"><div class="bs-score-bar-fill" style="width:' + stats.percent + '%;background:' + (passed ? 'var(--bs-green)' : 'var(--bs-red)') + '"></div></div>' +
'<div class="bs-score-bar-label">' + stats.percent + '% richtig (' + stats.correct + ' von ' + stats.total + ' Fragen)</div></div>' + scoreCards(stats);
if (passed) {
body.innerHTML += '<div class="bs-successbox bs-result-message">' + settings.successText + '</div>' +
'<div class="bs-confirmation"><label><input type="checkbox" id="bsConfirmCheckbox"><span>' + settings.confirmationText + '</span></label></div>' +
'<button type="button" class="bs-btn bs-btn-primary bs-btn-locked" id="bsFinishBtn">Schulung abschließen</button>';
var confirm = document.getElementById('bsConfirmCheckbox');
var finish = document.getElementById('bsFinishBtn');
confirm.addEventListener('change', function () { finish.classList.toggle('bs-btn-locked', !confirm.checked); });
finish.addEventListener('click', function () {
if (confirm.checked) document.getElementById('bsCompleteForm').submit();
});
} else {
body.innerHTML += '<div class="bs-warnbox bs-result-message"><div class="bs-warnbox-title">Nicht bestanden</div>' + settings.failureText + '</div>' +
'<button type="button" class="bs-btn bs-btn-primary" id="bsRestartBtn">Schulung neu starten</button>';
document.getElementById('bsRestartBtn').addEventListener('click', restartTraining);
}
chapters.forEach(function (chapter) { chapter.hidden = true; });
if (scoreSection) scoreSection.hidden = true;
finalSection.hidden = false;
scrollToTop();
}
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);
}());
@@ -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);
<head>
<?php require_once $_SERVER['DOCUMENT_ROOT'].'/intranet_repo/projects/_central/php/html_head.php'; ?>
<link rel="stylesheet" href="/intranet_repo/projects/_central/layouts/admin_brandschutz.css">
<script id="bsEditorData" type="application/json"><?= json_encode([
'config' => $courseConfig,
'revision' => (int) $courseState['revision'],
'csrfToken' => $csrfToken,
'images' => $courseImages,
], JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) ?></script>
<script src="/intranet_repo/projects/_central/scripts/admin_brandschutz.js" defer></script>
</head>
@@ -126,6 +169,51 @@ unset($bsUser);
<div class="content" id="fullsize">
<?php $adminActiveTab = 'brandschutz'; require __DIR__.'/_tabs.php'; ?>
<div class="standard_overlay bs-course-editor-overlay" id="bsCourseEditorOverlay" aria-hidden="true">
<section class="bs-editor-shell" id="bs-course-editor" role="dialog" aria-modal="true" aria-labelledby="bsCourseEditorTitle">
<div class="bs-editor-header">
<div>
<h1 id="bsCourseEditorTitle">Schulungseditor</h1>
<p>Kapitel, Seiten, Inhalte und Prüfungsfragen zentral verwalten.</p>
</div>
<div class="bs-editor-header-actions">
<button type="button" class="bs-editor-button" id="bsEditSettings">Einstellungen</button>
<button type="button" class="bs-course-editor-close" id="bsCloseCourseEditor" aria-label="Schulungseditor schließen">×</button>
</div>
</div>
<?php if ($editorError !== null): ?><div class="bs-editor-notice bs-editor-notice--error"><?= htmlspecialchars($editorError, ENT_QUOTES, 'UTF-8') ?></div><?php endif; ?>
<div class="bs-editor-notice" id="bsEditorStatus" role="status" aria-live="polite" hidden></div>
<div class="bs-editor-grid">
<aside class="bs-editor-panel">
<div class="bs-editor-panel-title"><span>Kapitel</span><button type="button" id="bsAddChapter" aria-label="Kapitel hinzufügen">+ Hinzufügen</button></div>
<div class="bs-editor-list" id="bsChapterList"></div>
</aside>
<aside class="bs-editor-panel">
<div class="bs-editor-panel-title"><span>Seiten</span><button type="button" id="bsAddPage" aria-label="Seite hinzufügen">+ Hinzufügen</button></div>
<div class="bs-editor-list" id="bsPageList"></div>
</aside>
<main class="bs-editor-panel bs-elements-panel">
<div class="bs-editor-panel-title"><span id="bsElementPanelTitle">Elemente</span><button type="button" id="bsAddElement">+ Baustein</button></div>
<div class="bs-element-list" id="bsElementList"></div>
</main>
</div>
</section>
</div>
<div class="standard_overlay bs-editor-overlay" id="bsEditorModal" aria-hidden="true">
<div class="bs-editor-modal" role="dialog" aria-modal="true" aria-labelledby="bsEditorModalTitle">
<button type="button" class="bs-editor-modal-close" id="bsEditorModalClose" aria-label="Schließen">×</button>
<h2 id="bsEditorModalTitle">Baustein bearbeiten</h2>
<div id="bsEditorModalBody"></div>
<div class="bs-editor-modal-actions">
<button type="button" class="bs-editor-button" id="bsEditorModalCancel">Abbrechen</button>
<button type="button" class="bs-editor-button bs-editor-button--primary" id="bsEditorModalSave">Übernehmen</button>
</div>
</div>
</div>
<div class="admin-users-shell">
<div class="admin-table-card">
<div class="admin-list-header">
@@ -133,6 +221,7 @@ unset($bsUser);
<span class="admin-list-title">Brandschutz-Schulungen</span>
<div class="admin-list-hint"><?= count($bsUsers) ?> Nutzer mit Brandschutz-Rolle</div>
</div>
<button type="button" class="bs-editor-button bs-editor-button--primary" id="bsOpenCourseEditor">Schulungseditor öffnen</button>
</div>
<table class="admin-user-table" id="bsTable">
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,443 @@
<?php
declare(strict_types=1);
const BRANDSCHUTZ_COURSE_SCHEMA_VERSION = 1;
const BRANDSCHUTZ_MEDIA_MAX_BYTES = 8388608;
const BRANDSCHUTZ_MEDIA_URL = '/intranet_repo/projects/brandschutz/uploads';
function brandschutz_media_directory(): string
{
return dirname(__DIR__) . '/uploads';
}
function brandschutz_course_ensure_storage(PDO $pdo): void
{
$pdo->exec(
'CREATE TABLE IF NOT EXISTS brandschutz_course_config ('
. 'id SMALLINT PRIMARY KEY CHECK (id = 1), '
. 'config JSONB NOT NULL, '
. 'revision INTEGER NOT NULL DEFAULT 1, '
. 'updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), '
. 'updated_by BIGINT NULL)'
);
$statement = $pdo->query('SELECT 1 FROM brandschutz_course_config WHERE id = 1');
if ($statement !== false && $statement->fetchColumn() !== false) {
return;
}
$initialConfig = brandschutz_course_initial_config();
$insert = $pdo->prepare(
'INSERT INTO brandschutz_course_config (id, config, revision) '
. 'VALUES (1, CAST(? AS JSONB), 1) ON CONFLICT (id) DO NOTHING'
);
$insert->execute([json_encode($initialConfig, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR)]);
}
function brandschutz_course_initial_config(): array
{
return brandschutz_course_normalize([
'version' => BRANDSCHUTZ_COURSE_SCHEMA_VERSION,
'title' => 'Brandschutzunterweisung',
'settings' => [],
'chapters' => [],
]);
}
/** @return array{config: array, revision: int, updated_at: ?string} */
function brandschutz_course_load(PDO $pdo): array
{
brandschutz_course_ensure_storage($pdo);
$statement = $pdo->query('SELECT config, revision, updated_at FROM brandschutz_course_config WHERE id = 1');
$row = $statement !== false ? $statement->fetch(PDO::FETCH_ASSOC) : false;
if (!$row) {
throw new RuntimeException('Die Schulungskonfiguration konnte nicht geladen werden.');
}
try {
$config = json_decode((string) $row['config'], true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new RuntimeException('Die gespeicherte Schulungskonfiguration ist beschädigt.', 0, $exception);
}
return [
'config' => brandschutz_course_normalize($config),
'revision' => (int) $row['revision'],
'updated_at' => isset($row['updated_at']) ? (string) $row['updated_at'] : null,
];
}
function brandschutz_course_save(PDO $pdo, array $config, int $expectedRevision, int $userId): int
{
if ($expectedRevision < 1) {
throw new InvalidArgumentException('Ungültiger Bearbeitungsstand. Bitte laden Sie die Seite neu.');
}
$normalized = brandschutz_course_normalize($config);
$json = json_encode($normalized, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
$statement = $pdo->prepare(
'UPDATE brandschutz_course_config SET config = CAST(? AS JSONB), '
. 'revision = revision + 1, updated_at = NOW(), updated_by = ? '
. 'WHERE id = 1 AND revision = ? RETURNING revision'
);
$statement->execute([$json, $userId > 0 ? $userId : null, $expectedRevision]);
$revision = $statement->fetchColumn();
if ($revision === false) {
throw new RuntimeException('Die Schulung wurde zwischenzeitlich geändert. Bitte laden Sie die Seite neu und prüfen Sie Ihre Änderungen.');
}
return (int) $revision;
}
function brandschutz_course_string(mixed $value, int $maxLength, string $fallback = ''): string
{
$string = trim(is_scalar($value) ? (string) $value : '');
if (mb_strlen($string, 'UTF-8') > $maxLength) {
throw new InvalidArgumentException('Ein Inhalt überschreitet die maximal erlaubte Länge.');
}
return $string !== '' ? $string : $fallback;
}
function brandschutz_course_id(mixed $value, string $prefix, array &$knownIds): string
{
$id = is_string($value) ? trim($value) : '';
if (!preg_match('/^[a-zA-Z0-9_-]{1,80}$/', $id)) {
$id = $prefix . '-' . bin2hex(random_bytes(8));
}
if (isset($knownIds[$id])) {
throw new InvalidArgumentException('Die Konfiguration enthält doppelte interne IDs.');
}
$knownIds[$id] = true;
return $id;
}
function brandschutz_course_sanitize_html(mixed $value): string
{
$html = brandschutz_course_string($value, 30000);
if ($html === '') {
return '';
}
$document = new DOMDocument('1.0', 'UTF-8');
$previous = libxml_use_internal_errors(true);
$document->loadHTML(
'<?xml encoding="UTF-8"><div id="brandschutz-sanitize-root">' . $html . '</div>',
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
libxml_use_internal_errors($previous);
$root = $document->getElementById('brandschutz-sanitize-root');
if (!$root instanceof DOMElement) {
return htmlspecialchars(strip_tags($html), ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
brandschutz_course_sanitize_children($root);
$output = '';
foreach ($root->childNodes as $child) {
$output .= $document->saveHTML($child);
}
return trim($output);
}
function brandschutz_course_sanitize_children(DOMNode $parent): void
{
$allowed = ['a', 'br', 'em', 'i', 'p', 'strong', 'b', 'u'];
for ($node = $parent->firstChild; $node !== null;) {
$next = $node->nextSibling;
if ($node instanceof DOMComment) {
$parent->removeChild($node);
} elseif ($node instanceof DOMElement) {
brandschutz_course_sanitize_children($node);
$tag = strtolower($node->tagName);
if (!in_array($tag, $allowed, true)) {
while ($node->firstChild !== null) {
$parent->insertBefore($node->firstChild, $node);
}
$parent->removeChild($node);
} else {
$href = $tag === 'a' ? trim($node->getAttribute('href')) : '';
while ($node->attributes->length > 0) {
$node->removeAttributeNode($node->attributes->item(0));
}
if ($tag === 'a' && brandschutz_course_safe_link($href)) {
$node->setAttribute('href', $href);
$node->setAttribute('target', '_blank');
$node->setAttribute('rel', 'noopener noreferrer');
} elseif ($tag === 'a') {
while ($node->firstChild !== null) {
$parent->insertBefore($node->firstChild, $node);
}
$parent->removeChild($node);
}
}
}
$node = $next;
}
}
function brandschutz_course_safe_link(string $href): bool
{
if ($href === '' || str_starts_with($href, '//')) {
return false;
}
if (str_starts_with($href, '/')) {
return !str_contains($href, "\n") && !str_contains($href, "\r");
}
$scheme = strtolower((string) parse_url($href, PHP_URL_SCHEME));
return in_array($scheme, ['http', 'https', 'mailto'], true);
}
function brandschutz_course_plain_list(mixed $value, int $limit = 100): array
{
if (!is_array($value) || count($value) > $limit) {
if (is_array($value) && count($value) > $limit) {
throw new InvalidArgumentException('Ein Element enthält zu viele Einträge.');
}
return [];
}
$result = [];
foreach ($value as $item) {
$text = brandschutz_course_sanitize_html($item);
if ($text !== '') {
$result[] = $text;
}
}
return $result;
}
function brandschutz_course_normalize(mixed $input): array
{
if (!is_array($input)) {
throw new InvalidArgumentException('Die Schulungskonfiguration muss ein Objekt sein.');
}
$knownIds = [];
$chaptersInput = $input['chapters'] ?? [];
if (!is_array($chaptersInput) || count($chaptersInput) > 50) {
throw new InvalidArgumentException('Die Schulung darf höchstens 50 Kapitel enthalten.');
}
$settingsInput = is_array($input['settings'] ?? null) ? $input['settings'] : [];
$passingScore = filter_var($settingsInput['passing_score'] ?? 70, FILTER_VALIDATE_INT);
$maxAttempts = filter_var($settingsInput['max_attempts'] ?? 3, FILTER_VALIDATE_INT);
$config = [
'version' => BRANDSCHUTZ_COURSE_SCHEMA_VERSION,
'title' => brandschutz_course_string($input['title'] ?? '', 160, 'Brandschutzunterweisung'),
'settings' => [
'passing_score' => $passingScore !== false ? max(1, min(100, (int) $passingScore)) : 70,
'max_attempts' => $maxAttempts !== false ? max(1, min(10, (int) $maxAttempts)) : 3,
'success_title' => brandschutz_course_string($settingsInput['success_title'] ?? '', 200, 'Schulung bestanden!'),
'success_text' => brandschutz_course_sanitize_html($settingsInput['success_text'] ?? ''),
'failure_title' => brandschutz_course_string($settingsInput['failure_title'] ?? '', 200, 'Schulung nicht bestanden'),
'failure_text' => brandschutz_course_sanitize_html($settingsInput['failure_text'] ?? ''),
'confirmation_text' => brandschutz_course_sanitize_html($settingsInput['confirmation_text'] ?? ''),
],
'chapters' => [],
];
$pageCount = 0;
$elementCount = 0;
foreach ($chaptersInput as $chapterInput) {
if (!is_array($chapterInput)) continue;
$pagesInput = is_array($chapterInput['pages'] ?? null) ? $chapterInput['pages'] : [];
$chapter = [
'id' => brandschutz_course_id($chapterInput['id'] ?? '', 'chapter', $knownIds),
'title' => brandschutz_course_string($chapterInput['title'] ?? '', 200, 'Unbenanntes Kapitel'),
'short_title' => brandschutz_course_string($chapterInput['short_title'] ?? '', 80, 'Kapitel'),
'pages' => [],
];
foreach ($pagesInput as $pageInput) {
if (!is_array($pageInput)) continue;
$pageCount++;
if ($pageCount > 250) throw new InvalidArgumentException('Die Schulung darf höchstens 250 Seiten enthalten.');
$elementsInput = is_array($pageInput['elements'] ?? null) ? $pageInput['elements'] : [];
$page = [
'id' => brandschutz_course_id($pageInput['id'] ?? '', 'page', $knownIds),
'title' => brandschutz_course_string($pageInput['title'] ?? '', 200, 'Unbenannte Seite'),
'elements' => [],
];
foreach ($elementsInput as $elementInput) {
if (!is_array($elementInput)) continue;
$elementCount++;
if ($elementCount > 2000) throw new InvalidArgumentException('Die Schulung darf höchstens 2.000 Elemente enthalten.');
$page['elements'][] = brandschutz_course_normalize_element($elementInput, $knownIds);
}
$chapter['pages'][] = $page;
}
$config['chapters'][] = $chapter;
}
return $config;
}
function brandschutz_course_normalize_element(array $input, array &$knownIds): array
{
$types = ['text', 'infobox', 'warnbox', 'list', 'accordion', 'checklist', 'scenario', 'multiple_choice', 'fill_blank', 'matching', 'image'];
$type = is_string($input['type'] ?? null) ? $input['type'] : '';
if (!in_array($type, $types, true)) {
throw new InvalidArgumentException('Die Konfiguration enthält einen unbekannten Elementtyp.');
}
$base = ['id' => brandschutz_course_id($input['id'] ?? '', 'element', $knownIds), 'type' => $type];
if ($type === 'text') {
$base['content'] = brandschutz_course_sanitize_html($input['content'] ?? '');
} elseif (in_array($type, ['infobox', 'warnbox', 'scenario'], true)) {
$base['title'] = brandschutz_course_string($input['title'] ?? '', 200);
$base['content'] = brandschutz_course_sanitize_html($input['content'] ?? '');
} elseif (in_array($type, ['list', 'checklist'], true)) {
if ($type === 'list') $base['ordered'] = !empty($input['ordered']);
$base['items'] = brandschutz_course_plain_list($input['items'] ?? []);
} elseif ($type === 'accordion') {
$base['items'] = [];
$items = is_array($input['items'] ?? null) ? $input['items'] : [];
if (count($items) > 100) throw new InvalidArgumentException('Ein Akkordeon enthält zu viele Einträge.');
foreach ($items as $item) {
if (!is_array($item)) continue;
$base['items'][] = [
'title' => brandschutz_course_string($item['title'] ?? '', 200, 'Abschnitt'),
'content' => brandschutz_course_sanitize_html($item['content'] ?? ''),
];
}
} elseif ($type === 'multiple_choice') {
$base += brandschutz_course_assessment_fields($input);
$base['answers'] = [];
$answers = is_array($input['answers'] ?? null) ? $input['answers'] : [];
if (count($answers) > 50) throw new InvalidArgumentException('Eine Frage enthält zu viele Antworten.');
foreach ($answers as $answer) {
if (!is_array($answer)) continue;
$text = brandschutz_course_string($answer['text'] ?? '', 1000);
if ($text !== '') $base['answers'][] = ['text' => $text, 'correct' => !empty($answer['correct'])];
}
if ($base['answers'] === [] || !array_filter($base['answers'], static fn(array $answer): bool => $answer['correct'])) {
throw new InvalidArgumentException('Jede Multiple-Choice-Frage benötigt Antworten und mindestens eine korrekte Lösung.');
}
} elseif ($type === 'fill_blank') {
$base += brandschutz_course_assessment_fields($input);
$base['text'] = brandschutz_course_string($input['text'] ?? '', 10000);
$base['blanks'] = [];
$blanks = is_array($input['blanks'] ?? null) ? $input['blanks'] : [];
if (count($blanks) > 50) throw new InvalidArgumentException('Ein Lückentext enthält zu viele Lücken.');
foreach ($blanks as $blank) {
if (!is_array($blank)) continue;
$answers = [];
foreach (($blank['answers'] ?? []) as $answer) {
$text = brandschutz_course_string($answer, 300);
if ($text !== '') $answers[] = $text;
}
if ($answers !== []) $base['blanks'][] = ['answers' => array_values(array_unique($answers))];
}
if ($base['blanks'] === []) throw new InvalidArgumentException('Ein Lückentext benötigt mindestens eine erwartete Lösung.');
foreach (array_keys($base['blanks']) as $index) {
if (!str_contains($base['text'], '{{' . ($index + 1) . '}}')) {
throw new InvalidArgumentException('Der Lückentext enthält nicht für jede Lösung einen Platzhalter wie {{1}}.');
}
}
} elseif ($type === 'matching') {
$base += brandschutz_course_assessment_fields($input);
$base['choices'] = [];
foreach ((is_array($input['choices'] ?? null) ? $input['choices'] : []) as $choice) {
if (!is_array($choice)) continue;
$value = brandschutz_course_string($choice['value'] ?? '', 80);
$label = brandschutz_course_string($choice['label'] ?? '', 300);
if ($value !== '' && $label !== '') $base['choices'][] = ['value' => $value, 'label' => $label];
}
$validChoices = array_column($base['choices'], 'value');
$base['pairs'] = [];
foreach ((is_array($input['pairs'] ?? null) ? $input['pairs'] : []) as $pair) {
if (!is_array($pair)) continue;
$item = brandschutz_course_string($pair['item'] ?? '', 500);
$correct = brandschutz_course_string($pair['correct'] ?? '', 80);
if ($item !== '' && in_array($correct, $validChoices, true)) $base['pairs'][] = ['item' => $item, 'correct' => $correct];
}
if ($base['choices'] === [] || $base['pairs'] === []) throw new InvalidArgumentException('Eine Zuordnung benötigt Auswahlmöglichkeiten und Aufgaben.');
} elseif ($type === 'image') {
$file = basename(brandschutz_course_string($input['file'] ?? '', 255));
if ($file !== '' && !preg_match('/^[a-f0-9]{32}\.(?:jpg|jpeg|png|webp|gif)$/i', $file)) {
throw new InvalidArgumentException('Ein Bildelement verweist auf eine ungültige Datei.');
}
$base['file'] = $file;
$base['alt'] = brandschutz_course_string($input['alt'] ?? '', 500);
$base['caption'] = brandschutz_course_string($input['caption'] ?? '', 1000);
}
return $base;
}
function brandschutz_course_assessment_fields(array $input): array
{
return [
'question' => brandschutz_course_string($input['question'] ?? '', 2000, 'Frage'),
'feedback_correct' => brandschutz_course_string($input['feedback_correct'] ?? '', 3000, 'Richtig!'),
'feedback_incorrect' => brandschutz_course_string($input['feedback_incorrect'] ?? '', 3000, 'Leider nicht richtig.'),
'score' => !array_key_exists('score', $input) || !empty($input['score']),
];
}
function brandschutz_media_list(): array
{
$directory = brandschutz_media_directory();
if (!is_dir($directory)) return [];
$files = [];
foreach (scandir($directory) ?: [] as $file) {
if (!preg_match('/^[a-f0-9]{32}\.(?:jpg|jpeg|png|webp|gif)$/i', $file)) continue;
$path = $directory . DIRECTORY_SEPARATOR . $file;
if (!is_file($path)) continue;
$files[] = ['file' => $file, 'url' => BRANDSCHUTZ_MEDIA_URL . '/' . rawurlencode($file), 'size' => filesize($path) ?: 0, 'modified' => filemtime($path) ?: 0];
}
usort($files, static fn(array $a, array $b): int => $b['modified'] <=> $a['modified']);
return $files;
}
function brandschutz_media_upload(array $upload): array
{
if (($upload['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK || !is_uploaded_file((string) ($upload['tmp_name'] ?? ''))) {
throw new InvalidArgumentException('Das Bild konnte nicht hochgeladen werden.');
}
$size = (int) ($upload['size'] ?? 0);
if ($size <= 0 || $size > BRANDSCHUTZ_MEDIA_MAX_BYTES) {
throw new InvalidArgumentException('Das Bild darf höchstens 8 MB groß sein.');
}
$temporaryPath = (string) $upload['tmp_name'];
$imageInfo = @getimagesize($temporaryPath);
$mime = $imageInfo['mime'] ?? '';
$extensions = ['image/jpeg' => 'jpg', 'image/png' => 'png', 'image/webp' => 'webp', 'image/gif' => 'gif'];
if (!isset($extensions[$mime])) {
throw new InvalidArgumentException('Erlaubt sind JPEG-, PNG-, WebP- und GIF-Bilder.');
}
if (($imageInfo[0] ?? 0) > 8000 || ($imageInfo[1] ?? 0) > 8000) {
throw new InvalidArgumentException('Das Bild darf höchstens 8.000 × 8.000 Pixel groß sein.');
}
$directory = brandschutz_media_directory();
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
throw new RuntimeException('Der Bildspeicher ist nicht verfügbar.');
}
$filename = bin2hex(random_bytes(16)) . '.' . $extensions[$mime];
if (!move_uploaded_file($temporaryPath, $directory . DIRECTORY_SEPARATOR . $filename)) {
throw new RuntimeException('Das Bild konnte nicht gespeichert werden.');
}
return ['file' => $filename, 'url' => BRANDSCHUTZ_MEDIA_URL . '/' . rawurlencode($filename), 'size' => $size, 'modified' => time()];
}
function brandschutz_course_uses_image(array $config, string $filename): bool
{
foreach ($config['chapters'] ?? [] as $chapter) {
foreach ($chapter['pages'] ?? [] as $page) {
foreach ($page['elements'] ?? [] as $element) {
if (($element['type'] ?? '') === 'image' && ($element['file'] ?? '') === $filename) return true;
}
}
}
return false;
}
function brandschutz_media_delete(string $filename, array $config): void
{
$filename = basename($filename);
if (!preg_match('/^[a-f0-9]{32}\.(?:jpg|jpeg|png|webp|gif)$/i', $filename)) {
throw new InvalidArgumentException('Ungültige Bilddatei.');
}
if (brandschutz_course_uses_image($config, $filename)) {
throw new InvalidArgumentException('Das Bild wird noch in der Schulung verwendet und kann nicht gelöscht werden.');
}
$path = brandschutz_media_directory() . DIRECTORY_SEPARATOR . $filename;
if (is_file($path) && !unlink($path)) {
throw new RuntimeException('Das Bild konnte nicht gelöscht werden.');
}
}
@@ -0,0 +1,130 @@
<?php
declare(strict_types=1);
require_once __DIR__ . '/brandschutz_course.php';
function brandschutz_h(string $value): string
{
return htmlspecialchars($value, ENT_QUOTES | ENT_SUBSTITUTE, 'UTF-8');
}
function brandschutz_render_element(array $element): void
{
$type = (string) ($element['type'] ?? '');
$id = brandschutz_h((string) ($element['id'] ?? ''));
if ($type === 'text') {
echo '<div class="bs-text">' . brandschutz_course_sanitize_html($element['content'] ?? '') . '</div>';
return;
}
if ($type === 'infobox' || $type === 'warnbox') {
$class = $type === 'warnbox' ? 'bs-warnbox' : 'bs-infobox';
echo '<div class="' . $class . '">';
if (($element['title'] ?? '') !== '') echo '<div class="' . $class . '-title">' . brandschutz_h((string) $element['title']) . '</div>';
echo brandschutz_course_sanitize_html($element['content'] ?? '') . '</div>';
return;
}
if ($type === 'list') {
$tag = !empty($element['ordered']) ? 'ol' : 'ul';
$class = $tag === 'ol' ? 'bs-list-numbered' : 'bs-list';
echo '<' . $tag . ' class="' . $class . '">';
foreach ($element['items'] ?? [] as $item) echo '<li>' . brandschutz_course_sanitize_html($item) . '</li>';
echo '</' . $tag . '>';
return;
}
if ($type === 'accordion') {
foreach ($element['items'] ?? [] as $item) {
echo '<div class="bs-accordion"><button type="button" class="bs-accordion-header" aria-expanded="false">';
echo '<span>' . brandschutz_h((string) ($item['title'] ?? '')) . '</span><span class="bs-accordion-arrow" aria-hidden="true">▼</span></button>';
echo '<div class="bs-accordion-content">' . brandschutz_course_sanitize_html($item['content'] ?? '') . '</div></div>';
}
return;
}
if ($type === 'checklist') {
echo '<ul class="bs-checklist" data-checklist="true">';
foreach ($element['items'] ?? [] as $item) {
echo '<li><button type="button" class="bs-checklist-item"><span class="bs-check-box" aria-hidden="true">●</span><span>';
echo brandschutz_course_sanitize_html($item) . '</span></button></li>';
}
echo '</ul>';
return;
}
if ($type === 'scenario') {
echo '<div class="bs-scenario"><div class="bs-scenario-header">' . brandschutz_h((string) ($element['title'] ?? '')) . '</div>';
echo '<div class="bs-scenario-body"><div class="bs-scenario-text">' . brandschutz_course_sanitize_html($element['content'] ?? '') . '</div></div></div>';
return;
}
if ($type === 'multiple_choice') {
brandschutz_render_assessment_start($element, $id, 'multiple_choice');
echo '<div class="bs-quiz-options">';
foreach ($element['answers'] ?? [] as $answer) {
echo '<label class="bs-quiz-option"><input type="checkbox" data-correct="' . (!empty($answer['correct']) ? 'true' : 'false') . '">';
echo '<span class="bs-quiz-indicator" aria-hidden="true">●</span><span>' . brandschutz_h((string) ($answer['text'] ?? '')) . '</span></label>';
}
echo '</div>';
brandschutz_render_assessment_end($id);
return;
}
if ($type === 'fill_blank') {
brandschutz_render_assessment_start($element, $id, 'fill_blank');
echo '<div class="bs-text bs-fill-text">';
$parts = preg_split('/(\{\{\d+\}\})/', (string) ($element['text'] ?? ''), -1, PREG_SPLIT_DELIM_CAPTURE) ?: [];
foreach ($parts as $part) {
if (preg_match('/^\{\{(\d+)\}\}$/', $part, $match)) {
$index = (int) $match[1] - 1;
$answers = $element['blanks'][$index]['answers'] ?? [];
$answerJson = json_encode(array_values($answers), JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_THROW_ON_ERROR);
echo '<input type="text" class="bs-fill-input" autocomplete="off" aria-label="Lücke ' . ($index + 1) . '" data-answers="' . brandschutz_h($answerJson) . '">';
} else {
echo nl2br(brandschutz_h($part));
}
}
echo '</div>';
brandschutz_render_assessment_end($id);
return;
}
if ($type === 'matching') {
brandschutz_render_assessment_start($element, $id, 'matching');
echo '<div class="bs-dropdown-match">';
foreach ($element['pairs'] ?? [] as $pair) {
echo '<div class="bs-dropdown-match-row"><span class="bs-dropdown-match-label">' . brandschutz_h((string) ($pair['item'] ?? '')) . '</span>';
echo '<select class="bs-dropdown-match-select" data-correct="' . brandschutz_h((string) ($pair['correct'] ?? '')) . '"><option value="">-- Bitte wählen --</option>';
foreach ($element['choices'] ?? [] as $choice) {
echo '<option value="' . brandschutz_h((string) ($choice['value'] ?? '')) . '">' . brandschutz_h((string) ($choice['label'] ?? '')) . '</option>';
}
echo '</select></div>';
}
echo '</div>';
brandschutz_render_assessment_end($id);
return;
}
if ($type === 'image') {
$file = (string) ($element['file'] ?? '');
if ($file === '') return;
$path = brandschutz_media_directory() . DIRECTORY_SEPARATOR . $file;
if (!is_file($path)) {
echo '<div class="bs-warnbox"><div class="bs-warnbox-title">Bild nicht verfügbar</div>Das hinterlegte Bild konnte nicht geladen werden.</div>';
return;
}
$url = BRANDSCHUTZ_MEDIA_URL . '/' . rawurlencode($file);
echo '<figure class="bs-image"><img src="' . brandschutz_h($url) . '" alt="' . brandschutz_h((string) ($element['alt'] ?? '')) . '" loading="lazy">';
if (($element['caption'] ?? '') !== '') echo '<figcaption>' . brandschutz_h((string) $element['caption']) . '</figcaption>';
echo '</figure>';
}
}
function brandschutz_render_assessment_start(array $element, string $escapedId, string $kind): void
{
echo '<div class="bs-quiz bs-assessment" id="' . $escapedId . '" data-assessment-kind="' . $kind . '" data-score="' . (!empty($element['score']) ? 'true' : 'false') . '"';
echo ' data-feedback-correct="' . brandschutz_h((string) ($element['feedback_correct'] ?? 'Richtig!')) . '"';
echo ' data-feedback-incorrect="' . brandschutz_h((string) ($element['feedback_incorrect'] ?? 'Leider nicht richtig.')) . '">';
echo '<div class="bs-quiz-question">' . brandschutz_h((string) ($element['question'] ?? '')) . '</div>';
}
function brandschutz_render_assessment_end(string $escapedId): void
{
echo '<div class="bs-quiz-feedback" aria-live="polite"></div><div class="bs-quiz-btn-group">';
echo '<button type="button" class="bs-btn bs-btn-primary bs-check-assessment" data-assessment-id="' . $escapedId . '">Antwort prüfen</button>';
echo '</div></div>';
}
@@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/initialise.php';
require_once __DIR__ . '/util/camera_remote_helpers.php';
header('Content-Type: application/json; charset=utf-8');
function camera_remote_response(int $status, array $payload): never
{
http_response_code($status);
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
camera_remote_response(405, ['ok' => false, 'error' => 'Methode nicht erlaubt.']);
}
if (!camera_remote_is_authorized(is_array($groups ?? null) ? $groups : [])) {
camera_remote_response(403, ['ok' => false, 'error' => 'Keine Berechtigung für die Kamera-Fernbedienung.']);
}
try {
camera_remote_verify_csrf($_POST['csrf_token'] ?? null);
$action = is_string($_POST['action'] ?? null) ? $_POST['action'] : '';
camera_remote_run($action);
camera_remote_response(200, ['ok' => true]);
} catch (InvalidArgumentException $exception) {
camera_remote_response(400, ['ok' => false, 'error' => $exception->getMessage()]);
} catch (RuntimeException $exception) {
camera_remote_response(503, ['ok' => false, 'error' => $exception->getMessage()]);
} catch (Throwable $exception) {
error_log('Camera Remote: ' . $exception->getMessage());
camera_remote_response(500, ['ok' => false, 'error' => 'Der Kamerabefehl konnte nicht ausgeführt werden.']);
}
@@ -0,0 +1,104 @@
.camera-remote-layout {
max-width: 1100px;
}
.camera-remote-main {
min-height: 0;
}
.camera-remote-heading {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xl);
margin-bottom: var(--space-lg);
}
.camera-remote-heading h1 {
margin-bottom: var(--space-sm);
}
.camera-remote-heading p {
margin: 0;
color: var(--gray-600);
}
.camera-power-control {
min-width: 180px;
}
.camera-remote .camera-power-control .button {
margin: 0;
}
.camera-remote .mainpage_notice {
margin-bottom: var(--space-lg);
}
.camera-sections {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: var(--space-md);
}
.camera-remote-panel {
min-height: 0;
padding: var(--space-lg);
}
.camera-remote-panel h2 {
margin-bottom: var(--space-md);
font-size: var(--font-size-lg);
}
.camera-presets {
display: grid;
gap: var(--space-sm);
}
.camera-remote .camera-presets .button_fullwidth {
margin: 0;
}
.camera-remote .camera-presets .camera-preset-active {
background: var(--theme-hover);
box-shadow: inset 5px 0 var(--nak-blue), var(--shadow-md);
}
.camera-remote .button_fullwidth:disabled {
opacity: 0.45;
cursor: not-allowed;
}
@media (max-width: 768px) {
#base.camera-remote {
padding: 0;
}
.camera-remote-heading {
align-items: flex-start;
}
.camera-sections {
grid-template-columns: 1fr;
}
}
@media (max-width: 480px) {
.camera-remote-layout {
padding: var(--space-md);
}
.camera-remote-heading {
flex-direction: column;
gap: var(--space-md);
}
.camera-power-control {
width: 100%;
}
.camera-remote-panel {
padding: var(--space-md);
}
}
@@ -0,0 +1,80 @@
(function () {
'use strict';
var configElement = document.getElementById('cameraRemoteConfig');
if (!configElement) return;
var config = JSON.parse(configElement.textContent);
var presets = Array.from(document.querySelectorAll('.camera-presets [data-camera-action]'));
var power = document.querySelector('[data-camera-action="power"]');
var status = document.getElementById('cameraRemoteStatus');
var busy = false;
presets.forEach(function (preset) { preset.setAttribute('aria-pressed', 'false'); });
function showError(message) {
status.textContent = message;
status.hidden = false;
}
function clearError() {
status.textContent = '';
status.hidden = true;
}
function setBusy(active) {
busy = active;
presets.forEach(function (preset) { preset.disabled = active || !config.configured; });
power.disabled = active || !config.configured;
}
function selectPreset(selected) {
presets.forEach(function (preset) {
var active = preset === selected;
preset.classList.toggle('camera-preset-active', active);
preset.setAttribute('aria-pressed', String(active));
});
}
async function request(action) {
var form = new FormData();
form.append('csrf_token', config.csrfToken);
form.append('action', action);
var response = await fetch('/intranet_repo/projects/camera_remote/api.php', {
method: 'POST',
body: form,
headers: {'Accept': 'application/json'},
});
var result;
try {
result = await response.json();
} catch (parseError) {
throw new Error('Der Server hat unerwartet geantwortet. Bitte laden Sie die Seite neu.');
}
if (!response.ok || !result.ok) throw new Error(result.error || 'Der Kamerabefehl ist fehlgeschlagen.');
}
async function runAction(control) {
if (busy || !config.configured) return;
clearError();
if (control === power) selectPreset(null);
setBusy(true);
try {
await request(control.dataset.cameraAction);
if (control !== power) {
selectPreset(control);
}
} catch (error) {
showError(error.message || 'Der Kamerabefehl konnte nicht ausgeführt werden.');
} finally {
setBusy(false);
}
}
presets.forEach(function (preset) {
preset.addEventListener('click', function () { runAction(preset); });
});
power.addEventListener('click', function () { runAction(power); });
}());
@@ -0,0 +1,88 @@
<?php
declare(strict_types=1);
require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/general_files/initialise.php';
require_once __DIR__ . '/util/camera_remote_helpers.php';
if (!camera_remote_is_authorized(is_array($groups ?? null) ? $groups : [])) {
http_response_code(403);
exit('Keine Berechtigung für die Kamera-Fernbedienung.');
}
$configured = camera_remote_is_configured();
$clientConfig = [
'csrfToken' => camera_remote_csrf_token(),
'configured' => $configured,
];
?>
<!DOCTYPE html>
<html lang="de" dir="ltr">
<head>
<?php require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/projects/_central/php/html_head.php'; ?>
<link rel="stylesheet" href="/intranet_repo/projects/camera_remote/camera_remote.css?v=<?= (int) filemtime(__DIR__ . '/camera_remote.css') ?>">
<script id="cameraRemoteConfig" type="application/json"><?= json_encode($clientConfig, JSON_HEX_TAG | JSON_HEX_AMP | JSON_HEX_APOS | JSON_HEX_QUOT | JSON_THROW_ON_ERROR) ?></script>
<script src="/intranet_repo/projects/camera_remote/camera_remote.js?v=<?= (int) filemtime(__DIR__ . '/camera_remote.js') ?>" defer></script>
</head>
<body class="theme-admin">
<?php require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/projects/_central/php/header.php'; ?>
<?php include $g_phpSnippetsPath . 'overlay_message.php'; ?>
<div class="header_title ht_admin text_white" id="header_title">
<span class="header_title_text">Kamera-Fernbedienung</span>
</div>
<main class="content camera-remote" id="base">
<div class="content_flexcontainer camera-remote-layout">
<section class="content_panel camera-remote-main" aria-labelledby="cameraRemoteTitle">
<div class="camera-remote-heading">
<div>
<h1 id="cameraRemoteTitle">Kamera steuern</h1>
<p>Wählen Sie eine Kameraposition oder schalten Sie die Kamera ein beziehungsweise aus.</p>
</div>
<div class="camera-power-control">
<input class="button" type="button" data-camera-action="power" value="Kamera ein/aus"<?= $configured ? '' : ' disabled' ?>>
</div>
</div>
<?php $statusText = $configured ? '' : 'Home Assistant ist noch nicht konfiguriert. Bitte HA_BASE_URL und HA_TOKEN in der Umgebung hinterlegen.'; ?>
<div class="mainpage_notice mainpage_notice--error" id="cameraRemoteStatus" role="alert"<?= $configured ? ' hidden' : '' ?>>
<?= htmlspecialchars($statusText, ENT_QUOTES, 'UTF-8') ?>
</div>
<div class="camera-sections" aria-label="Kamerapositionen">
<section class="content_panel camera-remote-panel" aria-labelledby="cameraGeneralTitle">
<h2 id="cameraGeneralTitle">Allgemein</h2>
<div class="camera-presets">
<input class="button_fullwidth" type="button" data-camera-action="total" value="Totale"<?= $configured ? '' : ' disabled' ?>>
<input class="button_fullwidth" type="button" data-camera-action="lectern" value="Pult"<?= $configured ? '' : ' disabled' ?>>
</div>
</section>
<section class="content_panel camera-remote-panel" aria-labelledby="cameraAltarTitle">
<h2 id="cameraAltarTitle">Altar</h2>
<div class="camera-presets">
<input class="button_fullwidth" type="button" data-camera-action="altar" value="Altar"<?= $configured ? '' : ' disabled' ?>>
<input class="button_fullwidth" type="button" data-camera-action="chancel" value="Altarraum"<?= $configured ? '' : ' disabled' ?>>
<input class="button_fullwidth" type="button" data-camera-action="before_altar" value="Vor dem Altar"<?= $configured ? '' : ' disabled' ?>>
<input class="button_fullwidth" type="button" data-camera-action="flowers" value="Blumenschmuck"<?= $configured ? '' : ' disabled' ?>>
</div>
</section>
<section class="content_panel camera-remote-panel" aria-labelledby="cameraMusicTitle">
<h2 id="cameraMusicTitle">Musik</h2>
<div class="camera-presets">
<input class="button_fullwidth" type="button" data-camera-action="choir" value="Chor"<?= $configured ? '' : ' disabled' ?>>
<input class="button_fullwidth" type="button" data-camera-action="piano" value="Klavier"<?= $configured ? '' : ' disabled' ?>>
<input class="button_fullwidth" type="button" data-camera-action="organ" value="Orgel"<?= $configured ? '' : ' disabled' ?>>
<input class="button_fullwidth" type="button" data-camera-action="orchestra" value="Orchester/Kinderchor"<?= $configured ? '' : ' disabled' ?>>
</div>
</section>
</div>
</section>
</div>
</main>
<?php require_once $_SERVER['DOCUMENT_ROOT'] . '/intranet_repo/projects/_central/php/footer.php'; ?>
</body>
</html>
@@ -0,0 +1,120 @@
<?php
declare(strict_types=1);
const CAMERA_REMOTE_ACTIONS = [
'power' => ['entity_id' => 'script.kamera_on_off', 'calls' => 1],
'total' => ['entity_id' => 'script.kamera_totale', 'calls' => 2],
'lectern' => ['entity_id' => 'script.kamera_pult', 'calls' => 2],
'altar' => ['entity_id' => 'script.kamera_altar', 'calls' => 2],
'chancel' => ['entity_id' => 'script.kamera_altarraum', 'calls' => 2],
'before_altar' => ['entity_id' => 'script.kamera_vor_dem_altar', 'calls' => 2],
'flowers' => ['entity_id' => 'script.kamera_blumenschmuck', 'calls' => 2],
'choir' => ['entity_id' => 'script.kamera_chor', 'calls' => 2],
'piano' => ['entity_id' => 'script.kamera_klavier', 'calls' => 2],
'organ' => ['entity_id' => 'script.kamera_orgel', 'calls' => 2],
'orchestra' => ['entity_id' => 'script.kamera_orchester_kinderchor', 'calls' => 2],
];
function camera_remote_is_authorized(array $groups): bool
{
return !empty($groups['Technik']) || !empty($groups['Admin']);
}
function camera_remote_csrf_token(): string
{
return $_SESSION['camera_remote_csrf_token'] ??= bin2hex(random_bytes(32));
}
function camera_remote_verify_csrf(mixed $token): void
{
$sessionToken = $_SESSION['camera_remote_csrf_token'] ?? '';
if (!is_string($token) || !is_string($sessionToken) || $sessionToken === '' || !hash_equals($sessionToken, $token)) {
throw new InvalidArgumentException('Ungültige Anfrage. Bitte laden Sie die Seite neu.');
}
}
/** @return array{base_url: string, token: string} */
function camera_remote_config(): array
{
$baseUrl = rtrim(trim((string) getenv('HA_BASE_URL')), '/');
$token = trim((string) getenv('HA_TOKEN'));
$scheme = strtolower((string) parse_url($baseUrl, PHP_URL_SCHEME));
$host = parse_url($baseUrl, PHP_URL_HOST);
if ($baseUrl === '' || $token === '') {
throw new RuntimeException('Home Assistant ist noch nicht konfiguriert.');
}
if (!in_array($scheme, ['http', 'https'], true) || !is_string($host) || $host === '') {
throw new RuntimeException('Die Home-Assistant-Adresse ist ungültig.');
}
return ['base_url' => $baseUrl, 'token' => $token];
}
function camera_remote_is_configured(): bool
{
try {
camera_remote_config();
return true;
} catch (RuntimeException) {
return false;
}
}
function camera_remote_request(string $path, ?array $payload = null): void
{
$config = camera_remote_config();
$handle = curl_init($config['base_url'] . $path);
if ($handle === false) {
throw new RuntimeException('Die Verbindung zu Home Assistant konnte nicht vorbereitet werden.');
}
$options = [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => [
'Authorization: Bearer ' . $config['token'],
'Content-Type: application/json',
'Accept: application/json',
],
CURLOPT_CONNECTTIMEOUT => 3,
CURLOPT_TIMEOUT => 8,
CURLOPT_FOLLOWLOCATION => false,
];
if ($payload !== null) {
$options[CURLOPT_POST] = true;
$options[CURLOPT_POSTFIELDS] = json_encode($payload, JSON_THROW_ON_ERROR);
}
curl_setopt_array($handle, $options);
try {
$response = curl_exec($handle);
$status = (int) curl_getinfo($handle, CURLINFO_HTTP_CODE);
$error = curl_error($handle);
} finally {
curl_close($handle);
}
if ($response === false || $status < 200 || $status >= 300) {
error_log('Camera Remote: Home Assistant antwortete mit ' . ($error !== '' ? $error : 'HTTP ' . $status));
throw new RuntimeException('Home Assistant konnte den Kamerabefehl nicht ausführen.');
}
try {
json_decode($response, true, 512, JSON_THROW_ON_ERROR);
} catch (JsonException $exception) {
throw new RuntimeException('Home Assistant hat unerwartet geantwortet.', 0, $exception);
}
}
function camera_remote_run(string $action): void
{
$command = CAMERA_REMOTE_ACTIONS[$action] ?? null;
if (!is_array($command)) {
throw new InvalidArgumentException('Unbekannter Kamerabefehl.');
}
for ($call = 0; $call < $command['calls']; $call++) {
camera_remote_request('/api/services/script/turn_on', ['entity_id' => $command['entity_id']]);
}
}
@@ -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());
@@ -45,6 +45,7 @@ $config = require __DIR__ . '/config.php';
<section class="dashboard-panel text-panel" id="departures-panel" aria-labelledby="departures-title">
<div class="panel-heading">
<h2 id="departures-title">Abfahrten: Eichborndamm/Antonienstr.</h2>
<a class="departures-source" href="https://www.vbb.de/" target="_blank" rel="noopener">Fahrplandaten: VBB</a>
</div>
<div class="text-stage"><p class="empty-state">Abfahrten werden geladen …</p></div>
</section>
@@ -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:
@@ -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; }
@@ -1,53 +0,0 @@
<?php
declare(strict_types=1);
const DASHBOARD_BVG_STOP = '900086103';
function dashboard_fetch_departures(): array
{
$directory = __DIR__ . '/../cache';
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory)) {
throw new RuntimeException('BVG-Cache nicht beschreibbar.');
}
$file = fopen($directory . '/bvg-' . DASHBOARD_BVG_STOP . '.json', 'c+');
if (!$file) {
throw new RuntimeException('BVG-Cache nicht verfügbar.');
}
try {
// Gemeinsamer Cache: alle 12 Sekunden und zusätzlich zum Minutenwechsel.
if (!flock($file, LOCK_EX)) {
throw new RuntimeException('BVG-Cache konnte nicht gesperrt werden.');
}
$cache = json_decode(stream_get_contents($file), true) ?: [];
if (microtime(true) >= ($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&regional=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);
}
}
@@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
const DASHBOARD_VBB_STOP_ID = '900086103';
const DASHBOARD_VBB_BUS_PRODUCTS = 8;
function dashboard_vbb_datetime(array $departure, bool $realtime = false): ?DateTimeImmutable
{
$time = trim((string) ($departure[$realtime ? 'rtTime' : 'time'] ?? ''));
if ($time === '') {
return null;
}
$date = trim((string) ($departure[$realtime ? 'rtDate' : 'date'] ?? $departure['date'] ?? ''));
$value = DateTimeImmutable::createFromFormat(
'!Y-m-d H:i:s',
$date . ' ' . $time,
new DateTimeZone(DASHBOARD_TIMEZONE)
);
return $value ?: null;
}
function dashboard_vbb_normalize_departure(array $departure): ?array
{
$product = $departure['ProductAtStop'] ?? ($departure['Product'][0] ?? []);
if (!is_array($product) || stripos((string) ($product['catOut'] ?? $product['catOutL'] ?? ''), 'bus') === false) {
return null;
}
$planned = dashboard_vbb_datetime($departure);
if (!$planned) {
return null;
}
$actual = dashboard_vbb_datetime($departure, true);
$status = strtoupper(trim((string) ($departure['JourneyStatus'] ?? '')));
$cancelled = filter_var($departure['cancelled'] ?? false, FILTER_VALIDATE_BOOLEAN) || $status === 'C';
$line = trim((string) ($product['displayNumber'] ?? ''));
$line = $line !== '' ? $line : trim((string) ($product['line'] ?? $product['name'] ?? $departure['name'] ?? 'Bus'));
return [
'plannedWhen' => $planned->format(DATE_ATOM),
'when' => $actual?->format(DATE_ATOM),
'delay' => $actual ? $actual->getTimestamp() - $planned->getTimestamp() : null,
'cancelled' => $cancelled,
'line' => ['name' => $line],
'direction' => trim((string) ($departure['direction'] ?? '')),
];
}
function dashboard_fetch_departures(): array
{
$directory = __DIR__ . '/../cache';
if (!is_dir($directory) && !@mkdir($directory, 0775, true) && !is_dir($directory)) {
throw new RuntimeException('VBB-Cache nicht beschreibbar.');
}
$file = fopen($directory . '/vbb-' . DASHBOARD_VBB_STOP_ID . '.json', 'c+');
if (!$file) {
throw new RuntimeException('VBB-Cache nicht verfügbar.');
}
try {
if (!flock($file, LOCK_EX)) {
throw new RuntimeException('VBB-Cache konnte nicht gesperrt werden.');
}
$cache = json_decode(stream_get_contents($file), true) ?: [];
if (microtime(true) >= ($cache['retryAt'] ?? 0)) {
try {
$query = http_build_query([
'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);
}
}
@@ -88,6 +88,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32"><path d="M19 4h-1V2h-2v2H8V2H6v2H5c-1.11 0-2 .9-2 2v14c0 1.1.89 2 2 2h14c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 16H5V10h14v10zm0-12H5V6h14v2z"/></svg>
<div class="text">Monatsplan</div>
</button>
<?php if (!empty($groups["Technik"]) || !empty($groups["Admin"])) { ?>
<button class="button_mainpage unselectable bg_black" onclick="window.location.href='/intranet_repo/projects/camera_remote/camera_remote.php'">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="currentColor" width="32" height="32"><path d="M17 10.5V7c0-1.1-.9-2-2-2H5C3.9 5 3 5.9 3 7v10c0 1.1.9 2 2 2h10c1.1 0 2-.9 2-2v-3.5l4 4v-11l-4 4z"/></svg>
<div class="text">Kamera</div>
</button>
<?php } ?>
<?php if ($groups["Amtsgeschwister"] == 1) { ?>
<button class="button_mainpage unselectable bg_purple" onclick="window.location.href='/intranet_repo/projects/seelsorge/seelsorge.php'">