Display-Seiten individuell gestalten
Some checks are pending
CI / PHP Lint (push) Waiting to run
CI / PHP Lint (pull_request) Waiting to run
CI / PHP Lint-1 (pull_request) Waiting to run
CI / PHP Lint-1 (push) Waiting to run
CI / Unit Tests & Static Analysis (pull_request) Waiting to run
CI / Unit Tests & Static Analysis (push) Waiting to run

Jede Display-Seite bringt jetzt ihr eigenes Erscheinungsbild mit – der
Empfang sieht anders aus als der Tagungsraum nebenan:

- eigenes Logo (leer = Logo aus den Einstellungen)
- formatfüllendes Hintergrundbild mit einstellbarer Abdunklung (0–90 %),
  damit die Karte auf hellen Fotos lesbar bleibt
- eigene Akzentfarbe für den Knopf (leer = Farbe aus dem Design-Tab)
- Karte wahlweise hell oder dunkel; auf Fotos wirkt dunkel meist ruhiger

Logo und Hintergrund lassen sich hochladen oder als URL hinterlegen; beim
Löschen einer Display-Seite verschwinden die hochgeladenen Dateien mit.

Nebenbei aufgeräumt: das Bildfeld (Vorschau + Upload + URL + Entfernen)
lag als Funktion in admin/settings.php und wird jetzt von beiden Seiten
genutzt – Ui::imageField() für die Darstellung, Upload::resolveField()
für die Auswertung.

Sicherheit: die Akzentfarbe landet in einem style-Attribut, deshalb wird
sie sowohl beim Speichern als auch beim Ausgeben auf eine echte Hex-Farbe
geprüft; ein Test hält das fest.

Migration 0006, database.sql nachgezogen, 4 neue Tests (42 gesamt),
Screenshots ergänzt, Version 2.8.0.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Friederich Loheide 2026-09-23 16:58:54 +00:00
parent 943e427150
commit 4159b92268
31 changed files with 405 additions and 82 deletions

View file

@ -16,6 +16,7 @@ require_once __DIR__ . '/../includes/Auth.php';
require_once __DIR__ . '/../includes/I18n.php';
require_once __DIR__ . '/../includes/Ui.php';
require_once __DIR__ . '/../includes/Kiosk.php';
require_once __DIR__ . '/../includes/Upload.php';
$auth = new Auth();
$auth->requireAdmin();
@ -40,9 +41,20 @@ function kioskInput(): array
'cooldown_seconds' => max(0, min(3600, (int)($_POST['cooldown_seconds'] ?? Kiosk::DEFAULT_COOLDOWN))),
'display_seconds' => max(10, min(600, (int)($_POST['display_seconds'] ?? Kiosk::DEFAULT_DISPLAY_SECONDS))),
'is_active' => isset($_POST['is_active']) ? 1 : 0,
'bg_overlay' => max(0, min(90, (int)($_POST['bg_overlay'] ?? 45))),
'accent_color' => self_accent($_POST['accent_color'] ?? ''),
'card_style' => ($_POST['card_style'] ?? 'light') === 'dark' ? 'dark' : 'light',
];
}
/** Nur echte Hex-Farben durchlassen der Wert landet in einem style-Attribut. */
function self_accent($value): ?string
{
$value = strtolower(trim((string)$value));
return preg_match('/^#[0-9a-f]{6}$/', $value) ? $value : null;
}
if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_kiosk'])) {
if (!$auth->validateCsrfToken($_POST['csrf_token'] ?? '')) {
$error = __('error_csrf');
@ -52,12 +64,18 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['add_kiosk'])) {
if ($in['name'] === '') throw new Exception(__('error_name_req'));
if ($in['site_id'] <= 0) throw new Exception(__('error_site_req'));
$logo = Upload::resolveField('logo_url', '', 'image');
$bg = Upload::resolveField('background_url', '', 'image');
$db->execute(
"INSERT INTO kiosks (site_id, template_id, name, token, headline, subline, daily_limit, cooldown_seconds, display_seconds, is_active, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)",
"INSERT INTO kiosks (site_id, template_id, name, token, headline, subline,
logo_url, background_url, bg_overlay, accent_color, card_style,
daily_limit, cooldown_seconds, display_seconds, is_active, created_by)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?)",
[$in['site_id'], $in['template_id'], $in['name'], Kiosk::newToken(),
$in['headline'], $in['subline'], $in['daily_limit'], $in['cooldown_seconds'],
$in['display_seconds'], $_SESSION['user_id']]
$in['headline'], $in['subline'], $logo, $bg, $in['bg_overlay'],
$in['accent_color'], $in['card_style'], $in['daily_limit'],
$in['cooldown_seconds'], $in['display_seconds'], $_SESSION['user_id']]
);
$auth->writeAuditLog($_SESSION['user_id'], 'kiosk_created', 'kiosk', null, $in['name']);
$success = __('kiosks_added');
@ -77,11 +95,17 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['edit_kiosk'])) {
if ($in['name'] === '') throw new Exception(__('error_name_req'));
if ($in['site_id'] <= 0) throw new Exception(__('error_site_req'));
$current = $db->fetchOne("SELECT logo_url, background_url FROM kiosks WHERE id = ?", [$id]) ?: [];
$logo = Upload::resolveField('logo_url', (string)($current['logo_url'] ?? ''), 'image');
$bg = Upload::resolveField('background_url', (string)($current['background_url'] ?? ''), 'image');
$db->execute(
"UPDATE kiosks SET site_id=?, template_id=?, name=?, headline=?, subline=?,
logo_url=?, background_url=?, bg_overlay=?, accent_color=?, card_style=?,
daily_limit=?, cooldown_seconds=?, display_seconds=?, is_active=?
WHERE id=?",
[$in['site_id'], $in['template_id'], $in['name'], $in['headline'], $in['subline'],
$logo, $bg, $in['bg_overlay'], $in['accent_color'], $in['card_style'],
$in['daily_limit'], $in['cooldown_seconds'], $in['display_seconds'], $in['is_active'], $id]
);
$auth->writeAuditLog($_SESSION['user_id'], 'kiosk_updated', 'kiosk', $id, $in['name']);
@ -106,6 +130,11 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_POST['renew_token'])) {
if (isset($_GET['delete'], $_GET['token'])) {
if ($auth->validateCsrfToken($_GET['token'])) {
$old = $db->fetchOne("SELECT logo_url, background_url FROM kiosks WHERE id = ?", [(int)$_GET['delete']]);
if ($old) {
Upload::delete((string)($old['logo_url'] ?? ''));
Upload::delete((string)($old['background_url'] ?? ''));
}
$db->execute("DELETE FROM kiosks WHERE id = ?", [(int)$_GET['delete']]);
$auth->writeAuditLog($_SESSION['user_id'], 'kiosk_deleted', 'kiosk', (int)$_GET['delete'], '');
$success = __('kiosks_deleted');
@ -223,6 +252,9 @@ $adminBase = '';
"id" => (int)$k["id"], "site_id" => (int)$k["site_id"],
"template_id" => (int)$k["template_id"], "name" => $k["name"],
"headline" => $k["headline"], "subline" => $k["subline"],
"logo_url" => $k["logo_url"], "background_url" => $k["background_url"],
"bg_overlay" => (int)$k["bg_overlay"], "accent_color" => $k["accent_color"],
"card_style" => $k["card_style"],
"daily_limit" => (int)$k["daily_limit"],
"cooldown_seconds" => (int)$k["cooldown_seconds"],
"display_seconds" => (int)$k["display_seconds"],
@ -257,7 +289,7 @@ $adminBase = '';
<h2 class="modal-title" id="kioskModalTitle"><?= __('kiosks_add') ?></h2>
<button class="modal-close" type="button" onclick="closeModal()" aria-label="<?= __('btn_cancel') ?>">&times;</button>
</div>
<form method="post">
<form method="post" enctype="multipart/form-data">
<div class="modal-body">
<input type="hidden" name="csrf_token" value="<?= htmlspecialchars($csrf) ?>">
<input type="hidden" name="kiosk_id" id="kiosk_id" value="">
@ -305,6 +337,37 @@ $adminBase = '';
<hr class="section-divider">
<h3 style="font-size:14px;margin-bottom:14px;"><?= __('kiosks_appearance') ?></h3>
<?= Ui::imageField('logo_url', __('kiosks_logo'), '', __('kiosks_logo_hint')) ?>
<?= Ui::imageField('background_url', __('kiosks_background'), '', __('kiosks_background_hint')) ?>
<div class="form-grid">
<div class="form-group">
<label for="bg_overlay"><?= __('kiosks_overlay') ?></label>
<input type="number" id="bg_overlay" name="bg_overlay" min="0" max="90" value="45">
<div class="help-text"><?= __('kiosks_overlay_hint') ?></div>
</div>
<div class="form-group">
<label for="accent_color"><?= __('kiosks_accent') ?></label>
<div class="color-field">
<input type="color" class="color-swatch" data-target="accent_color" value="<?= Ui::DEFAULT_ACCENT ?>">
<input type="text" id="accent_color" name="accent_color" placeholder="<?= __('kiosks_accent_default') ?>">
</div>
<div class="help-text"><?= __('kiosks_accent_hint') ?></div>
</div>
<div class="form-group">
<label for="card_style"><?= __('kiosks_card_style') ?></label>
<select id="card_style" name="card_style">
<option value="light"><?= __('kiosks_card_light') ?></option>
<option value="dark"><?= __('kiosks_card_dark') ?></option>
</select>
<div class="help-text"><?= __('kiosks_card_hint') ?></div>
</div>
</div>
<hr class="section-divider">
<div class="form-grid">
<div class="form-group">
<label for="daily_limit"><?= __('kiosks_daily_limit') ?></label>
@ -364,6 +427,8 @@ function openAddModal() {
document.getElementById('kioskModalTitle').textContent = <?= json_encode(__('kiosks_add')) ?>;
document.querySelector('#kioskModal form').reset();
document.getElementById('kiosk_id').value = '';
setImageField('logo_url', '');
setImageField('background_url', '');
document.getElementById('submitAdd').style.display = '';
document.getElementById('submitEdit').style.display = 'none';
document.getElementById('activeRow').style.display = 'none';
@ -378,6 +443,11 @@ function openEditModal(data) {
document.getElementById('template_id').value = data.template_id || 0;
document.getElementById('headline').value = data.headline || '';
document.getElementById('subline').value = data.subline || '';
document.getElementById('bg_overlay').value = data.bg_overlay;
document.getElementById('accent_color').value = data.accent_color || '';
document.getElementById('card_style').value = data.card_style || 'light';
setImageField('logo_url', data.logo_url || '');
setImageField('background_url', data.background_url || '');
document.getElementById('daily_limit').value = data.daily_limit;
document.getElementById('cooldown_seconds').value = data.cooldown_seconds;
document.getElementById('display_seconds').value = data.display_seconds;
@ -390,6 +460,25 @@ function openEditModal(data) {
function closeModal() { document.getElementById('kioskModal').classList.remove('active'); }
/**
* Bildfeld im Modal auf den Wert des Kiosks setzen: Vorschau, URL-Feld und
* der Entfernen-Schalter hängen am selben Namen.
*/
function setImageField(name, value) {
const wrapper = document.querySelector('[name="' + name + '"]').closest('.form-group');
const text = wrapper.querySelector('input[type="text"]');
const preview = wrapper.querySelector('.image-preview');
const remove = wrapper.querySelector('input[type="checkbox"]');
if (text) text.value = value;
if (remove) remove.checked = false;
if (preview) {
const src = value && !/^https?:|^\//.test(value) ? '../' + value : value;
preview.innerHTML = value
? '<img src="' + src + '" alt="">'
: '<i class="fas fa-image" aria-hidden="true"></i>';
}
}
function showQr(url, name) {
var target = document.getElementById('qrTarget');
target.innerHTML = '';