Compare commits

..

47 Commits

Author SHA1 Message Date
Erik Thiele
f754c7f0a3 docs: Medien-Cache in AGENTS.md beschreiben
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:45:23 +02:00
Erik Thiele
efcbaac5a4 chore: Version auf 6.2.12 heben
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:45:16 +02:00
Erik Thiele
96355f6897 feat: Medien im Player mit ?v=<mtime> und langem Cache ausliefern
Bisher lieferte Flask Medien mit no-cache aus; der Player musste jede Datei
bei jeder Anzeige neu anfragen. Versionierte URLs duerfen jetzt ein Jahr
ohne Rueckfrage aus dem Cache kommen, unversionierte bleiben unveraendert.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-25 12:45:16 +02:00
Erik Thiele
2c72f48e28 docs: One-Pager (PDF + HTML-Quelle) fuer Anwender
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 20:08:44 +02:00
Erik Thiele
302811fe29 docs: README und Hilfeseite auf Stand 6.2.11 bringen
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 19:55:53 +02:00
Erik Thiele
a09375cb31 chore: Version auf 6.2.10 heben
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 19:50:29 +02:00
Erik Thiele
ac5bf8f739 feat: 50 Verlaufseintraege je Seite
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 19:50:29 +02:00
Erik Thiele
e3afda9a8f chore: Version auf 6.2.9 heben
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 19:47:35 +02:00
Erik Thiele
20055c3b74 feat: Verlauf im Dashboard seitenweise blaettern
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 19:47:35 +02:00
Erik Thiele
40ee841553 chore: Version auf 6.2.8 heben
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 16:50:08 +02:00
Erik Thiele
3a2ff20815 feat: Willkommensseite ohne Login erreichbar
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 16:50:08 +02:00
Erik Thiele
9d317ee592 chore: Version auf 6.2.7 heben 2026-09-08 09:09:37 +02:00
Erik Thiele
cbadf096fd fix: Loeschen entfernt auch den Playlist-Eintrag der Datei
Der Filter in delete_file verglich nur item["url"], traf damit aber nur
URL-Eintraege. Bei Dateien wurde die Datei vom Datentraeger geloescht,
der Eintrag {"name": ...} blieb als Karteileiche in der config.json
stehen - in der Priority-Playlist wie in jeder Screen-Playlist.
Vergleich laeuft jetzt ueber playlist_item_name(), das alle drei
Eintragsformen abdeckt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:09:37 +02:00
Erik Thiele
ccfd90d4b5 chore: Version auf 6.2.6 heben 2026-09-08 09:04:38 +02:00
Erik Thiele
03ae5bf4ad feat: Priority-Eintraege in den Screen-Playlisten anzeigen
Die Priority-Playlist gilt fuer alle Screens eines Standorts, liegt aber
in einem eigenen Menuepunkt und wurde beim Bearbeiten der normalen
Playlisten leicht uebersehen. Sie erscheint jetzt oberhalb jeder
Screen-Playlist als abgesetzte, ausgegraute Liste mit Prio-Badge.

Reine Anzeige: keine Checkbox, keine Buttons, kein Drag. Die Zeilen
liegen bewusst in einer eigenen ul ausserhalb von #playlist-<screen>,
damit savePlaylist() sie nicht mit einsammelt. Sichtbar nur, wenn die
Priority-Playlist des Standorts aktiviert ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 09:04:38 +02:00
Erik Thiele
aeab158e47 fix: Aktiv-Haken zeigt gespeicherten Status statt Zeitfenster
Die Admin-Ansicht hat der Checkbox das Ergebnis von is_item_active()
uebergeben. Ausserhalb des Zeitfensters erschien ein zeitgesteuerter
Eintrag dadurch als deaktiviert, und beim naechsten Speichern der
Reihenfolge schrieb der Client diesen Anzeigezustand als enabled=false
zurueck - der Eintrag war danach dauerhaft stillgelegt.

Die Checkbox spiegelt jetzt das gespeicherte enabled-Flag. Der
Live-Zustand steckt in einem neuen Feld active und faerbt nur noch das
Zeit-Badge (grau, wenn das Fenster gerade zu ist). Der Player filtert
entsprechend ueber beide Felder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-08 08:47:43 +02:00
Erik Thiele
c76a917a6d chore: Version auf 6.2.4 heben 2026-09-07 10:46:09 +02:00
Erik Thiele
c56006c18d fix: Trusted Device bei MFA zuverlaessig speichern und pruefen
- Dropdown fuer die Merk-Dauer aktiviert jetzt die Checkbox mit; bisher
  wurde die Auswahl ohne gesetzten Haken verworfen und trotzdem nach
  jedem Login ein Code verlangt.
- Abgelaufene oder defekte Trusted-Device-Eintraege fuehrten beim Login
  zu einem NameError (users_data war nicht geladen); Bereinigung neu
  geschrieben.
- Cookie erhaelt das Secure-Flag, wenn die Verbindung HTTPS ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:46:09 +02:00
Erik Thiele
ac343cef67 chore: Version auf 6.2.3 heben
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 12:18:22 +02:00
Erik Thiele
af308535fe fix: Standort ueber Seiten ohne Standortbezug hinweg merken
Wer von Stuttgart aus das Admin-Dashboard oeffnete, sah im Header
ploetzlich Karlsruhe - und landete beim Zurueckgehen ueber die
Standortauswahl dort. Grund: Dashboard, Hilfe, User-Verwaltung und die
MFA-Seiten setzten current_site fest auf get_site_list(cfg)[0], also den
global ersten Standort. Der Context-Processor tat dasselbe, fuer
angemeldete User sogar unabhaengig davon, ob sie auf diesen Standort
ueberhaupt zugreifen duerfen.

- remember_site() legt den zuletzt besuchten Standort in der Session ab;
  aufgerufen aus admin(), admin_priority() und add_customer().
- current_site_for() liefert diesen Standort, sonst den ersten
  *zugaenglichen*. Verwendet vom Context-Processor und den genannten
  Seiten.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 11:59:58 +02:00
Erik Thiele
f275615f3c chore: config.json nachziehen
Enthaelt die Migration der Priority-Playlist: der globale Schluessel
"priority" ist entfallen, jeder Standort hat seinen eigenen Block.

Ausserdem aus dem laufenden Betrieb: der Standort "test" mit den Screens
"vorstand" und "casino", die Schalter "Willkommensseite hier anzeigen"
sowie Playlist-Eintraege. Der Phantom-Screen "test/logos" - entstanden,
weil der Ordner fuer hochgeladene Logos als Screen interpretiert wurde -
ist entfernt.

Keine neuen Zugangsdaten im Diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:46:49 +02:00
Erik Thiele
d2938f87f3 fix: Priority-Playlist je Standort statt global
Die Priority-Playlist lag unter config["priority"] und wirkte damit auf
*alle* Standorte. In der Praxis lief so ein Stuttgart-spezifischer
Eintrag (wbxroompresence...find=Stuttgart) auch auf den Karlsruher
Screens.

Sie liegt jetzt unter sites.<site>.priority, die Medien unter
media/<site>/priority/:
- get_priority_config(cfg, site) liefert die Konfiguration des
  Standorts. Ist dort nichts hinterlegt, wird ein globaler Altbestand
  einmalig uebernommen - per deepcopy, damit spaetere Aenderungen nicht
  zwischen Standorten durchschlagen.
- load_priority_files(site), priority_dir(site) sowie Player, Hash,
  Upload, Loeschen, URL-Anlegen, Umsortieren und Einzeleintrag-
  Bearbeitung arbeiten durchgaengig standortbezogen.
- Route /media/priority/<datei> -> /media/<site>/priority/<datei>,
  Templates entsprechend.
- Der Ordnername "priority" ist wie "logos" in der Screen-Erkennung
  ausgenommen. media/<site>/priority/ waere sonst als Screen in der
  Konfiguration gelandet; "priority" ist in den Routen ohnehin als
  Kennzeichen belegt, ein Screen dieses Namens war nie moeglich.

Migration der vorhandenen Konfiguration: der globale Block wurde in alle
drei Standorte uebernommen und der globale Schluessel entfernt. Der
leere globale Ordner media/priority/ ist entfallen.

README, AGENTS.md und Hilfeseite nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 10:32:27 +02:00
Erik Thiele
a9cbe42df9 docs: Luecken in README und AGENTS.md schliessen
Abgleich der Doku gegen den Code ergab:
- /admin/help und /customer fehlten in der Routen-Tabelle
- Der Verzeichnisbaum zeigte nur die Screen-Ordner, nicht die Dateien
  auf Standort-Ebene (background.*, logo_<slot>.png, welcome.html) -
  gerade die sind aber bewusst Dateien und keine Ordner, weil jeder
  Unterordner als Screen interpretiert wird
- Der Docker-Abschnitt erwaehnte weder das noetige --build noch die
  Log-Einstellungen (PYTHONUNBUFFERED, access-logfile), die fuer jede
  Fehlersuche die Grundlage sind

AGENTS.md bekommt dazu einen Abschnitt "Betrieb / Diagnose".

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 18:25:13 +02:00
Erik Thiele
1dbb88538d chore: Version auf 6.2.2 heben
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 17:59:53 +02:00
Erik Thiele
f8d9539959 feat: Erklaerungssatz unter die Kundenfelder ziehen
"Das System sucht automatisch nach den Kundenlogos und erstellt eine
angepasste Willkommensseite." stand bisher ganz unten bei den Angaben
zu Zielscreens und Markdown. Der Satz beschreibt aber, was mit den
eingegebenen Kundennamen passiert, und steht jetzt direkt darunter -
gefolgt vom Hinweis zum Logo-Upload und der bestehenden Trennlinie vor
"Anzeigen bis". Aus zwei Absaetzen wurde ein Satz.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 17:50:16 +02:00
Erik Thiele
7e2f9a404d feat: Trennlinie zwischen Ansprechpartner und Logo-Upload
Je Kundenspalte eine <hr class="mt-4 mb-3"> zwischen dem
Ansprechpartner-Feld und "Logo hochladen" - gleiche Gestaltung wie die
uebrigen Abschnittstrenner der Seite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 17:45:19 +02:00
Erik Thiele
7f9a1632b3 feat: gewaehlte Logo-Datei im Formular anzeigen
Ein Neuladen der Willkommensseite - etwa beim Wechsel des Standorts -
leert alle <input type="file">. Die zuvor gewaehlte Datei ist damit weg,
ohne dass es auffaellt, und das Absenden erzeugt eine Willkommensseite
ohne das hochgeladene Logo.

Unter jedem Datei-Feld steht jetzt "Keine Datei gewählt" bzw. nach der
Auswahl Name und Groesse der Datei. Damit ist vor dem Absenden
erkennbar, ob wirklich etwas angehaengt ist.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 16:01:35 +02:00
Erik Thiele
556ccb211a fix: Zugriffsprotokoll von gunicorn aktivieren
Ohne --access-logfile protokolliert gunicorn keine Requests. Im Log war
dadurch nicht erkennbar, ob eine Anfrage den Container ueberhaupt
erreicht - beim Nachstellen eines Upload-Problems fehlte damit die
grundlegendste Information.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 15:57:46 +02:00
Erik Thiele
8f0e702cca fix: PYTHONUNBUFFERED im Container setzen
Ohne die Variable puffert Python stdout blockweise, sobald es kein
Terminal ist. Saemtliche print()-Ausgaben der App - darunter die
Diagnosezeilen zum Logo-Upload - erschienen dadurch nicht in
"docker compose logs", sondern blieben im Puffer haengen. In der
gesamten Log-Historie des Containers stand kein einziger APP.PY-Eintrag.
Tracebacks waren sichtbar, weil Flask sie ueber stderr ausgibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 15:55:23 +02:00
Erik Thiele
fe2846dfa6 chore: Upload-Diagnose im Willkommensseiten-Formular
Protokolliert je Kundenfeld, ob eine Datei angekommen ist. Ein
fehlendes enctype im Formular - etwa wenn der Browser eine aeltere
Fassung der Seite aus dem Cache liefert - sieht serverseitig sonst
genauso aus wie "kein Logo hochgeladen", und der Unterschied war aus
dem Log nicht erkennbar.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 15:37:44 +02:00
Erik Thiele
a25566aab4 fix: Logo nicht komplett wegradieren, wenn es dem Hintergrund aehnelt
Ein helles Logo auf hellem Grund liegt innerhalb der Farbtoleranz des
Flood Fill. Der Fuellvorgang lief vom Rand in das Logo hinein und
loeschte es mit - das Ergebnis war ein vollstaendig transparentes PNG,
auf der Willkommensseite also gar kein Logo. Ohne jede Rueckmeldung:
die bisherige Pruefung schlug nur an, wenn *nichts* entfernt wurde, nicht
wenn alles entfernt wurde.

remove_logo_background() legt jetzt vor dem Eingriff eine Kopie an und
misst danach ueber das Alpha-Histogramm, wie viel sichtbar geblieben
ist. Unter 2% gilt der Versuch als misslungen: das unveraenderte Bild
wird zurueckgegeben und die Oberflaeche nennt den Grund samt Auswegen
(transparentes PNG hochladen oder "Hintergrund behalten" ankreuzen).

Getestet mit Logos von dunkelrot bis reinweiss auf weissem Grund sowie
einem dunklen Logo auf schwarzem Grund.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 15:27:06 +02:00
Erik Thiele
a3b9fe59b7 fix: hochgeladene Logos nicht mehr in einem Screen-Ordner ablegen
Hochgeladene Logos landeten unter media/<site>/logos/. admin() behandelt
aber jeden Unterordner von media/<site>/ als Screen und schreibt ihn per
get_screen_config() dauerhaft in die Konfiguration. Der Logo-Ordner
erschien dadurch als Screen in der Oberflaeche, bekam beim Generieren
eine eigene welcome.html und liess sich wie ein Screen bespielen - und
die darin liegenden Logos wie Screen-Medien loeschen. Genau so ist eines
der hochgeladenen Logos verschwunden.

Logos liegen jetzt als media/<site>/logo_<slot>.png direkt im
Standort-Verzeichnis, so wie das Hintergrundbild als
media/<site>/background.* - aus demselben Grund. Die URL bleibt
/media/<site>/logos/<datei>, die Route liest aus dem
Standort-Verzeichnis; bestehende welcome.html funktionieren damit
unveraendert weiter.

Zusaetzlich wird der Ordnername "logos" bei der Screen-Erkennung
uebersprungen, damit Altbestaende nicht erneut als Screen auftauchen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 15:14:20 +02:00
Erik Thiele
8de8a116bb fix: hochgeladene Logos auf Anzeigegroesse zuschneiden
Ein hochgeladenes Logo erschien auf der Willkommensseite deutlich
kleiner als ein automatisch gefundenes. Ursache ist nicht die CSS -
beide nutzen dieselbe Klasse - sondern der Bildinhalt: nach dem
Freistellen ist der Rand zwar durchsichtig, zaehlt aber weiter zur
Bildflaeche. .customer-logo skaliert mit object-fit: contain auf 120px
Hoehe, ein Logo mit breitem Rand erscheint dadurch nur einen Bruchteil
davon gross (gemessen: 30px statt 120px). Brandfetch-Logos sind bereits
eng beschnitten und fuellen den Kasten aus.

trim_transparent_border() schneidet die durchsichtigen Raender nach dem
Freistellen weg. Greift auch bei bereits transparenten PNGs; bei
abgewaehltem Freistellen passiert nichts, da es dort keinen
transparenten Rand gibt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 15:06:20 +02:00
Erik Thiele
9dae841461 feat: Schalter platziert die Willkommensseite sofort
Die Willkommensseite ist je Standort identisch. Bisher musste man nach
dem Einschalten des Schalters "Willkommensseite hier anzeigen" erneut
generieren, damit sie auf dem Screen ankam - ein Handgriff ohne anderes
Ergebnis, und ohne Rueckmeldung, dass er noetig ist.

- sync_welcome_for_screen() kopiert beim Einschalten eine vorhandene
  welcome.html in den Screen und setzt sie vorne in die Playlist; beim
  Ausschalten werden Eintrag und Datei entfernt. Aufgerufen aus
  update_screen(), nur wenn sich der Schalter wirklich aendert.
- save_welcome_page() legt zusaetzlich eine Standort-Kopie unter
  media/<site>/welcome.html an, die nie geloescht wird. Ohne sie waere
  nach dem Abschalten aller Screens keine Quelle mehr vorhanden und der
  Schalter wuerde wieder stillschweigend nichts tun.

Nebenbei behoben: beim Generieren wurde welcome.html aus abgewaehlten
Screens nur aus der Playlist entfernt, die Datei blieb liegen. Bei
leerer Playlist spielt player() noch nicht verzeichnete Dateien aus dem
Ordner ab - die Begruessung waere dort also weiter zu sehen gewesen.

README, AGENTS.md und Hilfeseite nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 14:54:44 +02:00
Erik Thiele
048f2499d8 fix: fehlende Volume-Mounts und Zeitzone in docker-compose
Mit read_only: true und ohne volumes lag /app vollstaendig auf einem
schreibgeschuetzten Dateisystem. Die App konnte weder history.json noch
config.json oder users.json schreiben und auch keine Medien ablegen -
Login endete mit OSError, Einstellungen und Willkommensseiten wurden
nicht gespeichert. Das README dokumentierte die Mounts bereits, die
Datei selbst hatte sie nie.

Zusaetzlich TZ=Europe/Berlin: is_item_active() wertet die Serverzeit
aus, ohne TZ laeuft der Container in UTC und alle Zeitfenster der
Playlist-Zeitsteuerung waeren um ein bis zwei Stunden verschoben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 14:54:32 +02:00
Erik Thiele
3844ceacf2 chore: env.sh aus der Versionierung nehmen und ignorieren
Die Datei enthielt OPENAI_API_KEY und BRANDFETCH_API_KEY im Klartext und
war versioniert. Sie wurde lokal entfernt; der Loeschvorgang wird hier
nachgezogen und env.sh in .gitignore aufgenommen, damit sie nicht erneut
hineinrutscht.

Hinweis: Die Schluessel stehen weiterhin in der Repo-Historie und sollten
rotiert werden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 13:59:15 +02:00
Erik Thiele
64db742d31 feat: Logo-Upload mit automatischem Freistellen
Findet die automatische Suche kein Logo, kann pro Kunde eine Bilddatei
hochgeladen werden. Ein Upload hat Vorrang vor der Suche.

Freistellen:
- remove_logo_background() entfernt den Hintergrund per Flood Fill von
  den Bildraendern her. Bewusst kein globaler Farbvergleich - sonst
  verschwindet auch Weiss innerhalb des Logos (etwa Text in einer
  Flaeche). Weiche Kante gegen Treppeneffekte.
- Umgesetzt mit Pillow, das ueber qrcode[pil] ohnehin vorhanden ist.
  Bewusst kein ML-Modell wie rembg: der Container laeuft mit read-only
  Root-FS, ein Modell-Cache zur Laufzeit waere dort nicht moeglich.
- Bereits transparente Bilder bleiben unangetastet. Ist der Bildrand
  nicht einfarbig, wird nicht freigestellt und die Oberflaeche weist
  darauf hin, statt ein beschaedigtes Logo zu liefern.
- Checkbox "Hintergrund behalten" schaltet das Freistellen pro Kunde ab.
- Gespeichert wird immer als PNG (JPEG kann keine Transparenz), grosse
  Bilder werden auf 800px verkleinert.

Ablage unter media/<site>/logos/logo_<slot>.png, neue Route
/media/<site>/logos/<datei>. Die URL traegt einen Zeitstempel, damit
Player kein altes Logo aus dem Cache zeigen.

Nebenbei behoben: welcome_data speicherte nur die erfolgreichen Kunden
statt der Felder 1-3. Fiel Kunde 1 aus, rutschte Kunde 2 beim naechsten
Aufruf in Feld 1 und Logo wie Ansprechpartner verschoben sich mit. Jetzt
feldweise - dadurch bleibt auch ein hochgeladenes Logo beim erneuten
Generieren erhalten, solange der Kundenname im selben Feld steht.

Die drei Kundenbloecke im Formular sind zu einer Schleife
zusammengefasst, damit die Upload-Felder nicht dreifach gepflegt werden
muessen.

README, AGENTS.md und Hilfeseite nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 13:55:40 +02:00
Erik Thiele
4974ad92df fix: Struktur der Willkommensseite bereinigen, Doku nachziehen
templates/customer.html:
- <div class="page"> wurde nie geschlossen. Ergaenzt, analog zu
  help.html.
- Der {% include "_footer.html" %} war zusaetzlich in ein eigenes
  <footer class="app-footer"> gewickelt, obwohl das Include dieses
  Element bereits vollstaendig mitbringt - es standen also zwei
  ineinander verschachtelte footer im Markup. Wrapper entfernt.
- Leeren <script>-Block entfernt.

Drei Stellen behaupteten noch, die Willkommensseite ginge in die
Lobby-Playlist:
- README.md: Abschnitt "Willkommensseite", dazu Zielscreens und
  "Anzeigen bis" beschrieben
- generate_welcome_page.py: Modul-Docstring
- templates/customer.html: Kommentarblock ueber dem Formular

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 12:54:02 +02:00
Erik Thiele
94bfe865ef chore: Version auf 6.2.1 heben
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 12:49:19 +02:00
Erik Thiele
03d663588c chore: .env ignorieren, Konfiguration und Verlauf nachziehen
- .gitignore: .env aufgenommen, damit lokale Zugangsdaten (OpenAI,
  Brandfetch) nicht versehentlich versioniert werden
- config.json: Screen 'videosysteme' an Standort Karlsruhe, dazu eine
  Zeitsteuerung am Priority-Eintrag (Mo 08:00-09:00)
- history.json: zugehoerige Verlaufseintraege

Reine Laufzeit- und Konfigurationsdaten, keine Codeaenderung.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 12:36:04 +02:00
Erik Thiele
848bcf93ab feat: Willkommensseite auf mehreren Screens anzeigen
Die Willkommensseite ging bisher fest in die Lobby-Playlist. Welche
Screens Begruessungsschirme sind, ist aber eine Eigenschaft der
Installation und nicht des einzelnen Kundenbesuchs - deshalb ein Flag
pro Screen statt einer Auswahl bei jedem Vorgang.

- Neue Screen-Einstellung "welcome_enabled" (Checkbox im Tab
  Einstellungen). Die generierte welcome.html wird in jeden markierten
  Screen des Standorts geschrieben und dort an Position 0 gesetzt.
- welcome_target_screens() faellt nur auf "lobby" zurueck, wenn kein
  Screen des Standorts den Schluessel besitzt. Bestehende Standorte
  verhalten sich damit ohne Konfigurationsaufwand unveraendert.
- add_customer_to_playlists() (frueher add_customer_to_lobby_playlist)
  raeumt welcome.html aus allen Screens des Standorts und fuegt sie nur
  in den Zielscreens wieder ein. Ein abgewaehlter Screen bleibt so nicht
  mit einer alten Begruessung stehen.
- Eingefuegt wird ein Dict statt eines Strings, damit der Eintrag
  enabled und schedule tragen kann.
- Optionales "Anzeigen bis" im Formular: setzt eine Zeitsteuerung, die
  Seite verschwindet danach auf allen Screens von selbst.
- Das Formular zeigt die aktuellen Zielscreens an und meldet
  verstaendlich, wenn keiner markiert ist.
- screens.setdefault("lobby", {}) entfernt - das legte an Standorten
  ohne Lobby stillschweigend einen Screen an. Es werden nur noch
  vorhandene Screens bespielt.

Die Seite referenziert alle Bilder absolut (/static/..., /media/...),
ist also ortsunabhaengig; je Screen liegt eine identische Kopie.

README, AGENTS.md und die Hilfeseite nachgezogen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-02 12:26:22 +02:00
Erik Thiele
3761497421 chore: Version auf 6.2.0 heben
Die Zeitsteuerung fuer Playlist-Eintraege ist seit 6.1.0 dazugekommen.
AGENTS.md versieht Feature-Abschnitte mit einer Versionsangabe - der
Abschnitt "Zeitsteuerung" traegt sie jetzt nach.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 20:51:24 +02:00
Erik Thiele
5dda8abe2e docs: Zeitsteuerung in AGENTS.md und DESIGN.md nachziehen
AGENTS.md:
- Helper-Liste nannte playlist_item_enabled(), die es nicht mehr gibt;
  ersetzt durch is_item_active() und die neuen Schedule-Helfer
- Playlist-Items: auch Datei-Eintraege koennen Dicts sein, save_playlist
  kopiert bestehende Dicts, daher ueberlebt schedule das Umsortieren
- Neuer Abschnitt "Zeitsteuerung": Format, Auswertungsregeln,
  Reload-Kopplung ueber playlist_hash() und die beteiligten Stellen.
  Festgehalten sind besonders die Punkte, die man beim Umbau kaputt
  macht: unlesbare Angaben blenden nicht aus, Mitternachtsfenster
  gehoert zum Starttag, daily ignoriert days.

DESIGN.md:
- Playlist-Grid war mit 6 Spalten dokumentiert, tatsaechlich sind es 7
  (Edit-Button fehlte). Dazu die Regel, dass Statusmarkierungen in den
  .playlist-name-Span gehoeren - als Geschwisterelement verschieben sie
  die gesamte Zeile.
- Cards: festgehalten, dass .card global gestylt ist und deshalb nicht
  zum Gruppieren von Formularfeldern taugt; dafuer schlichte Zeilen mit
  <hr> + <h3>.
- Schedule-Badge dokumentiert.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 19:55:18 +02:00
Erik Thiele
90baa6130f docs: Zeitsteuerung in README und Hilfeseite dokumentieren
README:
- Feature-Liste und Routen-Tabelle ergaenzt
  (POST /admin/<site>/update-playlist-item/<screen>)
- Neuer Abschnitt "Playlist-Eintraege" mit dem Dict-Format fuer URL- und
  Datei-Eintraege sowie dem schedule-Objekt: Felder, Beispiele und die
  Verhaltensregeln (nur Uhrzeit bei daily/weekly, days nur bei weekly,
  Fenster ueber Mitternacht gehoert zum Starttag, unlesbare Angaben
  blenden nicht aus, Aktiv-Zustand fliesst in den Playlist-Hash ein)
- Helper-Liste aktualisiert: playlist_item_enabled() gibt es nicht mehr,
  dafuer is_item_active(), _schedule_time(), _window_start_date(),
  _in_validity_range() und der Filter schedule_text()

Hilfeseite (/admin/help):
- Neuer Abschnitt "Zeitsteuerung" mit Modus-Tabelle, Beispielen und den
  Punkten, ueber die man sonst stolpert
- Playlist-Abschnitt: Edit-Button gilt jetzt fuer alle Eintragstypen,
  "Zeit"-Badge erklaert
- Priority-Abschnitt: Zeitsteuerung steht dort ebenfalls zur Verfuegung

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 19:52:58 +02:00
Erik Thiele
29caaa2e0e chore: .DS_Store aus der Versionierung nehmen
Die Datei stand bereits in .gitignore, war aber weiterhin getrackt -
Ignore-Regeln greifen bei bereits versionierten Dateien nicht. Lokal
bleibt sie erhalten, nur der Repo-Eintrag entfaellt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 19:45:19 +02:00
Erik Thiele
f540458b74 fix: Zeitsteuerung reparieren und vervollstaendigen
Der vorige Commit 26efed8 war nicht lauffaehig - fehlgeschlagene
Patch-Anwendungen hatten Zeilen aus ihren Bloecken geschoben.

Reparaturen:
- admin(): zerschossene Playlist-Schleife neu aufgebaut
- player(): return render_template war in den else-Zweig gerutscht,
  wodurch die View bei aktiver Priority-Playlist None lieferte (HTTP 500)
- playlist_item_enabled() existierte nicht mehr (umbenannt zu
  is_item_active), zwei Aufrufe im Player blieben stehen -> NameError
- update_playlist_item(): Absturz bei new_url=null, und der
  enabled-Status ging beim Bearbeiten verloren
- Dateityp-Erkennung mit else-Fallback, ftype konnte ungebunden bleiben

Zeitsteuerung:
- Zeitfenster ueber Mitternacht (22:00-02:00) funktionieren; bei weekly
  gehoert ein solches Fenster zum Wochentag seines Starts
- Optionaler Gueltigkeitszeitraum (valid_from/valid_until) fuer daily
  und weekly, beide Grenzen einzeln optional und inklusiv
- daily/weekly speichern nur noch die Uhrzeit ("08:00") statt eines
  Zeitstempels mit bedeutungslosem Datumsanteil; _schedule_time() liest
  weiterhin auch Altbestaende mit vollem ISO-Zeitstempel
- playlist_hash() bezieht den ausgewerteten Aktiv-Zustand ein, sonst
  bemerkt der Player ein ablaufendes Zeitfenster erst beim naechsten
  Reload aus anderem Grund
- Zeitsteuerung auch fuer Datei-Eintraege, nicht nur URLs
- Neuer Filter schedule_text fuer den Tooltip am "Zeit"-Badge

Oberflaeche:
- required lag auf ausgeblendeten Feldern und blockierte das Absenden
  des Formulars "URL hinzufuegen" ohne sichtbare Meldung
- updateScheduleDaysVisibility() war innerhalb von DOMContentLoaded
  definiert, der Edit-Handler darueber -> ReferenceError bei jedem Klick
- data-schedule mit tojson zerlegte das Attribut, da tojson " nicht
  escaped
- Zeitsteuerungs-Block als schlichte Formularzeilen statt als card, die
  global den roten Rand und grauen Header erhaelt
- Zeitsteuerung auch in der Priority-Playlist

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-29 19:42:03 +02:00
Erik Thiele
26efed87a5 feat: Add time-based scheduling for playlist items 2026-08-29 16:29:40 +02:00
23 changed files with 3056 additions and 363 deletions

BIN
.DS_Store vendored

Binary file not shown.

2
.gitignore vendored
View File

@@ -5,6 +5,8 @@ test*
.venv/ .venv/
.DS_Store .DS_Store
.vscode .vscode
.env
env.sh
media/ media/
*.mp4 *.mp4
*.mov *.mov

View File

@@ -29,7 +29,7 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
| `GET /admin/<site>` | Admin-Dashboard für einen Standort (`@site_access_required`) | | `GET /admin/<site>` | Admin-Dashboard für einen Standort (`@site_access_required`) |
| `GET /admin/<site>/priority` | Priority-Playlist als separate Seite | | `GET /admin/<site>/priority` | Priority-Playlist als separate Seite |
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern | | `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
| `GET /media/priority/<file>` | Priority-Medien (global) | | `GET /media/<site>/priority/<file>` | Priority-Medien des Standorts |
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite | | `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
| `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme für Auto-Reload + zeichnet Heartbeat auf (Online-Status) | | `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme für Auto-Reload + zeichnet Heartbeat auf (Online-Status) |
| `GET /willkommen?site=<site>` | Willkommensseite für Standort generieren | | `GET /willkommen?site=<site>` | Willkommensseite für Standort generieren |
@@ -107,19 +107,30 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- `GET /player/<site>/<screen>` renders the playlist and auto-reloads from `/playlist/<site>/<screen>/hash`. - `GET /player/<site>/<screen>` renders the playlist and auto-reloads from `/playlist/<site>/<screen>/hash`.
- `GET /admin` requires login; redirects to first accessible site for the user. - `GET /admin` requires login; redirects to first accessible site for the user.
- URL playlist items are stored as dicts like `{"url": "https://...", "zoom": 0.8}` and the zoom value must survive save/reorder flows. - URL playlist items are stored as dicts like `{"url": "https://...", "zoom": 0.8}` and the zoom value must survive save/reorder flows. File items may also be dicts (`{"name": "bild.jpg", ...}`); `save_playlist` copies existing dicts, so extra keys like `schedule` survive reordering.
- `.html` items in `media/` are rendered inline as content, not in an iframe. - `.html` items in `media/` are rendered inline as content, not in an iframe.
- `config.priority.enabled` makes the priority playlist show on every screen. - `sites.<site>.priority.enabled` makes the priority playlist show on every screen **of that site**.
- `POST /api/customer` generates `welcome.html` and inserts it at the front of the lobby playlist for the specified site. - `POST /api/customer` generates `welcome.html` and inserts it at the front of the playlist of every target screen for the specified site. Target screens are those with `welcome_enabled`; if no screen of the site has the key at all, the old behaviour applies (`lobby` only). Optional `valid_until` (`"YYYY-MM-DD"`) attaches a schedule so the page disappears on its own.
- New standorte can be added by creating `media/<neuer-standort>/<screen>/` directories and optionally adding config to `config.json["sites"][<neuer-standort>]`. - New standorte can be added by creating `media/<neuer-standort>/<screen>/` directories and optionally adding config to `config.json["sites"][<neuer-standort>]`.
- Priority playlist (`config.priority`) is global and affects all sites/screens. - **Die Priority-Playlist gilt je Standort** (`sites.<site>.priority`), nicht global. Frueher lag sie unter `config.priority` und wirkte auf alle Standorte - dadurch lief Stuttgarter Inhalt auch in Karlsruhe. `get_priority_config(cfg, site)` liefert die Konfiguration des Standorts und uebernimmt einen globalen Altbestand beim ersten Zugriff einmalig (mit `deepcopy`, damit Aenderungen nicht zwischen Standorten durchschlagen).
- Priority-Medien liegen unter `media/<site>/priority/`, ausgeliefert ueber `/media/<site>/priority/<datei>`. Die Ordnernamen `priority` und `logos` sind in der Screen-Erkennung ausgenommen - sonst wuerden sie als Screens in der Konfiguration landen.
- Willkommensseite (`customer.html`) accepts up to 3 customer names; logos are fetched via OpenAI→Brandfetch and displayed in a flex row (portrait: column). - Willkommensseite (`customer.html`) accepts up to 3 customer names; logos are fetched via OpenAI→Brandfetch and displayed in a flex row (portrait: column).
- `generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None)` takes lists for up to 3 customers; logos have equal width (280px) with fixed height 120px, text left-aligned below each logo. - `generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None)` takes lists for up to 3 customers; logos have equal width (280px) with fixed height 120px, text left-aligned below each logo.
- Contact info supports Markdown: `**bold**`, `*italic*`, `` `code` ``, `- list items`, and line breaks (preserved as `<br>`). - Contact info supports Markdown: `**bold**`, `*italic*`, `` `code` ``, `- list items`, and line breaks (preserved as `<br>`).
- `customer_names` are preserved in form fields after POST (via `value`-Attribute). - `customer_names` are preserved in form fields after POST (via `value`-Attribute).
- Admin-UI nutzt keyadmin-Design: `brand-surface` (#2b2f36), `nav-surface` (rot #DA002D), Dark Mode per `localStorage("signage-theme")`. - Admin-UI nutzt keyadmin-Design: `brand-surface` (#2b2f36), `nav-surface` (rot #DA002D), Dark Mode per `localStorage("signage-theme")`.
- Gemeinsame HTML-Bausteine: `_header.html`, `_footer.html`, `_styles.html` (CSS-Variablen `--ccm-*`, Dark Mode, Card-Border-Radius 1rem). - Gemeinsame HTML-Bausteine: `_header.html`, `_footer.html`, `_styles.html` (CSS-Variablen `--ccm-*`, Dark Mode, Card-Border-Radius 1rem).
- `add_customer_to_lobby_playlist` entfernt `welcome.html` sowohl als String als auch als Dict aus der Playlist vor dem Einfügen. - `add_customer_to_playlists` (früher `add_customer_to_lobby_playlist`) entfernt `welcome.html` als String *und* als Dict aus **allen** Screens des Standorts und fügt sie danach nur in den Zielscreens an Position 0 wieder ein – ein abgewählter Screen bleibt so nicht mit einer alten Begrüßung stehen. Eingefügt wird ein Dict, kein String, damit `enabled` und `schedule` mitgeführt werden können.
- `welcome_target_screens(config, site)` fällt nur dann auf `lobby` zurück, wenn **kein** Screen des Standorts den Schlüssel `welcome_enabled` besitzt. Wurde er überall bewusst auf `False` gesetzt, ist die leere Liste das gewollte Ergebnis.
- **Logo-Upload**: Pro Kunde kann im Willkommensseiten-Formular eine Bilddatei hochgeladen werden (`logo_file_1..3`); ein Upload hat Vorrang vor `search_customer_logo`. Ablage als `media/<site>/logo_<slot>.png`, Auslieferung über `/media/<site>/logos/<datei>` (die Route liest aus dem Standort-Verzeichnis).
- **Keine neuen Unterordner unter `media/<site>/` anlegen.** `admin()` behandelt jeden Unterordner dort als Screen und persistiert ihn ueber `get_screen_config()` in der Konfiguration - ein Ordner `logos` erschien dadurch als Phantom-Screen, bekam eine `welcome.html` und liess sich ueber die Admin-UI bespielen und loeschen. Deshalb liegen Logos wie das Hintergrundbild als Dateien direkt im Standort-Verzeichnis. Der Name `logos` ist in der Screen-Erkennung zusaetzlich ausgenommen, damit Altbestaende nicht wieder auftauchen. Die URL traegt einen `?v=<timestamp>`, damit Player kein altes Logo aus dem Cache zeigen.
- Nach dem Freistellen schneidet `trim_transparent_border()` die durchsichtigen Raender weg. Das ist kein Schoenheitsdetail: `.customer-logo` skaliert mit `object-fit: contain` auf 120px Hoehe, ein breiter Rand zaehlt dabei mit und laesst ein hochgeladenes Logo deutlich kleiner erscheinen als ein eng beschnittenes Brandfetch-Logo (gemessen: 30px statt 120px). Der Schnitt greift auch bei bereits transparenten PNGs, aber nicht bei abgewaehltem Freistellen - dort gibt es keinen transparenten Rand.
- `remove_logo_background()` arbeitet mit Pillow (Flood Fill von den Raendern, weiche Kante). **Bewusst kein ML-Modell** wie rembg: der Container laeuft mit read-only Root-FS ohne Modell-Cache. Flood Fill statt globalem Farbvergleich ist wesentlich - sonst verschwindet auch Weiss *innerhalb* des Logos. Bereits transparente Bilder werden unveraendert durchgereicht; bei unruhigem Bildrand bleibt das Bild unangetastet und die Funktion gibt einen Hinweis zurueck. **Ebenso, wenn nach dem Fuellen weniger als 2% sichtbar waeren**: ein helles Logo auf hellem Grund liegt innerhalb der Toleranz, der Flood Fill frisst es mit auf und das Ergebnis waere ein leeres PNG - auf der Willkommensseite also gar kein Logo. In dem Fall wird die vor dem Eingriff angelegte Kopie zurueckgegeben.
- `welcome_data` in der Site-Config wird **feldweise** gespeichert (Slot 1-3, auch leere), nicht nur die erfolgreichen Kunden - sonst verschiebt sich die Zuordnung von Name, Logo und Ansprechpartner beim naechsten Aufruf. Ein zuvor hochgeladenes Logo wird beim erneuten Generieren wiederverwendet, solange der Kundenname im selben Feld unveraendert ist.
- **Der Schalter `welcome_enabled` wirkt sofort**: `update_screen` ruft bei einer Aenderung `sync_welcome_for_screen()`, das die vorhandene `welcome.html` in den Screen kopiert und vorne einfuegt bzw. beim Abschalten Eintrag und Datei entfernt. Kein erneutes Generieren noetig - die Seite ist je Standort identisch. Die Funktion aendert nur das uebergebene config-Dict, gespeichert wird vom Aufrufer.
- `save_welcome_page` legt zusaetzlich eine Standort-Kopie unter `media/<site>/welcome.html` an, die **nie geloescht** wird. Ohne sie waere nach dem Abschalten aller Screens keine Quelle mehr da und der Schalter wuerde stillschweigend nichts tun.
- Beim Generieren wird `welcome.html` aus abgewaehlten Screens auch als **Datei** entfernt, nicht nur aus der Playlist: bei leerer Playlist spielt `player()` sonst noch nicht verzeichnete Dateien aus dem Ordner ab.
- Weder `save_welcome_page` noch `add_customer_to_playlists` legen Screens an. Das frühere `screens.setdefault("lobby", {})` erzeugte an Standorten ohne Lobby stillschweigend einen Screen.
- `add_screen` legt ein Verzeichnis unter `media/<site>/<screen>/` an und einen Config-Eintrag. - `add_screen` legt ein Verzeichnis unter `media/<site>/<screen>/` an und einen Config-Eintrag.
- `delete_screen` entfernt den Screen aus der Config und löscht das Verzeichnis rekursiv. - `delete_screen` entfernt den Screen aus der Config und löscht das Verzeichnis rekursiv.
- Custom-URL-Button: pro Screen konfigurierbar (`custom_url` + `custom_url_label` + `custom_url_enabled` + `custom_url_target` + `custom_url_position`) im Admin-Formular (Aktionen-Tab); wird im Player als Button an wählbarer Position (9 Positionen: oben/mitte/unten × links/mitte/rechts) angezeigt und öffnet die URL wahlweise in einem iframe-Overlay mit Zurück-Button (`overlay`) oder per Direkt-Weiterleitung (`redirect`); Player pausiert während das Overlay geöffnet ist. Die unteren Positionen weichen automatisch 16px über dem Newsticker-Balken aus. - Custom-URL-Button: pro Screen konfigurierbar (`custom_url` + `custom_url_label` + `custom_url_enabled` + `custom_url_target` + `custom_url_position`) im Admin-Formular (Aktionen-Tab); wird im Player als Button an wählbarer Position (9 Positionen: oben/mitte/unten × links/mitte/rechts) angezeigt und öffnet die URL wahlweise in einem iframe-Overlay mit Zurück-Button (`overlay`) oder per Direkt-Weiterleitung (`redirect`); Player pausiert während das Overlay geöffnet ist. Die unteren Positionen weichen automatisch 16px über dem Newsticker-Balken aus.
@@ -133,6 +144,7 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- `delete_site` entfernt den Standort aus Config und löscht das Medienverzeichnis rekursiv (nur Admins). - `delete_site` entfernt den Standort aus Config und löscht das Medienverzeichnis rekursiv (nur Admins).
- `config.json["server_url"]` (z. B. `http://signage.ccmake.de`) wird in der Admin-Ansicht für die Player-URLs verwendet. - `config.json["server_url"]` (z. B. `http://signage.ccmake.de`) wird in der Admin-Ansicht für die Player-URLs verwendet.
- **Query-Parameter-Passthrough**: Query-Parameter an der Player-URL (`/player/<site>/<screen>`) können pro Playlist-URL im Edit-Modal (Checkbox) gesteuert werden. Standardmäßig **aus** – nur URLs mit aktivierter Checkbox erhalten die Parameter. Beispiel: `/player/stuttgart/lobby?customer=acme` hängt `?customer=acme` nur an URLs mit Passthrough=an. In der Playlist-Zeile zeigt ein Link-Icon (`ti-link`, grau) den Status an: sichtbar = an, durchgestrichen (`ti-link-off`, hellgrau) = aus. Funktioniert über `appendParams()` in `player.html` + `player_params` im Flask-Route. - **Query-Parameter-Passthrough**: Query-Parameter an der Player-URL (`/player/<site>/<screen>`) können pro Playlist-URL im Edit-Modal (Checkbox) gesteuert werden. Standardmäßig **aus** – nur URLs mit aktivierter Checkbox erhalten die Parameter. Beispiel: `/player/stuttgart/lobby?customer=acme` hängt `?customer=acme` nur an URLs mit Passthrough=an. In der Playlist-Zeile zeigt ein Link-Icon (`ti-link`, grau) den Status an: sichtbar = an, durchgestrichen (`ti-link-off`, hellgrau) = aus. Funktioniert über `appendParams()` in `player.html` + `player_params` im Flask-Route.
- **Zeitsteuerung**: Optionales `schedule`-Objekt pro Playlist-Eintrag (URL *und* Datei, Screen-Playlist *und* Priority). Ausgewertet ausschliesslich serverseitig in `is_item_active()`; siehe Abschnitt "Zeitsteuerung" unten.
- Screen-Card-Body hat Tabler-Tabs: **Playlist** (1, aktiv), **Einstellungen** (2), **Aktionen** (3), **Digital Voice Agent** (4), **Medien** (5), **Info** (6); Priority-Seite ebenfalls Tabs **Playlist** und **Medien**. - Screen-Card-Body hat Tabler-Tabs: **Playlist** (1, aktiv), **Einstellungen** (2), **Aktionen** (3), **Digital Voice Agent** (4), **Medien** (5), **Info** (6); Priority-Seite ebenfalls Tabs **Playlist** und **Medien**.
- `stay_on_first`: Wenn aktiviert bleibt der Player auf dem ersten Playlist-Element stehen (kein Durchlauf). - `stay_on_first`: Wenn aktiviert bleibt der Player auf dem ersten Playlist-Element stehen (kein Durchlauf).
- Tab-Reihenfolge in Screen-Cards: Playlist → Einstellungen → Aktionen → Digital Voice Agent → Medien → Info. - Tab-Reihenfolge in Screen-Cards: Playlist → Einstellungen → Aktionen → Digital Voice Agent → Medien → Info.
@@ -142,13 +154,22 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- User-Dropdown im Header: zeigt E-Mail + Role-Badge, Menü mit Passwort ändern, MFA einrichten (mit "Aktiv"-Badge wenn MFA eingeschaltet), Userverwaltung (Admin), Abmelden. - User-Dropdown im Header: zeigt E-Mail + Role-Badge, Menü mit Passwort ändern, MFA einrichten (mit "Aktiv"-Badge wenn MFA eingeschaltet), Userverwaltung (Admin), Abmelden.
- Info-Tab in Screen-Cards: zeigt Client-Info-Tabelle (IP, Browser, Auflösung, zuletzt gesehen) aus dem Heartbeat-JSON für den jeweiligen Screen - Info-Tab in Screen-Cards: zeigt Client-Info-Tabelle (IP, Browser, Auflösung, zuletzt gesehen) aus dem Heartbeat-JSON für den jeweiligen Screen
- **Medien-Cache**: `player()` gibt jedem Datei-Eintrag `v` (mtime) mit, der Player haengt `?v=<mtime>` an. `send_media()` setzt dann `max-age` 1 Jahr + `immutable`; ohne `?v` bleibt Flasks `no-cache`. Eine ersetzte Datei bekommt so automatisch eine neue URL. Grosse Videos cacht der Browser trotzdem oft nicht (eigener Media-Cache) - dafuer braeuchte es einen Service Worker.
## Betrieb / Diagnose
- `docker compose up -d` startet nur neu und **baut nicht**. Nach Code-Aenderungen `--build` verwenden, sonst laeuft weiter das alte Image - das hat bei der Fehlersuche mehrfach zu falschen Schluessen gefuehrt.
- Das Dockerfile setzt `PYTHONUNBUFFERED=1`. Ohne das puffert Python `stdout` blockweise (kein TTY) und **keine** `print()`-Ausgabe der App erscheint in `docker compose logs`. Tracebacks waren sichtbar, weil Flask sie ueber stderr schreibt - das machte den blinden Fleck schwer erkennbar.
- gunicorn laeuft mit `--access-logfile -`, protokolliert also jeden Request. Ohne das laesst sich nicht feststellen, ob eine Anfrage den Container ueberhaupt erreicht.
- `add_customer` protokolliert je Kundenfeld `Upload = <dateiname>` bzw. `Upload = keiner`. Ein geleertes `<input type="file">` - etwa nach einem Reload der Seite - sieht serverseitig sonst genauso aus wie "kein Logo hochgeladen".
## Repo Quirks ## Repo Quirks
- `media/` and media file extensions are gitignored. - `media/` and media file extensions are gitignored.
- `users.json` is NOT gitignored (tracked for initial admin setup, contains no secrets by default). - `users.json` is NOT gitignored (tracked for initial admin setup, contains no secrets by default).
- The app has no configured tests, lint, typecheck, formatter, or CI. - The app has no configured tests, lint, typecheck, formatter, or CI.
- Hardcoded secrets exist in tracked files; do not commit new secrets or reshuffle them casually. - Hardcoded secrets exist in tracked files; do not commit new secrets or reshuffle them casually.
- Wichtige Helper-Funktionen in `app.py`: `load_config()`, `save_config()`, `get_site_list()`, `get_screen_config()`, `is_url()`, `normalize_url()`, `playlist_item_name()`, `playlist_item_enabled()`, `load_priority_files()`, `prio_redirect()`, `get_background_url()`, `load_users()`, `save_users()`, `get_user()`, `init_user_db()`, `get_accessible_sites()`, `record_heartbeat()`, `screen_is_active()`, `get_client_info()`, `add_history_entry()`. - Wichtige Helper-Funktionen in `app.py`: `load_config()`, `save_config()`, `get_site_list()`, `get_screen_config()`, `is_url()`, `normalize_url()`, `playlist_item_name()`, `is_item_active()`, `_schedule_time()`, `_window_start_date()`, `_in_validity_range()`, `load_priority_files(site)`, `get_priority_config(cfg, site)`, `priority_dir(site)`, `prio_redirect()`, `get_background_url()`, `load_users()`, `save_users()`, `get_user()`, `init_user_db()`, `get_accessible_sites()`, `record_heartbeat()`, `screen_is_active()`, `get_client_info()`, `add_history_entry()`.
- Zugriffs-Dekoratoren in `app.py`: `admin_required`, `site_access_required`. - Zugriffs-Dekoratoren in `app.py`: `admin_required`, `site_access_required`.
- Hintergrundbild der Willkommensseite wird pro Standort unter `media/<site>/background.*` gespeichert; Fallback auf `static/wallpaper.png` wenn keine Datei existiert. - Hintergrundbild der Willkommensseite wird pro Standort unter `media/<site>/background.*` gespeichert; Fallback auf `static/wallpaper.png` wenn keine Datei existiert.
- `get_background_url(site)` prüft auf benutzerdefiniertes Hintergrundbild für einen Standort. - `get_background_url(site)` prüft auf benutzerdefiniertes Hintergrundbild für einen Standort.
@@ -158,6 +179,38 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- `admin_priority` rendert `priority.html` mit `site_list`, `current_site`, `priority_files`, `server_url`. - `admin_priority` rendert `priority.html` mit `site_list`, `current_site`, `priority_files`, `server_url`.
- `static/dva.png`: Bild für den Voice-Agent-Button (optional, per Admin-Toggle ein-/ausblendbar). - `static/dva.png`: Bild für den Voice-Agent-Button (optional, per Admin-Toggle ein-/ausblendbar).
## Zeitsteuerung (seit v6.2.0)
Jeder Playlist-Eintrag kann ein optionales `schedule`-Objekt tragen — URL wie Datei, in Screen-Playlists wie in der Priority-Playlist.
```jsonc
{ "mode": "daily", "start": "08:00", "end": "12:00",
"valid_from": "2026-09-10", "valid_until": "2026-09-20" } // valid_* optional
{ "mode": "weekly", "start": "09:00", "end": "17:00", "days": ["0", "2", "4"] }
{ "mode": "once", "start": "2026-09-10T08:00", "end": "2026-09-20T12:00" }
```
### Regeln
- `once` vergleicht vollständige Zeitstempel. `daily`/`weekly` werten **nur die Uhrzeit** aus; ein Datum wird dort ausschliesslich über `valid_from`/`valid_until` gesetzt (beide optional, beide inklusiv).
- Bei `daily` gilt **jeder** Wochentag; ein vorhandenes `days` wird in diesem Modus nicht ausgewertet (bleibt aber erhalten, damit ein Moduswechsel die Auswahl nicht vernichtet).
- `start` > `end` bedeutet ein Fenster **über Mitternacht**. Ein solches Fenster gehört zum Wochentag *und* Gültigkeitstag seines **Starts** — deshalb liefert `_window_start_date()` das Datum des Fensterbeginns und nicht einfach `now.date()`.
- Unlesbare oder unvollständige Zeitangaben geben `True` zurück. Ein Konfigurationsfehler soll den Screen nicht stillschweigend leeren — beim Ändern dieser Logik unbedingt beibehalten.
- `_schedule_time()` liest `"HH:MM"`, `"HH:MM:SS"` und volle ISO-Zeitstempel. Letzteres ist nötig, weil ältere Versionen bei `daily`/`weekly` einen kompletten Zeitstempel gespeichert haben, dessen Datumsanteil ignoriert wurde.
- Auswertung erfolgt **nur serverseitig** (`datetime.now()` des Servers, nicht des Players).
### Reload-Kopplung
`playlist_hash()` hasht neben der Konfiguration auch `[is_item_active(item) for item in ...]` für Screen- und Priority-Playlist. Ohne das würde der Player ein ablaufendes Zeitfenster erst beim nächsten Reload aus anderem Grund bemerken, da sich die Konfiguration dabei nicht ändert.
### Beteiligte Stellen
- `is_item_active()`, `_window_start_date()`, `_in_validity_range()`, `_schedule_time()` in `app.py`
- Jinja-Filter `schedule_text()` für den Tooltip am "Zeit"-Badge
- `add_url()` liest bei `daily`/`weekly` die Felder `schedule_start_time`/`schedule_end_time`, bei `once` `schedule_start`/`schedule_end`
- `update_playlist_item()` behandelt `new_url: null` als Datei-Eintrag und schreibt `{"name": ...}`; `schedule: {}` entfernt die Zeitsteuerung, ein fehlender Key behält sie
- UI in `admin.html` und `priority.html`: zwei Feldpaare (`datetime-local` für `once`, `time` für `daily`/`weekly`), per Modus umgeschaltet
## Heartbeat / Online-Status (seit v6.1.0) ## Heartbeat / Online-Status (seit v6.1.0)
Der Player-Status (Online/Offline) wird mittels dateibasiertem Heartbeat ermittelt. Der Player-Status (Online/Offline) wird mittels dateibasiertem Heartbeat ermittelt.

View File

@@ -141,6 +141,7 @@ The palette is rooted in CANCOM's corporate red and high-contrast neutrals.
- **Muted (#6B7280 / #9AA4B2 Dark):** Secondary text, captions, metadata, placeholders. - **Muted (#6B7280 / #9AA4B2 Dark):** Secondary text, captions, metadata, placeholders.
- **Online/Offline:** Green (#2FB344) for online status badge, Red (#D63939) for offline. - **Online/Offline:** Green (#2FB344) for online status badge, Red (#D63939) for offline.
- **Role Badges:** Red for Admin, Purple (#8B5CF6) for Superuser, Blue (#3B82F6) for User. - **Role Badges:** Red for Admin, Purple (#8B5CF6) for Superuser, Blue (#3B82F6) for User.
- **Schedule Badge:** Bootstrap `bg-info` with white text, label "Zeit", marks a scheduled playlist item; the tooltip carries the plain-text schedule from the `schedule_text` filter.
- **Newsticker:** Red bar (#DA002D) with white text. - **Newsticker:** Red bar (#DA002D) with white text.
### States ### States
@@ -194,7 +195,9 @@ Screen management cards have:
### Grid Areas ### Grid Areas
The playlist row uses a fixed grid: `28px 1fr 90px 80px 90px 32px` for checkbox, name, type badge, size, delete button, drag handle. The playlist row uses a fixed grid: `28px 1fr 90px 80px 50px 40px 32px` for checkbox, name, type badge, size, delete button, edit button, drag handle — identical in `admin.html` and `priority.html`.
**The column count is fixed.** Anything added to a row becomes an extra grid item and shifts every following column. Status markers therefore live *inside* the `.playlist-name` span, not as siblings — this applies to the passthrough link icon and the "Zeit" badge.
## Elevation & Depth ## Elevation & Depth
@@ -250,6 +253,10 @@ Red background (`--ccm-primary`). Contains white outline buttons (`btn-white`):
- Body contains Tabler tabs (6 tabs for screen cards) - Body contains Tabler tabs (6 tabs for screen cards)
- Tab background matches card surface; tab link text uses muted color; active tab uses surface background - Tab background matches card surface; tab link text uses muted color; active tab uses surface background
**`.card` is styled globally**, so the red left accent and grey header apply to *every* element carrying the class. The card is the container for a top-level entity — a screen, a help topic, the priority playlist. It is not a way to group fields.
Grouping form fields inside a modal or a tab uses plain rows (`mb-3`, `form-check`) with an `<hr>` and an `<h3>` where a separator is wanted. A `.card` there reads as a foreign tile inside the form and violates the one-red-accent-per-card rule.
### Player Media Elements ### Player Media Elements
- img, video, iframe: fullscreen (`100vw × 100vh`), fixed position, centered via `top:50%; left:50%; transform:translate(-50%,-50%)` - img, video, iframe: fullscreen (`100vw × 100vh`), fixed position, centered via `top:50%; left:50%; transform:translate(-50%,-50%)`
@@ -323,6 +330,8 @@ Same base as action button plus:
- Do keep player pages completely chromeless — no headers, navs, or footers - Do keep player pages completely chromeless — no headers, navs, or footers
- Don't add shadows to cards; use border + left accent instead - Don't add shadows to cards; use border + left accent instead
- Don't use more than one red accent per card (the 6px left border is sufficient) - Don't use more than one red accent per card (the 6px left border is sufficient)
- Don't use `.card` to group form fields inside a modal or tab — it inherits the global red accent and grey header; use plain rows with `<hr>` + `<h3>` instead
- Don't add elements as direct children of a playlist row — the grid has a fixed column count; put status markers inside `.playlist-name`
- Don't mix corner radii: cards use 1rem, player buttons use 20px — keep each category consistent - Don't mix corner radii: cards use 1rem, player buttons use 20px — keep each category consistent
- Do set `data-bs-theme` synchronously before rendering to prevent dark-mode flash - Do set `data-bs-theme` synchronously before rendering to prevent dark-mode flash
- Do persist theme preference in `localStorage("signage-theme")` - Do persist theme preference in `localStorage("signage-theme")`

View File

@@ -14,7 +14,12 @@ RUN addgroup --system --gid 1000 app && \
USER app USER app
ENV HOME=/tmp ENV HOME=/tmp
# Ohne das puffert Python stdout blockweise, weil es kein Terminal ist -
# die print()-Ausgaben der App tauchen dann nicht in "docker compose logs" auf.
ENV PYTHONUNBUFFERED=1
EXPOSE 5005 EXPOSE 5005
CMD ["gunicorn", "-b", "0.0.0.0:5005", "-w", "4", "app:app"] # --access-logfile - schreibt jeden Request nach stdout. Ohne das protokolliert
# gunicorn keine Zugriffe, und man sieht nicht, ob eine Anfrage ueberhaupt ankommt.
CMD ["gunicorn", "-b", "0.0.0.0:5005", "-w", "4", "--access-logfile", "-", "app:app"]

177
README.md
View File

@@ -9,8 +9,8 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens.
- Browserbasierter Player (Kiosk-Modus, Chromium, Firefox) - Browserbasierter Player (Kiosk-Modus, Chromium, Firefox)
- Bilder, Videos (MP4/H.264), HTML-Seiten, URL-Playlist-Einträge - Bilder, Videos (MP4/H.264), HTML-Seiten, URL-Playlist-Einträge
- **Multi-Standort**: Standorte (sites) gruppieren Screens - **Multi-Standort**: Standorte (sites) gruppieren Screens
- **Priority-Playlist**: globale Inhalte wirken auf alle Player - **Priority-Playlist**: pro Standort; die Inhalte laufen auf allen Screens **dieses** Standorts, nicht standortübergreifend. Sie werden zusätzlich als **reine Vorschau** oberhalb jeder Screen-Playlist angezeigt (grau, mit „Prio"-Badge), damit sie beim Bearbeiten nicht übersehen werden – bedient wird weiterhin nur auf der Priority-Seite
- **Willkommensseite**: bis zu 3 Kundenlogos via OpenAI + Brandfetch, pro Standort konfigurierbares Hintergrundbild - **Willkommensseite** (ohne Login erreichbar): bis zu 3 Kundenlogos via OpenAI + Brandfetch oder **manueller Upload mit automatischem Freistellen** (transparenter Hintergrund), pro Standort konfigurierbares Hintergrundbild; erscheint auf allen Screens mit `welcome_enabled` (mehrere möglich), optional mit Ablaufdatum
- Pro Screen eigene Playlist mit Drag-&-Drop-Reihenfolge - Pro Screen eigene Playlist mit Drag-&-Drop-Reihenfolge
- Auto-Reload bei Playlist-Änderungen - Auto-Reload bei Playlist-Änderungen
- Newsticker pro Screen - Newsticker pro Screen
@@ -22,9 +22,10 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens.
- **User-Verwaltung**: Mehrere User mit Rollen (Admin / Superuser / User), E-Mail als Login, Passwort-Hashing (scrypt), Berechtigungen pro Standort - **User-Verwaltung**: Mehrere User mit Rollen (Admin / Superuser / User), E-Mail als Login, Passwort-Hashing (scrypt), Berechtigungen pro Standort
- **Passwort-Workflow**: First-Login-Änderung, Admin-Reset mit temporärem Passwort, Notfall-Login bei fehlendem Hash - **Passwort-Workflow**: First-Login-Änderung, Admin-Reset mit temporärem Passwort, Notfall-Login bei fehlendem Hash
- **MFA (Multi-Faktor)**: Optionale TOTP-Authentifizierung (Google Authenticator, Authy) per User aktivierbar; QR-Code-Scan + Code-Verifikation, 8 Recovery-Codes, Deaktivierung mit Passwort-Bestätigung, **Trusted Device** (Gerät für 1–30 Tage speichern, überspringt MFA beim nächsten Login) - **MFA (Multi-Faktor)**: Optionale TOTP-Authentifizierung (Google Authenticator, Authy) per User aktivierbar; QR-Code-Scan + Code-Verifikation, 8 Recovery-Codes, Deaktivierung mit Passwort-Bestätigung, **Trusted Device** (Gerät für 1–30 Tage speichern, überspringt MFA beim nächsten Login)
- **Admin Dashboard**: `/admin/dashboard` – Statistiken (Sites, Screens, User, Admins, Superuser) mit Sparkline-Charts + Trendanzeige, Aktivitätsverlauf - **Admin Dashboard**: `/admin/dashboard` – Statistiken (Sites, Screens, User, Admins, Superuser) mit Sparkline-Charts + Trendanzeige, Aktivitätsverlauf mit Seitennavigation
- **Aktivitätsverlauf**: Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden in `history.json` protokolliert und im Dashboard angezeigt - **Aktivitätsverlauf**: Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden in `history.json` protokolliert (bis zu 1000 Einträge) und im Dashboard seitenweise angezeigt (50 je Seite)
- Tab-basierte Admin-UI pro Screen: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien, Info (Tabler Tabs) - Tab-basierte Admin-UI pro Screen: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien, Info (Tabler Tabs)
- **Zeitsteuerung**: Pro Playlist-Eintrag (URL *und* Datei, auch in der Priority-Playlist) ein Zeitfenster festlegen – einmalig (Datum + Uhrzeit), täglich oder wöchentlich (Uhrzeit + optionaler Gültigkeitszeitraum, wöchentlich zusätzlich mit Wochentagen). Fenster über Mitternacht werden unterstützt. Ein "Zeit"-Badge in der Playlist-Zeile zeigt die Einstellung im Tooltip
- **Query-Parameter-Passthrough**: Per Player-URL übergebene Query-Parameter (z. B. `/player/stuttgart/lobby?customer=acme`) können pro Playlist-URL im Edit-Modal aktiviert werden – standardmäßig aus. Link-Icon in der Playlist-Zeile zeigt Status (an/aus) an. - **Query-Parameter-Passthrough**: Per Player-URL übergebene Query-Parameter (z. B. `/player/stuttgart/lobby?customer=acme`) können pro Playlist-URL im Edit-Modal aktiviert werden – standardmäßig aus. Link-Icon in der Playlist-Zeile zeigt Status (an/aus) an.
- Priority-Seite ebenfalls mit Tabs: Playlist und Medien - Priority-Seite ebenfalls mit Tabs: Playlist und Medien
- Dark Mode (localStorage-persistiert) - Dark Mode (localStorage-persistiert)
@@ -52,7 +53,7 @@ Flask App (Server)
│ │ │ ├── bild.jpg │ │ │ ├── bild.jpg
│ │ │ ├── video.mp4 │ │ │ ├── video.mp4
│ │ │ └── welcome.html │ │ │ └── welcome.html
│ └── priority/ │ │ └── priority/
└── generate_welcome_page.py └── generate_welcome_page.py
``` ```
@@ -74,10 +75,13 @@ signage/
├── history.json # Aktivitätsverlauf (Dashboard) ├── history.json # Aktivitätsverlauf (Dashboard)
├── media/ ├── media/
│ ├── <site>/ │ ├── <site>/
│ │ ├── lobby/ │ │ ├── lobby/ # Screen-Ordner (jeder Unterordner = ein Screen!)
│ │ ├── casino/ │ │ ├── casino/
│ │ └── videosysteme/ │ │ ├── videosysteme/
│ └── priority/ │ │ ├── background.jpg # Hintergrundbild der Willkommensseite (optional)
│ │ ├── priority/ # Priority-Medien dieses Standorts
│ │ ├── logo_1.png # hochgeladene Kundenlogos (Slot 1-3)
│ │ └── welcome.html # Standort-Kopie der Willkommensseite
├── templates/ ├── templates/
│ ├── admin.html # Admin-Dashboard (Übersicht) │ ├── admin.html # Admin-Dashboard (Übersicht)
│ ├── admin_dashboard.html # Admin Dashboard (Statistiken + Verlauf) │ ├── admin_dashboard.html # Admin Dashboard (Statistiken + Verlauf)
@@ -174,6 +178,25 @@ docker compose up -d
Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User). Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User).
**Wichtig:** `docker compose up -d` startet nur neu, es baut nicht. Nach Code-Änderungen:
```bash
docker compose up -d --build
```
### Logs
```bash
docker compose logs -f signage
```
Der Container ist so eingestellt, dass dort alles Nötige ankommt:
- `ENV PYTHONUNBUFFERED=1` im Dockerfile – ohne das puffert Python `stdout` blockweise, sobald es kein Terminal ist, und sämtliche `print()`-Ausgaben der App bleiben unsichtbar.
- `gunicorn --access-logfile -` – protokolliert jeden Request. Ohne das ist nicht einmal erkennbar, ob eine Anfrage den Container überhaupt erreicht.
Beides fehlte anfangs und machte die Fehlersuche unnötig schwer.
--- ---
## Routen ## Routen
@@ -184,7 +207,7 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
| `GET /login` | Admin-Login | | `GET /login` | Admin-Login |
| `GET /logout` | Ausloggen | | `GET /logout` | Ausloggen |
| `GET /admin` | Redirect zum ersten konfigurierten Standort | | `GET /admin` | Redirect zum ersten konfigurierten Standort |
| `GET /admin/dashboard` | Admin Dashboard (Statistiken + Verlauf) | | `GET /admin/dashboard` | Admin Dashboard (Statistiken + Verlauf, `?page=<n>`) |
| `GET /admin/<site>` | Admin-Dashboard für einen Standort | | `GET /admin/<site>` | Admin-Dashboard für einen Standort |
| `GET /admin/<site>/priority` | Priority-Playlist (separate Seite) | | `GET /admin/<site>/priority` | Priority-Playlist (separate Seite) |
| `POST /admin/<site>/update/<screen>` | Allgemeine Screen-Einstellungen speichern (Intervall, Newsticker, Bilder/Videos, stay_on_first) | | `POST /admin/<site>/update/<screen>` | Allgemeine Screen-Einstellungen speichern (Intervall, Newsticker, Bilder/Videos, stay_on_first) |
@@ -194,6 +217,7 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
| `POST /admin/<site>/add-url/<screen>` | URL zur Playlist hinzufügen | | `POST /admin/<site>/add-url/<screen>` | URL zur Playlist hinzufügen |
| `POST /admin/<site>/delete/<screen>/<filename>` | Datei löschen | | `POST /admin/<site>/delete/<screen>/<filename>` | Datei löschen |
| `POST /admin/<site>/playlist/<screen>` | Playlist-Reihenfolge speichern (JSON) | | `POST /admin/<site>/playlist/<screen>` | Playlist-Reihenfolge speichern (JSON) |
| `POST /admin/<site>/update-playlist-item/<screen>` | Einzelnen Playlist-Eintrag ändern: URL, Zoom, Passthrough, Zeitsteuerung (JSON) |
| `POST /admin/<site>/delete-screen/<screen>` | Screen + Medien löschen | | `POST /admin/<site>/delete-screen/<screen>` | Screen + Medien löschen |
| `GET /admin/<site>/add-screen?name=<name>` | Neuen Screen anlegen | | `GET /admin/<site>/add-screen?name=<name>` | Neuen Screen anlegen |
| `GET /add-site?name=<name>` | Neuen Standort anlegen | | `GET /add-site?name=<name>` | Neuen Standort anlegen |
@@ -202,7 +226,7 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
| `POST /admin/<site>/delete-background` | Hintergrundbild zurücksetzen | | `POST /admin/<site>/delete-background` | Hintergrundbild zurücksetzen |
| `GET /player/<site>/<screen>` | Player-Ansicht | | `GET /player/<site>/<screen>` | Player-Ansicht |
| `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme (für Auto-Reload) + Heartbeat (Online-Status) | | `GET /playlist/<site>/<screen>/hash` | Playlist-Checksumme (für Auto-Reload) + Heartbeat (Online-Status) |
| `GET /willkommen?site=<site>` | Willkommensseite-Formular (GET + POST) | | `GET /willkommen?site=<site>` | Willkommensseite-Formular (GET + POST) – **ohne Login** |
| `POST /api/customer` | API-Endpunkt für Willkommensseite (JSON) | | `POST /api/customer` | API-Endpunkt für Willkommensseite (JSON) |
| `GET /change-password` | Passwort ändern (First-Login / nach Admin-Reset) | | `GET /change-password` | Passwort ändern (First-Login / nach Admin-Reset) |
| `GET /admin/users` | User-Liste (Admin) | | `GET /admin/users` | User-Liste (Admin) |
@@ -218,7 +242,10 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
| `POST /admin/users/reset-password/<email>` | Passwort-Reset mit temporärem Passwort (Admin) | | `POST /admin/users/reset-password/<email>` | Passwort-Reset mit temporärem Passwort (Admin) |
| `POST /admin/users/edit/<email>` | User bearbeiten speichern (Admin) | | `POST /admin/users/edit/<email>` | User bearbeiten speichern (Admin) |
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern | | `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
| `GET /media/priority/<file>` | Priority-Medien (global) | | `GET /media/<site>/priority/<file>` | Priority-Medien des Standorts |
| `GET /media/<site>/logos/<file>` | Manuell hochgeladenes Kundenlogo |
| `GET /admin/help` | Hilfeseite zur Bedienung |
| `GET /customer` | Alias für `/willkommen` |
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite | | `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
### Heartbeat / Online-Status ### Heartbeat / Online-Status
@@ -260,6 +287,8 @@ http://localhost:5005/admin/dashboard
- 5 Statistik-Cards (Standorte, Screens, User, Admins, Superuser) mit ApexCharts-Sparkline + Trendanzeige - 5 Statistik-Cards (Standorte, Screens, User, Admins, Superuser) mit ApexCharts-Sparkline + Trendanzeige
- Aktivitätsverlauf (History) als Liste mit Zeitstempel, Aktion und User - Aktivitätsverlauf (History) als Liste mit Zeitstempel, Aktion und User
- Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden protokolliert - Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden protokolliert
- **Seitennavigation**: 50 Einträge je Seite (`HISTORY_PER_PAGE`), Aufruf über `?page=<n>`; aufbewahrt werden 1000 Einträge (`HISTORY_MAX`)
- Kennzahlen und Sparklines beziehen sich immer auf die **jüngsten 30 Aktionen** und ändern sich beim Blättern bewusst nicht – sonst würden die Trendpfeile springen, obwohl sich nichts geändert hat
### Player-URL ### Player-URL
@@ -289,7 +318,19 @@ Beispiel:
http://localhost:5005/willkommen?site=stuttgart http://localhost:5005/willkommen?site=stuttgart
``` ```
Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `welcome.html` wird in der Lobby-Playlist vorne eingefügt. Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Fallback auf `static/wallpaper.png`). **Ohne Login erreichbar:** Die Seite ist bewusst nicht durch `@login_required` geschützt, damit auch Mitarbeitende ohne Benutzerkonto eine Begrüßung erstellen können. Ein angemeldeter User sieht dabei weiterhin nur seine freigegebenen Standorte; ohne Anmeldung stehen alle Standorte zur Auswahl, ein unbekannter Standort führt zu 403. Im Aktivitätsverlauf erscheinen solche Einträge als „nicht angemeldet". Die programmatische Variante `POST /api/customer` bleibt angemeldet.
Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `welcome.html` wird in den Playlists aller Zielscreens vorne eingefügt. Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Fallback auf `static/wallpaper.png`).
**Zielscreens:** Jeder Screen mit `welcome_enabled` (Schalter „Willkommensseite hier anzeigen" im Tab Einstellungen). Besitzt kein Screen des Standorts den Schlüssel, greift das alte Verhalten und die Seite geht nur in `lobby`.
**Der Schalter wirkt sofort:** Beim Einschalten wird eine bereits vorhandene Willkommensseite unmittelbar in die Playlist des Screens gesetzt, beim Ausschalten wieder entfernt (samt Datei) – ohne erneutes Generieren. Die Seite ist je Standort identisch, ein zweiter Handgriff wäre also nur Aufwand ohne anderes Ergebnis. Beim Generieren wird zusätzlich eine Standort-Kopie unter `media/<site>/welcome.html` abgelegt, die nie gelöscht wird; dadurch findet der Schalter auch dann noch eine Quelle, wenn die Seite zwischenzeitlich auf allen Screens abgeschaltet war.
**Logo-Upload:** Findet die automatische Suche kein Logo (oder passt es nicht), kann pro Kunde eine Bilddatei hochgeladen werden. Ein Upload hat Vorrang vor der Suche. Die Datei wird als `media/<site>/logo_<slot>.png` abgelegt und über `/media/<site>/logos/<datei>` ausgeliefert. **Bewusst als Datei im Standort-Verzeichnis, nicht in einem Unterordner:** `admin()` behandelt jeden Unterordner von `media/<site>/` als Screen und schreibt ihn dauerhaft in die Konfiguration – ein Ordner `logos` würde also als Phantom-Screen in der Oberfläche auftauchen. Das Hintergrundbild liegt aus demselben Grund als `media/<site>/background.*`. Der Ordnername `logos` ist zusätzlich reserviert und wird bei der Screen-Erkennung übersprungen.
**Freistellen und Zuschneiden:** Beim Upload wird der Hintergrund automatisch entfernt, damit das Logo nicht in einem weissen Kasten auf dem Hintergrundbild steht. Anschliessend werden die durchsichtigen Ränder weggeschnitten (`trim_transparent_border`). Ohne diesen Schnitt erscheint ein hochgeladenes Logo kleiner als ein automatisch gefundenes: die Willkommensseite skaliert mit `object-fit: contain` auf eine feste Höhe von 120px, und ein breiter Rand zählt dabei mit – Brandfetch-Logos sind bereits eng beschnitten. Verfahren: Flood Fill von den Bildrändern her (bewusst kein globaler Farbvergleich – so bleibt Weiss *innerhalb* des Logos erhalten), weiche Kante gegen Treppeneffekte, Speicherung immer als PNG. Bereits transparente Bilder werden unverändert übernommen; ist der Bildrand nicht einfarbig, bleibt das Bild unangetastet und die Oberfläche weist darauf hin. Dasselbe gilt, wenn beim Freistellen fast nichts übrig bliebe – ein helles Logo auf hellem Grund liegt innerhalb der Farbtoleranz und würde sonst vollständig verschwinden, was auf der Willkommensseite als „kein Logo" ankäme. Mit der Checkbox „Hintergrund behalten" lässt sich das Freistellen pro Kunde abschalten. Umgesetzt mit Pillow – bewusst ohne ML-Modell, da der Container mit read-only Root-FS läuft und kein Modell-Cache zur Verfügung steht.
**Anzeigen bis:** Optionales Datum im Formular. Ist es gesetzt, bekommt der Eintrag eine Zeitsteuerung (`daily 00:00–23:59` mit `valid_until`) und verschwindet danach auf allen Screens von selbst.
**Ansprechpartner:** Pro Kunde können Kontaktdaten eingegeben werden (Markdown-Format). **Ansprechpartner:** Pro Kunde können Kontaktdaten eingegeben werden (Markdown-Format).
- **Fett**: `**Text**` - **Fett**: `**Text**`
@@ -390,10 +431,6 @@ OPENAI_API_KEY=sk-...
"background_url": null "background_url": null
} }
} }
},
"priority": {
"enabled": true,
"playlist": [...]
} }
} }
``` ```
@@ -404,7 +441,7 @@ OPENAI_API_KEY=sk-...
| Feld | Typ | Beschreibung | | Feld | Typ | Beschreibung |
|------|-----|-------------| |------|-----|-------------|
| `playlist` | Array | Playlist-Einträge (Strings oder Dicts mit `url`/`zoom`) | | `playlist` | Array | Playlist-Einträge (Strings oder Dicts, siehe [Playlist-Einträge](#playlist-eintraege)) |
| `interval` | int | Anzeige-Intervall in Sekunden | | `interval` | int | Anzeige-Intervall in Sekunden |
| `show_images` | bool | Bilder anzeigen | | `show_images` | bool | Bilder anzeigen |
| `show_videos` | bool | Videos anzeigen | | `show_videos` | bool | Videos anzeigen |
@@ -416,12 +453,100 @@ OPENAI_API_KEY=sk-...
| `custom_url_target` | string | `"overlay"` (iframe) oder `"redirect"` | | `custom_url_target` | string | `"overlay"` (iframe) oder `"redirect"` |
| `custom_url_position` | string | Position des Buttons: `top-left`, `top-center`, `top-right`, `middle-left`, `middle-center`, `middle-right`, `bottom-left`, `bottom-center`, `bottom-right` | | `custom_url_position` | string | Position des Buttons: `top-left`, `top-center`, `top-right`, `middle-left`, `middle-center`, `middle-right`, `bottom-left`, `bottom-center`, `bottom-right` |
| `stay_on_first` | bool | Player bleibt auf erstem Element stehen (kein Refresh) | | `stay_on_first` | bool | Player bleibt auf erstem Element stehen (kein Refresh) |
| `welcome_enabled` | bool | Willkommensseite wird auf diesem Screen vorne in die Playlist gesetzt |
| `voice_agent_enabled` | bool | Voice-Agent-Button im Player anzeigen | | `voice_agent_enabled` | bool | Voice-Agent-Button im Player anzeigen |
| `voice_agent_show_image` | bool | Bild (dva.png) über dem Button anzeigen | | `voice_agent_show_image` | bool | Bild (dva.png) über dem Button anzeigen |
| `voice_agent_label` | string | Button-Beschriftung (Default: "Digitaler Assistent") | | `voice_agent_label` | string | Button-Beschriftung (Default: "Digitaler Assistent") |
| `voice_agent_target` | string | `"overlay"` (iframe) oder `"redirect"` | | `voice_agent_target` | string | `"overlay"` (iframe) oder `"redirect"` |
| `voice_agent_position` | string | Position des Buttons: `top-left`, `top-center`, `top-right`, `middle-left`, `middle-center`, `middle-right`, `bottom-left`, `bottom-center`, `bottom-right` | | `voice_agent_position` | string | Position des Buttons: `top-left`, `top-center`, `top-right`, `middle-left`, `middle-center`, `middle-right`, `bottom-left`, `bottom-center`, `bottom-right` |
## Priority-Playlist (pro Standort)
Liegt in `config.json` unter `sites.<site>.priority`, die Medien unter `media/<site>/priority/`:
```jsonc
"sites": {
"stuttgart": {
"priority": {
"enabled": true, // schaltet die Priority-Playlist dieses Standorts
"playlist": [ ... ] // gleiches Eintragsformat wie eine Screen-Playlist
},
"screens": { ... }
}
}
```
Die Einträge laufen auf allen Screens **dieses** Standorts, eingestreut zwischen den normalen Playlist-Einträgen. Ein anderer Standort ist davon nicht betroffen.
**Vorschau in den Screen-Playlisten:** Ist Priority für den Standort aktiviert, erscheinen die Einträge zusätzlich über jeder Screen-Playlist – ausgegraut, mit rotem „Prio"-Badge und ohne Bedienelemente. Sie liegen bewusst in einer **eigenen `<ul>`**, damit das Speichern der Reihenfolge (`savePlaylist()`) sie nicht mit einsammelt und in die Screen-Playlist schreibt.
> Früher lag die Priority-Playlist global unter `config.priority` und wirkte auf *alle* Standorte. `get_priority_config()` übernimmt einen solchen Altbestand beim ersten Zugriff einmalig in den jeweiligen Standort.
Die Ordnernamen `priority` und `logos` sind unter `media/<site>/` **reserviert** und werden bei der Screen-Erkennung übersprungen – sonst würden sie als Screens in der Oberfläche auftauchen.
---
<a id="playlist-eintraege"></a>
## Playlist-Einträge
Ein Eintrag ist entweder ein String (Dateiname oder URL, Altbestand) oder ein Dict:
```jsonc
// URL-Eintrag
{
"url": "https://example.com",
"zoom": 1.0, // Zoom-Faktor im Player
"enabled": true, // Checkbox in der Playlist (nur der Schalter, nicht das Zeitfenster)
"passthrough_params": false, // Query-Parameter der Player-URL durchreichen
"schedule": { ... } // optional, siehe unten
}
// Datei-Eintrag
{
"name": "bild.jpg",
"enabled": true,
"schedule": { ... } // optional
}
```
### Zeitsteuerung (`schedule`)
Optional pro Eintrag. Ohne `schedule` läuft der Eintrag immer (sofern `enabled`).
| Feld | Typ | Beschreibung |
|------|-----|-------------|
| `mode` | string | `"once"`, `"daily"` oder `"weekly"` |
| `start` / `end` | string | Bei `once` voller Zeitstempel (`"2026-09-10T08:00"`), bei `daily`/`weekly` nur die Uhrzeit (`"08:00"`) |
| `days` | Array | Nur bei `weekly`: Wochentage als Strings, `"0"` = Montag … `"6"` = Sonntag |
| `valid_from` | string | Optional, nur bei `daily`/`weekly`: Datum (`"2026-09-10"`), ab dem das Fenster gilt – inklusiv |
| `valid_until` | string | Optional, nur bei `daily`/`weekly`: Datum, bis zu dem das Fenster gilt – inklusiv |
```jsonc
// Täglich 08:00–12:00, nur vom 10.09. bis 20.09.2026
{ "mode": "daily", "start": "08:00", "end": "12:00",
"valid_from": "2026-09-10", "valid_until": "2026-09-20" }
// Mo/Mi/Fr von 09:00 bis 17:00, unbefristet
{ "mode": "weekly", "start": "09:00", "end": "17:00", "days": ["0", "2", "4"] }
// Einmalig, durchgehend über mehrere Tage
{ "mode": "once", "start": "2026-09-10T08:00", "end": "2026-09-20T12:00" }
```
**Verhaltensregeln:**
- Bei `daily` und `weekly` zählt **nur die Uhrzeit** von `start`/`end`. Ein Gültigkeitszeitraum wird über `valid_from`/`valid_until` gesetzt, nicht über den Datumsanteil.
- Bei `daily` sind **alle Wochentage** gemeint; `days` wird in diesem Modus nicht ausgewertet.
- Ist `start` > `end`, läuft das Fenster **über Mitternacht** (z. B. `22:00`–`02:00`). Ein solches Fenster gehört zum Wochentag bzw. Gültigkeitstag seines **Starts**: „Mi 22:00–02:00" läuft Mittwochabend bis Donnerstagfrüh.
- Unlesbare oder unvollständige Zeitangaben blenden den Eintrag **nicht** aus – ein Konfigurationsfehler soll nicht dazu führen, dass stillschweigend nichts mehr läuft.
- `_schedule_time()` liest neben `"08:00"` auch volle ISO-Zeitstempel, damit Einträge aus älteren Versionen unverändert weiterlaufen.
- Der Aktiv-Zustand fliesst in `/playlist/<site>/<screen>/hash` ein, damit der Player beim Öffnen und Schliessen eines Zeitfensters von selbst neu lädt.
- **Checkbox und Zeitfenster sind getrennt:** Die Checkbox in der Playlist-Zeile zeigt ausschliesslich den gespeicherten Schalter `enabled`, nicht den momentanen Zeitfenster-Zustand. Sie bleibt also gesetzt, auch wenn ein zeitgesteuerter Eintrag gerade nicht läuft. Ob er gerade läuft, sagt das „Zeit"-Badge: blau = läuft, grau = ausserhalb des Fensters (Tooltip nennt den Grund). Abgespielt wird nur, was **beides** erfüllt – Haken gesetzt *und* im Zeitfenster.
> Bis Version 6.2.4 zeigte die Checkbox den ausgewerteten Zustand. Wurde die Playlist ausserhalb des Zeitfensters gespeichert, schrieb die Oberfläche `enabled: false` in die Konfiguration und der Eintrag war dauerhaft abgeschaltet. Der Fehler ist behoben, wirkt aber nicht rückwirkend: bereits betroffene Einträge müssen einmal von Hand wieder aktiviert werden.
---
## Helper-Funktionen (`app.py`) ## Helper-Funktionen (`app.py`)
- `load_config()` / `save_config()` – JSON lesen/schreiben - `load_config()` / `save_config()` – JSON lesen/schreiben
@@ -430,8 +555,21 @@ OPENAI_API_KEY=sk-...
- `is_url(item)` – prüft ob Item eine URL ist - `is_url(item)` – prüft ob Item eine URL ist
- `normalize_url(item)` – normalisiert URL-Item zu `{"url", "zoom"}` - `normalize_url(item)` – normalisiert URL-Item zu `{"url", "zoom"}`
- `playlist_item_name(item)` – Name aus String oder Dict extrahieren - `playlist_item_name(item)` – Name aus String oder Dict extrahieren
- `playlist_item_enabled(item)` – Enabled-Status prüfen - `is_item_active(item)` – prüft `enabled` **und** die Zeitsteuerung
- `load_priority_files()` – Priority-Playlist + Dateien laden - `_schedule_time(value)` – Uhrzeit aus `"HH:MM"` oder vollem ISO-Zeitstempel lesen
- `_window_start_date(now, start, end)` – Kalendertag, an dem das laufende Zeitfenster begann (`None` = ausserhalb)
- `_in_validity_range(schedule, day)` – optionalen Gültigkeitszeitraum prüfen
- `schedule_text(schedule)` – Jinja-Filter: Klartext einer Zeitsteuerung für Tooltips
- `load_priority_files(site)` – Priority-Playlist eines Standorts + Dateien laden
- `_load_history()` / `get_history(limit, offset)` / `get_history_count()` – Aktivitätsverlauf lesen, seitenweise ausschneiden, zählen
- `get_priority_config(cfg, site)` – Priority-Konfiguration des Standorts (übernimmt einmalig einen globalen Altbestand)
- `priority_dir(site)` – Pfad zu `media/<site>/priority/`
- `welcome_target_screens(config, site)` – Zielscreens der Willkommensseite (Fallback `lobby`)
- `save_uploaded_logo(file, site, slot, freistellen=True)` – hochgeladenes Logo freistellen und als PNG ablegen
- `remove_logo_background(image)` – Hintergrund per Flood Fill entfernen, gibt `(bild, hinweis)` zurück
- `trim_transparent_border(img)` – durchsichtige Ränder wegschneiden, damit das Logo den Anzeigebereich ausfüllt
- `sync_welcome_for_screen(config, site, screen, enabled)` – Willkommensseite beim Umlegen des Schalters platzieren bzw. entfernen
- `find_welcome_source(site, config)` – vorhandene `welcome.html` als Kopiervorlage finden
- `prio_redirect(site)` – Redirect-Pfad zur Priority-Seite - `prio_redirect(site)` – Redirect-Pfad zur Priority-Seite
- `get_background_url(site)` – URL zum benutzerdefinierten Hintergrundbild oder `None` - `get_background_url(site)` – URL zum benutzerdefinierten Hintergrundbild oder `None`
- `load_users()` / `save_users()` / `get_user()` – User aus `users.json` - `load_users()` / `save_users()` / `get_user()` – User aus `users.json`
@@ -453,6 +591,7 @@ Wird über `localStorage("signage-theme")` persistiert. Umschalt-Button im Heade
## Sicherheit ## Sicherheit
- Admin-Bereich per Flask-Login geschützt (E-Mail + Passwort) - Admin-Bereich per Flask-Login geschützt (E-Mail + Passwort)
- **Ohne Login erreichbar** sind: Player und Medien (die Abspielgeräte melden sich nicht an) sowie das Formular der Willkommensseite. Letzteres legt Dateien an, schreibt in die Konfiguration und löst kostenpflichtige Logo-Abfragen aus – bei einer aus dem Internet erreichbaren Instanz empfiehlt sich eine Einschränkung auf das interne Netz im Reverse Proxy
- Optionale MFA (TOTP) per User aktivierbar (Google Authenticator, Authy) - Optionale MFA (TOTP) per User aktivierbar (Google Authenticator, Authy)
- 8 Recovery-Codes pro User, einmalig bei Aktivierung angezeigt - 8 Recovery-Codes pro User, einmalig bei Aktivierung angezeigt
- Passwörter gehasht (scrypt) in `users.json` - Passwörter gehasht (scrypt) in `users.json`

719
app.py

File diff suppressed because it is too large Load Diff

View File

@@ -1,16 +1,6 @@
{ {
"server_url": "http://localhost:5005", "server_url": "http://localhost:5005",
"voice_agent_url": "https://dva-stage.buds.cancom.ai/", "voice_agent_url": "https://dva-stage.buds.cancom.ai/",
"priority": {
"enabled": true,
"playlist": [
{
"url": "https://wbxroompresence.cancom.io/standort?find=Stuttgart",
"zoom": 0.8,
"enabled": false
}
]
},
"sites": { "sites": {
"stuttgart": { "stuttgart": {
"screens": { "screens": {
@@ -67,11 +57,13 @@
"enabled": true "enabled": true
} }
], ],
"newsticker_text": "Herzlich willkommen bei der CANCOM - wir w\u00fcnschen ihnen einen sch\u00f6nen Tag", "newsticker_text": "",
"newsticker_enabled": true, "newsticker_enabled": false,
"custom_url": "https://www.meteoblue.com/en/meteotv/d7b0fd", "custom_url": "https://www.meteoblue.com/en/meteotv/d7b0fd",
"custom_url_label": "Wetter", "custom_url_label": "Wetter",
"custom_url_enabled": false "custom_url_enabled": false,
"stay_on_first": false,
"welcome_enabled": false
}, },
"videosysteme": { "videosysteme": {
"interval": 20, "interval": 20,
@@ -93,6 +85,25 @@
"https://cdn.brandfetch.io/www.ibm.com/logo?c=1idyd4Tpb2nKaXIIc8T", "https://cdn.brandfetch.io/www.ibm.com/logo?c=1idyd4Tpb2nKaXIIc8T",
"https://cdn.brandfetch.io/www.soliver.com/logo?c=1idyd4Tpb2nKaXIIc8T" "https://cdn.brandfetch.io/www.soliver.com/logo?c=1idyd4Tpb2nKaXIIc8T"
] ]
},
"priority": {
"enabled": true,
"playlist": [
{
"url": "https://wbxroompresence.cancom.io/standort?find=Stuttgart",
"zoom": 0.8,
"enabled": false,
"passthrough_params": false,
"schedule": {
"mode": "weekly",
"start": "08:00",
"end": "09:00",
"days": [
"0"
]
}
}
]
} }
}, },
"karlsruhe": { "karlsruhe": {
@@ -115,6 +126,25 @@
"custom_url_target": "overlay", "custom_url_target": "overlay",
"brand_symbol_enabled": false, "brand_symbol_enabled": false,
"stay_on_first": false "stay_on_first": false
},
"videosysteme": {
"playlist": [
{
"url": "https://wbxroompresence.cancom.io/signage",
"zoom": 1.0,
"passthrough_params": true,
"enabled": true
},
{
"url": "https://www.meteoblue.com/en/meteotv/d7b0fd",
"zoom": 1.0,
"passthrough_params": false,
"enabled": true
}
],
"interval": 10,
"show_images": true,
"show_videos": true
} }
}, },
"welcome_data": { "welcome_data": {
@@ -124,6 +154,98 @@
"logo_urls": [ "logo_urls": [
"https://cdn.brandfetch.io/weisenburger.de/logo?c=1idyd4Tpb2nKaXIIc8T" "https://cdn.brandfetch.io/weisenburger.de/logo?c=1idyd4Tpb2nKaXIIc8T"
] ]
},
"priority": {
"enabled": true,
"playlist": [
{
"url": "https://wbxroompresence.cancom.io/standort?find=Stuttgart",
"zoom": 0.8,
"enabled": false,
"passthrough_params": false,
"schedule": {
"mode": "weekly",
"start": "08:00",
"end": "09:00",
"days": [
"0"
]
}
}
]
}
},
"test": {
"screens": {
"vorstand": {
"playlist": [
{
"name": "welcome.html",
"enabled": true
},
{
"name": "Artikelbild_Weindorf.jpg",
"enabled": true
}
],
"interval": 10,
"show_images": true,
"show_videos": true,
"newsticker_text": "",
"newsticker_enabled": false,
"stay_on_first": false,
"welcome_enabled": true
},
"casino": {
"playlist": [],
"interval": 10,
"show_images": true,
"show_videos": true,
"newsticker_text": "",
"newsticker_enabled": false,
"stay_on_first": false,
"welcome_enabled": false
}
},
"welcome_data": {
"names": [
"WIN",
"IBM",
"Gaufer"
],
"logo_urls": [
"/media/test/logos/logo_1.png?v=1788363702",
"https://cdn.brandfetch.io/www.ibm.com/logo?c=1idyd4Tpb2nKaXIIc8T",
"/media/test/logos/logo_3.png?v=1788364382"
],
"contacts": [
"",
"",
""
]
},
"priority": {
"enabled": true,
"playlist": [
{
"url": "https://wbxroompresence.cancom.io/standort?find=Stuttgart",
"zoom": 0.8,
"enabled": false,
"passthrough_params": false,
"schedule": {
"mode": "weekly",
"start": "08:00",
"end": "09:00",
"days": [
"0"
]
}
},
{
"name": "IMG_6249.jpeg",
"enabled": true
}
]
} }
} }
}, },

View File

@@ -18,5 +18,15 @@ services:
- ALL - ALL
security_opt: security_opt:
- no-new-privileges:true - no-new-privileges:true
volumes:
# Ohne diese Mounts liegt /app auf dem read-only Root-FS und die App
# kann weder Konfiguration noch Verlauf noch Medien schreiben.
- ./media:/app/media
- ./config.json:/app/config.json
- ./users.json:/app/users.json
- ./history.json:/app/history.json
environment: environment:
- OPENAI_API_KEY=${OPENAI_API_KEY:-} - OPENAI_API_KEY=${OPENAI_API_KEY:-}
# Zeitzone: die Zeitsteuerung der Playlist wertet die Serverzeit aus,
# ohne TZ laeuft der Container in UTC und Zeitfenster verschieben sich.
- TZ=Europe/Berlin

Binary file not shown.

4
docs/cancom.svg Executable file
View File

@@ -0,0 +1,4 @@
<?xml version="1.0"?>
<!-- Created with xTool Creative Space (https://www.xtool.com/pages/software) -->
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:xcs="https://www.xtool.com/pages/software" version="1.1" preserveAspectRatio="xMinYMin meet" width="61.682394106284505mm" height="9.909010347638684mm" viewBox="136.55880294685778 209.2954948261807 61.682394106284505 9.909010347638684" xcs:version="2.7.22"><style></style>
<path transform="matrix(0.158038,0,0,0.158038,133.2558,163.322122)" stroke="none" fill="#ffffff" data-view-type="laser" d="M125.9,352.1h18.9L119,291.7h-16.2l-25,60.4h18.2l14.6-38.9L125.9,352.1z M323.6,322 c0,8.8-6.1,16.4-15.4,16.4c-9.1,0-15.4-7.6-15.4-16.4s6.1-16.4,15.2-16.4C317,305.6,323.6,313.4,323.6,322 M340.5,322.3 c0-17.4-13.4-31.3-32.3-31.3c-18.7,0-32.3,14.1-32.3,31.3c0,17.4,13.4,31.3,32.3,31.3C326.9,353.6,340.5,339.4,340.5,322.3 M230.4,322.5c0-8.8,6.1-16.4,14.9-16.4c3,0,5.8,0.8,8.1,2l6.6-13.4c-3-2-8.1-3.8-14.9-3.8c-18.2,0-31.6,14.1-31.6,31.3 c0,18.2,12.6,31.1,31.6,31.1c17.2,0,26-10.6,28.8-21l-13.6-5.6c-1.8,5.8-6.3,11.6-14.4,11.6C236.5,338.4,230.4,331.4,230.4,322.5 M38,322.5c0-8.8,6.1-16.4,14.9-16.4c3,0,5.8,0.8,8.1,2l6.6-13.4c-3-2-8.1-3.8-14.9-3.8c-18.4,0-31.8,14.1-31.8,31.3 c0,18.2,12.6,31.1,31.6,31.1c17.2,0,26-10.6,28.8-21l-13.6-5.6c-1.8,5.8-6.3,11.6-14.4,11.6C44,338.4,38,331.4,38,322.5 M191.8,352.1h14.4v-59.8H190v33.1l-25-33.1h-15.4v60.1h16.2v-34.1L191.8,352.1z M411.2,352.1v-59.8h-17.7l-14.4,23.2l-14.4-23.2 h-17.7v60.1h16.4V318l15.4,23.2h0.3l15.4-23.5v34.6L411.2,352.1L411.2,352.1z" fill-rule="nonzero"></path></svg>

After

Width:  |  Height:  |  Size: 1.6 KiB

383
docs/onepager.html Normal file
View File

@@ -0,0 +1,383 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>CANCOM Simple Signage – Überblick</title>
<style>
@page { size: A4; margin: 12mm 13mm 11mm 13mm; }
:root {
--rot: #DA002D;
--dunkel: #2b2f36;
--grau: #5b6472;
--hellgrau: #f2f4f7;
--linie: #dfe3e9;
}
* { box-sizing: border-box; }
html, body {
margin: 0; padding: 0;
font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
font-size: 9.1pt;
line-height: 1.42;
color: var(--dunkel);
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
.seite { page-break-after: always; }
.seite:last-child { page-break-after: auto; }
/* ── Kopf ── */
.kopf {
display: flex; align-items: center; gap: 10mm;
border-bottom: 2.5pt solid var(--rot);
padding-bottom: 3.5mm; margin-bottom: 5mm;
}
.logobox { background: var(--dunkel); padding: 2.5mm 4mm; border-radius: 1.2mm; }
.logobox img { height: 6.5mm; display: block; }
.kopf .titel { margin-left: auto; text-align: right; }
.kopf h1 { font-size: 16pt; margin: 0; letter-spacing: -0.2pt; }
.kopf .unter { font-size: 8.4pt; color: var(--grau); margin-top: 0.8mm; }
.kopf-klein {
display: flex; align-items: baseline;
border-bottom: 1.2pt solid var(--linie);
padding-bottom: 2.5mm; margin-bottom: 4.5mm;
}
.kopf-klein h2 { font-size: 12.5pt; margin: 0; color: var(--rot); }
.kopf-klein .seitezahl { margin-left: auto; font-size: 8pt; color: var(--grau); }
/* ── Einleitung ── */
.intro {
background: var(--hellgrau);
border-left: 3pt solid var(--rot);
padding: 3mm 4mm; margin-bottom: 5mm;
font-size: 9.4pt;
}
.intro strong { color: var(--rot); }
/* ── Raster ── */
.raster { display: grid; grid-template-columns: 1fr 1fr; gap: 4mm 6mm; }
.raster-3 { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 3mm 5mm; }
h3 {
font-size: 9.6pt; margin: 0 0 1.2mm 0;
color: var(--dunkel);
display: flex; align-items: center; gap: 2mm;
}
h3::before {
content: ""; display: inline-block;
width: 3mm; height: 3mm; border-radius: 0.6mm;
background: var(--rot); flex: none;
}
h3.grau::before { background: var(--grau); }
p { margin: 0 0 1.6mm 0; }
.block { margin-bottom: 3.6mm; }
ul { margin: 0.5mm 0 0 0; padding-left: 4.2mm; }
li { margin-bottom: 0.7mm; }
.klein { font-size: 8.2pt; color: var(--grau); }
code {
font-family: "SF Mono", Menlo, Consolas, monospace;
font-size: 8.2pt;
background: var(--hellgrau);
padding: 0.3mm 1mm; border-radius: 0.8mm;
}
/* ── Ablauf-Schritte ── */
.schritte { display: grid; grid-template-columns: repeat(4, 1fr); gap: 3mm; margin-bottom: 5mm; }
.schritt {
border: 0.8pt solid var(--linie); border-radius: 1.5mm;
padding: 2.5mm 3mm; position: relative;
}
.schritt .nr {
display: inline-block; width: 5mm; height: 5mm; line-height: 5mm;
text-align: center; border-radius: 50%;
background: var(--rot); color: #fff;
font-size: 8pt; font-weight: bold; margin-bottom: 1.5mm;
}
.schritt b { display: block; font-size: 9pt; margin-bottom: 0.8mm; }
.schritt span { font-size: 8.2pt; color: var(--grau); line-height: 1.35; display: block; }
/* ── Tabelle ── */
table { width: 100%; border-collapse: collapse; font-size: 8.5pt; }
th, td { text-align: left; padding: 1.4mm 2mm; border-bottom: 0.6pt solid var(--linie); vertical-align: top; }
th { background: var(--hellgrau); font-size: 8.2pt; }
td:first-child { white-space: nowrap; font-weight: 600; }
/* ── Hinweiskasten ── */
.kasten {
border: 0.8pt solid var(--linie); border-radius: 1.5mm;
padding: 3mm 3.5mm; background: #fcfcfd;
}
.kasten h3 { margin-bottom: 1.8mm; }
.warn {
border-left: 3pt solid #f59f00; background: #fff9e6;
padding: 2.5mm 3.5mm; border-radius: 0 1.5mm 1.5mm 0;
margin-bottom: 4mm;
}
.badge {
display: inline-block; padding: 0.2mm 1.4mm; border-radius: 0.8mm;
font-size: 7.6pt; font-weight: 600; vertical-align: 0.3mm;
}
.b-rot { background: var(--rot); color: #fff; }
.b-blau { background: #206bc4; color: #fff; }
.b-grau { background: #e4e7ec; color: #555e6d; }
.b-gruen { background: #2fb344; color: #fff; }
/* ── Fuß ── */
.fuss {
border-top: 0.6pt solid var(--linie);
margin-top: 4mm; padding-top: 1.5mm;
font-size: 7.4pt; color: var(--grau);
display: flex;
}
.fuss .rechts { margin-left: auto; }
</style>
</head>
<body>
<!-- ══════════════ VORDERSEITE ══════════════ -->
<div class="seite">
<div class="kopf">
<span class="logobox"><img src="cancom.svg" alt="CANCOM"></span>
<div class="titel">
<h1>Simple Signage</h1>
<div class="unter">Infobildschirme selbst befüllen – ohne IT-Kenntnisse</div>
</div>
</div>
<div class="intro">
<strong>Was ist das?</strong> Simple Signage zeigt Bilder, Videos, Webseiten und Begrüßungen auf den
Info-Bildschirmen im Haus. Sie bestimmen im Browser, was wann auf welchem Bildschirm läuft – die
Änderung ist binnen Sekunden auf dem Schirm. Es wird nichts installiert: Sie brauchen nur einen Browser,
Ihre E-Mail-Adresse und Ihr Passwort.
</div>
<div class="schritte">
<div class="schritt">
<span class="nr">1</span>
<b>Anmelden</b>
<span>Browser öffnen, E-Mail und Passwort eingeben. Sie sehen nur die Standorte, für die Sie freigeschaltet sind.</span>
</div>
<div class="schritt">
<span class="nr">2</span>
<b>Bildschirm wählen</b>
<span>Jeder Bildschirm („Screen“) hat eine eigene Kachel mit seiner Abspielliste.</span>
</div>
<div class="schritt">
<span class="nr">3</span>
<b>Inhalte einstellen</b>
<span>Bilder oder Videos hochladen, Webseiten als Link eintragen, Reihenfolge per Maus ziehen.</span>
</div>
<div class="schritt">
<span class="nr">4</span>
<b>Fertig</b>
<span>Es gibt keinen Speichern-Knopf für die Liste – der Bildschirm übernimmt die Änderung von selbst.</span>
</div>
</div>
<div class="raster">
<div class="block">
<h3>Standort und Bildschirm</h3>
<p>Ein <b>Standort</b> ist ein Haus oder eine Niederlassung, zum Beispiel Stuttgart. Darin liegen die
einzelnen <b>Bildschirme</b> – Empfang, Kantine, Flur. Oben im Kopf wechseln Sie den Standort.</p>
<p>Jeder Bildschirm hat seine eigene Abspielliste. Was Sie am Empfang ändern, betrifft die Kantine nicht.</p>
</div>
<div class="block">
<h3>Die Abspielliste (Playlist)</h3>
<p>Die Liste wird von oben nach unten durchlaufen und beginnt danach von vorn. Pro Eintrag haben Sie:</p>
<ul>
<li><b>Haken</b> – Eintrag läuft mit oder pausiert</li>
<li><b>Griff links</b> – mit der Maus an eine andere Stelle ziehen</li>
<li><b>Stift</b> – Einstellungen des Eintrags ändern</li>
<li><b>Papierkorb</b> – Eintrag und Datei entfernen</li>
</ul>
</div>
<div class="block">
<h3>Was Sie anzeigen können</h3>
<table>
<tr><td>Bilder</td><td>JPG und PNG, am besten in Bildschirmgröße (1920×1080)</td></tr>
<tr><td>Videos</td><td>MP4, laufen ohne Ton</td></tr>
<tr><td>Webseiten</td><td>Als Adresse eintragen, z.&nbsp;B. ein Dashboard oder Intranet</td></tr>
<tr><td>Begrüßung</td><td>Wird im Formular erzeugt, siehe Rückseite</td></tr>
</table>
</div>
<div class="block">
<h3>Zeitsteuerung</h3>
<p>Jeder Eintrag kann auf ein Zeitfenster gelegt werden – <b>einmalig</b> (von Datum bis Datum),
<b>täglich</b> oder <b>wöchentlich</b> an bestimmten Wochentagen. Außerhalb des Fensters wird er
einfach übersprungen.</p>
<p class="klein">Typisch: Das Kantinenmenü nur von 11 bis 14 Uhr, der Veranstaltungshinweis nur bis zum Termin.</p>
</div>
<div class="block">
<h3>Haken und Zeit sind zweierlei</h3>
<p>Der <b>Haken</b> ist Ihr Schalter: gesetzt heißt „soll laufen“. Er bleibt gesetzt, auch wenn gerade
keine Sendezeit ist.</p>
<p>Das <b>Zeit-Badge</b> sagt, ob es gerade so weit ist:
<span class="badge b-blau">Zeit</span> läuft jetzt &nbsp;·&nbsp;
<span class="badge b-grau">Zeit</span> wartet auf sein Fenster.</p>
<p class="klein">Zu sehen ist ein Eintrag nur, wenn beides zutrifft: Haken gesetzt <i>und</i> im Zeitfenster.</p>
</div>
<div class="block">
<h3>Läuft der Bildschirm?</h3>
<p>Neben jeder Bildschirm-Kachel steht <span class="badge b-gruen">Online</span> oder
<span class="badge b-rot">Offline</span>. Grün heißt: Das Gerät hat sich in der letzten Minute gemeldet.</p>
<p class="klein">Bleibt es rot, ist meist das Gerät aus oder vom Netz getrennt – im Tab „Info“ stehen die
letzten Angaben dazu.</p>
</div>
</div>
<div class="kasten" style="margin-top:1mm;">
<h3 class="grau">So sieht ein typischer Arbeitstag damit aus</h3>
<div class="raster-3" style="margin-top:1.5mm;">
<div>
<b>Morgens: Besuch kündigt sich an</b>
<p class="klein">Unter „Willkommen“ den Firmennamen eintragen, Datum bis wann, fertig. Der Empfangsbildschirm
begrüßt den Gast, danach verschwindet die Seite von selbst.</p>
</div>
<div>
<b>Mittags: Speiseplan</b>
<p class="klein">Das Plakat als Bild hochladen und auf täglich 11–14 Uhr stellen. Ab dann kümmert es sich
jeden Tag von allein.</p>
</div>
<div>
<b>Kurzfristig: Hinweis für alle</b>
<p class="klein">„Aufzug außer Betrieb“ in die Priority-Liste – der Hinweis läuft sofort auf allen
Bildschirmen des Standorts mit.</p>
</div>
</div>
</div>
<div class="fuss">
<div>CANCOM Simple Signage – Kurzüberblick für Anwender</div>
<div class="rechts">Vorderseite &nbsp;·&nbsp; bitte wenden →</div>
</div>
</div>
<!-- ══════════════ RÜCKSEITE ══════════════ -->
<div class="seite">
<div class="kopf-klein">
<h2>Weitere Funktionen</h2>
<div class="seitezahl">CANCOM Simple Signage</div>
</div>
<div class="raster">
<div class="block">
<h3>Begrüßungsseite für Besuch</h3>
<p>Über den Menüpunkt <b>Willkommen</b> tragen Sie bis zu drei Firmennamen ein. Das Logo wird automatisch
gesucht; finden Sie es nicht passend, laden Sie selbst eines hoch. Dazu je ein Feld für den
Ansprechpartner.</p>
<ul>
<li>Die Seite erscheint ganz vorn auf allen Bildschirmen, die dafür eingestellt sind</li>
<li><b>Anzeigen bis:</b> Datum eintragen, dann verschwindet sie von selbst</li>
<li>Diese Seite können Sie auch <b>ohne Anmeldung</b> aufrufen – gedacht für den Empfang</li>
</ul>
</div>
<div class="block">
<h3>Priority-Liste: für alle gleichzeitig</h3>
<p>Was in die Priority-Liste eines Standorts kommt, läuft auf <b>allen</b> Bildschirmen dieses
Standorts mit – ohne dass Sie jede Liste einzeln anfassen. Gedacht für Ankündigungen, die alle
angehen.</p>
<p>In den normalen Listen sehen Sie diese Einträge grau hinterlegt mit dem Vermerk
<span class="badge b-rot">Prio</span>. Dort sind sie nur zur Ansicht – geändert wird im Menüpunkt
<b>Priority</b>.</p>
</div>
<div class="block">
<h3>Knöpfe auf dem Bildschirm</h3>
<p>Ist der Bildschirm berührungsempfindlich, lässt sich ein Knopf einblenden, der eine Seite öffnet –
etwa ein Formular oder den Lageplan. Position frei wählbar, mit Zurück-Knopf.</p>
<p>Ebenso der <b>Digital Voice Agent</b>: ein Sprachassistent, den Besucher selbst starten können.</p>
</div>
<div class="block">
<h3>Laufschrift und Ansicht</h3>
<ul>
<li><b>Newsticker:</b> Laufschrift am unteren Rand, pro Bildschirm</li>
<li><b>Stehenbleiben:</b> Der Bildschirm zeigt dauerhaft nur den ersten Eintrag, statt zu wechseln</li>
<li><b>Anzeigedauer:</b> Wie lange ein Bild stehen bleibt, bevor das nächste kommt</li>
</ul>
</div>
<div class="block">
<h3>Ihr Zugang</h3>
<ul>
<li>Beim ersten Anmelden vergeben Sie ein eigenes Passwort</li>
<li>Passwort vergessen? Ein Administrator setzt es zurück</li>
<li><b>Zwei-Faktor:</b> freiwillig einschaltbar – zusätzlich zum Passwort ein Zahlencode aus der
Authenticator-App. Ihr eigenes Gerät können Sie für bis zu 30 Tage merken lassen, dann entfällt
die Codeeingabe</li>
</ul>
</div>
<div class="block">
<h3>Wer darf was</h3>
<table>
<tr><td>Anwender</td><td>Pflegt die Bildschirme der ihm zugewiesenen Standorte</td></tr>
<tr><td>Superuser</td><td>Pflegt alle Standorte</td></tr>
<tr><td>Administrator</td><td>Zusätzlich Benutzer, Standorte und Bildschirme verwalten</td></tr>
</table>
</div>
</div>
<div class="warn">
<b>Drei Dinge, die häufig für Verwirrung sorgen</b>
<ul style="margin-top:1.2mm;">
<li>Ein zeitgesteuerter Eintrag sieht außerhalb seines Fensters „untätig“ aus – das ist richtig so. Prüfen
Sie das graue Zeit-Badge, bevor Sie etwas ändern.</li>
<li>Priority-Einträge stehen in der normalen Liste nur zur Ansicht. Ändern lassen sie sich ausschließlich
im Menüpunkt Priority.</li>
<li>Die Bildschirme selbst melden sich nicht an. Wer die Adresse kennt, sieht den Inhalt – legen Sie dort
nichts Vertrauliches ab.</li>
</ul>
</div>
<div class="kasten">
<h3 class="grau">Wenn etwas nicht so aussieht wie erwartet</h3>
<div class="raster-3" style="margin-top:1.5mm;">
<div>
<b>Der Bildschirm zeigt Altes</b>
<p class="klein">Er prüft alle fünf Sekunden auf Änderungen. Dauert es länger, steht der Status
vermutlich auf Offline.</p>
</div>
<div>
<b>Ein Eintrag fehlt</b>
<p class="klein">Haken gesetzt? Zeitfenster aktiv? Beides muss zutreffen, damit etwas erscheint.</p>
</div>
<div>
<b>Das Video ruckelt</b>
<p class="klein">MP4 mit H.264 und höchstens 1920×1080 verwenden. Größere Dateien überfordern
die Abspielgeräte.</p>
</div>
</div>
<p class="klein" style="margin-top:2.5mm; margin-bottom:0;">
Ausführliche Erklärungen zu jeder Funktion finden Sie jederzeit in der Oberfläche unter
<b>Hilfe</b> – im Menü hinter Ihrer E-Mail-Adresse oben rechts.</p>
</div>
<div class="fuss">
<div>CANCOM Simple Signage – Kurzüberblick für Anwender</div>
<div class="rechts">Rückseite</div>
</div>
</div>
</body>
</html>

2
env.sh
View File

@@ -1,2 +0,0 @@
export OPENAI_API_KEY="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"
export BRANDFETCH_API_KEY="eBwCQW_DcQ_jvdqZRNph0JBtRA36XoMTufaaU4AoirOBnqXX9fMqHDw7uYfiz8kSFYMNKGMZtuHxrmud9hn0WQ"

View File

@@ -3,11 +3,13 @@
Willkommensseite-Generierung für CANCOM Simple Signage. Willkommensseite-Generierung für CANCOM Simple Signage.
─────────────────────────────────────────────────────── ───────────────────────────────────────────────────────
Diese Hilfsdatei sucht anhand von Kundennamen Logos (via OpenAI + Brandfetch), Diese Hilfsdatei sucht anhand von Kundennamen Logos (via OpenAI + Brandfetch),
generiert daraus eine HTML-Willkommensseite und fügt sie in die Lobby-Playlist ein. generiert daraus eine HTML-Willkommensseite und fügt sie in die Playlists aller
Zielscreens ein (Screens mit "welcome_enabled", Fallback "lobby").
""" """
import os import os
import json import json
import time
from openai import OpenAI from openai import OpenAI
BASE_DIR = os.path.dirname(os.path.abspath(__file__)) BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -31,6 +33,204 @@ def get_openai_client():
# Brandfetch-Logo-Suche # Brandfetch-Logo-Suche
# ------------------------------------------------- # -------------------------------------------------
# -------------------------------------------------
# Logo-Upload: Freistellen
# -------------------------------------------------
LOGO_MAX_SIZE = 800 # Kantenlänge, auf die grosse Uploads verkleinert werden
LOGO_TOLERANCE = 32 # Farbabstand, bis zu dem ein Pixel als Hintergrund gilt
LOGO_BORDER_UNIFORMITY = 48 # Streuung, bis zu der ein Rand als einfarbig durchgeht
def _color_distance(a, b):
"""Abstand zweier RGB-Werte (Manhattan über die Kanäle, gemittelt)."""
return (abs(a[0] - b[0]) + abs(a[1] - b[1]) + abs(a[2] - b[2])) / 3.0
def _border_color(px, w, h):
"""Ermittelt die Hintergrundfarbe aus den Randpixeln.
Gibt (farbe, einfarbig) zurück - einfarbig=False, wenn der Rand zu
stark streut und ein Freistellen deshalb nicht sinnvoll ist."""
rand = []
schritt = max(1, min(w, h) // 100)
for x in range(0, w, schritt):
rand.append(px[x, 0][:3])
rand.append(px[x, h - 1][:3])
for y in range(0, h, schritt):
rand.append(px[0, y][:3])
rand.append(px[w - 1, y][:3])
mitte = tuple(sorted(k[i] for k in rand)[len(rand) // 2] for i in range(3))
streuung = sum(_color_distance(k, mitte) for k in rand) / len(rand)
return mitte, streuung <= LOGO_BORDER_UNIFORMITY
def trim_transparent_border(img):
"""Schneidet durchsichtige Raender weg, sodass das Logo die Bildflaeche
ausfuellt.
Ohne das erscheint ein hochgeladenes Logo kleiner als ein automatisch
gefundenes: die Willkommensseite skaliert mit object-fit: contain auf
eine feste Hoehe, und ein breiter durchsichtiger Rand zaehlt dabei mit.
Brandfetch-Logos sind bereits eng beschnitten."""
if img.mode != "RGBA":
return img
bbox = img.getchannel("A").getbbox()
if not bbox or bbox == (0, 0, img.width, img.height):
return img
return img.crop(bbox)
def remove_logo_background(image, tolerance=LOGO_TOLERANCE):
"""Stellt ein Logo frei: entfernt den zusammenhängenden Hintergrund von den
Rändern her und macht ihn transparent.
Bewusst ein Flood Fill von den Rändern und kein globaler Farbvergleich -
so bleibt Weiss *innerhalb* des Logos (etwa Text in einer Fläche) erhalten.
Gibt (bild, hinweis) zurück. hinweis ist None bei Erfolg, sonst ein Text
für die Oberfläche - das Bild wird dann unverändert zurückgegeben.
"""
from collections import deque
from PIL import Image
img = image.convert("RGBA")
if max(img.size) > LOGO_MAX_SIZE:
img.thumbnail((LOGO_MAX_SIZE, LOGO_MAX_SIZE), Image.LANCZOS)
# Bereits transparent? Dann nicht anfassen.
alpha = img.getchannel("A")
if alpha.getextrema()[0] < 255:
return img, None
w, h = img.size
px = img.load()
bg, einfarbig = _border_color(px, w, h)
if not einfarbig:
return img, ("Der Bildrand ist nicht einfarbig – das Logo wurde unverändert "
"übernommen. Am besten ein PNG mit transparentem Hintergrund hochladen.")
# Vor dem Eingriff sichern: ist das Logo farblich zu nah am Hintergrund,
# frisst der Flood Fill es mit auf und wir muessen zurueckfallen koennen.
original = img.copy()
# Flood Fill von allen Randpixeln aus
besucht = bytearray(w * h)
queue = deque()
for x in range(w):
for y in (0, h - 1):
queue.append((x, y))
for y in range(h):
for x in (0, w - 1):
queue.append((x, y))
while queue:
x, y = queue.popleft()
i = y * w + x
if besucht[i]:
continue
besucht[i] = 1
if _color_distance(px[x, y], bg) > tolerance:
continue
px[x, y] = (0, 0, 0, 0)
if x > 0: queue.append((x - 1, y))
if x < w - 1: queue.append((x + 1, y))
if y > 0: queue.append((x, y - 1))
if y < h - 1: queue.append((x, y + 1))
# Weiche Kante: Pixel direkt an der Freistellung teilweise durchscheinen
# lassen, sonst bleibt ein harter Treppenrand stehen.
for y in range(h):
for x in range(w):
r, g, b, a = px[x, y]
if a == 0:
continue
nachbar_frei = (
(x > 0 and px[x - 1, y][3] == 0) or
(x < w - 1 and px[x + 1, y][3] == 0) or
(y > 0 and px[x, y - 1][3] == 0) or
(y < h - 1 and px[x, y + 1][3] == 0)
)
if not nachbar_frei:
continue
d = _color_distance((r, g, b), bg)
if d < tolerance * 2:
px[x, y] = (r, g, b, int(255 * max(0.0, (d - tolerance) / tolerance)))
# Wie viel ist uebrig geblieben? Ein helles Logo auf hellem Grund liegt
# innerhalb der Toleranz und wuerde komplett mit entfernt - das Ergebnis
# waere ein leeres Bild und auf der Willkommensseite gar kein Logo.
histogramm = img.getchannel("A").histogram()
sichtbar = sum(histogramm[1:])
anteil = sichtbar / float(w * h)
if anteil < 0.02:
return original, ("Das Logo ist farblich zu nah am Hintergrund – beim Freistellen "
"wäre fast nichts übrig geblieben. Es wurde deshalb unverändert "
"übernommen. Am besten ein PNG mit transparentem Hintergrund "
"hochladen oder die Option \u201eHintergrund behalten\u201c ankreuzen.")
if anteil > 0.999:
return img, "Es wurde kein zusammenhängender Hintergrund gefunden."
return img, None
# Logos liegen als Dateien direkt im Standort-Verzeichnis, nicht in einem
# Unterordner: admin() behandelt jeden Unterordner von media/<site>/ als Screen
# und legt ihn dauerhaft in der Konfiguration an. Das Hintergrundbild wird aus
# demselben Grund als media/<site>/background.* abgelegt.
LOGO_URL_PREFIX = "logos" # nur der Pfadbestandteil in der URL
LOGO_FILENAME = "logo_{slot}.png"
LOGO_EXTENSIONS = (".png", ".jpg", ".jpeg", ".gif", ".webp", ".bmp")
def save_uploaded_logo(file_storage, site, slot, freistellen=True):
"""Nimmt ein hochgeladenes Logo entgegen, stellt es optional frei und legt es
unter media/<site>/logos/logo_<slot>.png ab.
Gespeichert wird immer als PNG - JPEG kann keine Transparenz.
Gibt (url, hinweis) zurück; url ist None, wenn die Datei unbrauchbar war.
Der Zeitstempel in der URL verhindert, dass Player ein altes Logo
aus dem Cache anzeigen."""
from PIL import Image, UnidentifiedImageError
name = (file_storage.filename or "").lower()
if not name.endswith(LOGO_EXTENSIONS):
return None, f"Format nicht unterstützt ({', '.join(LOGO_EXTENSIONS)})."
try:
img = Image.open(file_storage.stream)
img.load()
except (UnidentifiedImageError, OSError) as e:
return None, f"Datei konnte nicht gelesen werden: {e}"
hinweis = None
if freistellen:
img, hinweis = remove_logo_background(img)
else:
img = img.convert("RGBA")
# Nach dem Freistellen zuschneiden, damit das Logo genauso gross erscheint
# wie ein automatisch gefundenes
vorher = img.size
img = trim_transparent_border(img)
if img.size != vorher:
print(f"✅ Generate - Logo zugeschnitten: {vorher[0]}x{vorher[1]} → {img.size[0]}x{img.size[1]}")
site_dir = os.path.join(MEDIA_DIR, site)
os.makedirs(site_dir, exist_ok=True)
dateiname = LOGO_FILENAME.format(slot=slot)
img.save(os.path.join(site_dir, dateiname), "PNG")
url = f"/media/{site}/{LOGO_URL_PREFIX}/{dateiname}?v={int(time.time())}"
print(f"✅ Generate - Logo hochgeladen: {url}"
+ (f" (Hinweis: {hinweis})" if hinweis else ""))
return url, hinweis
def get_brandfetch_logo(domain): def get_brandfetch_logo(domain):
"""Versucht, ein Firmenlogo über Brandfetch CDN abzurufen. """Versucht, ein Firmenlogo über Brandfetch CDN abzurufen.
Baut eine direkte CDN-URL: https://cdn.brandfetch.io/{domain}/logo""" Baut eine direkte CDN-URL: https://cdn.brandfetch.io/{domain}/logo"""
@@ -296,57 +496,226 @@ def generate_welcome_html(customer_names, logo_urls, contacts=None, site="stuttg
# Speichern + Playlist-Einfügung # Speichern + Playlist-Einfügung
# ------------------------------------------------- # -------------------------------------------------
def save_welcome_page(customer_names, logo_urls, contacts=None, site="stuttgart", background_url=None): def welcome_target_screens(config, site):
"""Speichert die generierte Willkommensseite als welcome.html im Lobby-Verzeichnis. """Ermittelt die Screens eines Standorts, auf denen die Willkommensseite laufen soll.
Ein Screen ist Ziel, wenn "welcome_enabled" gesetzt ist. Ist der Schluessel bei
keinem Screen des Standorts vorhanden, greift das alte Verhalten: nur "lobby".
Damit bleiben bestehende Standorte ohne Konfigurationsaufwand unveraendert."""
screens = config.get("sites", {}).get(site, {}).get("screens", {})
configured = [name for name, cfg in screens.items()
if isinstance(cfg, dict) and cfg.get("welcome_enabled")]
if configured:
return sorted(configured)
# Kein Screen markiert: nur dann auf "lobby" zurückfallen, wenn noch nirgends
# eine bewusste Entscheidung getroffen wurde. Wurde das Häkchen überall
# entfernt, ist "keine Screens" das gewollte Ergebnis.
entschieden = any(isinstance(cfg, dict) and "welcome_enabled" in cfg
for cfg in screens.values())
if entschieden:
return []
return ["lobby"] if "lobby" in screens else []
def save_welcome_page(customer_names, logo_urls, contacts=None, site="stuttgart",
background_url=None, screens=None):
"""Speichert die generierte Willkommensseite als welcome.html in den Zielscreens.
screens: Liste der Screen-Namen; None → Zielscreens aus der Konfiguration.
contacts: Liste der Ansprechpartner-Texte (Markdown, optional) contacts: Liste der Ansprechpartner-Texte (Markdown, optional)
background_url: Benutzerdefiniertes Hintergrundbild oder None (Standard). background_url: Benutzerdefiniertes Hintergrundbild oder None (Standard).
Gibt den Dateinamen zurück oder None bei Fehler.""" Gibt den Dateinamen zurück oder None bei Fehler.
Die Seite referenziert alle Bilder absolut (/static/..., /media/<site>/...),
ist also ortsunabhängig - eine Kopie je Screen rendert identisch."""
try: try:
welcome_dir = os.path.join(MEDIA_DIR, site, "lobby") from app import load_config
if screens is None:
screens = welcome_target_screens(load_config(), site)
if not screens:
print(f"⚠️ Generate - Kein Zielscreen für die Willkommensseite (site: {site})")
return None
html_content = generate_welcome_html(customer_names, logo_urls, contacts=contacts,
site=site, background_url=background_url)
# Standort-Kopie zuerst - sie ueberlebt das Abschalten aller Screens
master = _welcome_master_path(site)
os.makedirs(os.path.dirname(master), exist_ok=True)
with open(master, "w", encoding="utf-8") as f:
f.write(html_content)
for screen in screens:
welcome_dir = os.path.join(MEDIA_DIR, site, screen)
os.makedirs(welcome_dir, exist_ok=True) os.makedirs(welcome_dir, exist_ok=True)
filepath = os.path.join(os.path.abspath(welcome_dir), WELCOME_FILENAME) filepath = os.path.join(os.path.abspath(welcome_dir), WELCOME_FILENAME)
html_content = generate_welcome_html(customer_names, logo_urls, contacts=contacts, site=site, background_url=background_url)
with open(filepath, "w", encoding="utf-8") as f: with open(filepath, "w", encoding="utf-8") as f:
f.write(html_content) f.write(html_content)
if os.path.getsize(filepath) == 0: if os.path.getsize(filepath) == 0:
raise IOError("File not written") raise IOError("File not written")
names_str = ", ".join(customer_names)
print(f"✅ Generate - Willkommensseite generiert für: {names_str} (site: {site})")
print(f"✅ Generate - Willkommensseite gespeichert in: {filepath}") print(f"✅ Generate - Willkommensseite gespeichert in: {filepath}")
names_str = ", ".join(customer_names)
print(f"✅ Generate - Willkommensseite generiert für: {names_str} "
f"(site: {site}, screens: {', '.join(screens)})")
return WELCOME_FILENAME return WELCOME_FILENAME
except Exception as e: except Exception as e:
print(f"❌ Error saving welcome page: {e}") print(f"❌ Error saving welcome page: {e}")
return None return None
def add_customer_to_lobby_playlist(html_filename, site="stuttgart"): def _strip_welcome(playlist, filename):
"""Fügt die Willkommensseite (welcome.html) an Position 0 in die Lobby-Playlist ein. """Entfernt alle welcome.html-Einträge aus einer Playlist (String- und Dict-Form)."""
Entfernt vorher alle vorhandenen welcome.html-Einträge (als String oder Dict). playlist[:] = [
item for item in playlist
if not (
(isinstance(item, str) and item == filename) or
(isinstance(item, dict) and item.get("name") == filename)
)
]
def _welcome_path(site, screen):
return os.path.join(MEDIA_DIR, site, screen, WELCOME_FILENAME)
def _welcome_master_path(site):
"""Standort-Kopie der Willkommensseite. Sie liegt neben den Screen-Ordnern,
wird beim Generieren mitgeschrieben und nie geloescht - damit der Schalter
eines Screens die Seite auch dann noch platzieren kann, wenn sie zuvor
auf allen Screens abgeschaltet war."""
return os.path.join(MEDIA_DIR, site, WELCOME_FILENAME)
def find_welcome_source(site, config):
"""Sucht eine vorhandene welcome.html für einen Standort.
Die Seite ist je Standort identisch, jede Kopie taugt als Quelle.
Gibt den Pfad zurück oder None, wenn noch nie eine erzeugt wurde."""
master = _welcome_master_path(site)
if os.path.exists(master):
return master
screens = config.get("sites", {}).get(site, {}).get("screens", {})
for screen in sorted(screens):
pfad = _welcome_path(site, screen)
if os.path.exists(pfad):
return pfad
return None
def _remove_welcome_file(site, screen):
"""Löscht die welcome.html eines Screens, falls vorhanden."""
pfad = _welcome_path(site, screen)
if os.path.exists(pfad):
try:
os.remove(pfad)
except OSError as e:
print(f"⚠️ Generate - welcome.html in {site}/{screen} nicht löschbar: {e}")
def sync_welcome_for_screen(config, site, screen, enabled):
"""Bringt einen einzelnen Screen mit dem Schalter "Willkommensseite hier
anzeigen" in Einklang - ohne dass neu generiert werden muss.
enabled=True : vorhandene welcome.html hineinkopieren und vorne einfügen
enabled=False: Eintrag entfernen und die Kopie löschen
Verändert nur das übergebene config-Dict; das Speichern übernimmt der
Aufrufer. Gibt True zurück, wenn sich an der Playlist etwas geändert hat."""
screen_cfg = config.get("sites", {}).get(site, {}).get("screens", {}).get(screen)
if screen_cfg is None:
return False
playlist = screen_cfg.setdefault("playlist", [])
vorher = list(playlist)
if not enabled:
_strip_welcome(playlist, WELCOME_FILENAME)
_remove_welcome_file(site, screen)
return playlist != vorher
quelle = find_welcome_source(site, config)
if not quelle:
# Noch keine Willkommensseite erzeugt - dann gibt es nichts zu platzieren.
return False
ziel = _welcome_path(site, screen)
if os.path.abspath(quelle) != os.path.abspath(ziel):
try:
os.makedirs(os.path.dirname(ziel), exist_ok=True)
with open(quelle, "rb") as q, open(ziel, "wb") as z:
z.write(q.read())
except OSError as e:
print(f"❌ Generate - welcome.html nach {site}/{screen} nicht kopierbar: {e}")
return False
# Vorhandenen Eintrag entfernen und vorne neu einfügen
_strip_welcome(playlist, WELCOME_FILENAME)
playlist.insert(0, {"name": WELCOME_FILENAME, "enabled": True})
print(f"✅ Generate - Willkommensseite in {site}/{screen} platziert")
return True
def add_customer_to_playlists(html_filename, site="stuttgart", screens=None, valid_until=None):
"""Setzt die Willkommensseite an Position 0 der Playlist jedes Zielscreens.
screens: Liste der Zielscreens; None → aus der Konfiguration ermitteln.
valid_until: optionales Datum "YYYY-MM-DD". Ist es gesetzt, bekommt der
Eintrag eine Zeitsteuerung und verschwindet danach von selbst.
Aus allen übrigen Screens des Standorts wird die Seite entfernt, damit ein
abgewählter Screen nicht mit einer alten Begrüßung stehen bleibt.
Importiert load_config/save_config aus app.py (kein Zirkelimport).""" Importiert load_config/save_config aus app.py (kein Zirkelimport)."""
try: try:
from app import load_config, save_config from app import load_config, save_config
config = load_config() config = load_config()
sites_cfg = config.setdefault("sites", {}) site_cfg = config.setdefault("sites", {}).setdefault(site, {"screens": {}})
site_cfg = sites_cfg.setdefault(site, {"screens": {}}) all_screens = site_cfg.setdefault("screens", {})
screens = site_cfg.setdefault("screens", {})
lobby = screens.setdefault("lobby", {}) if screens is None:
playlist = lobby.setdefault("playlist", []) screens = welcome_target_screens(config, site)
# Nur Screens, die es wirklich gibt - kein stillschweigendes Anlegen
targets = [name for name in screens if name in all_screens]
entry = {"name": html_filename, "enabled": True}
if valid_until:
# Ganztaegig bis einschliesslich valid_until
entry["schedule"] = {
"mode": "daily",
"start": "00:00",
"end": "23:59",
"valid_until": valid_until
}
for name, screen_cfg in all_screens.items():
if not isinstance(screen_cfg, dict):
continue
playlist = screen_cfg.setdefault("playlist", [])
_strip_welcome(playlist, html_filename)
if name in targets:
playlist.insert(0, dict(entry))
else:
# Auch die Datei entfernen - sonst bliebe auf einem abgewaehlten
# Screen eine verwaiste Kopie liegen, die bei leerer Playlist
# sogar noch abgespielt wuerde.
_remove_welcome_file(site, name)
full_filename = f"{html_filename}"
# Alte welcome.html-Einträge entfernen (sowohl String- als auch Dict-Form)
playlist[:] = [
item for item in playlist
if not (
(isinstance(item, str) and item == full_filename) or
(isinstance(item, dict) and item.get("name") == full_filename)
)
]
playlist.insert(0, full_filename)
save_config(config) save_config(config)
print(f"✅ Generate - Willkommensseite in Lobby-Playliste platziert (site: {site})")
return True if targets:
print(f"✅ Generate - Willkommensseite platziert (site: {site}, "
f"screens: {', '.join(targets)})")
else:
print(f"⚠️ Generate - Kein Zielscreen vorhanden (site: {site})")
return bool(targets)
except Exception as e: except Exception as e:
print(f"❌ Generate - Error adding to playlist: {e}") print(f"❌ Generate - Error adding to playlist: {e}")
return False return False

View File

@@ -1,4 +1,316 @@
[ [
{
"timestamp": "2026-09-07 10:45:56",
"action": "user_login",
"detail": "User 't@x.de' angemeldet (Trusted Device)",
"user": "t@x.de"
},
{
"timestamp": "2026-09-07 10:45:56",
"action": "user_login",
"detail": "User 't@x.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
"user": "t@x.de"
},
{
"timestamp": "2026-09-07 10:44:24",
"action": "user_login",
"detail": "User 't@x.de' angemeldet (Trusted Device)",
"user": "t@x.de"
},
{
"timestamp": "2026-09-07 10:44:24",
"action": "user_login",
"detail": "User 't@x.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
"user": "t@x.de"
},
{
"timestamp": "2026-09-04 10:38:34",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 17:58:05",
"action": "screen_deleted",
"detail": "Screen 'xxx' an Standort 'test' gel\u00f6scht",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 17:57:39",
"action": "screen_created",
"detail": "Screen 'xxx' an Standort 'test' angelegt",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 17:56:56",
"action": "background_uploaded",
"detail": "Hintergrundbild f\u00fcr Standort 'test' hochgeladen",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 17:53:02",
"action": "customer_added",
"detail": "Kunden (WIN, IBM, Gaufer) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: casino, vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 17:44:39",
"action": "screen_created",
"detail": "Screen 'casino' an Standort 'test' angelegt",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 17:44:31",
"action": "site_deleted",
"detail": "Standort 'casino' gel\u00f6scht",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 17:44:25",
"action": "site_created",
"detail": "Standort 'casino' angelegt",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 17:41:44",
"action": "customer_added",
"detail": "Kunden (WIN, IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 17:41:32",
"action": "customer_added",
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 17:40:55",
"action": "customer_added",
"detail": "Kunden (XXTT) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 16:10:33",
"action": "customer_added",
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:59:00",
"action": "customer_added",
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:52:39",
"action": "customer_added",
"detail": "Kunden (Teamshop Kuenzelsau, IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:52:13",
"action": "customer_added",
"detail": "Kunden (Teamshop Kuenzelsau, IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:52:04",
"action": "customer_added",
"detail": "Kunden (Teamshop Kuenzelsau, IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:42:42",
"action": "customer_added",
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:42:25",
"action": "customer_added",
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:36:52",
"action": "customer_added",
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "admin"
},
{
"timestamp": "2026-09-02 15:32:23",
"action": "customer_added",
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:32:06",
"action": "customer_added",
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:31:59",
"action": "customer_added",
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:31:44",
"action": "customer_added",
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:31:31",
"action": "customer_added",
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:31:14",
"action": "customer_added",
"detail": "Kunden (IBM) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:30:36",
"action": "customer_added",
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:28:22",
"action": "customer_added",
"detail": "Kunden (Teamshop Kuenzelsau) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "admin"
},
{
"timestamp": "2026-09-02 15:26:50",
"action": "customer_added",
"detail": "Kunden (HellesLogo) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "admin"
},
{
"timestamp": "2026-09-02 15:23:36",
"action": "customer_added",
"detail": "Kunden (UploadTest) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "admin"
},
{
"timestamp": "2026-09-02 15:22:22",
"action": "customer_added",
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:21:07",
"action": "customer_added",
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:19:35",
"action": "customer_added",
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:19:21",
"action": "customer_added",
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:09:15",
"action": "customer_added",
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:08:35",
"action": "customer_added",
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:08:14",
"action": "customer_added",
"detail": "Kunden (ibm) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:07:50",
"action": "customer_added",
"detail": "Kunden (ibm, lenovo, logitech) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:02:34",
"action": "customer_added",
"detail": "Kunden (ibm, lenovo, logitech) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:02:01",
"action": "customer_added",
"detail": "Kunden (ibm, lenovo, logitech) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 15:01:07",
"action": "customer_added",
"detail": "Kunden (ibm, lenovo, logitech) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: logos, vorstand)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 14:35:54",
"action": "customer_added",
"detail": "Kunden (Testkunde) zur Willkommensseite an Standort 'test' hinzugef\u00fcgt (Screens: vorstand)",
"user": "admin"
},
{
"timestamp": "2026-09-02 14:31:04",
"action": "screen_created",
"detail": "Screen 'vorstand' an Standort 'test' angelegt",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 14:30:58",
"action": "site_created",
"detail": "Standort 'test' angelegt",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 14:30:51",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 14:29:42",
"action": "password_changed",
"detail": "Passwort f\u00fcr User 'erik.thiele@cancom.de' ge\u00e4ndert",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-09-02 14:29:34",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (Passwort-\u00c4nderung erforderlich)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-08-29 16:20:09",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-07-10 17:34:19",
"action": "screen_created",
"detail": "Screen 'videosysteme' an Standort 'karlsruhe' angelegt",
"user": "erik.thiele@cancom.de"
},
{ {
"timestamp": "2026-07-10 16:15:59", "timestamp": "2026-07-10 16:15:59",
"action": "user_login", "action": "user_login",

View File

@@ -52,6 +52,19 @@
gap: 0.75rem; gap: 0.75rem;
} }
/* ─── Prio-Vorschau: reine Anzeige, keine Bedienung ─── */
.playlist-prio {
display: grid;
grid-template-columns: 28px 1fr 90px 80px;
align-items: center;
gap: 0.75rem;
opacity: 0.72;
background: var(--tblr-bg-surface-secondary, #f8f9fa);
}
.playlist-prio .playlist-name {
font-weight: 500;
}
.playlist-name { .playlist-name {
font-weight: 600; font-weight: 600;
overflow: hidden; overflow: hidden;
@@ -229,6 +242,32 @@
<!-- ═══ Tab: Playlist ═══ --> <!-- ═══ Tab: Playlist ═══ -->
<div class="tab-pane active" id="tab-playlist-{{ screen }}" role="tabpanel"> <div class="tab-pane active" id="tab-playlist-{{ screen }}" role="tabpanel">
{% if priority_preview %}
<!-- Priority-Playlist des Standorts: laeuft diesen Screens vorweg.
Reine Anzeige - Bedienung ausschliesslich unter "Priority". -->
<ul class="list-group mb-2">
{% for file in priority_preview %}
<li class="list-group-item playlist-prio">
<i class="ti ti-player-play" title="Laeuft vorweg"></i>
<span class="playlist-name">
{{ file.name }}
{% if file.schedule %}<span class="badge ms-1 {% if file.active %}bg-info text-white{% else %}bg-secondary-lt{% endif %}" title="{{ file.schedule | schedule_text }}{% if not file.active %} — laeuft gerade nicht{% endif %}">Zeit</span>{% endif %}
{% if not file.enabled %}<span class="badge bg-secondary-lt ms-1">inaktiv</span>{% endif %}
</span>
<span class="badge bg-red text-red-fg">Prio</span>
<span class="playlist-size">
{% if file.type == "url" %}{{ file.size }}{% else %}{{ file.size }} KB{% endif %}
</span>
</li>
{% endfor %}
</ul>
<p class="text-muted small mb-3">
Diese Einträge kommen aus der
<a href="{{ url_for('admin_priority', site=current_site) }}">Priority-Playlist</a>
des Standorts und laufen allen Screens vorweg. Bearbeiten nur dort.
</p>
{% endif %}
<ul class="list-group mb-3" id="playlist-{{ screen }}"> <ul class="list-group mb-3" id="playlist-{{ screen }}">
{% for file in media_files[screen] %} {% for file in media_files[screen] %}
<li class="list-group-item playlist-row" <li class="list-group-item playlist-row"
@@ -246,11 +285,12 @@
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-html="true" data-bs-html="true"
title="<img src='/media/{{ current_site }}/{{ screen }}/{{ file.name }}' style='max-width: 200px; max-height: 150px; border: none; background: white; padding: 2px;' alt='Vorschau'>" title="<img src='/media/{{ current_site }}/{{ screen }}/{{ file.name }}' style='max-width: 200px; max-height: 150px; border: none; background: white; padding: 2px;' alt='Vorschau'>"
{% elif file.type == "url" %} {% else %}
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}" title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"
{% endif %}> {% endif %}>
{{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %} {{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %}
{% if file.schedule %}<span class="badge ms-1 {% if file.active %}bg-info text-white{% else %}bg-secondary-lt{% endif %}" title="{{ file.schedule | schedule_text }}{% if not file.active %} — laeuft gerade nicht{% endif %}">Zeit</span>{% endif %}
</span> </span>
<!-- Typ-Badge --> <!-- Typ-Badge -->
@@ -273,7 +313,7 @@
<!-- Löschen --> <!-- Löschen -->
<form action="{{ url_for('delete_file', site=current_site, screen=screen, filename=file.name) }}" <form action="{{ url_for('delete_file', site=current_site, screen=screen, filename=file.name) }}"
method="post"> method="post" style="margin: 0; padding: 0;">
<button type="submit" <button type="submit"
class="btn btn-outline-danger btn-sm" class="btn btn-outline-danger btn-sm"
onclick="return confirm('Datei wirklich löschen?')"> onclick="return confirm('Datei wirklich löschen?')">
@@ -282,21 +322,19 @@
</form> </form>
<!-- Bearbeiten (nur URLs) --> <!-- Bearbeiten (nur URLs) -->
{% if file.type == "url" %}
<button type="button" <button type="button"
class="btn btn-outline-secondary btn-sm edit-url-btn" class="btn btn-outline-secondary btn-sm edit-url-btn"
title="URL bearbeiten" title="Bearbeiten"
data-name="{{ file.name | e }}" data-name="{{ file.name | e }}"
data-type="{{ file.type }}"
data-zoom="{{ file.zoom | default(1.0) }}" data-zoom="{{ file.zoom | default(1.0) }}"
data-site="{{ current_site }}" data-site="{{ current_site }}"
data-screen="{{ screen }}" data-screen="{{ screen }}"
data-oldname="{{ file.name | e }}" data-oldname="{{ file.name | e }}"
data-passthrough="{{ 'true' if file.get('passthrough', False) else 'false' }}"> data-passthrough="{{ 'true' if file.get('passthrough', False) else 'false' }}"
data-schedule='{{ file.schedule | tojson }}'>
Edit Edit
</button> </button>
{% else %}
<span></span>
{% endif %}
<!-- Drag-Handle (SortableJS) --> <!-- Drag-Handle (SortableJS) -->
<span class="drag-handle" title="Reihenfolge ändern">⇅</span> <span class="drag-handle" title="Reihenfolge ändern">⇅</span>
@@ -345,13 +383,21 @@
<label class="form-check-label">Videos anzeigen</label> <label class="form-check-label">Videos anzeigen</label>
</div> </div>
<div class="form-check form-switch mb-4"> <div class="form-check form-switch mb-3">
<input class="form-check-input" type="checkbox" <input class="form-check-input" type="checkbox"
name="stay_on_first" name="stay_on_first"
{% if cfg.stay_on_first %}checked{% endif %}> {% if cfg.stay_on_first %}checked{% endif %}>
<label class="form-check-label">Auf erstem Element stehen bleiben (kein Refresh)</label> <label class="form-check-label">Auf erstem Element stehen bleiben (kein Refresh)</label>
</div> </div>
<div class="form-check form-switch mb-1">
<input class="form-check-input" type="checkbox"
name="welcome_enabled"
{% if cfg.welcome_enabled %}checked{% endif %}>
<label class="form-check-label">Willkommensseite hier anzeigen</label>
</div>
<div class="form-hint mb-4">Erzeugte Willkommensseiten werden auf allen so markierten Screens dieses Standorts vorne in die Playlist gesetzt.</div>
<button type="submit" class="btn btn-primary"> <button type="submit" class="btn btn-primary">
Einstellungen speichern Einstellungen speichern
</button> </button>
@@ -492,6 +538,99 @@
<label class="form-label">Zoom Faktor (z.B. 1.0, 1.5, 2.0)</label> <label class="form-label">Zoom Faktor (z.B. 1.0, 1.5, 2.0)</label>
<input type="number" name="zoom" class="form-control" step="0.1" value="1.0" min="0.1"> <input type="number" name="zoom" class="form-control" step="0.1" value="1.0" min="0.1">
</div> </div>
<!-- Zeitsteuerung -->
<hr class="mt-4 mb-3">
<h3>Zeitsteuerung</h3>
<div class="mb-2 form-check">
<input class="form-check-input" type="checkbox" id="schedule-enabled-{{ screen }}" name="schedule_enabled">
<label class="form-check-label" for="schedule-enabled-{{ screen }}">Zeitgesteuerte Wiedergabe</label>
</div>
<div class="mb-3" id="schedule-options-{{ screen }}" style="display: none;">
<div class="mb-3">
<label class="form-label">Wiederholung</label>
<select class="form-select" name="schedule_mode">
<option value="once">Einmalig</option>
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
</select>
</div>
<!-- Einmalig: Datum + Uhrzeit -->
<div class="row mb-3" id="schedule-datetime-{{ screen }}">
<div class="col">
<label class="form-label">Start</label>
<input type="datetime-local" class="form-control" name="schedule_start">
</div>
<div class="col">
<label class="form-label">Ende</label>
<input type="datetime-local" class="form-control" name="schedule_end">
</div>
</div>
<!-- Täglich / Wöchentlich: nur Uhrzeit -->
<div class="row mb-3" id="schedule-time-{{ screen }}" style="display: none;">
<div class="col">
<label class="form-label">Von</label>
<input type="time" class="form-control" name="schedule_start_time">
</div>
<div class="col">
<label class="form-label">Bis</label>
<input type="time" class="form-control" name="schedule_end_time">
</div>
</div>
<div class="mb-3" id="schedule-validity-{{ screen }}" style="display: none;">
<label class="form-label">Gültig von / bis <span class="text-muted">(optional)</span></label>
<div class="row">
<div class="col">
<input type="date" class="form-control" name="schedule_valid_from">
</div>
<div class="col">
<input type="date" class="form-control" name="schedule_valid_until">
</div>
</div>
<small class="form-hint">Ohne Angabe läuft die Wiedergabe unbefristet.</small>
</div>
<div class="mb-3" id="schedule-days-{{ screen }}" style="display: none;">
<label class="form-label">Wochentage</label>
<div class="d-flex gap-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" name="schedule_days" value="0" id="mon-{{ screen }}">
<label class="form-check-label" for="mon-{{ screen }}">Mo</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="schedule_days" value="1" id="tue-{{ screen }}">
<label class="form-check-label" for="tue-{{ screen }}">Di</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="schedule_days" value="2" id="wed-{{ screen }}">
<label class="form-check-label" for="wed-{{ screen }}">Mi</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="schedule_days" value="3" id="thu-{{ screen }}">
<label class="form-check-label" for="thu-{{ screen }}">Do</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="schedule_days" value="4" id="fri-{{ screen }}">
<label class="form-check-label" for="fri-{{ screen }}">Fr</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="schedule_days" value="5" id="sat-{{ screen }}">
<label class="form-check-label" for="sat-{{ screen }}">Sa</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" name="schedule_days" value="6" id="sun-{{ screen }}">
<label class="form-check-label" for="sun-{{ screen }}">So</label>
</div>
</div>
</div>
</div>
<button class="btn btn-primary" type="submit">URL hinzufügen</button> <button class="btn btn-primary" type="submit">URL hinzufügen</button>
</form> </form>
</div> </div>
@@ -540,23 +679,105 @@
</div> <!-- /page --> </div> <!-- /page -->
<script> <script>
/* ─── Playlist-Item bearbeiten (URL + Zoom) ─── */ /* ─── Playlist-Item bearbeiten (URL + Zoom + Zeitsteuerung) ─── */
var editState = { site: "", screen: "", oldName: "" }; var editState = { site: "", screen: "", oldName: "", type: "url" };
/* Event-Delegation für Edit-Buttons (global, sofort aktiv) */ /* Zeitsteuerung: Wochentage nur im Modus "weekly" zeigen.
document.addEventListener("click", function (e) { Muss auf Top-Level stehen - der Edit-Handler unten läuft ausserhalb
von DOMContentLoaded und würde die Funktion sonst nicht finden. */
/* Uhrzeit aus "HH:MM" oder einem vollen ISO-Zeitstempel herausziehen */
function scheduleClock(value) {
if (!value) return "";
var text = String(value).replace(" ", "T");
var idx = text.indexOf("T");
if (idx !== -1) text = text.substring(idx + 1);
return text.substring(0, 5);
}
function updateScheduleDaysVisibility() {
var modeEl = document.getElementById("edit-schedule-mode");
if (!modeEl) return;
var wiederkehrend = modeEl.value === "daily" || modeEl.value === "weekly";
var daysEl = document.getElementById("edit-schedule-days");
var validityEl = document.getElementById("edit-schedule-validity");
var datetimeEl = document.getElementById("edit-schedule-datetime");
var timeEl = document.getElementById("edit-schedule-time");
// Wochentage nur bei "wöchentlich" - bei "täglich" gilt ohnehin jeder Tag
if (daysEl) daysEl.style.display = modeEl.value === "weekly" ? "" : "none";
// Gültigkeitszeitraum ergänzt die Tageszeit; bei "einmalig" steckt das
// Datum bereits in Start und Ende.
if (validityEl) validityEl.style.display = wiederkehrend ? "" : "none";
if (datetimeEl) datetimeEl.style.display = wiederkehrend ? "none" : "";
if (timeEl) timeEl.style.display = wiederkehrend ? "" : "none";
}
/* Event-Delegation für Edit-Buttons (global, sofort aktiv) */
document.addEventListener("click", function (e) {
var btn = e.target.closest(".edit-url-btn"); var btn = e.target.closest(".edit-url-btn");
if (!btn) return; if (!btn) return;
e.preventDefault(); e.preventDefault();
editState.site = btn.dataset.site; editState.site = btn.dataset.site;
editState.screen = btn.dataset.screen; editState.screen = btn.dataset.screen;
editState.oldName = btn.dataset.oldname; editState.oldName = btn.dataset.oldname;
editState.type = btn.dataset.type || "url";
var isUrl = editState.type === "url";
document.getElementById("edit-url-input").value = btn.dataset.oldname; document.getElementById("edit-url-input").value = btn.dataset.oldname;
document.getElementById("edit-zoom-input").value = btn.dataset.zoom; document.getElementById("edit-zoom-input").value = btn.dataset.zoom;
document.getElementById("edit-passthrough-input").checked = btn.dataset.passthrough === "true"; document.getElementById("edit-passthrough-input").checked = btn.dataset.passthrough === "true";
/* Bei Dateien gibt es keine URL, keinen Zoom und kein Passthrough -
dann bleibt nur die Zeitsteuerung übrig. */
document.getElementById("edit-url-row").style.display = isUrl ? "" : "none";
document.getElementById("edit-zoom-row").style.display = isUrl ? "" : "none";
document.getElementById("edit-passthrough-row").style.display = isUrl ? "" : "none";
document.getElementById("edit-url-modal-title").textContent =
isUrl ? "URL bearbeiten" : "Zeitsteuerung: " + btn.dataset.oldname;
// Zeitsteuerung aus Daten setzen
const schedule = JSON.parse(btn.dataset.schedule || 'null');
const enabledEl = document.getElementById("edit-schedule-enabled");
const optionsEl = document.getElementById("edit-schedule-options");
if (schedule) {
enabledEl.checked = true;
optionsEl.style.display = "";
document.getElementById("edit-schedule-mode").value = schedule.mode || "once";
var wiederkehrend = (schedule.mode === "daily" || schedule.mode === "weekly");
document.getElementById("edit-schedule-start").value = wiederkehrend ? "" : (schedule.start || "");
document.getElementById("edit-schedule-end").value = wiederkehrend ? "" : (schedule.end || "");
document.getElementById("edit-schedule-start-time").value = scheduleClock(schedule.start);
document.getElementById("edit-schedule-end-time").value = scheduleClock(schedule.end);
document.getElementById("edit-schedule-valid-from").value = schedule.valid_from || "";
document.getElementById("edit-schedule-valid-until").value = schedule.valid_until || "";
// Wochentage setzen
const days = schedule.days || [];
document.getElementById("edit-mon").checked = days.includes("0");
document.getElementById("edit-tue").checked = days.includes("1");
document.getElementById("edit-wed").checked = days.includes("2");
document.getElementById("edit-thu").checked = days.includes("3");
document.getElementById("edit-fri").checked = days.includes("4");
document.getElementById("edit-sat").checked = days.includes("5");
document.getElementById("edit-sun").checked = days.includes("6");
// Wochentage-Anzeige entsprechend Modus aktualisieren
updateScheduleDaysVisibility();
} else {
enabledEl.checked = false;
optionsEl.style.display = "none";
document.getElementById("edit-schedule-valid-from").value = "";
document.getElementById("edit-schedule-valid-until").value = "";
document.getElementById("edit-schedule-start-time").value = "";
document.getElementById("edit-schedule-end-time").value = "";
updateScheduleDaysVisibility();
}
document.getElementById("edit-url-error").style.display = "none"; document.getElementById("edit-url-error").style.display = "none";
new bootstrap.Modal(document.getElementById("edit-url-modal")).show(); new bootstrap.Modal(document.getElementById("edit-url-modal")).show();
}); });
document.addEventListener("DOMContentLoaded", function () { document.addEventListener("DOMContentLoaded", function () {
@@ -611,13 +832,52 @@ document.addEventListener("DOMContentLoaded", function () {
return new bootstrap.Tooltip(tooltipTriggerEl); return new bootstrap.Tooltip(tooltipTriggerEl);
}); });
/* ─── Zeitsteuerung: Änderungs-Events verarbeiten ─── */
document.getElementById("edit-schedule-enabled").addEventListener("change", function() {
document.getElementById("edit-schedule-options").style.display = this.checked ? "" : "none";
});
document.getElementById("edit-schedule-mode").addEventListener("change", function() {
updateScheduleDaysVisibility();
});
// Initiale Anzeige
updateScheduleDaysVisibility();
/* ─── "URL hinzufügen"-Formular: Zeitsteuerungs-Felder ein-/ausblenden ───
Läuft pro Screen, die IDs sind mit dem Screen-Namen suffigiert. */
document.querySelectorAll('[id^="schedule-enabled-"]').forEach(function (cb) {
var suffix = cb.id.substring("schedule-enabled-".length);
var optionsEl = document.getElementById("schedule-options-" + suffix);
var daysEl = document.getElementById("schedule-days-" + suffix);
var validityEl = document.getElementById("schedule-validity-" + suffix);
var datetimeEl = document.getElementById("schedule-datetime-" + suffix);
var timeEl = document.getElementById("schedule-time-" + suffix);
var modeEl = cb.closest("form") ? cb.closest("form").querySelector('[name="schedule_mode"]') : null;
function sync() {
var mode = modeEl ? modeEl.value : "once";
var wiederkehrend = (mode === "daily" || mode === "weekly");
if (optionsEl) optionsEl.style.display = cb.checked ? "" : "none";
if (daysEl) daysEl.style.display = (cb.checked && mode === "weekly") ? "" : "none";
if (validityEl) validityEl.style.display = (cb.checked && wiederkehrend) ? "" : "none";
if (datetimeEl) datetimeEl.style.display = wiederkehrend ? "none" : "";
if (timeEl) timeEl.style.display = wiederkehrend ? "" : "none";
}
cb.addEventListener("change", sync);
if (modeEl) modeEl.addEventListener("change", sync);
sync();
});
/* ─── Edit-URL-Modal: Speichern-Button ─── */ /* ─── Edit-URL-Modal: Speichern-Button ─── */
document.getElementById("edit-url-save").addEventListener("click", function () { document.getElementById("edit-url-save").addEventListener("click", function () {
const isUrl = editState.type === "url";
const newUrl = document.getElementById("edit-url-input").value.trim(); const newUrl = document.getElementById("edit-url-input").value.trim();
const zoom = parseFloat(document.getElementById("edit-zoom-input").value) || 1.0; const zoom = parseFloat(document.getElementById("edit-zoom-input").value) || 1.0;
const errorEl = document.getElementById("edit-url-error"); const errorEl = document.getElementById("edit-url-error");
if (!newUrl.startsWith("http://") && !newUrl.startsWith("https://")) { if (isUrl && !newUrl.startsWith("http://") && !newUrl.startsWith("https://")) {
errorEl.textContent = "Ungültige URL (muss mit http:// oder https:// beginnen)"; errorEl.textContent = "Ungültige URL (muss mit http:// oder https:// beginnen)";
errorEl.style.display = "block"; errorEl.style.display = "block";
return; return;
@@ -625,14 +885,70 @@ document.addEventListener("DOMContentLoaded", function () {
errorEl.style.display = "none"; errorEl.style.display = "none";
// Zeitsteuerung sammeln
const scheduleEnabled = document.getElementById("edit-schedule-enabled").checked;
let schedule = null;
if (scheduleEnabled) {
const mode = document.getElementById("edit-schedule-mode").value;
const wiederkehrend = (mode === "daily" || mode === "weekly");
// Einmalig arbeitet mit Datum + Uhrzeit, täglich/wöchentlich nur mit Uhrzeit
const start = wiederkehrend
? document.getElementById("edit-schedule-start-time").value
: document.getElementById("edit-schedule-start").value;
const end = wiederkehrend
? document.getElementById("edit-schedule-end-time").value
: document.getElementById("edit-schedule-end").value;
if (!start || !end) {
errorEl.textContent = wiederkehrend
? "Von- und Bis-Uhrzeit müssen angegeben werden"
: "Start und Ende müssen angegeben werden";
errorEl.style.display = "block";
return;
}
schedule = {
mode: mode,
start: start,
end: end
};
if (mode === "daily" || mode === "weekly") {
const validFrom = document.getElementById("edit-schedule-valid-from").value;
const validUntil = document.getElementById("edit-schedule-valid-until").value;
if (validFrom && validUntil && validFrom > validUntil) {
errorEl.textContent = "\"Gültig von\" liegt nach \"Gültig bis\"";
errorEl.style.display = "block";
return;
}
if (validFrom) schedule.valid_from = validFrom;
if (validUntil) schedule.valid_until = validUntil;
}
if (mode === "weekly") {
const days = [];
if (document.getElementById("edit-mon").checked) days.push("0");
if (document.getElementById("edit-tue").checked) days.push("1");
if (document.getElementById("edit-wed").checked) days.push("2");
if (document.getElementById("edit-thu").checked) days.push("3");
if (document.getElementById("edit-fri").checked) days.push("4");
if (document.getElementById("edit-sat").checked) days.push("5");
if (document.getElementById("edit-sun").checked) days.push("6");
schedule.days = days;
}
}
fetch("/admin/" + editState.site + "/update-playlist-item/" + editState.screen, { fetch("/admin/" + editState.site + "/update-playlist-item/" + editState.screen, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
old_name: editState.oldName, old_name: editState.oldName,
new_url: newUrl, new_url: isUrl ? newUrl : null,
zoom: zoom, zoom: zoom,
passthrough: document.getElementById("edit-passthrough-input").checked passthrough: isUrl ? document.getElementById("edit-passthrough-input").checked : null,
schedule: schedule || {}
}) })
}).then(function (r) { }).then(function (r) {
if (r.ok) { if (r.ok) {
@@ -652,27 +968,117 @@ document.addEventListener("DOMContentLoaded", function () {
}); });
</script> </script>
<!-- Modal: URL bearbeiten --> <!-- Modal: URL bearbeiten -->
<div class="modal fade" id="edit-url-modal" tabindex="-1"> <div class="modal fade" id="edit-url-modal" tabindex="-1">
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">URL bearbeiten</h5> <h5 class="modal-title" id="edit-url-modal-title">URL bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3" id="edit-url-row">
<label class="form-label">URL</label> <label class="form-label">URL</label>
<input type="url" class="form-control" id="edit-url-input"> <input type="url" class="form-control" id="edit-url-input">
</div> </div>
<div class="mb-3"> <div class="mb-3" id="edit-zoom-row">
<label class="form-label">Zoom Faktor (z.B. 1.0, 1.5, 2.0)</label> <label class="form-label">Zoom Faktor (z.B. 1.0, 1.5, 2.0)</label>
<input type="number" class="form-control" id="edit-zoom-input" step="0.1" min="0.1" value="1.0"> <input type="number" class="form-control" id="edit-zoom-input" step="0.1" min="0.1" value="1.0">
</div> </div>
<div class="mb-3 form-check"> <div class="mb-3 form-check" id="edit-passthrough-row">
<input type="checkbox" class="form-check-input" id="edit-passthrough-input"> <input type="checkbox" class="form-check-input" id="edit-passthrough-input">
<label class="form-check-label" for="edit-passthrough-input">Query-Parameter an diese URL übergeben</label> <label class="form-check-label" for="edit-passthrough-input">Query-Parameter an diese URL übergeben</label>
</div> </div>
<!-- Zeitsteuerung: gleiche schlichte Zeilen wie der Rest des Dialogs -->
<div class="mb-3 form-check">
<input class="form-check-input" type="checkbox" id="edit-schedule-enabled">
<label class="form-check-label" for="edit-schedule-enabled">Zeitgesteuerte Wiedergabe</label>
</div>
<div id="edit-schedule-options" style="display: none;">
<div class="mb-3">
<label class="form-label">Wiederholung</label>
<select class="form-select" id="edit-schedule-mode">
<option value="once">Einmalig</option>
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
</select>
</div>
<!-- Einmalig: Datum + Uhrzeit -->
<div class="row mb-3" id="edit-schedule-datetime">
<div class="col">
<label class="form-label">Start</label>
<input type="datetime-local" class="form-control" id="edit-schedule-start">
</div>
<div class="col">
<label class="form-label">Ende</label>
<input type="datetime-local" class="form-control" id="edit-schedule-end">
</div>
</div>
<!-- Täglich / Wöchentlich: nur Uhrzeit -->
<div class="row mb-3" id="edit-schedule-time" style="display: none;">
<div class="col">
<label class="form-label">Von</label>
<input type="time" class="form-control" id="edit-schedule-start-time">
</div>
<div class="col">
<label class="form-label">Bis</label>
<input type="time" class="form-control" id="edit-schedule-end-time">
</div>
</div>
<div class="mb-3" id="edit-schedule-validity" style="display: none;">
<label class="form-label">Gültig von / bis <span class="text-muted">(optional)</span></label>
<div class="row">
<div class="col">
<input type="date" class="form-control" id="edit-schedule-valid-from">
</div>
<div class="col">
<input type="date" class="form-control" id="edit-schedule-valid-until">
</div>
</div>
<small class="form-hint">Ohne Angabe läuft die Wiedergabe unbefristet.</small>
</div>
<div class="mb-3" id="edit-schedule-days" style="display: none;">
<label class="form-label">Wochentage</label>
<div class="d-flex gap-2">
<div class="form-check">
<input class="form-check-input" type="checkbox" value="0" id="edit-mon">
<label class="form-check-label" for="edit-mon">Mo</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="1" id="edit-tue">
<label class="form-check-label" for="edit-tue">Di</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="2" id="edit-wed">
<label class="form-check-label" for="edit-wed">Mi</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="3" id="edit-thu">
<label class="form-check-label" for="edit-thu">Do</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="4" id="edit-fri">
<label class="form-check-label" for="edit-fri">Fr</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="5" id="edit-sat">
<label class="form-check-label" for="edit-sat">Sa</label>
</div>
<div class="form-check">
<input class="form-check-input" type="checkbox" value="6" id="edit-sun">
<label class="form-check-label" for="edit-sun">So</label>
</div>
</div>
</div>
</div>
<div class="alert alert-danger py-2" id="edit-url-error" style="display:none"></div> <div class="alert alert-danger py-2" id="edit-url-error" style="display:none"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

@@ -121,6 +121,9 @@
<div class="card mt-4"> <div class="card mt-4">
<div class="card-header"> <div class="card-header">
<h3 class="card-title"><i class="ti ti-history me-1"></i>Verlauf</h3> <h3 class="card-title"><i class="ti ti-history me-1"></i>Verlauf</h3>
{% if history_total %}
<span class="card-subtitle text-secondary small ms-auto">{{ history_from }}–{{ history_to }} von {{ history_total }}</span>
{% endif %}
</div> </div>
<div class="card-body p-0"> <div class="card-body p-0">
<div class="list-group list-group-flush"> <div class="list-group list-group-flush">
@@ -171,6 +174,29 @@
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
{% if history_pages > 1 %}
<div class="card-footer d-flex align-items-center">
<p class="m-0 text-secondary small">Seite {{ history_page }} von {{ history_pages }}</p>
<ul class="pagination m-0 ms-auto">
<li class="page-item {% if history_page <= 1 %}disabled{% endif %}">
<a class="page-link" href="?page={{ history_page - 1 }}"><i class="ti ti-chevron-left"></i> zurück</a>
</li>
{# Nur ein Fenster um die aktuelle Seite zeigen, sonst wird die Leiste bei vielen Seiten zu lang #}
{% for p in range(1, history_pages + 1) %}
{% if p == 1 or p == history_pages or (p >= history_page - 2 and p <= history_page + 2) %}
<li class="page-item {% if p == history_page %}active{% endif %}">
<a class="page-link" href="?page={{ p }}">{{ p }}</a>
</li>
{% elif p == history_page - 3 or p == history_page + 3 %}
<li class="page-item disabled"><span class="page-link">…</span></li>
{% endif %}
{% endfor %}
<li class="page-item {% if history_page >= history_pages %}disabled{% endif %}">
<a class="page-link" href="?page={{ history_page + 1 }}">weiter <i class="ti ti-chevron-right"></i></a>
</li>
</ul>
</div>
{% endif %}
</div> </div>
</div> </div>

View File

@@ -83,7 +83,8 @@
<!-- ═══════════════════════════════════════════════════ <!-- ═══════════════════════════════════════════════════
CONTENT – Willkommensseite-Formular CONTENT – Willkommensseite-Formular
Bis zu 3 Kundennamen eingeben → Logos via OpenAI + Brandfetch Bis zu 3 Kundennamen eingeben → Logos via OpenAI + Brandfetch
→ welcome.html wird generiert und in Lobby-Playlist platziert → welcome.html wird generiert und in den Playlists aller Screens
mit "welcome_enabled" vorne platziert
═══════════════════════════════════════════════════ --> ═══════════════════════════════════════════════════ -->
<div class="page-wrapper"> <div class="page-wrapper">
<div class="container-xl mt-4"> <div class="container-xl mt-4">
@@ -103,6 +104,14 @@
{% if success %} {% if success %}
<div class="alert alert-success">{{ success }}</div> <div class="alert alert-success">{{ success }}</div>
{% endif %} {% endif %}
{% if logo_hinweise %}
<div class="alert alert-warning">
<strong>Hinweise zu den hochgeladenen Logos:</strong>
<ul class="mb-0">
{% for h in logo_hinweise %}<li>{{ h }}</li>{% endfor %}
</ul>
</div>
{% endif %}
<!-- ─── Logo-Vorschau (wird nach POST angezeigt) ─── --> <!-- ─── Logo-Vorschau (wird nach POST angezeigt) ─── -->
<div class="row g-3 mb-4"> <div class="row g-3 mb-4">
@@ -121,41 +130,67 @@
</div> </div>
<!-- ─── Eingabeformular (bis zu 3 Kunden) ─── --> <!-- ─── Eingabeformular (bis zu 3 Kunden) ─── -->
<form method="POST"> <form method="POST" enctype="multipart/form-data">
<input type="hidden" name="site" value="{{ site }}"> <input type="hidden" name="site" value="{{ site }}">
{# Echte Zeilenumbrueche statt &#10; - in einer Variablen wuerde Jinja
das kaufmaennische Und escapen und die Entity waere als Text sichtbar. #}
{% set slots = [
("z. B. Beispiel GmbH", "**Max Mustermann**\n+49 123 4567890\n*E-Mail*: max@beispiel.de"),
("z. B. Muster AG", "**Maria Schmidt**\n+49 456 7890123\n*E-Mail*: maria@muster.de"),
("z. B. Firma GmbH", "**Sabine Weber**\n+49 789 0123456\n*E-Mail*: sabine@firma.de")
] %}
<div class="row g-3 mb-3"> <div class="row g-3 mb-3">
{% for i in range(3) %}
<div class="col-md-4"> <div class="col-md-4">
<label class="form-label" for="customer_name_1">Kunde 1</label> <label class="form-label" for="customer_name_{{ i + 1 }}">Kunde {{ i + 1 }}{% if i > 0 %} (optional){% endif %}</label>
<input type="text" class="form-control" id="customer_name_1" <input type="text" class="form-control" id="customer_name_{{ i + 1 }}"
name="customer_name_1" placeholder="z. B. Beispiel GmbH" name="customer_name_{{ i + 1 }}" placeholder="{{ slots[i][0] }}"
value="{{ customer_names[0] if customer_names else '' }}"> value="{{ customer_names[i] if customer_names else '' }}">
<label class="form-label mt-2" for="contact_1">Ansprechpartner (optional)</label>
<textarea class="form-control" id="contact_1" name="contact_1" rows="4" <label class="form-label mt-2" for="contact_{{ i + 1 }}">Ansprechpartner (optional)</label>
placeholder="**Max Mustermann**&#10;+49 123 4567890&#10;*E-Mail*: max@beispiel.de">{{ contacts[0] if contacts else '' }}</textarea> <textarea class="form-control" id="contact_{{ i + 1 }}" name="contact_{{ i + 1 }}" rows="4"
placeholder="{{ slots[i][1] }}">{{ contacts[i] if contacts else '' }}</textarea>
<hr class="mt-4 mb-3">
<label class="form-label" for="logo_file_{{ i + 1 }}">Logo hochladen (optional)</label>
<input type="file" class="form-control logo-datei" id="logo_file_{{ i + 1 }}"
name="logo_file_{{ i + 1 }}" accept="image/*"
data-status="logo_status_{{ i + 1 }}">
<div class="form-hint" id="logo_status_{{ i + 1 }}">Keine Datei gewählt</div>
<div class="form-check mt-1">
<input class="form-check-input" type="checkbox"
id="logo_keep_bg_{{ i + 1 }}" name="logo_keep_bg_{{ i + 1 }}">
<label class="form-check-label small" for="logo_keep_bg_{{ i + 1 }}">Hintergrund behalten</label>
</div> </div>
<div class="col-md-4">
<label class="form-label" for="customer_name_2">Kunde 2 (optional)</label>
<input type="text" class="form-control" id="customer_name_2"
name="customer_name_2" placeholder="z. B. Muster AG"
value="{{ customer_names[1] if customer_names else '' }}">
<label class="form-label mt-2" for="contact_2">Ansprechpartner (optional)</label>
<textarea class="form-control" id="contact_2" name="contact_2" rows="4"
placeholder="**Maria Schmidt**&#10;+49 456 7890123&#10;*E-Mail*: maria@muster.de">{{ contacts[1] if contacts else '' }}</textarea>
</div> </div>
<div class="col-md-4"> {% endfor %}
<label class="form-label" for="customer_name_3">Kunde 3 (optional)</label>
<input type="text" class="form-control" id="customer_name_3"
name="customer_name_3" placeholder="z. B. Firma GmbH"
value="{{ customer_names[2] if customer_names else '' }}">
<label class="form-label mt-2" for="contact_3">Ansprechpartner (optional)</label>
<textarea class="form-control" id="contact_3" name="contact_3" rows="4"
placeholder="**Sabine Weber**&#10;+49 789 0123456&#10;*E-Mail*: sabine@firma.de">{{ contacts[2] if contacts else '' }}</textarea>
</div> </div>
<div class="text-muted mb-2">
Das System sucht automatisch nach den Kundenlogos und erstellt eine angepasste Willkommensseite.
</div>
<div class="text-muted small mb-3">
<i class="ti ti-info-circle me-1"></i>Ein hochgeladenes Logo hat Vorrang vor der automatischen Suche.
Der Hintergrund wird automatisch freigestellt, damit das Logo auf der Willkommensseite nicht in einem
weissen Kasten steht. Am besten funktioniert das bei einem Logo auf einfarbigem Grund; ein PNG mit
bereits transparentem Hintergrund wird unverändert übernommen.
</div>
<hr class="mt-4 mb-3">
<div class="mb-3">
<label class="form-label" for="valid_until">Anzeigen bis <span class="text-muted">(optional)</span></label>
<input type="date" class="form-control" id="valid_until" name="valid_until"
value="{{ request.form.get('valid_until', '') }}" style="max-width: 260px;">
<div class="form-hint">Ohne Angabe bleibt die Willkommensseite stehen, bis sie von Hand entfernt wird. Mit Datum verschwindet sie nach diesem Tag auf allen Screens von selbst.</div>
</div> </div>
<div class="mb-3 text-muted"> <div class="mb-3 text-muted">
<p class="mb-1">Das System sucht automatisch nach den Kundenlogos</p> <p class="mb-1">Sie erscheint auf allen Screens dieses Standorts, bei denen im Tab „Einstellungen" die Option <em>Willkommensseite hier anzeigen</em> aktiv ist{% if welcome_screens %} – aktuell: <strong>{{ welcome_screens | join(', ') }}</strong>{% else %} – <strong>aktuell keiner</strong>{% endif %}.</p>
<p class="mb-0">und erstellt eine angepasste Willkommensseite für die Lobby-Playlist.</p>
<p class="mb-0 small"><i class="ti ti-markdown me-1"></i>Ansprechpartner unterstützen <strong>fett</strong>, <em>kursiv</em>, <code>Code</code>, Aufzählungen (<code>- </code>) sowie Zeilenumbrüche.</p> <p class="mb-0 small"><i class="ti ti-markdown me-1"></i>Ansprechpartner unterstützen <strong>fett</strong>, <em>kursiv</em>, <code>Code</code>, Aufzählungen (<code>- </code>) sowie Zeilenumbrüche.</p>
</div> </div>
@@ -215,12 +250,26 @@
</div> </div>
</div> </div>
<footer class="app-footer">
{% include "_footer.html" %} {% include "_footer.html" %}
</footer> </div> <!-- /page -->
<script> <script>
/* Gewaehlte Logo-Datei sichtbar machen. Ein Neuladen der Seite - etwa beim
Wechsel des Standorts - leert Datei-Felder, ohne dass man es bemerkt. */
document.querySelectorAll(".logo-datei").forEach(function (feld) {
var status = document.getElementById(feld.dataset.status);
feld.addEventListener("change", function () {
if (feld.files && feld.files.length) {
var kb = Math.round(feld.files[0].size / 1024);
status.textContent = "Gewählt: " + feld.files[0].name + " (" + kb + " KB)";
status.className = "form-hint text-success";
} else {
status.textContent = "Keine Datei gewählt";
status.className = "form-hint";
}
});
});
</script> </script>
</body> </body>
</html> </html>

View File

@@ -51,6 +51,7 @@
<a class="nav-link py-1 px-2 small" href="#help-standort"><i class="ti ti-building-community me-1"></i>Standort</a> <a class="nav-link py-1 px-2 small" href="#help-standort"><i class="ti ti-building-community me-1"></i>Standort</a>
<a class="nav-link py-1 px-2 small" href="#help-screen"><i class="ti ti-devices me-1"></i>Screen</a> <a class="nav-link py-1 px-2 small" href="#help-screen"><i class="ti ti-devices me-1"></i>Screen</a>
<a class="nav-link py-1 px-2 small" href="#help-playlist"><i class="ti ti-list me-1"></i>Playlist</a> <a class="nav-link py-1 px-2 small" href="#help-playlist"><i class="ti ti-list me-1"></i>Playlist</a>
<a class="nav-link py-1 px-2 small" href="#help-zeitsteuerung"><i class="ti ti-clock me-1"></i>Zeitsteuerung</a>
<a class="nav-link py-1 px-2 small" href="#help-priority"><i class="ti ti-player-play me-1"></i>Priority</a> <a class="nav-link py-1 px-2 small" href="#help-priority"><i class="ti ti-player-play me-1"></i>Priority</a>
<a class="nav-link py-1 px-2 small" href="#help-actions"><i class="ti ti-external-link me-1"></i>Action Button</a> <a class="nav-link py-1 px-2 small" href="#help-actions"><i class="ti ti-external-link me-1"></i>Action Button</a>
<a class="nav-link py-1 px-2 small" href="#help-voice"><i class="ti ti-robot me-1"></i>Voice Agent</a> <a class="nav-link py-1 px-2 small" href="#help-voice"><i class="ti ti-robot me-1"></i>Voice Agent</a>
@@ -103,22 +104,80 @@
<li><strong>Medien hochladen:</strong> Im Tab "Medien" eine Datei auswählen und hochladen. Unterstützt werden Bilder (JPG, PNG) und Videos (MP4).</li> <li><strong>Medien hochladen:</strong> Im Tab "Medien" eine Datei auswählen und hochladen. Unterstützt werden Bilder (JPG, PNG) und Videos (MP4).</li>
<li><strong>URL hinzufügen:</strong> Im Tab "Playlist" eine URL eingeben und optional den Zoom-Faktor anpassen.</li> <li><strong>URL hinzufügen:</strong> Im Tab "Playlist" eine URL eingeben und optional den Zoom-Faktor anpassen.</li>
<li><strong>Sortieren:</strong> Per Drag & Drop die Reihenfolge der Playlist-Einträge ändern (Ziehgriff links).</li> <li><strong>Sortieren:</strong> Per Drag & Drop die Reihenfolge der Playlist-Einträge ändern (Ziehgriff links).</li>
<li><strong>Deaktivieren:</strong> Mit der Checkbox einzelne Einträge temporär ausblenden.</li> <li><strong>Deaktivieren:</strong> Mit der Checkbox einzelne Einträge temporär ausblenden. Die Checkbox ist ein reiner Schalter und hat mit der Zeitsteuerung nichts zu tun: Bei einem zeitgesteuerten Eintrag bleibt der Haken gesetzt, auch wenn der Eintrag gerade nicht läuft. Ob er läuft, zeigt das Zeit-Badge – <span class="badge bg-info text-white">Zeit</span> blau = läuft gerade, <span class="badge bg-secondary-lt">Zeit</span> grau = ausserhalb des Zeitfensters. Abgespielt wird nur, was <em>beides</em> erfüllt: Haken gesetzt und im Zeitfenster.</li>
<li><strong>Löschen:</strong> Über den roten Papierkorb-Button.</li> <li><strong>Löschen:</strong> Über den roten Papierkorb-Button. Die Datei wird dabei entfernt <em>und</em> der Eintrag aus der Playlist genommen.</li>
<li><strong>Bearbeiten:</strong> Der <em>Edit</em>-Button öffnet einen Dialog. Bei URLs lassen sich dort Adresse, Zoom, Query-Parameter-Passthrough und Zeitsteuerung ändern; bei Bildern, Videos und HTML-Seiten die Zeitsteuerung.</li>
<li><strong>Zeitsteuerung:</strong> Einträge können nur zu bestimmten Zeiten laufen – siehe <a href="#help-zeitsteuerung">Zeitsteuerung</a>. Ein Badge <span class="badge bg-info text-white">Zeit</span> in der Playlist-Zeile zeigt an, dass ein Eintrag zeitgesteuert ist; der Tooltip nennt die Einstellung im Klartext.</li>
<li><strong>Query-Parameter-Passthrough:</strong> Per URL-Edit-Button (Stift-Icon) im Playlist-Eintrag kann pro URL gesteuert werden, ob Query-Parameter von der Player-URL an die Playlist-URL übergeben werden. Standardmäßig <strong>aus</strong> – Checkbox "Query-Parameter an diese URL übergeben" aktivieren wenn gewünscht. Ein Link-Icon in der Playlist-Zeile zeigt den Status: <i class="ti ti-link"></i> = an, <i class="ti ti-link-off"></i> = aus.</li> <li><strong>Query-Parameter-Passthrough:</strong> Per URL-Edit-Button (Stift-Icon) im Playlist-Eintrag kann pro URL gesteuert werden, ob Query-Parameter von der Player-URL an die Playlist-URL übergeben werden. Standardmäßig <strong>aus</strong> – Checkbox "Query-Parameter an diese URL übergeben" aktivieren wenn gewünscht. Ein Link-Icon in der Playlist-Zeile zeigt den Status: <i class="ti ti-link"></i> = an, <i class="ti ti-link-off"></i> = aus.</li>
</ul> </ul>
<p class="text-muted small mb-0"><i class="ti ti-info-circle me-1"></i>Änderungen an der Playlist werden automatisch gespeichert. Der Player lädt die Seite automatisch neu (via Hash-Prüfung).</p> <p class="text-muted small mb-0"><i class="ti ti-info-circle me-1"></i>Änderungen an der Playlist werden automatisch gespeichert. Der Player lädt die Seite automatisch neu (via Hash-Prüfung).</p>
</div> </div>
</div> </div>
<div class="card mb-3">
<div class="card-header"><h3 class="card-title" id="help-zeitsteuerung"><i class="ti ti-clock me-1"></i>Zeitsteuerung</h3></div>
<div class="card-body">
<p>Jeder Playlist-Eintrag – URL, Bild, Video oder HTML-Seite – kann nur zu bestimmten Zeiten laufen. Das gilt für die Playlist eines Screens genauso wie für die Priority-Playlist.</p>
<p><strong>Einstellen:</strong> beim Anlegen im Tab "Medien" unter <em>Zeitsteuerung</em>, oder nachträglich über den <em>Edit</em>-Button des Eintrags. Häkchen bei "Zeitgesteuerte Wiedergabe" setzen und einen Modus wählen:</p>
<table class="table table-sm">
<thead>
<tr><th style="width:110px">Modus</th><th>Bedeutung</th><th>Felder</th></tr>
</thead>
<tbody>
<tr>
<td><strong>Einmalig</strong></td>
<td>Läuft einmal in einem zusammenhängenden Zeitraum – auch über mehrere Tage am Stück.</td>
<td>Start und Ende jeweils mit Datum <em>und</em> Uhrzeit</td>
</tr>
<tr>
<td><strong>Täglich</strong></td>
<td>Läuft jeden Tag im selben Zeitfenster.</td>
<td>Von / Bis als Uhrzeit, optional "Gültig von / bis"</td>
</tr>
<tr>
<td><strong>Wöchentlich</strong></td>
<td>Wie täglich, aber nur an den angehakten Wochentagen.</td>
<td>Von / Bis als Uhrzeit, Wochentage, optional "Gültig von / bis"</td>
</tr>
</tbody>
</table>
<p><strong>Gültig von / bis</strong> ist optional und begrenzt, an welchen <em>Tagen</em> das Zeitfenster überhaupt greift. Beide Grenzen lassen sich einzeln setzen und zählen den genannten Tag mit. Ohne Angabe läuft die Wiedergabe unbefristet.</p>
<p class="mb-1"><strong>Beispiele:</strong></p>
<ul>
<li>Täglich, 08:00 bis 12:00, ohne Gültigkeitszeitraum → jeden Vormittag, unbefristet.</li>
<li>Täglich, 08:00 bis 12:00, gültig vom 10.09. bis 20.09. → nur an diesen elf Tagen.</li>
<li>Wöchentlich, 09:00 bis 17:00, Mo/Mi/Fr → nur an diesen drei Tagen während der Bürozeit.</li>
<li>Täglich, 22:00 bis 02:00 → abends bis in die Nacht hinein, über Mitternacht hinweg.</li>
</ul>
<p class="mb-1"><strong>Gut zu wissen:</strong></p>
<ul>
<li>Bei "Täglich" und "Wöchentlich" zählt nur die <em>Uhrzeit</em> – ein Datum wird ausschliesslich unter "Gültig von / bis" angegeben.</li>
<li>Bei "Täglich" gilt automatisch jeder Wochentag; die Auswahl der Wochentage erscheint deshalb nur bei "Wöchentlich".</li>
<li>Ein Fenster über Mitternacht gehört zum Tag seines <em>Starts</em>: "Mi 22:00–02:00" läuft Mittwochabend bis Donnerstagfrüh.</li>
<li>Die Checkbox links im Playlist-Eintrag wirkt weiterhin unabhängig davon – ein deaktivierter Eintrag läuft auch innerhalb seines Zeitfensters nicht.</li>
<li>Der Player merkt von selbst, wenn ein Zeitfenster beginnt oder endet, und lädt neu. Ein manueller Eingriff am Screen ist nicht nötig.</li>
</ul>
<p class="text-muted small mb-0"><i class="ti ti-info-circle me-1"></i>Es gilt die Uhrzeit des Servers, nicht die des Players.</p>
</div>
</div>
<div class="card mb-3"> <div class="card mb-3">
<div class="card-header"><h3 class="card-title" id="help-priority"><i class="ti ti-player-play me-1"></i>Priority-Playlist</h3></div> <div class="card-header"><h3 class="card-title" id="help-priority"><i class="ti ti-player-play me-1"></i>Priority-Playlist</h3></div>
<div class="card-body"> <div class="card-body">
<p>Die Priority-Playlist ist <strong>global</strong> und wird auf <em>allen</em> Playern eines Standorts eingeblendet – zwischen den normalen Playlist-Einträgen.</p> <p>Die Priority-Playlist gilt <strong>pro Standort</strong>. Ihre Inhalte werden auf <em>allen</em> Screens dieses Standorts eingeblendet – zwischen den normalen Playlist-Einträgen.</p>
<ul> <ul>
<li>Aufruf über <code>/admin/&lt;standort&gt;/priority</code></li> <li>Aufruf über <code>/admin/&lt;standort&gt;/priority</code></li>
<li>Globale Medien und URLs verwalten, ebenfalls per Drag & Drop</li> <li>Medien und URLs dieses Standorts verwalten, ebenfalls per Drag & Drop</li>
<li>Priority muss in der Konfiguration aktiviert sein (<code>config.json → priority.enabled</code>)</li> <li>Priority muss für den Standort aktiviert sein (<code>config.json → sites.&lt;standort&gt;.priority.enabled</code>)</li>
<li>Die <a href="#help-zeitsteuerung">Zeitsteuerung</a> steht hier genauso zur Verfügung wie in den Screen-Playlists</li>
<li>Jeder Standort hat seine <strong>eigene</strong> Priority-Playlist – Inhalte eines Standorts erscheinen nie auf den Screens eines anderen.</li>
<li><strong>Vorschau in den Screen-Playlisten:</strong> Die Priority-Einträge werden zusätzlich oberhalb jeder normalen Playlist angezeigt – ausgegraut und mit rotem <span class="badge bg-red text-red-fg">Prio</span>-Badge. Das ist eine <em>reine Anzeige</em>: Sie lässt sich dort nicht sortieren, bearbeiten oder löschen. So ist beim Bearbeiten einer Screen-Playlist sofort sichtbar, was dem Screen ohnehin noch vorweg läuft, ohne dass man in den anderen Menüpunkt wechseln muss. Geändert wird weiterhin ausschliesslich auf der Priority-Seite.</li>
</ul> </ul>
</div> </div>
</div> </div>
@@ -190,9 +249,16 @@
<div class="card-header"><h3 class="card-title" id="help-willkommen"><i class="ti ti-users me-1"></i>Willkommensseite</h3></div> <div class="card-header"><h3 class="card-title" id="help-willkommen"><i class="ti ti-users me-1"></i>Willkommensseite</h3></div>
<div class="card-body"> <div class="card-body">
<p>Auf der Willkommensseite können bis zu 3 Kundennamen eingegeben werden. Die Logos werden automatisch via OpenAI + Brandfetch gesucht.</p> <p>Auf der Willkommensseite können bis zu 3 Kundennamen eingegeben werden. Die Logos werden automatisch via OpenAI + Brandfetch gesucht.</p>
<p class="text-muted small"><i class="ti ti-lock-open me-1"></i><strong>Ohne Anmeldung nutzbar:</strong> Das Formular kann auch ohne Benutzerkonto aufgerufen werden – etwa am Empfang. Wer angemeldet ist, sieht weiterhin nur die eigenen Standorte; ohne Anmeldung stehen alle zur Auswahl. Im Aktivitätsverlauf erscheinen solche Einträge als „nicht angemeldet".</p>
<ul> <ul>
<li>Aufruf über <code>/willkommen?site=&lt;standort&gt;</code> (oder Menüpunkt)</li> <li>Aufruf über <code>/willkommen?site=&lt;standort&gt;</code> (oder Menüpunkt)</li>
<li>Die generierte <code>welcome.html</code> wird automatisch an den Anfang der Lobby-Playlist gesetzt</li> <li><strong>Zielscreens:</strong> Die generierte <code>welcome.html</code> wird automatisch an den Anfang der Playlist <em>jedes</em> Screens gesetzt, bei dem im Tab "Einstellungen" die Option <em>Willkommensseite hier anzeigen</em> aktiv ist. So lässt sich dieselbe Begrüßung auf mehreren Screens zeigen, ohne sie mehrfach anzulegen. Ist an einem Standort nichts eingestellt, geht sie wie bisher in die Lobby.</li>
<li><strong>Der Schalter wirkt sofort:</strong> Schaltest du ihn bei einem weiteren Screen ein, erscheint eine bereits erzeugte Willkommensseite dort umgehend in der Playlist – ohne dass du sie neu generieren musst. Schaltest du ihn aus, verschwindet sie dort wieder.</li>
<li><strong>Anzeigen bis:</strong> Optionales Datum im Formular. Ist es gesetzt, verschwindet die Willkommensseite nach diesem Tag auf allen Screens von selbst – kein manuelles Aufräumen nach dem Besuch. Ohne Angabe bleibt sie stehen.</li>
<li><strong>Logo hochladen:</strong> Findet die automatische Suche kein Logo oder passt es nicht, kann pro Kunde eine eigene Bilddatei hochgeladen werden. Ein Upload hat immer Vorrang vor der automatischen Suche.</li>
<li><strong>Freistellen:</strong> Der Hintergrund eines hochgeladenen Logos wird automatisch entfernt, damit es auf der Willkommensseite nicht in einem weissen Kasten steht. Das gelingt am besten bei einem Logo auf einfarbigem Grund – ein PNG mit bereits transparentem Hintergrund wird unverändert übernommen. Bei unruhigem Bildrand – oder wenn das Logo farblich zu nah am Hintergrund liegt und beim Freistellen verschwinden würde – bleibt das Bild unangetastet und es erscheint ein Hinweis. Mit „Hintergrund behalten" lässt sich das Freistellen abschalten.</li>
<li><strong>Grösse:</strong> Nach dem Freistellen werden leere Ränder weggeschnitten, damit das hochgeladene Logo auf der Willkommensseite genauso gross erscheint wie ein automatisch gefundenes.</li>
<li>Ein einmal hochgeladenes Logo bleibt beim erneuten Generieren erhalten, solange der Kundenname im selben Feld steht.</li>
<li>Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Tab "Medien" im Standort-Dashboard)</li> <li>Pro Standort kann ein eigenes Hintergrundbild hochgeladen werden (Tab "Medien" im Standort-Dashboard)</li>
<li>Pro Kunde können <strong>Ansprechpartner</strong> eingegeben werden (Markdown-Format): <li>Pro Kunde können <strong>Ansprechpartner</strong> eingegeben werden (Markdown-Format):
<ul> <ul>
@@ -244,6 +310,7 @@
<li><strong>Sparkline:</strong> Kleine Liniendiagramme zeigen den Verlauf der letzten Änderungen</li> <li><strong>Sparkline:</strong> Kleine Liniendiagramme zeigen den Verlauf der letzten Änderungen</li>
<li><strong>Trend:</strong> Pfeil nach oben/unten mit Netto-Änderung aus den letzten 30 Aktionen</li> <li><strong>Trend:</strong> Pfeil nach oben/unten mit Netto-Änderung aus den letzten 30 Aktionen</li>
<li><strong>Aktivitätsverlauf:</strong> Alle Erstell-, Lösch-, Änderungsaktionen sowie An-/Abmeldungen werden protokolliert</li> <li><strong>Aktivitätsverlauf:</strong> Alle Erstell-, Lösch-, Änderungsaktionen sowie An-/Abmeldungen werden protokolliert</li>
<li><strong>Blättern:</strong> Der Verlauf zeigt 50 Einträge je Seite; über die Leiste unter der Liste lässt sich weiter zurückblättern. Aufbewahrt werden die letzten 1000 Einträge. Statistik-Cards, Sparklines und Trendpfeile beziehen sich dabei immer auf die jüngsten 30 Aktionen und ändern sich beim Blättern nicht.</li>
</ul> </ul>
</div> </div>
</div> </div>

View File

@@ -46,7 +46,7 @@
<input class="form-check-input" type="checkbox" id="remember" name="remember" value="7"> <input class="form-check-input" type="checkbox" id="remember" name="remember" value="7">
<label class="form-check-label" for="remember"> <label class="form-check-label" for="remember">
Gerät für Gerät für
<select class="form-select form-select-sm d-inline-block w-auto" id="remember_days" onchange="document.getElementById('remember').value=this.value"> <select class="form-select form-select-sm d-inline-block w-auto" id="remember_days">
<option value="1">1 Tag</option> <option value="1">1 Tag</option>
<option value="3">3 Tage</option> <option value="3">3 Tage</option>
<option value="7" selected>7 Tage</option> <option value="7" selected>7 Tage</option>
@@ -61,10 +61,13 @@
var cb = document.getElementById('remember'); var cb = document.getElementById('remember');
var sel = document.getElementById('remember_days'); var sel = document.getElementById('remember_days');
cb.addEventListener('change', function() { cb.addEventListener('change', function() {
if (this.checked) this.value = sel.value; this.value = sel.value;
}); });
// Wer im Dropdown eine Dauer waehlt, will das Geraet merken:
// Checkbox automatisch mit aktivieren.
sel.addEventListener('change', function() { sel.addEventListener('change', function() {
if (cb.checked) cb.value = this.value; cb.checked = true;
cb.value = this.value;
}); });
})(); })();
</script> </script>

View File

@@ -472,7 +472,8 @@ function playNext() {
} }
// Datei aus dem Medienverzeichnis // Datei aus dem Medienverzeichnis
const src = entry.isPrio ? `/media/priority/${item.name}` : `/media/${site}/${basePath}/${item.name}`; // ?v=<mtime> erlaubt dem Browser, die Datei ohne Rueckfrage aus dem Cache zu nehmen
const src = `/media/${site}/${basePath}/${item.name}` + (item.v ? `?v=${item.v}` : "");
if (isVideo(item)) { if (isVideo(item)) {
vid.style.display = "block"; vid.style.display = "block";
vid.src = src; vid.src = src;

View File

@@ -147,12 +147,13 @@
{% if file.type == "image" %} {% if file.type == "image" %}
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
data-bs-html="true" data-bs-html="true"
title="<img src='/media/priority/{{ file.name }}' style='max-width: 200px; max-height: 150px; border: none; background: white; padding: 2px;' alt='Vorschau'>" title="<img src='/media/{{ current_site }}/priority/{{ file.name }}' style='max-width: 200px; max-height: 150px; border: none; background: white; padding: 2px;' alt='Vorschau'>"
{% elif file.type == "url" %} {% elif file.type == "url" %}
data-bs-toggle="tooltip" data-bs-toggle="tooltip"
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}" title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"
{% endif %}> {% endif %}>
{{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %} {{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% endif %}{% if file.type == "url" %}{% if file.get('passthrough', False) %} <i class="ti ti-link" style="color:#6c757d;" title="Query-Parameter werden übergeben"></i>{% else %} <i class="ti ti-link-off" style="color:#adb5bd;" title="Query-Parameter werden nicht übergeben"></i>{% endif %}{% endif %}
{% if file.schedule %}<span class="badge ms-1 {% if file.active %}bg-info text-white{% else %}bg-secondary-lt{% endif %}" title="{{ file.schedule | schedule_text }}{% if not file.active %} — laeuft gerade nicht{% endif %}">Zeit</span>{% endif %}
</span> </span>
{% if file.type == "image" %} {% if file.type == "image" %}
@@ -183,22 +184,20 @@
</button> </button>
</form> </form>
<!-- Bearbeiten (nur URLs) --> <!-- Bearbeiten -->
{% if file.type == "url" %}
<button type="button" <button type="button"
class="btn btn-outline-secondary btn-sm edit-url-btn" class="btn btn-outline-secondary btn-sm edit-url-btn"
title="URL bearbeiten" title="Bearbeiten"
data-name="{{ file.name | e }}" data-name="{{ file.name | e }}"
data-type="{{ file.type }}"
data-zoom="{{ file.zoom | default(1.0) }}" data-zoom="{{ file.zoom | default(1.0) }}"
data-site="{{ current_site }}" data-site="{{ current_site }}"
data-screen="priority" data-screen="priority"
data-oldname="{{ file.name | e }}" data-oldname="{{ file.name | e }}"
data-passthrough="{{ 'true' if file.get('passthrough', False) else 'false' }}"> data-passthrough="{{ 'true' if file.get('passthrough', False) else 'false' }}"
data-schedule='{{ file.schedule | tojson }}'>
Edit Edit
</button> </button>
{% else %}
<span></span>
{% endif %}
<!-- Drag --> <!-- Drag -->
<span class="drag-handle" title="Reihenfolge ändern">⇅</span> <span class="drag-handle" title="Reihenfolge ändern">⇅</span>
@@ -235,6 +234,77 @@
<label class="form-label">Zoom Faktor (z.B. 1.0, 1.5, 2.0)</label> <label class="form-label">Zoom Faktor (z.B. 1.0, 1.5, 2.0)</label>
<input type="number" name="zoom" class="form-control" step="0.1" value="1.0" min="0.1"> <input type="number" name="zoom" class="form-control" step="0.1" value="1.0" min="0.1">
</div> </div>
<!-- Zeitsteuerung -->
<hr class="mt-4 mb-3">
<h3>Zeitsteuerung</h3>
<div class="mb-2 form-check">
<input class="form-check-input" type="checkbox" id="schedule-enabled-priority" name="schedule_enabled">
<label class="form-check-label" for="schedule-enabled-priority">Zeitgesteuerte Wiedergabe</label>
</div>
<div class="mb-3" id="schedule-options-priority" style="display: none;">
<div class="mb-3">
<label class="form-label">Wiederholung</label>
<select class="form-select" name="schedule_mode">
<option value="once">Einmalig</option>
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
</select>
</div>
<!-- Einmalig: Datum + Uhrzeit -->
<div class="row mb-3" id="schedule-datetime-priority">
<div class="col">
<label class="form-label">Start</label>
<input type="datetime-local" class="form-control" name="schedule_start">
</div>
<div class="col">
<label class="form-label">Ende</label>
<input type="datetime-local" class="form-control" name="schedule_end">
</div>
</div>
<!-- Täglich / Wöchentlich: nur Uhrzeit -->
<div class="row mb-3" id="schedule-time-priority" style="display: none;">
<div class="col">
<label class="form-label">Von</label>
<input type="time" class="form-control" name="schedule_start_time">
</div>
<div class="col">
<label class="form-label">Bis</label>
<input type="time" class="form-control" name="schedule_end_time">
</div>
</div>
<div class="mb-3" id="schedule-validity-priority" style="display: none;">
<label class="form-label">Gültig von / bis <span class="text-muted">(optional)</span></label>
<div class="row">
<div class="col">
<input type="date" class="form-control" name="schedule_valid_from">
</div>
<div class="col">
<input type="date" class="form-control" name="schedule_valid_until">
</div>
</div>
<small class="form-hint">Ohne Angabe läuft die Wiedergabe unbefristet.</small>
</div>
<div class="mb-3" id="schedule-days-priority" style="display: none;">
<label class="form-label">Wochentage</label>
<div class="d-flex gap-2">
{% for nr, tag in [(0, 'Mo'), (1, 'Di'), (2, 'Mi'), (3, 'Do'), (4, 'Fr'), (5, 'Sa'), (6, 'So')] %}
<div class="form-check">
<input class="form-check-input" type="checkbox" name="schedule_days" value="{{ nr }}" id="prio-day-{{ nr }}">
<label class="form-check-label" for="prio-day-{{ nr }}">{{ tag }}</label>
</div>
{% endfor %}
</div>
</div>
</div>
<button class="btn btn-primary" type="submit">URL hinzufügen</button> <button class="btn btn-primary" type="submit">URL hinzufügen</button>
</form> </form>
</div> </div>
@@ -254,7 +324,38 @@
<script> <script>
/* ─── Global: editState ─── */ /* ─── Global: editState ─── */
var editState = { site: "", screen: "", oldName: "" }; var editState = { site: "", screen: "", oldName: "", type: "url" };
/* Uhrzeit aus "HH:MM" oder einem vollen ISO-Zeitstempel herausziehen */
function scheduleClock(value) {
if (!value) return "";
var text = String(value).replace(" ", "T");
var idx = text.indexOf("T");
if (idx !== -1) text = text.substring(idx + 1);
return text.substring(0, 5);
}
/* Sichtbarkeit der Zeitsteuerungs-Felder im Modal an den Modus koppeln.
Muss auf Top-Level stehen - der Edit-Handler unten läuft ausserhalb
von DOMContentLoaded. */
function updateScheduleDaysVisibility() {
var modeEl = document.getElementById("edit-schedule-mode");
if (!modeEl) return;
var wiederkehrend = modeEl.value === "daily" || modeEl.value === "weekly";
var daysEl = document.getElementById("edit-schedule-days");
var validityEl = document.getElementById("edit-schedule-validity");
var datetimeEl = document.getElementById("edit-schedule-datetime");
var timeEl = document.getElementById("edit-schedule-time");
// Wochentage nur bei "wöchentlich" - bei "täglich" gilt ohnehin jeder Tag
if (daysEl) daysEl.style.display = modeEl.value === "weekly" ? "" : "none";
// Gültigkeitszeitraum ergänzt die Tageszeit; bei "einmalig" steckt das
// Datum bereits in Start und Ende.
if (validityEl) validityEl.style.display = wiederkehrend ? "" : "none";
if (datetimeEl) datetimeEl.style.display = wiederkehrend ? "none" : "";
if (timeEl) timeEl.style.display = wiederkehrend ? "" : "none";
}
/* Event-Delegation für Edit-Buttons (global, sofort aktiv) */ /* Event-Delegation für Edit-Buttons (global, sofort aktiv) */
document.addEventListener("click", function (e) { document.addEventListener("click", function (e) {
@@ -264,9 +365,57 @@ document.addEventListener("click", function (e) {
editState.site = btn.dataset.site; editState.site = btn.dataset.site;
editState.screen = btn.dataset.screen; editState.screen = btn.dataset.screen;
editState.oldName = btn.dataset.oldname; editState.oldName = btn.dataset.oldname;
editState.type = btn.dataset.type || "url";
var isUrl = editState.type === "url";
document.getElementById("edit-url-input").value = btn.dataset.oldname; document.getElementById("edit-url-input").value = btn.dataset.oldname;
document.getElementById("edit-zoom-input").value = btn.dataset.zoom; document.getElementById("edit-zoom-input").value = btn.dataset.zoom;
document.getElementById("edit-passthrough-input").checked = btn.dataset.passthrough === "true"; document.getElementById("edit-passthrough-input").checked = btn.dataset.passthrough === "true";
/* Bei Dateien gibt es keine URL, keinen Zoom und kein Passthrough -
dann bleibt nur die Zeitsteuerung übrig. */
document.getElementById("edit-url-row").style.display = isUrl ? "" : "none";
document.getElementById("edit-zoom-row").style.display = isUrl ? "" : "none";
document.getElementById("edit-passthrough-row").style.display = isUrl ? "" : "none";
document.getElementById("edit-url-modal-title").textContent =
isUrl ? "URL bearbeiten" : "Zeitsteuerung: " + btn.dataset.oldname;
var schedule = JSON.parse(btn.dataset.schedule || "null");
var enabledEl = document.getElementById("edit-schedule-enabled");
var optionsEl = document.getElementById("edit-schedule-options");
if (schedule) {
enabledEl.checked = true;
optionsEl.style.display = "";
document.getElementById("edit-schedule-mode").value = schedule.mode || "once";
var wiederkehrend = (schedule.mode === "daily" || schedule.mode === "weekly");
document.getElementById("edit-schedule-start").value = wiederkehrend ? "" : (schedule.start || "");
document.getElementById("edit-schedule-end").value = wiederkehrend ? "" : (schedule.end || "");
document.getElementById("edit-schedule-start-time").value = scheduleClock(schedule.start);
document.getElementById("edit-schedule-end-time").value = scheduleClock(schedule.end);
document.getElementById("edit-schedule-valid-from").value = schedule.valid_from || "";
document.getElementById("edit-schedule-valid-until").value = schedule.valid_until || "";
var days = schedule.days || [];
for (var i = 0; i < 7; i++) {
document.getElementById("edit-prio-day-" + i).checked = days.indexOf(String(i)) !== -1;
}
} else {
enabledEl.checked = false;
optionsEl.style.display = "none";
document.getElementById("edit-schedule-start").value = "";
document.getElementById("edit-schedule-end").value = "";
document.getElementById("edit-schedule-start-time").value = "";
document.getElementById("edit-schedule-end-time").value = "";
document.getElementById("edit-schedule-valid-from").value = "";
document.getElementById("edit-schedule-valid-until").value = "";
for (var j = 0; j < 7; j++) {
document.getElementById("edit-prio-day-" + j).checked = false;
}
}
updateScheduleDaysVisibility();
document.getElementById("edit-url-error").style.display = "none"; document.getElementById("edit-url-error").style.display = "none";
new bootstrap.Modal(document.getElementById("edit-url-modal")).show(); new bootstrap.Modal(document.getElementById("edit-url-modal")).show();
}); });
@@ -319,13 +468,48 @@ document.addEventListener("DOMContentLoaded", function () {
return new bootstrap.Tooltip(tooltipTriggerEl); return new bootstrap.Tooltip(tooltipTriggerEl);
}); });
/* ─── Zeitsteuerung: Änderungs-Events im Modal ─── */
document.getElementById("edit-schedule-enabled").addEventListener("change", function () {
document.getElementById("edit-schedule-options").style.display = this.checked ? "" : "none";
});
document.getElementById("edit-schedule-mode").addEventListener("change", updateScheduleDaysVisibility);
updateScheduleDaysVisibility();
/* ─── "URL hinzufügen"-Formular: Zeitsteuerungs-Felder ein-/ausblenden ─── */
(function () {
var cb = document.getElementById("schedule-enabled-priority");
if (!cb) return;
var optionsEl = document.getElementById("schedule-options-priority");
var daysEl = document.getElementById("schedule-days-priority");
var validityEl = document.getElementById("schedule-validity-priority");
var datetimeEl = document.getElementById("schedule-datetime-priority");
var timeEl = document.getElementById("schedule-time-priority");
var modeEl = cb.closest("form").querySelector('[name="schedule_mode"]');
function sync() {
var mode = modeEl ? modeEl.value : "once";
var wiederkehrend = (mode === "daily" || mode === "weekly");
if (optionsEl) optionsEl.style.display = cb.checked ? "" : "none";
if (daysEl) daysEl.style.display = (cb.checked && mode === "weekly") ? "" : "none";
if (validityEl) validityEl.style.display = (cb.checked && wiederkehrend) ? "" : "none";
if (datetimeEl) datetimeEl.style.display = wiederkehrend ? "none" : "";
if (timeEl) timeEl.style.display = wiederkehrend ? "" : "none";
}
cb.addEventListener("change", sync);
if (modeEl) modeEl.addEventListener("change", sync);
sync();
})();
/* ─── Edit-URL-Modal: Speichern-Button ─── */ /* ─── Edit-URL-Modal: Speichern-Button ─── */
document.getElementById("edit-url-save").addEventListener("click", function () { document.getElementById("edit-url-save").addEventListener("click", function () {
const isUrl = editState.type === "url";
const newUrl = document.getElementById("edit-url-input").value.trim(); const newUrl = document.getElementById("edit-url-input").value.trim();
const zoom = parseFloat(document.getElementById("edit-zoom-input").value) || 1.0; const zoom = parseFloat(document.getElementById("edit-zoom-input").value) || 1.0;
const errorEl = document.getElementById("edit-url-error"); const errorEl = document.getElementById("edit-url-error");
if (!newUrl.startsWith("http://") && !newUrl.startsWith("https://")) { if (isUrl && !newUrl.startsWith("http://") && !newUrl.startsWith("https://")) {
errorEl.textContent = "Ungültige URL (muss mit http:// oder https:// beginnen)"; errorEl.textContent = "Ungültige URL (muss mit http:// oder https:// beginnen)";
errorEl.style.display = "block"; errorEl.style.display = "block";
return; return;
@@ -333,14 +517,62 @@ document.addEventListener("DOMContentLoaded", function () {
errorEl.style.display = "none"; errorEl.style.display = "none";
// Zeitsteuerung sammeln
const scheduleEnabled = document.getElementById("edit-schedule-enabled").checked;
let schedule = null;
if (scheduleEnabled) {
const mode = document.getElementById("edit-schedule-mode").value;
const wiederkehrend = (mode === "daily" || mode === "weekly");
// Einmalig arbeitet mit Datum + Uhrzeit, täglich/wöchentlich nur mit Uhrzeit
const start = wiederkehrend
? document.getElementById("edit-schedule-start-time").value
: document.getElementById("edit-schedule-start").value;
const end = wiederkehrend
? document.getElementById("edit-schedule-end-time").value
: document.getElementById("edit-schedule-end").value;
if (!start || !end) {
errorEl.textContent = wiederkehrend
? "Von- und Bis-Uhrzeit müssen angegeben werden"
: "Start und Ende müssen angegeben werden";
errorEl.style.display = "block";
return;
}
schedule = { mode: mode, start: start, end: end };
if (wiederkehrend) {
const validFrom = document.getElementById("edit-schedule-valid-from").value;
const validUntil = document.getElementById("edit-schedule-valid-until").value;
if (validFrom && validUntil && validFrom > validUntil) {
errorEl.textContent = "\"Gültig von\" liegt nach \"Gültig bis\"";
errorEl.style.display = "block";
return;
}
if (validFrom) schedule.valid_from = validFrom;
if (validUntil) schedule.valid_until = validUntil;
}
if (mode === "weekly") {
const days = [];
for (var i = 0; i < 7; i++) {
if (document.getElementById("edit-prio-day-" + i).checked) days.push(String(i));
}
schedule.days = days;
}
}
fetch("/admin/" + editState.site + "/update-playlist-item/" + editState.screen, { fetch("/admin/" + editState.site + "/update-playlist-item/" + editState.screen, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
old_name: editState.oldName, old_name: editState.oldName,
new_url: newUrl, new_url: isUrl ? newUrl : null,
zoom: zoom, zoom: zoom,
passthrough: document.getElementById("edit-passthrough-input").checked passthrough: isUrl ? document.getElementById("edit-passthrough-input").checked : null,
schedule: schedule || {}
}) })
}).then(function (r) { }).then(function (r) {
if (r.ok) { if (r.ok) {
@@ -365,22 +597,90 @@ document.addEventListener("DOMContentLoaded", function () {
<div class="modal-dialog"> <div class="modal-dialog">
<div class="modal-content"> <div class="modal-content">
<div class="modal-header"> <div class="modal-header">
<h5 class="modal-title">URL bearbeiten</h5> <h5 class="modal-title" id="edit-url-modal-title">URL bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button> <button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="mb-3"> <div class="mb-3" id="edit-url-row">
<label class="form-label">URL</label> <label class="form-label">URL</label>
<input type="url" class="form-control" id="edit-url-input"> <input type="url" class="form-control" id="edit-url-input">
</div> </div>
<div class="mb-3"> <div class="mb-3" id="edit-zoom-row">
<label class="form-label">Zoom Faktor (z.B. 1.0, 1.5, 2.0)</label> <label class="form-label">Zoom Faktor (z.B. 1.0, 1.5, 2.0)</label>
<input type="number" class="form-control" id="edit-zoom-input" step="0.1" min="0.1" value="1.0"> <input type="number" class="form-control" id="edit-zoom-input" step="0.1" min="0.1" value="1.0">
</div> </div>
<div class="mb-3 form-check"> <div class="mb-3 form-check" id="edit-passthrough-row">
<input type="checkbox" class="form-check-input" id="edit-passthrough-input"> <input type="checkbox" class="form-check-input" id="edit-passthrough-input">
<label class="form-check-label" for="edit-passthrough-input">Query-Parameter an diese URL übergeben</label> <label class="form-check-label" for="edit-passthrough-input">Query-Parameter an diese URL übergeben</label>
</div> </div>
<!-- Zeitsteuerung -->
<div class="mb-3 form-check">
<input class="form-check-input" type="checkbox" id="edit-schedule-enabled">
<label class="form-check-label" for="edit-schedule-enabled">Zeitgesteuerte Wiedergabe</label>
</div>
<div id="edit-schedule-options" style="display: none;">
<div class="mb-3">
<label class="form-label">Wiederholung</label>
<select class="form-select" id="edit-schedule-mode">
<option value="once">Einmalig</option>
<option value="daily">Täglich</option>
<option value="weekly">Wöchentlich</option>
</select>
</div>
<!-- Einmalig: Datum + Uhrzeit -->
<div class="row mb-3" id="edit-schedule-datetime">
<div class="col">
<label class="form-label">Start</label>
<input type="datetime-local" class="form-control" id="edit-schedule-start">
</div>
<div class="col">
<label class="form-label">Ende</label>
<input type="datetime-local" class="form-control" id="edit-schedule-end">
</div>
</div>
<!-- Täglich / Wöchentlich: nur Uhrzeit -->
<div class="row mb-3" id="edit-schedule-time" style="display: none;">
<div class="col">
<label class="form-label">Von</label>
<input type="time" class="form-control" id="edit-schedule-start-time">
</div>
<div class="col">
<label class="form-label">Bis</label>
<input type="time" class="form-control" id="edit-schedule-end-time">
</div>
</div>
<div class="mb-3" id="edit-schedule-validity" style="display: none;">
<label class="form-label">Gültig von / bis <span class="text-muted">(optional)</span></label>
<div class="row">
<div class="col">
<input type="date" class="form-control" id="edit-schedule-valid-from">
</div>
<div class="col">
<input type="date" class="form-control" id="edit-schedule-valid-until">
</div>
</div>
<small class="form-hint">Ohne Angabe läuft die Wiedergabe unbefristet.</small>
</div>
<div class="mb-3" id="edit-schedule-days" style="display: none;">
<label class="form-label">Wochentage</label>
<div class="d-flex gap-2">
{% for nr, tag in [(0, 'Mo'), (1, 'Di'), (2, 'Mi'), (3, 'Do'), (4, 'Fr'), (5, 'Sa'), (6, 'So')] %}
<div class="form-check">
<input class="form-check-input" type="checkbox" value="{{ nr }}" id="edit-prio-day-{{ nr }}">
<label class="form-check-label" for="edit-prio-day-{{ nr }}">{{ tag }}</label>
</div>
{% endfor %}
</div>
</div>
</div>
<div class="alert alert-danger py-2" id="edit-url-error" style="display:none"></div> <div class="alert alert-danger py-2" id="edit-url-error" style="display:none"></div>
</div> </div>
<div class="modal-footer"> <div class="modal-footer">

View File

@@ -19,7 +19,6 @@
"trusted_devices": [] "trusted_devices": []
}, },
"erik.thiele@cancom.de": { "erik.thiele@cancom.de": {
"password_hash": "scrypt:32768:8:1$D8XYprsEYMO5EPrG$2829b18f77bbd7d9f0cbb945b0bdbc32e16d7889f8711881f82dd35accd26ddb7c31d3fa85e3b89d19d6d1f0b92d8d18765e1c82044188cab8fd0db2d35a0d15",
"role": "admin", "role": "admin",
"sites": [], "sites": [],
"must_change_password": false, "must_change_password": false,
@@ -27,24 +26,13 @@
"last_name": "Thiele", "last_name": "Thiele",
"department": "CSO", "department": "CSO",
"notes": "Standort Stuttgart", "notes": "Standort Stuttgart",
"mfa_secret": "LR5DYI3SQPMW5XWKO33HPETHY5TPOWRG",
"mfa_enabled": true,
"mfa_recovery_codes": [
"7BBD93",
"EB1D3B",
"BEE291",
"D471D5",
"D43C00",
"75DBAC",
"8B00CB",
"7AF72D"
],
"trusted_devices": [ "trusted_devices": [
{ {
"token": "5kMgj4JPxd2IHuzV5MagAAcYyi_DBImyGimBSvZUNVg", "token": "5kMgj4JPxd2IHuzV5MagAAcYyi_DBImyGimBSvZUNVg",
"expires": "2026-07-24T15:40:28.955130" "expires": "2026-07-24T15:40:28.955130"
} }
] ],
"password_hash": "scrypt:32768:8:1$9k9WAwHOnYTdKY4Q$869e9bec6a838c212543a228edc7a516933b37b9494bb1d10424f18adef3ea132d4c340ab939ca023c22ab0d4def13ad64c44a648f64ce519c2097adc0537f92"
}, },
"test1@test.de": { "test1@test.de": {
"password_hash": "scrypt:32768:8:1$35kBhL79TJyVA3Rz$98b2e269db5ae96c9285323a274a0cc5b199fb45078e7530b5e72cdb9b96d5b522aa18fd06ce0f801bd19ec68ed323ffa04ce484cfb849eba73b9260ca6a50e1", "password_hash": "scrypt:32768:8:1$35kBhL79TJyVA3Rz$98b2e269db5ae96c9285323a274a0cc5b199fb45078e7530b5e72cdb9b96d5b522aa18fd06ce0f801bd19ec68ed323ffa04ce484cfb849eba73b9260ca6a50e1",