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

108
tools/demo/build.py Executable file
View file

@ -0,0 +1,108 @@
#!/usr/bin/env python3
"""Baut eine Demo-Instanz des Voucher-Tools ohne Datenbank.
Die Demo dient zwei Zwecken:
* Screenshots fuer die Dokumentation reproduzierbar erzeugen
* Smoke-Test: rendert jede Seite einmal ohne echte Datenbank
Ablauf: Projekt in ein Zielverzeichnis kopieren, das Overlay darueberlegen
(Stubs fuer Database/Auth) und ein paar klar benannte Demo-Zustaende in die
Kopie patchen. Das Original bleibt unveraendert.
python3 tools/demo/build.py /tmp/uvt-demo
php -S 127.0.0.1:8123 -t /tmp/uvt-demo
"""
import os
import shutil
import sys
ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
OVERLAY = os.path.join(ROOT, 'tools', 'demo', 'overlay')
# Nur auf oberster Ebene ueberspringen assets/vendor gehoert dazu!
SKIP_TOP = {'.git', '.github', 'tools', 'docs', 'tests', 'vendor', 'node_modules'}
def copy_project(target: str) -> None:
if os.path.exists(target):
shutil.rmtree(target)
def ignore(directory, names):
if os.path.abspath(directory) == ROOT:
return [n for n in names if n in SKIP_TOP]
return []
shutil.copytree(ROOT, target, ignore=ignore)
for name in os.listdir(OVERLAY):
src = os.path.join(OVERLAY, name)
dst = os.path.join(target, name)
if os.path.isdir(src):
shutil.copytree(src, dst, dirs_exist_ok=True)
else:
shutil.copy2(src, dst)
def patch(path: str, anchor: str, replacement: str) -> None:
"""Ersetzt einen Anker in der Demo-Kopie. Fehlt der Anker, bricht der
Build ab so faellt auf, wenn sich die Vorlage geaendert hat."""
with open(path, encoding='utf-8') as fh:
content = fh.read()
if anchor not in content:
raise SystemExit('Anker nicht gefunden in %s:\n%s' % (path, anchor[:80]))
with open(path, 'w', encoding='utf-8') as fh:
fh.write(content.replace(anchor, replacement, 1))
def apply_demo_states(target: str) -> None:
# Voucher-Ergebnis und Bulk-Ansicht ohne Controller-Zugriff zeigen
anchor = "$currentUser = $auth->isLoggedIn() ? $auth->getCurrentUser() : null;"
patch(os.path.join(target, 'index.php'), anchor, anchor + """
// --- Demo-Instanz: Zustaende ohne UniFi-Controller darstellen ---
if (($_GET['demo'] ?? '') === 'result') {
$voucherCreated = true;
$voucherCode = '4829-17364';
$voucherData = ['code' => '4829-17364', 'site_name' => 'Hauptstandort Nord', 'max_uses' => 2,
'expire_min' => 480, 'expiry_date' => '23.09.2026', 'expiry_time' => '08:00'];
}
if (($_GET['demo'] ?? '') === 'bulk') {
$bulkCreated = true;
$bulkVouchers = [];
foreach (['4829-17364', '5517-90422', '7731-64508', '2094-38177', '6640-52913'] as $code) {
$bulkVouchers[] = ['code' => $code, 'site_name' => 'Hauptstandort Nord', 'max_uses' => 2,
'expire_min' => 480, 'expiry_date' => '23.09.2026', 'expiry_time' => '08:00'];
}
}
""")
# Live-Voucher-Liste automatisch laden (sonst wartet sie auf Site-Auswahl)
patch(os.path.join(target, 'admin', 'vouchers.php'), "</script>\n</body>", """</script>
<script>
// Demo-Instanz: Site vorauswaehlen und Liste laden
document.addEventListener('DOMContentLoaded', function () {
const sel = document.getElementById('siteSelect');
if (sel) { sel.value = '1'; loadVouchers(false); }
});
</script>
</body>""")
# Frisch erzeugten API-Schluessel zeigen
patch(os.path.join(target, 'admin', 'api_keys.php'), '$keys = $db->fetchAll(',
"if (($_GET['demo'] ?? '') === 'new') { $newKey = 'uvt_3f9a2c7d41e8b60592af18cc4d7e0b3a95f2617c'; }\n$keys = $db->fetchAll(")
# Theme per Query-Parameter erzwingen (fuer Dark-Mode-Screenshots)
ui = os.path.join(target, 'includes', 'Ui.php')
patch(ui, 'var s=localStorage.getItem("theme");',
'var s=new URLSearchParams(location.search).get("theme")||localStorage.getItem("theme");')
def main() -> int:
target = sys.argv[1] if len(sys.argv) > 1 else '/tmp/uvt-demo'
copy_project(target)
apply_demo_states(target)
print('Demo-Instanz gebaut: %s' % target)
print('Start: php -S 127.0.0.1:8123 -t %s' % target)
return 0
if __name__ == '__main__':
raise SystemExit(main())