Alle Frontend-Assets lokal ausliefern statt über CDNs

Inter, Font Awesome, Chart.js, qrcodejs und TinyMCE liegen jetzt unter
assets/vendor/ und werden vom eigenen Server ausgeliefert.

Warum:
- Datenschutz: bisher ging bei jedem Seitenaufruf die IP der Nutzer an
  Google Fonts, cdnjs, jsDelivr und Tiny Cloud
- Funktion: UniFi-Installationen stehen oft in abgeschotteten Netzen –
  dort fehlten bisher Schrift, Icons, Diagramme und Editor

Neu: includes/Ui.php
- Ui::head()/Ui::script() binden die Assets ein und hängen einen
  Versionsstempel an (?v=filemtime), damit Browser nach einem Update
  nicht das alte CSS aus dem Cache nehmen
- Ui::themeScript() setzt das Theme aus der gespeicherten Auswahl oder
  – wenn keine vorliegt – aus prefers-color-scheme; global.js folgt
  Systemwechseln live, solange nichts manuell gewählt wurde
- <meta name="color-scheme"> ergänzt, damit Formularelemente passen

Der TinyMCE-API-Key entfällt: der Editor läuft immer lokal (GPL-Variante),
inklusive deutscher Oberfläche, wenn die App auf Deutsch steht.

Neu: tools/demo/build.py – baut aus dem Projekt eine Demo-Instanz ohne
Datenbank (Stubs für Database/Auth, feste Beispieldaten). Damit lassen
sich Screenshots reproduzierbar erzeugen und alle Seiten einmal rendern
(Smoke-Test), ohne eine MySQL-Instanz aufzusetzen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Friederich Loheide 2026-09-23 06:23:44 +00:00
parent ba90ffef03
commit 6da46f040a
57 changed files with 1479 additions and 267 deletions

View file

@ -0,0 +1,164 @@
<?php
/** Demo-Stub: liefert feste Beispieldaten statt echter DB-Zugriffe. */
class Database {
private static $instance = null;
private $settings = [
'app_title' => 'UniFi Voucher System',
'logo_url' => '', 'favicon_url' => '',
'public_access' => '1', 'smtp_enabled' => '1', 'sms_enabled' => '1',
'm365_enabled' => '1', 'oidc_enabled' => '0', 'oidc_name' => 'Keycloak',
'default_expire_minutes' => '480', 'default_max_uses' => '2', 'max_uses_limit' => '10',
'instruction_header' => 'So verbinden Sie sich',
'instruction_text' => 'WLAN „Gast-WiFi" wählen, Code eingeben und bestätigen. Bei Fragen hilft der Empfang gerne weiter.',
'last_cron_sync' => '2026-09-22 14:35:00',
'session_driver' => 'db', 'captcha_mode' => 'math',
'enforce_2fa_admins' => '1', 'user_daily_voucher_limit' => '25',
'webhook_enabled' => '1', 'webhook_url' => 'https://hooks.slack.com/services/T024/B0197/XYZ',
'twilio_sid' => 'AC8f2c1d94e77b40a2', 'twilio_from' => '+4915112345678',
'trusted_proxy' => '10.0.0.1, 172.18.0.1',
'cleanup_expired_days' => '90', 'cleanup_audit_days' => '365', 'cleanup_login_days' => '30',
'last_cleanup' => '2026-09-22 03:00:00',
'print_template' => '<div style="padding:20px;border:1px dashed #999;"><h2>{APP_TITLE}</h2><p>{SITE_NAME}</p><h1>{VOUCHER_CODE}</h1></div>',
];
public function __construct()
{
// Screenshot-Varianten: ?brand=custom|gradient|nopanel
$brand = $_GET['brand'] ?? '';
if ($brand === 'custom') {
$this->settings = array_merge($this->settings, [
'app_title' => 'Hotel Seeblick',
'login_brand_name' => 'Hotel Seeblick',
'login_logo_url' => '/demo-assets/logo.svg',
'login_claim_title' => 'Willkommen im Hotel Seeblick.',
'login_claim_text' => 'Gäste-WLAN für Zimmer, Tagungsräume und Restaurant Zugangscodes direkt an der Rezeption erstellen.',
'login_features' => "Code direkt beim Check-in ausdrucken\nTagungsgäste per E-Mail versorgen\nAuswertung je Haus und Etage",
'login_footer' => '© 2026 Hotel Seeblick GmbH · Datenschutz · Impressum',
'login_bg_image' => '/demo-assets/background.svg',
'login_bg_overlay' => '45',
'brand_accent' => '#0f766e',
'brand_accent_dark' => '#2dd4bf',
'brand_gradient_from' => '#0f766e',
'brand_gradient_to' => '#0ea5e9',
]);
} elseif ($brand === 'nopanel') {
$this->settings = array_merge($this->settings, [
'login_panel_enabled' => '0',
'login_brand_name' => 'Stadtwerke Nordheim',
]);
}
}
public static function getInstance() { return self::$instance ??= new self(); }
public function getConnection() { return null; }
public function getSetting($key, $default = null) { return $this->settings[$key] ?? $default; }
public function setSetting($key, $value) { $this->settings[$key] = $value; }
public function execute($sql, $params = []) { return 1; }
public function query($sql, $params = []) { return new DemoStmt(); }
public function fetchOne($sql, $params = []) { $r = $this->fetchAll($sql, $params); return $r[0] ?? false; }
public function fetchAll($sql, $params = []) {
$s = preg_replace('/\s+/', ' ', strtolower($sql));
if (str_contains($s, 'count(*) as count from sites')) return [['count'=>4]];
if (str_contains($s, 'count(*) as count from users')) return [['count'=>12]];
if (str_contains($s, 'count(*) as count from vouchers where date(created_at)=curdate()')) return [['count'=>18]];
if (str_contains($s, 'count(*) as count from vouchers where date(created_at)=')) {
$day = $params[0] ?? '';
$map = [6,9,5,12,8,4,7,14,11,16,9,6,13,18];
$idx = abs((int)((strtotime($day) - strtotime('today')) / 86400));
return [['count' => $map[13 - min($idx, 13)]]];
}
if (str_contains($s, 'from vouchers where site_id=?') && str_contains($s, 'count(*) as total')) {
$per = [1=>['total'=>128,'valid'=>74,'used'=>39,'expired'=>15],
2=>['total'=>63,'valid'=>41,'used'=>18,'expired'=>4],
3=>['total'=>22,'valid'=>14,'used'=>6,'expired'=>2],
4=>['total'=>0,'valid'=>0,'used'=>0,'expired'=>0]];
return [$per[(int)($params[0] ?? 1)] ?? $per[4]];
}
if (str_contains($s, 'count(*) as total') && str_contains($s, 'from vouchers')) {
return [['total'=>213,'valid'=>129,'used'=>63,'expired'=>21]];
}
if (str_contains($s, 'from sites')) {
return [
['id'=>1,'name'=>'Hauptstandort Nord','site_id'=>'default','unifi_controller_url'=>'https://unifi.example.com:8443','unifi_username'=>'voucher-api','unifi_password'=>'x','is_active'=>1,'use_ssl'=>1,'created_at'=>'2026-01-12 09:00:00'],
['id'=>2,'name'=>'Campus West','site_id'=>'campus-west','unifi_controller_url'=>'https://unifi.example.com:8443','unifi_username'=>'voucher-api','unifi_password'=>'x','is_active'=>1,'use_ssl'=>1,'created_at'=>'2026-02-03 11:30:00'],
['id'=>3,'name'=>'Showroom Berlin','site_id'=>'showroom','unifi_controller_url'=>'https://unifi-b.example.com:8443','unifi_username'=>'voucher-api','unifi_password'=>'x','is_active'=>1,'use_ssl'=>1,'created_at'=>'2026-04-21 15:10:00'],
['id'=>4,'name'=>'Lager Süd','site_id'=>'lager','unifi_controller_url'=>'https://unifi-s.example.com:8443','unifi_username'=>'voucher-api','unifi_password'=>'x','is_active'=>1,'use_ssl'=>0,'created_at'=>'2026-06-08 08:45:00'],
];
}
if (str_contains($s, 'count(v.id) as voucher_count') || str_contains($s, 'from users u left join vouchers')) {
return [
['name'=>'Marie Sander','email'=>'marie.sander@example.com','voucher_count'=>48],
['name'=>'Jonas Weber','email'=>'j.weber@example.com','voucher_count'=>31],
['name'=>'Alina Brandt','email'=>'alina.brandt@example.com','voucher_count'=>27],
['name'=>'Tim Kurz','email'=>'tim.kurz@example.com','voucher_count'=>19],
['name'=>'Sara Vogt','email'=>'sara.vogt@example.com','voucher_count'=>12],
];
}
if (str_contains($s, 'from vouchers')) {
if (str_contains($s, 'group by date')) {
$out = []; $vals = [6,9,5,12,8,4,7,14,11,16,9,6,13,18];
foreach ($vals as $i => $c) { $out[] = ['date'=>date('d.m', strtotime('-'.(13-$i).' days')), 'count'=>$c]; }
return $out;
}
$codes = [['H7K2-M9QF',1,'Hauptstandort Nord','valid'],['P3RT-8ZXC',2,'Campus West','used'],
['QW4E-7TYU',1,'Hauptstandort Nord','valid'],['LM2N-5BVD',3,'Showroom Berlin','expired'],
['ZX9C-3PLO',2,'Campus West','valid'],['FG6H-1JKL',1,'Hauptstandort Nord','used']];
$names = ['Besuch Agentur Nordlicht','Workshop Raum 2','Empfang Tagesgast','Messe-Stand','Handwerker Haustechnik','Bewerbungsgespräch'];
$out = [];
foreach ($codes as $i => [$c,$sid,$sname,$st]) {
$out[] = ['id'=>$i+1,'voucher_code'=>$c,'site_id'=>$sid,'site_name'=>$sname,'user_name'=>'Marie Sander',
'status'=>$st,'created_at'=>date('Y-m-d H:i:s', strtotime("-{$i} hours")),
'voucher_name'=>$names[$i],'unifi_voucher_id'=>'65f1a'.$i,
'expires_at'=>date('Y-m-d H:i:s', strtotime('+'.(8-$i).' hours')),
'max_uses'=>[2,5,1,3,2,1][$i],'used_count'=>[2,1,0,3,0,1][$i],'expire_minutes'=>480];
}
return $out;
}
if (str_contains($s, 'from users')) {
return [
['id'=>1,'name'=>'Marie Sander','email'=>'marie.sander@example.com','is_admin'=>1,'is_active'=>1,'created_at'=>'2026-01-10 09:00:00','last_login'=>'2026-09-22 08:12:00','totp_enabled'=>1,'auth_source'=>'local','voucher_count'=>48,'password_hash'=>'x'],
['id'=>2,'name'=>'Jonas Weber','email'=>'j.weber@example.com','is_admin'=>0,'is_active'=>1,'created_at'=>'2026-03-02 13:20:00','last_login'=>'2026-09-21 17:40:00','totp_enabled'=>0,'auth_source'=>'m365','voucher_count'=>31,'password_hash'=>''],
['id'=>3,'name'=>'Alina Brandt','email'=>'alina.brandt@example.com','is_admin'=>0,'is_active'=>1,'created_at'=>'2026-05-18 10:05:00','last_login'=>'2026-09-20 09:31:00','totp_enabled'=>1,'auth_source'=>'local','voucher_count'=>27,'password_hash'=>'x'],
];
}
if (str_contains($s, 'from api_keys')) {
return [
['id'=>1,'name'=>'Buchungssystem','key_prefix'=>'3f9a2c','scope'=>'write','rate_limit'=>60,'is_active'=>1,'last_used_at'=>'2026-09-22 09:12:00','creator'=>'Marie Sander','created_at'=>'2026-06-01 10:00:00'],
['id'=>2,'name'=>'Terminal Foyer','key_prefix'=>'b71e04','scope'=>'read','rate_limit'=>0,'is_active'=>1,'last_used_at'=>'2026-09-21 17:40:00','creator'=>'Marie Sander','created_at'=>'2026-07-14 16:20:00'],
['id'=>3,'name'=>'Altes Kassensystem','key_prefix'=>'c0d5f8','scope'=>'write','rate_limit'=>30,'is_active'=>0,'last_used_at'=>null,'creator'=>'Jonas Weber','created_at'=>'2026-02-09 08:05:00'],
];
}
if (str_contains($s, 'from audit_log')) {
if (str_contains($s, 'count(')) return [['total'=>1284,'c'=>1284,'count'=>1284]];
if (str_contains($s, 'distinct action')) {
return [['action'=>'voucher_created'],['action'=>'user_login'],['action'=>'settings_saved'],['action'=>'user_created'],['action'=>'template_updated']];
}
$rows = [['2026-09-22 14:32:00','Marie Sander','voucher_created','Hauptstandort Nord · H7K2-M9QF','192.168.10.42'],
['2026-09-22 13:58:00','Jonas Weber','user_login','Microsoft 365','83.112.4.17'],
['2026-09-22 11:04:00','Marie Sander','settings_saved','Tab: E-Mail (SMTP)','192.168.10.42'],
['2026-09-21 17:40:00','Alina Brandt','user_created','tim.kurz@example.com','192.168.10.88'],
['2026-09-21 09:12:00','Marie Sander','template_updated','Tagesgast','192.168.10.42'],
['2026-09-20 16:03:00','Jonas Weber','voucher_bulk','5 Vouchers · Campus West','83.112.4.17']];
$out = [];
foreach ($rows as $i => [$d,$u,$a,$det,$ip]) {
$out[] = ['id'=>$i+1,'created_at'=>$d,'user_name'=>$u,'user_email'=>'demo@example.com','action'=>$a,'details'=>$det,'ip_address'=>$ip,'entity_type'=>'voucher','entity_id'=>$i+1];
}
return $out;
}
if (str_contains($s, 'templates')) {
return [
['id'=>1,'name'=>'Tagesgast','description'=>'Standardprofil für Besucher am Empfang','max_uses'=>2,'expire_minutes'=>480,'qos_rate_max_down'=>20000,'qos_rate_max_up'=>5000,'qos_usage_quota'=>0,'is_active'=>1,'is_default'=>1,'created_at'=>'2026-01-15 10:00:00'],
['id'=>2,'name'=>'Konferenz','description'=>'Mehrtägige Veranstaltungen','max_uses'=>5,'expire_minutes'=>4320,'qos_rate_max_down'=>50000,'qos_rate_max_up'=>10000,'qos_usage_quota'=>0,'is_active'=>1,'is_default'=>0,'created_at'=>'2026-02-20 11:30:00'],
['id'=>3,'name'=>'Handwerker','description'=>'Kurzzugang für Dienstleister','max_uses'=>1,'expire_minutes'=>240,'qos_rate_max_down'=>10000,'qos_rate_max_up'=>2000,'qos_usage_quota'=>1024,'is_active'=>1,'is_default'=>0,'created_at'=>'2026-03-08 09:15:00'],
];
}
return [];
}
}
class DemoStmt {
public function fetch($m = null) { return false; }
public function fetchAll($m = null) { return []; }
public function rowCount() { return 1; }
public function execute($p = []) { return true; }
}