Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce90820094 | ||
|
|
3f98cf2663 | ||
|
|
7d3f99c7b8 | ||
|
|
055b6d10c4 | ||
|
|
2d88c64e48 | ||
|
|
01b4be301f |
@@ -23,6 +23,7 @@ web/intranet_repo/projects/contentpanel/insertion/news/*
|
||||
|
||||
# Generierte Dashboard-Terminbilder
|
||||
web/intranet_repo/projects/dashboard/month/*
|
||||
web/intranet_repo/projects/dashboard/cache/
|
||||
|
||||
# Lokale Backup-Dateien
|
||||
*.bak
|
||||
|
||||
@@ -48,16 +48,20 @@ COPY docker/scheduler.py /opt/intranet/docker/scheduler.py
|
||||
RUN mkdir -p \
|
||||
/data/ckal-xlsx /data/pdf /data/state \
|
||||
/var/www/html/intranet_repo/general_files/profile_pictures \
|
||||
/var/www/html/intranet_repo/projects/brandschutz/uploads \
|
||||
/var/www/html/intranet_repo/projects/contentpanel/insertion/news \
|
||||
/var/www/html/intranet_repo/projects/developerpanel/work \
|
||||
/var/www/html/intranet_repo/projects/seelsorge/data \
|
||||
/var/www/html/intranet_repo/projects/dashboard/cache \
|
||||
/var/www/html/intranet_repo/projects/dashboard/month \
|
||||
&& chown -R www-data:www-data \
|
||||
/data \
|
||||
/var/www/html/intranet_repo/general_files/profile_pictures \
|
||||
/var/www/html/intranet_repo/projects/brandschutz/uploads \
|
||||
/var/www/html/intranet_repo/projects/contentpanel/insertion/news \
|
||||
/var/www/html/intranet_repo/projects/developerpanel/work \
|
||||
/var/www/html/intranet_repo/projects/seelsorge/data \
|
||||
/var/www/html/intranet_repo/projects/dashboard/cache \
|
||||
/var/www/html/intranet_repo/projects/dashboard/month \
|
||||
&& chmod +x /opt/intranet/docker/scheduler.py /opt/intranet/worker/pdf_python/pdf_to_png.sh /opt/intranet/worker/contentpanel_aushang/process.py /opt/intranet/worker/contentpanel_aushang/betteroffice_render.py
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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 {'&': '&', '<': '<', '>': '>', "'": ''', '"': '"'}[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">< Zurück</button>';
|
||||
scoreSection.querySelector('.bs-actions-right').innerHTML = '<button type="button" class="bs-btn bs-btn-primary bs-score-next">Weiter ></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']]);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
@@ -4,16 +4,10 @@ declare(strict_types=1);
|
||||
|
||||
// Zentrale Anzeige- und Lesbarkeitswerte, ohne Build-Schritt.
|
||||
return [
|
||||
// Original: (97vh - 7px) × 47,5% Feld × 90,5% Leiste × 95% Bildhöhe.
|
||||
'POSTER_MIN_RENDER_HEIGHT_VH' => 39.61298125,
|
||||
'POSTER_MIN_RENDER_HEIGHT_OFFSET_PX' => 2.85866875,
|
||||
'POSTER_MAX_VISIBLE_PAGES' => 12,
|
||||
'POSTER_MIN_DWELL_TIME_S' => 12,
|
||||
'ANNOUNCEMENT_MIN_FONT_SIZE_PX' => 20,
|
||||
'ANNOUNCEMENT_META_MIN_FONT_SIZE_PX' => 15,
|
||||
'BIBLE_FONT_SIZE_PX' => 23,
|
||||
'ANNOUNCEMENT_LINE_HEIGHT' => 1.35,
|
||||
'ANNOUNCEMENT_MAX_LINE_LENGTH_CH' => 85,
|
||||
'CONTENT_MIN_CONTRAST_RATIO' => 4.5,
|
||||
// Ohne Aushänge gibt es keinen Durchlauf; dann weiterhin regelmäßig prüfen.
|
||||
'EMPTY_REFRESH_MS' => 30000,
|
||||
|
||||
@@ -3,13 +3,6 @@
|
||||
|
||||
const config = JSON.parse(document.getElementById('dashboard-config').textContent);
|
||||
const root = document.body;
|
||||
for (const [property, value] of Object.entries({
|
||||
'--announcement-detail-font': 'calc(' + config.ANNOUNCEMENT_MIN_FONT_SIZE_PX + ' * var(--dashboard-unit))',
|
||||
'--announcement-meta-font': 'calc(' + config.ANNOUNCEMENT_META_MIN_FONT_SIZE_PX + ' * var(--dashboard-unit))',
|
||||
'--announcement-leading': config.ANNOUNCEMENT_LINE_HEIGHT,
|
||||
'--bible-font': 'calc(' + config.BIBLE_FONT_SIZE_PX + ' * var(--dashboard-unit))',
|
||||
'--announcement-measure': config.ANNOUNCEMENT_MAX_LINE_LENGTH_CH + 'ch',
|
||||
})) root.style.setProperty(property, value);
|
||||
|
||||
function node(tag, className, text) {
|
||||
const element = document.createElement(tag);
|
||||
@@ -35,7 +28,9 @@
|
||||
}
|
||||
}
|
||||
|
||||
const aspectRatio = pages => pages.reduce((sum, item) => sum + item.width / item.height, 0);
|
||||
const imageSize = item => item.crop || item;
|
||||
const aspectRatio = pages => pages.reduce((sum, item) =>
|
||||
sum + imageSize(item).width / imageSize(item).height, 0);
|
||||
const layoutKey = layout => [layout.width, layout.height, layout.groups.map(group => group.length)].join('/');
|
||||
|
||||
const dayFormatter = new Intl.DateTimeFormat('en-CA', {
|
||||
@@ -63,9 +58,9 @@
|
||||
message, rotate, items: [], current: [], images: new Map(),
|
||||
index: 0, generation: 0, signature: '', layoutKey: '', failed: false, pending: false,
|
||||
});
|
||||
this.panel = document.getElementById(id);
|
||||
this.stage = this.panel.querySelector('.image-stage');
|
||||
this.progress = this.panel.querySelector('.poster-progress');
|
||||
const panel = document.getElementById(id);
|
||||
this.stage = panel.querySelector('.image-stage');
|
||||
this.progress = panel.querySelector('.poster-progress');
|
||||
new ResizeObserver(() => this.resize()).observe(this.stage);
|
||||
}
|
||||
|
||||
@@ -75,7 +70,7 @@
|
||||
const style = getComputedStyle(this.stage);
|
||||
const width = bounds.width - parseFloat(style.paddingLeft) - parseFloat(style.paddingRight);
|
||||
const height = bounds.height - parseFloat(style.paddingTop) - parseFloat(style.paddingBottom);
|
||||
const gap = this.rotate ? parseFloat(style.columnGap) : 0;
|
||||
const gap = parseFloat(style.columnGap);
|
||||
const frame = this.rotate ? parseFloat(style.getPropertyValue('--poster-frame-width')) : 0;
|
||||
if (!this.rotate) return { groups: [this.items], width, height, gap, frame };
|
||||
const minimum = innerHeight * config.POSTER_MIN_RENDER_HEIGHT_VH / 100
|
||||
@@ -134,6 +129,23 @@
|
||||
image.dataset.source = item.url;
|
||||
image.src = localUrl;
|
||||
await image.decode();
|
||||
if (!this.rotate) {
|
||||
const canvas = document.createElement('canvas');
|
||||
canvas.width = image.naturalWidth;
|
||||
canvas.height = image.naturalHeight;
|
||||
const context = canvas.getContext('2d');
|
||||
context.drawImage(image, 0, 0);
|
||||
const pixels = context.getImageData(0, 0, canvas.width, canvas.height).data;
|
||||
let left = canvas.width, top = canvas.height, right = 0, bottom = 0;
|
||||
for (let y = 0; y < canvas.height; y++) for (let x = 0; x < canvas.width; x++) {
|
||||
if (!pixels[(y * canvas.width + x) * 4 + 3]) continue;
|
||||
left = Math.min(left, x); top = Math.min(top, y);
|
||||
right = Math.max(right, x + 1); bottom = Math.max(bottom, y + 1);
|
||||
}
|
||||
if (right) Object.defineProperty(item, 'crop', {
|
||||
value: { left, top, width: right - left, height: bottom - top }, configurable: true,
|
||||
});
|
||||
}
|
||||
return image;
|
||||
}).catch(() => {
|
||||
if (localUrl) URL.revokeObjectURL(localUrl);
|
||||
@@ -159,7 +171,7 @@
|
||||
this.message = message;
|
||||
this.items = items;
|
||||
// Nur neue Bildversionen laden; unveränderte Blob-URLs wiederverwenden.
|
||||
for (const item of items) this.loadImage(item);
|
||||
const loading = items.map(item => this.loadImage(item));
|
||||
if (!items.length) {
|
||||
this.generation++;
|
||||
clearTimeout(this.timer);
|
||||
@@ -180,7 +192,7 @@
|
||||
this.pending = true;
|
||||
return;
|
||||
}
|
||||
return this.show(0);
|
||||
return this.rotate ? this.show(0) : Promise.all(loading).then(() => this.show(0));
|
||||
}
|
||||
|
||||
resize() {
|
||||
@@ -191,9 +203,10 @@
|
||||
clearTimeout(this.timer);
|
||||
if (!this.rotate) return;
|
||||
const duration = this.items.length ? config.POSTER_MIN_DWELL_TIME_S * 1000 : config.EMPTY_REFRESH_MS;
|
||||
const changing = this.items.length && this.layout().groups.length > 1;
|
||||
this.progressAnimation?.cancel();
|
||||
this.progress.toggleAttribute('hidden', !this.items.length);
|
||||
if (this.items.length) {
|
||||
this.progress.toggleAttribute('hidden', !changing);
|
||||
if (changing) {
|
||||
this.progressAnimation = this.progress.querySelector('.poster-progress-value').animate(
|
||||
[{ strokeDashoffset: 100 }, { strokeDashoffset: 0 }],
|
||||
{ duration, easing: 'linear', fill: 'forwards' });
|
||||
@@ -219,6 +232,8 @@
|
||||
const documents = this.rotate ? groupDocuments(batch) : [batch];
|
||||
const renderHeight = Math.min(layout.height - 2 * layout.frame,
|
||||
(layout.width - (documents.length - 1) * layout.gap - documents.length * 2 * layout.frame) / aspectRatio(batch));
|
||||
const monthScale = this.rotate ? 0 : Math.min(layout.height / Math.max(...batch.map(item => imageSize(item).height)),
|
||||
(layout.width - (batch.length - 1) * layout.gap) / batch.reduce((sum, item) => sum + imageSize(item).width, 0));
|
||||
const generation = ++this.generation;
|
||||
clearTimeout(this.timer);
|
||||
const sheet = node('div', 'image-sheet');
|
||||
@@ -229,13 +244,23 @@
|
||||
if (this.rotate) sheet.append(wrapper);
|
||||
return pages.map(async item => {
|
||||
const cell = node('div', 'image-cell');
|
||||
cell.style.height = renderHeight + 'px';
|
||||
cell.style.width = (renderHeight * item.width / item.height) + 'px';
|
||||
const size = imageSize(item);
|
||||
cell.style.height = ((this.rotate ? renderHeight : size.height * monthScale)) + 'px';
|
||||
cell.style.width = ((this.rotate ? renderHeight * item.width / item.height : size.width * monthScale)) + 'px';
|
||||
wrapper.append(cell);
|
||||
const copy = (await this.loadImage(item))?.cloneNode();
|
||||
if (copy) {
|
||||
try {
|
||||
await copy.decode();
|
||||
if (item.crop) {
|
||||
cell.classList.add('is-cropped');
|
||||
Object.assign(copy.style, {
|
||||
width: item.width / size.width * 100 + '%',
|
||||
height: item.height / size.height * 100 + '%',
|
||||
left: -size.left / size.width * 100 + '%',
|
||||
top: -size.top / size.height * 100 + '%',
|
||||
});
|
||||
}
|
||||
cell.append(copy);
|
||||
return;
|
||||
} catch { /* Die übrigen Seiten bleiben unabhängig darstellbar. */ }
|
||||
@@ -324,7 +349,8 @@
|
||||
if (!imageData) return;
|
||||
// Monatsplan dauerhaft separat: keine Vermischung mit der Aushangrotation.
|
||||
const months = imageData.month_images;
|
||||
if (months.length) root.style.setProperty('--month-aspect', aspectRatio(months));
|
||||
if (months.length) root.style.setProperty('--month-aspect',
|
||||
months.reduce((sum, item) => sum + item.width / item.height, 0));
|
||||
return Promise.all([
|
||||
month.update(months),
|
||||
posters.update(imageData.posters.filter(item => item.expires >= currentDay)),
|
||||
@@ -371,13 +397,76 @@
|
||||
const dateFormat = new Intl.DateTimeFormat('de-DE', {
|
||||
timeZone: 'Europe/Berlin', day: '2-digit', month: 'long', year: 'numeric',
|
||||
});
|
||||
const clock = document.getElementById('clock');
|
||||
const date = document.getElementById('date');
|
||||
function fitClock() {
|
||||
const width = clock.getBoundingClientRect().width;
|
||||
const target = date.getBoundingClientRect().width;
|
||||
if (width && Math.abs(width - target) > 1.5) {
|
||||
clock.style.fontSize = parseFloat(getComputedStyle(clock).fontSize) * target / width + 'px';
|
||||
}
|
||||
}
|
||||
new ResizeObserver(fitClock).observe(date);
|
||||
|
||||
const departures = new FixedText('departures-panel', rows => {
|
||||
const list = node('ul', 'text-page departures-list');
|
||||
for (const item of rows) {
|
||||
const row = node('li', 'departure-row is-' + item.state);
|
||||
row.title = item.detail;
|
||||
row.append(node('span', 'departure-line', item.line), node('span', 'departure-direction', item.direction),
|
||||
node('span', 'departure-remaining', item.remaining), node('span', 'departure-status', item.status));
|
||||
list.append(row);
|
||||
}
|
||||
return list;
|
||||
});
|
||||
const departureData = { departures: [], updatedAt: 0 };
|
||||
let nextDepartureRequest = 0;
|
||||
function renderDepartures(now) {
|
||||
const age = now - departureData.updatedAt;
|
||||
// Der globale Feed-Zeitstempel kann älter sein; entscheidend sind die
|
||||
// Prognosen der einzelnen Fahrten und das Alter unseres letzten Abrufs.
|
||||
const fresh = age < 300000 && !departureData.unavailable;
|
||||
const rows = (age < 7200000 ? departureData.departures : [])
|
||||
.map(d => ({ ...d, timestamp: Date.parse(d.when || d.plannedWhen) }))
|
||||
.filter(d => Number.isFinite(d.timestamp) && d.timestamp >= now)
|
||||
.sort((a, b) => a.timestamp - b.timestamp).slice(0, 5).map(d => {
|
||||
const live = fresh && Number.isFinite(d.delay) && Boolean(d.when);
|
||||
const state = !fresh ? 'stale' : d.cancelled ? 'cancelled' : !live ? 'scheduled'
|
||||
: d.delay > 0 ? 'delayed' : d.delay < 0 ? 'early' : 'ontime';
|
||||
const status = { stale: 'veraltet', cancelled: 'entfällt', scheduled: 'Fahrplaninfo', ontime: 'pünktlich',
|
||||
delayed: '+' + Math.ceil(d.delay / 60) + ' Min.', early: '−' + Math.ceil(-d.delay / 60) + ' Min.' }[state];
|
||||
const minutes = Math.ceil((d.timestamp - now) / 60000);
|
||||
const planned = Date.parse(d.plannedWhen);
|
||||
return { state, status, line: d.line?.name || 'Bus', direction: d.direction || 'Richtung unbekannt',
|
||||
remaining: d.cancelled ? '' : minutes ? minutes + ' Min' : 'jetzt',
|
||||
detail: (Number.isFinite(planned) ? 'Geplant ' + timeFormat.format(planned) + ' · ' : '')
|
||||
+ (d.direction || '') + ' · ' + status };
|
||||
});
|
||||
departures.update(rows, fresh ? 'Keine Busabfahrten in den nächsten 120 Minuten.' : 'Live-Abfahrten derzeit nicht verfügbar.');
|
||||
}
|
||||
const refreshDepartures = createRefresh('departures_eich', data => {
|
||||
if (!Array.isArray(data.departures) || !Number.isFinite(data.updatedAt)) throw new Error('Ungültige VBB-Antwort');
|
||||
Object.assign(departureData, data);
|
||||
nextDepartureRequest = Number(data.retryAt) * 1000 || 0;
|
||||
renderDepartures(Date.now());
|
||||
}, () => {
|
||||
departureData.unavailable = true;
|
||||
nextDepartureRequest = Date.now() + 30000;
|
||||
renderDepartures(Date.now());
|
||||
});
|
||||
function tick() {
|
||||
const now = new Date();
|
||||
const nextDay = berlinDay(now);
|
||||
document.getElementById('clock').textContent = timeFormat.format(now);
|
||||
document.getElementById('clock').dateTime = now.toISOString();
|
||||
document.getElementById('date').textContent = dateFormat.format(now);
|
||||
document.getElementById('date').dateTime = nextDay;
|
||||
clock.textContent = timeFormat.format(now);
|
||||
clock.dateTime = now.toISOString();
|
||||
date.textContent = dateFormat.format(now);
|
||||
date.dateTime = nextDay;
|
||||
fitClock();
|
||||
renderDepartures(now.getTime());
|
||||
if (!document.hidden && now.getTime() >= nextDepartureRequest) {
|
||||
nextDepartureRequest = now.getTime() + 1000;
|
||||
refreshDepartures();
|
||||
}
|
||||
if (nextDay !== currentDay) {
|
||||
currentDay = nextDay;
|
||||
// Texte erst mit einer erfolgreichen Antwort austauschen; abgelaufene
|
||||
@@ -392,7 +481,25 @@
|
||||
imageData = data;
|
||||
return layoutImages();
|
||||
}, imagesUnavailable);
|
||||
const temperatureFormat = new Intl.NumberFormat('de-DE', { maximumFractionDigits: 1 });
|
||||
const refreshTemperatures = [
|
||||
['indoor', data => [data.indoor.value, data.indoor.unit]],
|
||||
['outdoor', data => [data.outdoor.temperature, '°C']],
|
||||
].map(([id, read]) => {
|
||||
const output = document.getElementById(id);
|
||||
return createRefresh(id, data => {
|
||||
const [value, unit] = read(data);
|
||||
if (!Number.isFinite(value)) throw new Error('Ungültige Temperatur');
|
||||
const text = temperatureFormat.format(value) + ' ' + unit;
|
||||
if (output.textContent !== text) output.textContent = text;
|
||||
output.classList.remove('is-unavailable');
|
||||
}, () => {
|
||||
if (output.classList.contains('is-unavailable')) output.textContent = 'Nicht verfügbar';
|
||||
});
|
||||
});
|
||||
function refreshAll() {
|
||||
// Langsame Temperatursensoren dürfen den Aushangswechsel nicht verzögern.
|
||||
refreshTemperatures.forEach(refresh => refresh());
|
||||
return Promise.all([refreshContent(), refreshImages()]);
|
||||
}
|
||||
posters.onCycle = refreshAll;
|
||||
@@ -400,6 +507,7 @@
|
||||
tick();
|
||||
setInterval(tick, 1000);
|
||||
document.fonts.ready.then(() => {
|
||||
fitClock();
|
||||
announcements.rebuild();
|
||||
bible.rebuild();
|
||||
// Ein Mindestkontrast wird auch bei später geänderten zentralen Tokens geprüft.
|
||||
|
||||
@@ -20,29 +20,35 @@ $config = require __DIR__ . '/config.php';
|
||||
</head>
|
||||
<body class="dashboard">
|
||||
<main class="dashboard-main" aria-label="Gemeindeinformationen">
|
||||
<section class="dashboard-panel text-panel" id="announcements-panel" aria-labelledby="announcements-title">
|
||||
<div class="panel-heading"><h1 id="announcements-title">Ansagen</h1></div>
|
||||
<div class="text-stage"><p class="empty-state">Ansagen werden geladen …</p></div>
|
||||
</section>
|
||||
|
||||
<div class="community-column">
|
||||
<section class="dashboard-overview" aria-label="Gemeinde, Datum und Uhrzeit">
|
||||
<div class="dashboard-brand">
|
||||
<img src="../_central/img/nak-logo.svg" alt="Neuapostolische Kirche" width="64" height="64">
|
||||
<div class="dashboard-wordmark">
|
||||
<span>Neuapostolische Kirche</span>
|
||||
<strong>Berlin-Reinickendorf</strong>
|
||||
</div>
|
||||
</div>
|
||||
<div class="dashboard-column announcements-column">
|
||||
<section class="dashboard-overview" aria-label="Datum, Uhrzeit und Temperaturen">
|
||||
<div class="dashboard-clock">
|
||||
<time id="clock" aria-label="Uhrzeit Europe/Berlin">–:–</time>
|
||||
<time id="date">Datum wird geladen</time>
|
||||
</div>
|
||||
<dl class="temperatures">
|
||||
<div><dt>Innen</dt><dd id="indoor" class="is-unavailable">Wird geladen …</dd></div>
|
||||
<div><dt>Außen</dt><dd id="outdoor" class="is-unavailable">Wird geladen …</dd></div>
|
||||
</dl>
|
||||
</section>
|
||||
<section class="dashboard-panel text-panel" id="announcements-panel" aria-labelledby="announcements-title">
|
||||
<div class="panel-heading"><h1 id="announcements-title">Ansagen</h1></div>
|
||||
<div class="text-stage"><p class="empty-state">Ansagen werden geladen …</p></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<div class="dashboard-column bible-column">
|
||||
<section class="dashboard-panel text-panel bible-panel" id="bible-panel" aria-labelledby="bible-title">
|
||||
<div class="panel-heading"><h2 id="bible-title">Bibelwort für heute</h2></div>
|
||||
<div class="text-stage"><p class="empty-state">Bibelwort wird geladen …</p></div>
|
||||
</section>
|
||||
<section class="dashboard-panel text-panel" id="departures-panel" aria-labelledby="departures-title">
|
||||
<div class="panel-heading">
|
||||
<h2 id="departures-title">Abfahrten: Eichborndamm/Antonienstr.</h2>
|
||||
<a class="departures-source" href="https://www.vbb.de/" target="_blank" rel="noopener">Fahrplandaten: VBB</a>
|
||||
</div>
|
||||
<div class="text-stage"><p class="empty-state">Abfahrten werden geladen …</p></div>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section class="dashboard-panel image-panel" id="month-panel" aria-labelledby="month-title">
|
||||
|
||||
@@ -9,7 +9,6 @@ date_default_timezone_set('Europe/Berlin');
|
||||
require_once __DIR__ . '/util/dashboard_helpers.php';
|
||||
|
||||
$resource = (string) ($_GET['resource'] ?? 'content');
|
||||
$pdo = null;
|
||||
|
||||
try {
|
||||
switch ($resource) {
|
||||
@@ -29,6 +28,10 @@ try {
|
||||
case 'outdoor':
|
||||
$payload = ['outdoor' => dashboard_fetch_outdoor_weather()];
|
||||
break;
|
||||
case 'departures_eich':
|
||||
require_once __DIR__ . '/util/vbb.php';
|
||||
$payload = dashboard_fetch_departures();
|
||||
break;
|
||||
default:
|
||||
http_response_code(400);
|
||||
$payload = ['error' => 'Unbekannte Dashboard-Ressource.'];
|
||||
@@ -39,6 +42,4 @@ try {
|
||||
http_response_code(502);
|
||||
error_log('Dashboard data endpoint (' . $resource . '): ' . $exception->getMessage());
|
||||
echo json_encode(['error' => 'Dashboard-Daten konnten nicht geladen werden.'], JSON_UNESCAPED_UNICODE);
|
||||
} finally {
|
||||
$pdo = null;
|
||||
}
|
||||
|
||||
@@ -13,7 +13,6 @@ body.dashboard {
|
||||
--announcement-detail-font: calc(20 * var(--dashboard-unit));
|
||||
--announcement-meta-font: calc(15 * var(--dashboard-unit));
|
||||
--announcement-leading: 1.35;
|
||||
--announcement-measure: 85ch;
|
||||
--bible-font: calc(23 * var(--dashboard-unit));
|
||||
display: block;
|
||||
height: 100vh;
|
||||
@@ -27,49 +26,55 @@ body.dashboard {
|
||||
.dashboard [hidden] { display: none !important; }
|
||||
.dashboard-main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.05fr) minmax(0, 0.85fr) calc(var(--month-image-height) * var(--month-aspect) + 2px);
|
||||
grid-template-rows: calc(var(--month-image-height) + var(--panel-heading-height) + 2px) minmax(0, 1fr);
|
||||
grid-template-areas: "community announcements month" "posters posters posters";
|
||||
grid-template-columns: minmax(0, 0.85fr) minmax(0, 1.05fr) calc(var(--month-image-height) * var(--month-aspect));
|
||||
grid-template-rows: calc(var(--month-image-height) + var(--panel-heading-height)) minmax(0, 1fr);
|
||||
grid-template-areas: "announcements bible month" "posters posters posters";
|
||||
gap: var(--space-sm);
|
||||
height: 100%;
|
||||
min-height: 0;
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
#month-panel { grid-area: month; --panel-accent: var(--nak-darkblue); }
|
||||
#poster-panel { grid-area: posters; --panel-accent: var(--nak-orange); --poster-frame-width: 2px; }
|
||||
#announcements-panel { grid-area: announcements; --panel-accent: var(--nak-teal); }
|
||||
.community-column { grid-area: community; display: grid; grid-template-rows: auto minmax(0, 1fr); gap: var(--space-sm); min-height: 0; min-width: 0; }
|
||||
.dashboard-overview { background: var(--panel-surface); box-shadow: inset 0 calc(3 * var(--dashboard-unit)) var(--nak-blue); display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: calc(12 * var(--dashboard-unit)); padding: calc(8 * var(--dashboard-unit)) calc(12 * var(--dashboard-unit)); }
|
||||
.dashboard-brand { display: flex; align-items: flex-end; gap: calc(10 * var(--dashboard-unit)); }
|
||||
.dashboard-brand img { display: block; flex: 0 0 auto; width: calc(64 * var(--dashboard-unit)); height: calc(64 * var(--dashboard-unit)); }
|
||||
.dashboard-wordmark { font-size: var(--announcement-meta-font); line-height: 1.2; transform: translateY(calc(4 * var(--dashboard-unit))); }
|
||||
.dashboard-wordmark span, .dashboard-wordmark strong { display: block; white-space: nowrap; }
|
||||
.dashboard-wordmark strong { font-weight: 500; }
|
||||
.dashboard-clock { margin-left: auto; transform: translateY(calc(3 * var(--dashboard-unit))); }
|
||||
.dashboard-clock time { display: block; }
|
||||
#clock { font-size: calc(48 * var(--dashboard-unit)); line-height: 1; font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
#month-panel { grid-area: month; --panel-accent: var(--nak-purple); }
|
||||
#poster-panel { grid-area: posters; --panel-accent: var(--nak-orange); --poster-frame-width: 1px; }
|
||||
#announcements-panel { --panel-accent: var(--nak-teal); }
|
||||
.dashboard-column { display: grid; gap: var(--space-sm); min-height: 0; min-width: 0; }
|
||||
.announcements-column { grid-area: announcements; grid-template-rows: auto minmax(0, 1fr); }
|
||||
.bible-column { grid-area: bible; grid-template-rows: minmax(0, 1fr) auto; }
|
||||
.dashboard-panel { min-width: 0; min-height: 0; background: var(--panel-surface); }
|
||||
.dashboard-overview { display: flex; align-items: flex-end; justify-content: space-between; gap: calc(8 * var(--dashboard-unit)); padding: calc(16 * var(--dashboard-unit)) calc(12 * var(--dashboard-unit)) calc(8 * var(--dashboard-unit)); background: var(--panel-surface); box-shadow: inset 0 calc(3 * var(--dashboard-unit)) var(--nak-blue); }
|
||||
.dashboard-clock { display: grid; flex: 0 0 auto; justify-items: start; gap: calc(4 * var(--dashboard-unit)); }
|
||||
#clock, #date { width: max-content; }
|
||||
#clock { font-size: calc(64 * var(--dashboard-unit)); line-height: 0.9; font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
#date { font-size: var(--announcement-meta-font); white-space: nowrap; }
|
||||
.dashboard-panel { min-width: 0; min-height: 0; background: var(--panel-surface); border: 1px solid var(--gray-300); border-radius: 0; }
|
||||
.temperatures { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); min-width: 0; gap: calc(8 * var(--dashboard-unit)); margin: 0; }
|
||||
.temperatures > div { min-width: 0; }
|
||||
.temperatures dt { font-size: var(--announcement-meta-font); color: var(--dashboard-meta); }
|
||||
.temperatures dd { margin: 0; font-size: var(--announcement-detail-font); line-height: var(--announcement-leading); font-weight: 500; font-variant-numeric: tabular-nums; }
|
||||
.temperatures .is-unavailable { font-size: calc(12 * var(--dashboard-unit)); font-weight: 300; color: var(--dashboard-meta); }
|
||||
.image-panel, .text-panel { display: grid; grid-template-rows: var(--panel-heading-height) minmax(0, 1fr); }
|
||||
.panel-heading { display: flex; align-items: center; padding: calc(3 * var(--dashboard-unit)) calc(8 * var(--dashboard-unit)) 0; border-bottom: 1px solid var(--gray-300); background: var(--panel-surface); box-shadow: inset 0 calc(3 * var(--dashboard-unit)) var(--panel-accent); }
|
||||
.panel-heading { display: flex; align-items: center; padding: calc(3 * var(--dashboard-unit)) calc(8 * var(--dashboard-unit)) 0; background: var(--panel-surface); background: color-mix(in srgb, var(--panel-accent) 12%, white); box-shadow: inset 0 calc(3 * var(--dashboard-unit)) var(--panel-accent); }
|
||||
.panel-heading h1, .panel-heading h2 { margin: 0; color: var(--dashboard-text); font-size: calc(20 * var(--dashboard-unit)); line-height: 1.1; font-weight: 500; }
|
||||
.poster-progress { width: calc(18 * var(--dashboard-unit)); height: calc(18 * var(--dashboard-unit)); margin-left: auto; flex: 0 0 auto; fill: none; stroke-width: 2; }
|
||||
.poster-progress-track { stroke: var(--gray-300); }
|
||||
.poster-progress-value { stroke: var(--panel-accent); stroke-dasharray: 100; stroke-dashoffset: 100; transform: rotate(-90deg); transform-origin: center; }
|
||||
.image-stage { min-height: 0; min-width: 0; padding: 0; column-gap: var(--space-sm); }
|
||||
#month-panel .image-stage,
|
||||
#poster-panel .image-stage { padding: calc(4 * var(--dashboard-unit)); }
|
||||
.image-sheet { display: flex; justify-content: center; align-items: center; gap: var(--space-sm); width: 100%; height: 100%; min-height: 0; }
|
||||
#month-panel .image-sheet { gap: 0; }
|
||||
#month-panel .image-sheet { align-items: flex-start; gap: calc(4 * var(--dashboard-unit)); }
|
||||
.image-cell { margin: 0; min-height: 0; min-width: 0; flex: 0 0 auto; }
|
||||
.poster-document { display: flex; flex: 0 0 auto; gap: 0; background: #fff; border: var(--poster-frame-width) solid var(--gray-500); }
|
||||
.image-cell img { display: block; width: 100%; height: 100%; object-fit: contain; }
|
||||
.image-cell.is-cropped { position: relative; overflow: hidden; }
|
||||
.image-cell.is-cropped img { position: absolute; max-width: none; }
|
||||
.image-cell .empty-state { padding: var(--space-sm); font-size: var(--announcement-font); }
|
||||
.text-stage { min-height: 0; margin: calc(6 * var(--dashboard-unit)) calc(8 * var(--dashboard-unit)); overflow: hidden; position: relative; }
|
||||
#announcements-panel .text-stage { margin: 0; }
|
||||
#announcements-panel .text-stage > .empty-state { padding: 0 calc(10 * var(--dashboard-unit)); }
|
||||
.text-page { max-width: var(--announcement-measure); color: var(--dashboard-text); }
|
||||
.text-page { color: var(--dashboard-text); }
|
||||
.text-page p { margin: 0 0 var(--space-xs); white-space: pre-wrap; overflow-wrap: anywhere; font-size: var(--announcement-font); line-height: var(--announcement-leading); }
|
||||
.text-page p:last-child { margin-bottom: 0; }
|
||||
.text-page .announcement-title { font-size: var(--announcement-detail-font); }
|
||||
.text-page .announcement-title, .text-page .bible-reference { font-weight: 500; }
|
||||
.text-page .announcement-subtitle { font-size: var(--announcement-detail-font); color: var(--dashboard-meta); }
|
||||
.text-page .announcement-meta { font-size: var(--announcement-meta-font); color: var(--dashboard-meta); }
|
||||
@@ -83,7 +88,22 @@ body.dashboard {
|
||||
.announcement-when { min-width: 0; font-weight: 300; color: var(--dashboard-meta); font-variant-numeric: tabular-nums; }
|
||||
.announcement-when p { margin-bottom: 0; font-size: var(--announcement-detail-font); }
|
||||
.bible-panel { --panel-accent: var(--nak-yellow); }
|
||||
.bible-panel .text-stage { margin-inline: calc(24 * var(--dashboard-unit)); margin-top: calc(12 * var(--dashboard-unit)); }
|
||||
.bible-panel .text-page { max-width: none; }
|
||||
#departures-panel { --panel-accent: var(--nak-darkblue); height: calc(161 * var(--dashboard-unit)); }
|
||||
#departures-panel .text-stage { margin: 0; }
|
||||
#departures-title { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: calc(18 * var(--dashboard-unit)); }
|
||||
.departures-source { margin-left: auto; color: var(--dashboard-meta); font-size: calc(10 * var(--dashboard-unit)); white-space: nowrap; }
|
||||
.departures-list { max-width: none; margin: 0; padding: 0; list-style: none; }
|
||||
.departure-row { --departure-color: var(--gray-500); display: grid; grid-template-columns: calc(38 * var(--dashboard-unit)) minmax(0, 1fr) calc(52 * var(--dashboard-unit)) calc(100 * var(--dashboard-unit)); align-items: center; gap: calc(6 * var(--dashboard-unit)); height: calc(26 * var(--dashboard-unit)); padding: 0 calc(5 * var(--dashboard-unit)) 0 calc(8 * var(--dashboard-unit)); border-right: calc(6 * var(--dashboard-unit)) solid var(--departure-color); background: var(--panel-surface); font-size: var(--announcement-meta-font); line-height: 1.2; }
|
||||
.departure-row:nth-child(even) { background: var(--gray-50); }
|
||||
.departure-row.is-ontime { --departure-color: var(--nak-darkgreen); }
|
||||
.departure-row.is-delayed { --departure-color: var(--nak-orange); }
|
||||
.departure-row.is-early, .departure-row.is-cancelled { --departure-color: var(--nak-red); }
|
||||
.departure-line { font-weight: 500; }
|
||||
.departure-direction { overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
|
||||
.departure-remaining, .departure-status { text-align: right; white-space: nowrap; font-variant-numeric: tabular-nums; }
|
||||
#departures-panel .empty-state { padding: calc(8 * var(--dashboard-unit)); font-size: var(--announcement-meta-font); }
|
||||
.text-stage.is-overflowing::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
@@ -100,7 +120,7 @@ body.dashboard {
|
||||
.dashboard-main {
|
||||
height: auto;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
grid-template-areas: "community" "announcements" "month" "posters";
|
||||
grid-template-rows: 520px 320px 65vh 55vh;
|
||||
grid-template-areas: "announcements" "bible" "month" "posters";
|
||||
grid-template-rows: 520px 520px 65vh 55vh;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,8 +8,8 @@ require_once __DIR__ . '/../../../general_files/ini/runtime_env.php';
|
||||
|
||||
const DASHBOARD_TIMEZONE = 'Europe/Berlin';
|
||||
const DASHBOARD_HA_ENTITY_ID = 'sensor.temp_temperatur';
|
||||
const DASHBOARD_OPENWEATHER_LATITUDE = '52.571291';
|
||||
const DASHBOARD_OPENWEATHER_LONGITUDE = '13.310422';
|
||||
const DASHBOARD_OPENWEATHER_LATITUDE = '52.571524';
|
||||
const DASHBOARD_OPENWEATHER_LONGITUDE = '13.312494';
|
||||
const DASHBOARD_NEWS_URL = '/intranet_repo/projects/contentpanel/insertion/news/png/';
|
||||
|
||||
function dashboard_now(): DateTimeImmutable
|
||||
@@ -91,15 +91,12 @@ function dashboard_prepare_announcement(array $announcement, DateTimeImmutable $
|
||||
$place = trim((string) ($announcement['ansage_ort'] ?? ''));
|
||||
|
||||
return [
|
||||
'start' => $start,
|
||||
'end' => $end,
|
||||
'date_label' => $label,
|
||||
'end_label' => $endLabel,
|
||||
'time' => $time,
|
||||
'place' => $place,
|
||||
'title' => trim((string) ($announcement['ansage_titel'] ?? '')),
|
||||
'subtitle' => trim((string) ($announcement['ansage_untertitel'] ?? '')),
|
||||
'meta' => implode(' · ', array_filter([$label, $endLabel, $time !== '' ? $time . ' Uhr' : '', $place])),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -146,8 +143,8 @@ function dashboard_list_posters(DateTimeImmutable $now, string $directory = CONT
|
||||
$title = str_replace('_', ' ', substr($match[1], 11));
|
||||
$posters[] = $image + [
|
||||
'document' => $match[1],
|
||||
'kind' => 'poster', 'expires' => $expiry, 'page' => (int) $match[2],
|
||||
'title' => $title, 'alt' => $title . ' · Seite ' . (int) $match[2],
|
||||
'expires' => $expiry, 'page' => (int) $match[2],
|
||||
'alt' => $title . ' · Seite ' . (int) $match[2],
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -162,10 +159,7 @@ function dashboard_month_images(?string $directory = null): array
|
||||
$filename = 'terminuebersicht-' . $page . '.png';
|
||||
$image = dashboard_image($directory . '/' . $filename, 'month/' . $filename);
|
||||
if ($image) {
|
||||
$images[] = $image + [
|
||||
'kind' => 'month', 'page' => $page, 'title' => 'Monatsplan',
|
||||
'alt' => 'Monatsplan · Seite ' . $page,
|
||||
];
|
||||
$images[] = $image + ['alt' => 'Monatsplan · Seite ' . $page];
|
||||
}
|
||||
}
|
||||
return $images;
|
||||
@@ -183,7 +177,7 @@ function dashboard_fetch_indoor_temperature(): array
|
||||
}
|
||||
|
||||
return [
|
||||
'value' => round((float) $data['state'], 1),
|
||||
'value' => (int) ceil((float) $data['state'] - 0.4),
|
||||
'unit' => (string) ($data['attributes']['unit_of_measurement'] ?? '°C'),
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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'">
|
||||
|
||||
@@ -35,7 +35,7 @@ def validate_template(template: Path) -> None:
|
||||
_placeholders(minidom.parseString(archive.read('word/document.xml')))
|
||||
|
||||
|
||||
def _set_text(paragraph, text):
|
||||
def _set_text(paragraph, text):
|
||||
if any(ord(char) < 32 and char not in '\t\n\r' for char in text):
|
||||
raise TemplateError('Text enthält ungültige Steuerzeichen.')
|
||||
nodes = paragraph.getElementsByTagNameNS(W, 't')
|
||||
@@ -44,22 +44,49 @@ def _set_text(paragraph, text):
|
||||
node.removeChild(node.firstChild)
|
||||
document, anchor = paragraph.ownerDocument, nodes[0]
|
||||
text = text.replace('\r\n', '\n').replace('\r', '\n')
|
||||
for part in re.split(r'(\n|\t)', text):
|
||||
for part in re.split(r'(\n|\t)', text):
|
||||
if part in ('\n', '\t'):
|
||||
node = document.createElementNS(W, 'w:br' if part == '\n' else 'w:tab')
|
||||
else:
|
||||
node = document.createElementNS(W, 'w:t')
|
||||
node.setAttribute('xml:space', 'preserve')
|
||||
node.appendChild(document.createTextNode(part))
|
||||
anchor.parentNode.insertBefore(node, anchor)
|
||||
anchor.parentNode.removeChild(anchor)
|
||||
# Preserve verse/signature lines without stretching them to the full width.
|
||||
if '\n' in text:
|
||||
for alignment in paragraph.getElementsByTagNameNS(W, 'jc'):
|
||||
alignment.setAttributeNS(W, 'w:val', 'left')
|
||||
|
||||
|
||||
def fill_template(template: Path, output: Path, title: str, date_label: str, paragraphs: list[str]) -> None:
|
||||
anchor.parentNode.insertBefore(node, anchor)
|
||||
anchor.parentNode.removeChild(anchor)
|
||||
if '\n' in text:
|
||||
paragraph.getElementsByTagNameNS(W, 'jc')[0].setAttributeNS(W, 'w:val', 'left')
|
||||
|
||||
|
||||
def _items(text):
|
||||
lines = [line.strip() for line in text.replace('\r\n', '\n').replace('\r', '\n').split('\n') if line.strip()]
|
||||
bullets = [line[1:].lstrip() if line.startswith('•') and line[1:].strip() else None for line in lines]
|
||||
if not any(bullets):
|
||||
return [(text, None)]
|
||||
return [(bullet, i + 1 < len(lines) and bullets[i + 1] is not None) if bullet else (line, None)
|
||||
for i, (line, bullet) in enumerate(zip(lines, bullets))]
|
||||
|
||||
|
||||
def _format_bullet(paragraph, compact):
|
||||
properties = paragraph.getElementsByTagNameNS(W, 'pPr')[0]
|
||||
anchor = properties.getElementsByTagNameNS(W, 'jc')[0]
|
||||
document = paragraph.ownerDocument
|
||||
tabs = document.createElementNS(W, 'w:tabs')
|
||||
tab = document.createElementNS(W, 'w:tab')
|
||||
tab.setAttributeNS(W, 'w:val', 'left')
|
||||
tab.setAttributeNS(W, 'w:pos', '480')
|
||||
tabs.appendChild(tab)
|
||||
properties.insertBefore(tabs, anchor)
|
||||
if compact:
|
||||
spacing = document.createElementNS(W, 'w:spacing')
|
||||
spacing.setAttributeNS(W, 'w:after', '0')
|
||||
properties.insertBefore(spacing, anchor)
|
||||
indent = document.createElementNS(W, 'w:ind')
|
||||
indent.setAttributeNS(W, 'w:left', '480')
|
||||
indent.setAttributeNS(W, 'w:hanging', '360')
|
||||
properties.insertBefore(indent, anchor)
|
||||
|
||||
|
||||
def fill_template(template: Path, output: Path, title: str, date_label: str, paragraphs: list[str]) -> None:
|
||||
paragraphs = [p.strip() for p in paragraphs if p.strip()]
|
||||
if not title.strip() or not date_label.strip() or not paragraphs:
|
||||
raise TemplateError('Titel, Datum und Inhalt dürfen nicht leer sein.')
|
||||
@@ -69,16 +96,19 @@ def fill_template(template: Path, output: Path, title: str, date_label: str, par
|
||||
_set_text(placeholders[TITLE_TOKEN], title.strip())
|
||||
_set_text(placeholders[DATE_TOKEN], date_label.strip())
|
||||
body = placeholders[BODY_TOKEN]
|
||||
used = {p.getAttributeNS(W14, 'paraId') for p in document.getElementsByTagNameNS(W, 'p')}
|
||||
index = 1
|
||||
for text in paragraphs:
|
||||
paragraph = body.cloneNode(deep=True)
|
||||
while f'{index:08X}' in used:
|
||||
index += 1
|
||||
paragraph.setAttributeNS(W14, 'w14:paraId', f'{index:08X}')
|
||||
used.add(f'{index:08X}')
|
||||
_set_text(paragraph, text)
|
||||
body.parentNode.insertBefore(paragraph, body)
|
||||
used = {p.getAttributeNS(W14, 'paraId') for p in document.getElementsByTagNameNS(W, 'p')}
|
||||
index = 1
|
||||
for text in paragraphs:
|
||||
for value, compact in _items(text):
|
||||
paragraph = body.cloneNode(deep=True)
|
||||
while f'{index:08X}' in used:
|
||||
index += 1
|
||||
paragraph.setAttributeNS(W14, 'w14:paraId', f'{index:08X}')
|
||||
used.add(f'{index:08X}')
|
||||
_set_text(paragraph, f'•\t{value}' if compact is not None else value)
|
||||
if compact is not None:
|
||||
_format_bullet(paragraph, compact)
|
||||
body.parentNode.insertBefore(paragraph, body)
|
||||
body.parentNode.removeChild(body)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
with zipfile.ZipFile(output, 'w') as target:
|
||||
|
||||
@@ -139,7 +139,7 @@ class PipelineTests(unittest.TestCase):
|
||||
self.assertEqual(xml.count(DATE_TOKEN), 1)
|
||||
self.assertEqual(xml.count(BODY_TOKEN), 1)
|
||||
|
||||
def test_original_line_breaks_use_word_elements(self):
|
||||
def test_original_line_breaks_use_word_elements(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
target = Path(temp) / 'lines.docx'
|
||||
fill_template(TEMPLATE, target, 'Gruß', _date_label('2026-09-02'), ['Von Herzen\nEure Gastgeber'])
|
||||
@@ -148,10 +148,34 @@ class PipelineTests(unittest.TestCase):
|
||||
ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
|
||||
paragraph = next(p for p in document.findall('.//w:p', ns) if p.find('.//w:br', ns) is not None)
|
||||
self.assertEqual(len(paragraph.findall('.//w:br', ns)), 1)
|
||||
self.assertEqual([t.text for t in paragraph.findall('.//w:t', ns)], ['Von Herzen', 'Eure Gastgeber'])
|
||||
self.assertEqual(paragraph.find('w:pPr/w:jc', ns).get('{' + ns['w'] + '}val'), 'left')
|
||||
|
||||
def test_template_fill_creates_multiple_paragraphs(self):
|
||||
self.assertEqual([t.text for t in paragraph.findall('.//w:t', ns)], ['Von Herzen', 'Eure Gastgeber'])
|
||||
self.assertEqual(paragraph.find('w:pPr/w:jc', ns).get('{' + ns['w'] + '}val'), 'left')
|
||||
|
||||
def test_bullet_lines_become_hanging_list_paragraphs(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
target = Path(temp) / 'bullets.docx'
|
||||
fill_template(TEMPLATE, target, 'Einladung', _date_label('2026-09-04'), [
|
||||
'Einleitung.',
|
||||
'SEPTEMBER\n•05.09.2026: Erster Stichpunkt.\n• Zweiter Stichpunkt.\nOKTOBER\n• Dritter Stichpunkt.',
|
||||
'Abschluss.',
|
||||
])
|
||||
with zipfile.ZipFile(target) as archive:
|
||||
document = ET.fromstring(archive.read('word/document.xml'))
|
||||
ns = {'w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main'}
|
||||
q = lambda name: '{' + ns['w'] + '}' + name
|
||||
bullets = [paragraph for paragraph in document.findall('.//w:p', ns)
|
||||
if ''.join(paragraph.itertext()).startswith('•')]
|
||||
self.assertEqual(len(bullets), 3)
|
||||
self.assertTrue({'SEPTEMBER', 'OKTOBER'} <= {''.join(p.itertext()) for p in document.findall('.//w:p', ns)})
|
||||
for paragraph in bullets:
|
||||
self.assertIsNotNone(paragraph.find('w:r/w:tab', ns))
|
||||
self.assertEqual(paragraph.find('w:pPr/w:tabs/w:tab', ns).get(q('pos')), '480')
|
||||
indent = paragraph.find('w:pPr/w:ind', ns)
|
||||
self.assertEqual((indent.get(q('left')), indent.get(q('hanging'))), ('480', '360'))
|
||||
self.assertEqual(bullets[0].find('w:pPr/w:spacing', ns).get(q('after')), '0')
|
||||
self.assertIsNone(bullets[1].find('w:pPr/w:spacing', ns))
|
||||
|
||||
def test_template_fill_creates_multiple_paragraphs(self):
|
||||
with tempfile.TemporaryDirectory() as temp:
|
||||
target = Path(temp) / "filled.docx"
|
||||
paragraphs = [f"Absatz {i}: Inhalt für den Aushang." for i in range(1, 8)]
|
||||
|
||||
@@ -32,11 +32,13 @@ class RenderingTests(unittest.TestCase):
|
||||
cls.document = cls.output / "verified.docx"
|
||||
with zipfile.ZipFile(TEMPLATE) as archive:
|
||||
(cls.output / "original-logo.png").write_bytes(archive.read("word/media/image1.png"))
|
||||
paragraphs = [
|
||||
("Dies ist ein langer Testabsatz zur Prüfung von Schriftgröße, Zeilenumbruch und Seitenaufteilung. " * 12).strip()
|
||||
for _ in range(3)
|
||||
] + ["Erste Gedichtzeile\nZweite Gedichtzeile\n\nVon Herzen\nEure Gastgeber",
|
||||
"Abschlussmarker: Dieser letzte Absatz muss vollständig auf der letzten Seite erscheinen."]
|
||||
paragraphs = [
|
||||
("Dies ist ein langer Testabsatz zur Prüfung von Schriftgröße, Zeilenumbruch und Seitenaufteilung. " * 12).strip()
|
||||
for _ in range(3)
|
||||
] + ["• Ein längerer Stichpunkt, dessen zweite Zeile unter dem Text und nicht unter dem Aufzählungszeichen beginnt.\n"
|
||||
"• Ein zweiter Stichpunkt bestätigt den gleichmäßigen Abstand der Liste.",
|
||||
"Erste Gedichtzeile\nZweite Gedichtzeile\n\nVon Herzen\nEure Gastgeber",
|
||||
"Abschlussmarker: Dieser letzte Absatz muss vollständig auf der letzten Seite erscheinen."]
|
||||
fill_template(TEMPLATE, cls.document,
|
||||
"Verabschiedung von Diakon Georg aus der Gemeinde Reinickendorf",
|
||||
_date_label("2026-09-02"), paragraphs)
|
||||
|
||||
@@ -170,9 +170,10 @@ class WebPreviewTests(unittest.TestCase):
|
||||
self.assertIn('Enddatum darf nicht', self.page.locator('.sb-alert-error').inner_text())
|
||||
|
||||
def test_original_upload_and_delete_through_real_page(self):
|
||||
self.page.locator('#news_date').fill('2030-12-31')
|
||||
self.page.locator('#news_name').fill('Uploadtest')
|
||||
self.page.locator('#fileToUpload').set_input_files(self.pdf)
|
||||
self.page.locator('#news_date').fill('2030-12-31')
|
||||
self.page.locator('#news_name').fill('Uploadtest')
|
||||
self.page.locator('[name="format_mode"][value="original"]').check()
|
||||
self.page.locator('#fileToUpload').set_input_files(self.pdf)
|
||||
self.page.get_by_role('button', name='Datei hochladen').click()
|
||||
self.page.wait_for_selector('.sb-alert', timeout=60000)
|
||||
self.assertEqual('Aushang wurde veröffentlicht.', self.page.locator('.sb-alert').inner_text())
|
||||
@@ -215,9 +216,9 @@ class WebPreviewTests(unittest.TestCase):
|
||||
self.page.get_by_role('button', name='Datei hochladen').click()
|
||||
self.page.wait_for_selector('.sb-alert')
|
||||
self.assertEqual(states[-1], ['block', True])
|
||||
self.assertIn('erfolgreich hochgeladen', self.page.locator('.sb-alert').inner_text())
|
||||
self.assertIn('Hintergrund', self.page.locator('.sb-alert').inner_text())
|
||||
self.assertIn('E-Mail', self.page.locator('.sb-alert').inner_text())
|
||||
message = self.page.locator('.sb-alert').inner_text()
|
||||
self.assertIn('Hintergrund', message)
|
||||
self.assertIn('E-Mail', message)
|
||||
self.assertFalse(list(self.news.glob('*' + title + '*')))
|
||||
self.assertTrue(self.page.get_by_role('button', name='Datei hochladen').is_enabled())
|
||||
self.assertEqual(self.page.locator('.sb-progress, .sb-processing-note').count(), 0)
|
||||
|
||||
@@ -61,12 +61,11 @@ def has_style(value, declaration):
|
||||
|
||||
|
||||
def event_rows(cell):
|
||||
blocks = cell.find_all("div", class_="calEventBox")
|
||||
titles = cell.find_all(style=lambda value: has_style(value, "margin-right:.2em"))
|
||||
|
||||
for index, title in enumerate(titles):
|
||||
time_text, place = "", "--"
|
||||
block = blocks[index] if index < len(blocks) else None
|
||||
titles = cell.find_all(style=lambda value: has_style(value, "margin-right:.2em"))
|
||||
|
||||
for title in titles:
|
||||
time_text = ""
|
||||
block = title.find_parent("div", class_="calEventBox")
|
||||
details = block and block.find(
|
||||
style=lambda value: has_style(value, "font-weight:normal")
|
||||
)
|
||||
@@ -74,9 +73,10 @@ def event_rows(cell):
|
||||
parts = [part.strip() for part in details.stripped_strings if part.strip()]
|
||||
if parts and ":" in parts[0]:
|
||||
time_text = parts.pop(0)
|
||||
if parts:
|
||||
place = parts[0].lstrip(", ") or "--"
|
||||
yield time_text, title.get_text(strip=True), place
|
||||
# Nur das Ortsfeld auswerten, nicht Trennzeichen, Datumsbereiche oder Notizen.
|
||||
location = block and block.find(attrs={"name": "show_place"})
|
||||
place = location.get_text(" ", strip=True).lstrip(", ") if location else ""
|
||||
yield time_text, title.get_text(strip=True), "" if place == "--" else place
|
||||
|
||||
|
||||
def next_month(month):
|
||||
@@ -204,10 +204,14 @@ def add_month(pdf, cells, month, today=None):
|
||||
|
||||
|
||||
def add_month_section(pdf, cells, month, now, today=None, planning_note=False):
|
||||
name = MONTHS[month.month - 1]
|
||||
pdf.set_font("helvetica", "B", 16)
|
||||
pdf.cell(160, 7, pdf_safe(f"Terminübersicht: {name.upper()} {month.year}"), 0, 1)
|
||||
pdf.set_font("helvetica", "", 10)
|
||||
name = MONTHS[month.month - 1]
|
||||
pdf.set_font("helvetica", "B", 16)
|
||||
pdf.cell(160, 7, pdf_safe(f"Terminübersicht: {name.upper()} {month.year}"), 0, 1)
|
||||
logo = os.path.join(os.path.dirname(__file__), "nak_logo.png")
|
||||
if not os.path.isfile(logo):
|
||||
raise RuntimeError(f"Logo fehlt: {logo}")
|
||||
pdf.image(logo, 188, pdf.get_y() - 7.5, 11)
|
||||
pdf.set_font("helvetica", "", 10)
|
||||
subtitle = (
|
||||
f"Die Termine für {name} befinden sich noch in Planung und können sich ändern."
|
||||
if planning_note else "der Gemeinde Berlin-Reinickendorf"
|
||||
@@ -230,14 +234,11 @@ def create_pdf(cells, output_path, now=None):
|
||||
pdf.set_auto_page_break(auto=True, margin=15)
|
||||
pdf.add_page()
|
||||
|
||||
logo = os.path.join(os.path.dirname(__file__), "nak_logo.png")
|
||||
if not os.path.isfile(logo):
|
||||
raise RuntimeError(f"Logo fehlt: {logo}")
|
||||
pdf.image(logo, 188, 9.5, 11)
|
||||
|
||||
for index, month in enumerate(months):
|
||||
if index:
|
||||
pdf.cell(0, 6, " ", 0, 1)
|
||||
for index, month in enumerate(months):
|
||||
if index and pdf.page_no() == 1:
|
||||
pdf.add_page()
|
||||
elif index:
|
||||
pdf.cell(0, 6, " ", 0, 1)
|
||||
add_month_section(
|
||||
pdf, cells, month, now,
|
||||
today=today if index == 0 else None,
|
||||
|
||||
Reference in New Issue
Block a user