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>
259 lines
10 KiB
PHP
259 lines
10 KiB
PHP
<?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} · {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);
|
||
}
|
||
}
|