camera - (real) remote control, brandschutz - configurable course, dashboard - vbb licensed API (a real vbb license, how cool)

This commit is contained in:
2026-09-08 18:21:51 +02:00
parent 3f98cf2663
commit ce90820094
24 changed files with 3044 additions and 2065 deletions
@@ -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>';
}