Unifi-Voucher-Tool/includes/Ui.php
Friederich Loheide 4159b92268
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
Display-Seiten individuell gestalten
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>
2026-09-23 16:58:54 +00:00

259 lines
10 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
/**
* Gemeinsame Bausteine fuer den Seitenkopf.
*
* - liefert versionierte Asset-URLs (Cache-Busting nach Updates)
* - bindet die lokal ausgelieferten Assets ein (keine externen CDNs)
* - erzeugt die Branding-Overrides aus den Einstellungen
*
* Alle Methoden funktionieren auch ohne Datenbank ($db = null), damit der
* Installer dieselbe Optik nutzen kann.
*/
class Ui
{
/** Standardwerte des Design-Systems (siehe assets/global.css). */
public const DEFAULT_ACCENT = '#5b5bd6';
public const DEFAULT_ACCENT_DARK = '#8b8bf5';
public const DEFAULT_GRADIENT_FROM = '#5b5bd6';
public const DEFAULT_GRADIENT_TO = '#8b5cf6';
public const DEFAULT_RADIUS = 14;
/** Projektwurzel im Dateisystem. */
private static function root(): string
{
return dirname(__DIR__);
}
/**
* URL eines Projekt-Assets inkl. Versionsstempel.
* $base ist der Pfad zur Projektwurzel ('' im Root, '../' in /admin).
*/
public static function asset(string $path, string $base = ''): string
{
$file = self::root() . '/' . ltrim($path, '/');
$version = is_file($file) ? (string)filemtime($file) : '0';
return $base . $path . '?v=' . $version;
}
/** <script src> mit Versionsstempel. */
public static function script(string $path, string $base = '', bool $defer = false): string
{
return '<script src="' . htmlspecialchars(self::asset($path, $base)) . '"'
. ($defer ? ' defer' : '') . '></script>';
}
/**
* Theme-Bootstrap: gespeicherte Auswahl, sonst Systemeinstellung.
* Muss im <head> stehen, damit nichts hell aufblitzt.
*/
public static function themeScript(): string
{
return '<script>(function(){'
. 'var s=localStorage.getItem("theme");'
. 'var t=s||(window.matchMedia&&window.matchMedia("(prefers-color-scheme: dark)").matches?"dark":"light");'
. 'document.documentElement.setAttribute("data-theme",t);'
. '})();</script>';
}
/** Gueltige Hex-Farbe oder Fallback. */
private static function color(?string $value, string $fallback): string
{
$value = trim((string)$value);
return preg_match('/^#[0-9a-fA-F]{6}$/', $value) ? strtolower($value) : $fallback;
}
/**
* CSS-Overrides fuer die Markenfarben. Gibt einen leeren String zurueck,
* wenn nichts vom Standard abweicht.
*/
public static function brandingStyle($db = null): string
{
if (!$db) {
return '';
}
$accent = self::color($db->getSetting('brand_accent', ''), self::DEFAULT_ACCENT);
$accentDark = self::color($db->getSetting('brand_accent_dark', ''), self::DEFAULT_ACCENT_DARK);
$from = self::color($db->getSetting('brand_gradient_from', ''), self::DEFAULT_GRADIENT_FROM);
$to = self::color($db->getSetting('brand_gradient_to', ''), self::DEFAULT_GRADIENT_TO);
$radius = (int)$db->getSetting('brand_radius', (string)self::DEFAULT_RADIUS);
$radius = max(0, min(28, $radius));
$isDefault = $accent === self::DEFAULT_ACCENT
&& $accentDark === self::DEFAULT_ACCENT_DARK
&& $from === self::DEFAULT_GRADIENT_FROM
&& $to === self::DEFAULT_GRADIENT_TO
&& $radius === self::DEFAULT_RADIUS;
if ($isDefault) {
return '';
}
// Abgeleitete Töne über color-mix so genügt eine einzige Grundfarbe.
return '<style>'
. ':root{'
. "--accent:{$accent};"
. "--accent-hover:color-mix(in srgb, {$accent} 84%, #000);"
. "--accent-soft:color-mix(in srgb, {$accent} 12%, #fff);"
. "--accent-border:color-mix(in srgb, {$accent} 32%, #fff);"
. "--accent-2:{$to};"
. "--input-focus:{$accent};"
. "--ring:0 0 0 4px color-mix(in srgb, {$accent} 22%, transparent);"
. "--brand-gradient:linear-gradient(135deg, {$from} 0%, {$to} 100%);"
. "--r-lg:{$radius}px;"
. "--r-xl:" . ($radius + 6) . 'px;'
. '}'
. '[data-theme="dark"]{'
. "--accent:{$accentDark};"
. "--accent-hover:color-mix(in srgb, {$accentDark} 80%, #fff);"
. "--accent-soft:color-mix(in srgb, {$accentDark} 20%, #0a0c11);"
. "--accent-border:color-mix(in srgb, {$accentDark} 42%, #0a0c11);"
. "--input-focus:{$accentDark};"
. "--ring:0 0 0 4px color-mix(in srgb, {$accentDark} 26%, transparent);"
. '}'
. '</style>';
}
/**
* Bildfeld mit Vorschau, Upload und URL-Eingabe.
* Wird von den Einstellungen und der Kiosk-Verwaltung genutzt.
*/
public static function imageField(
string $name,
string $label,
?string $value,
string $hint = '',
string $accept = 'image/*',
string $base = '../'
): string {
$value = (string)$value;
$preview = self::mediaUrl($value, $base);
$esc = static fn ($text) => htmlspecialchars((string)$text, ENT_QUOTES);
$t = static fn ($key, $fallback) => function_exists('__') ? __($key) : $fallback;
$thumb = $preview !== ''
? '<img src="' . $esc($preview) . '" alt="">'
: '<i class="fas fa-image" aria-hidden="true"></i>';
$remove = $value !== ''
? '<label class="chk"><input type="checkbox" name="' . $esc($name) . '_remove" value="1"> '
. $esc($t('settings_image_remove', 'Bild entfernen')) . '</label>'
: '';
return '<div class="form-group">'
. '<label>' . $esc($label) . '</label>'
. '<div class="image-field">'
. '<div class="image-preview">' . $thumb . '</div>'
. '<div class="image-field-controls">'
. '<input type="file" name="' . $esc($name) . '_file" accept="' . $esc($accept) . '">'
. '<input type="text" name="' . $esc($name) . '" value="' . $esc($value) . '" '
. 'placeholder="' . $esc($t('settings_image_url_placeholder', 'https://… oder Datei hochladen')) . '">'
. $remove
. '</div></div>'
. ($hint !== '' ? '<div class="help-text">' . $esc($hint) . '</div>' : '')
. '</div>';
}
/**
* Version aus der Datei VERSION im Projektstamm.
* Damit tragen Oberfläche und Release-Paket dieselbe Nummer.
*/
public static function version(): string
{
static $version = null;
if ($version === null) {
$file = self::root() . '/VERSION';
$version = is_file($file) ? trim((string)file_get_contents($file)) : '';
}
return $version;
}
/** Entwicklerhinweis bewusst an einer Stelle gepflegt. */
public const CREDIT_NAME = 'Loheide.eu';
public const CREDIT_URL = 'https://loheide.eu';
/**
* Dezenter Hinweis auf den Entwickler, wie er im Seitenfuß erscheint.
*/
public static function credit(bool $withVersion = false): string
{
$label = function_exists('__') ? __('credit_by') : 'Entwickelt von';
$prefix = '';
if ($withVersion && self::version() !== '') {
$prefix = 'v' . htmlspecialchars(self::version()) . ' · ';
}
return '<p class="app-credit">' . $prefix . htmlspecialchars($label) . ' '
. '<a href="' . self::CREDIT_URL . '" target="_blank" rel="noopener">'
. self::CREDIT_NAME . '</a></p>';
}
/**
* Standard-Druckvorlage (wird nur verwendet, solange keine eigene
* Vorlage gespeichert ist). {QR_CODE} fuellt der Browser.
*/
public static function defaultPrintTemplate(): string
{
$validUntil = function_exists('__') ? __('print_valid_until') : 'Gültig bis';
$devices = function_exists('__') ? __('print_devices') : 'Geräte';
return '<div style="font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica,Arial,sans-serif;'
. 'max-width:420px;margin:0 auto;padding:26px;border:1px dashed #9aa1ae;border-radius:14px;text-align:center;">'
. '<div style="font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:#6b7280;">{APP_TITLE}</div>'
. '<div style="margin:6px 0 18px;font-size:15px;color:#101625;">{SITE_NAME}</div>'
. '{QR_CODE}'
. '<div style="margin:18px 0 6px;font-family:Consolas,Menlo,monospace;font-size:30px;font-weight:700;letter-spacing:.14em;color:#101625;">{VOUCHER_CODE}</div>'
. '<div style="font-size:13px;color:#525c6e;">' . $validUntil . ' {EXPIRY_DATE} {EXPIRY_TIME} &middot; {MAX_USES} ' . $devices . '</div>'
. '<div style="margin-top:16px;padding-top:14px;border-top:1px solid #e5e8ee;font-size:12px;color:#525c6e;text-align:left;">{INSTRUCTIONS}</div>'
. '</div>';
}
/**
* URL eines Bildes aus den Einstellungen.
* Hochgeladene Dateien liegen relativ zur Projektwurzel (uploads/…),
* externe Adressen bleiben unveraendert.
*/
public static function mediaUrl(string $value, string $base = ''): string
{
$value = trim($value);
if ($value === '') {
return '';
}
if (preg_match('#^(https?:)?//#i', $value) || strncmp($value, 'data:', 5) === 0 || $value[0] === '/') {
return $value;
}
return $base . $value;
}
/**
* Kompletter Standard-Kopf: Favicon, Schrift, Icons, Design-System,
* Theme-Bootstrap und Branding.
*/
public static function head($db = null, string $base = ''): string
{
$out = [];
$favicon = $db ? self::mediaUrl((string)$db->getSetting('favicon_url', ''), $base) : '';
if ($favicon !== '') {
$out[] = '<link rel="icon" href="' . htmlspecialchars($favicon) . '">';
}
$out[] = '<meta name="color-scheme" content="light dark">';
$out[] = '<link rel="stylesheet" href="' . htmlspecialchars(self::asset('assets/vendor/inter/inter.css', $base)) . '">';
$out[] = '<link rel="stylesheet" href="' . htmlspecialchars(self::asset('assets/vendor/fontawesome/fontawesome.css', $base)) . '">';
$out[] = '<link rel="stylesheet" href="' . htmlspecialchars(self::asset('assets/global.css', $base)) . '">';
$out[] = self::themeScript();
$branding = self::brandingStyle($db);
if ($branding !== '') {
$out[] = $branding;
}
return implode("\n ", $out);
}
}