Compare commits

..

2 Commits

Author SHA1 Message Date
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
8 changed files with 273 additions and 51 deletions

1
.gitignore vendored
View File

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

View File

@@ -121,6 +121,9 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- Gemeinsame HTML-Bausteine: `_header.html`, `_footer.html`, `_styles.html` (CSS-Variablen `--ccm-*`, Dark Mode, Card-Border-Radius 1rem).
- `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>/logos/logo_<slot>.png`, Auslieferung über `/media/<site>/logos/<datei>`. Die URL traegt einen `?v=<timestamp>`, damit Player kein altes Logo aus dem Cache zeigen.
- `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.
- `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.
- 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.
- `delete_screen` entfernt den Screen aus der Config und löscht das Verzeichnis rekursiv.

View File

@@ -10,7 +10,7 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens.
- Bilder, Videos (MP4/H.264), HTML-Seiten, URL-Playlist-Einträge
- **Multi-Standort**: Standorte (sites) gruppieren Screens
- **Priority-Playlist**: globale Inhalte wirken auf alle Player
- **Willkommensseite**: bis zu 3 Kundenlogos via OpenAI + Brandfetch, pro Standort konfigurierbares Hintergrundbild; erscheint auf allen Screens mit `welcome_enabled` (mehrere möglich), optional mit Ablaufdatum
- **Willkommensseite**: 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
- Auto-Reload bei Playlist-Änderungen
- Newsticker pro Screen
@@ -221,6 +221,7 @@ Server läuft auf `http://localhost:5005` (gunicorn mit 4 Workern, non-root User
| `POST /admin/users/edit/<email>` | User bearbeiten speichern (Admin) |
| `GET /media/<site>/<screen>/<file>` | Medien-Datei ausliefern |
| `GET /media/priority/<file>` | Priority-Medien (global) |
| `GET /media/<site>/logos/<file>` | Manuell hochgeladenes Kundenlogo |
| `GET /media/<site>/background/<filename>` | Hintergrundbild der Willkommensseite |
### Heartbeat / Online-Status
@@ -295,6 +296,10 @@ Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `
**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`. Wird ein Screen später abgewählt, verschwindet die Willkommensseite dort beim nächsten Generieren.
**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>/logos/logo_<slot>.png` abgelegt und über `/media/<site>/logos/<datei>` ausgeliefert.
**Freistellen:** Beim Upload wird der Hintergrund automatisch entfernt, damit das Logo nicht in einem weissen Kasten auf dem Hintergrundbild steht. 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. 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).
@@ -502,6 +507,8 @@ Optional pro Eintrag. Ohne `schedule` läuft der Eintrag immer (sofern `enabled`
- `schedule_text(schedule)` – Jinja-Filter: Klartext einer Zeitsteuerung für Tooltips
- `load_priority_files()` – Priority-Playlist + Dateien laden
- `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
- `prio_redirect(site)` – Redirect-Pfad zur Priority-Seite
- `get_background_url(site)` – URL zum benutzerdefinierten Hintergrundbild oder `None`
- `load_users()` / `save_users()` / `get_user()` – User aus `users.json`

82
app.py
View File

@@ -493,6 +493,14 @@ def media_background(site, filename):
return send_from_directory(path, filename)
@app.route("/media/<site>/logos/<path:filename>")
def media_logo(site, filename):
"""Liefert ein manuell hochgeladenes Kundenlogo aus.
Ohne Login erreichbar wie die übrigen Medien - der Player ist nicht angemeldet."""
path = os.path.join(MEDIA_DIR, site, generate_welcome_page.LOGO_DIRNAME)
return send_from_directory(path, filename)
# -------------------------------------------------
# User-Verwaltung (Flask-Login mit users.json)
# -------------------------------------------------
@@ -901,6 +909,7 @@ def add_customer():
customer_names = ["", "", ""]
contacts = ["", "", ""]
valid_until = (request.form.get("valid_until") or "").strip()
logo_hinweise = []
site = request.args.get("site") or request.form.get("site") or ""
# Fallback auf ersten für den User zugänglichen Standort
@@ -934,31 +943,52 @@ def add_customer():
error = "Kein Standort ausgewählt"
else:
try:
found_logos = []
for name in filled_names:
logo = generate_welcome_page.search_customer_logo(name)
found_logos.append(logo)
# Logo je Kunde bestimmen: ein hochgeladenes Logo hat Vorrang vor
# der automatischen Suche - der Upload ist eine bewusste Entscheidung.
# Zuletzt verwendete Logos, um einen früheren Upload nicht bei jedem
# erneuten Generieren zu verlieren
frueher = load_config().get("sites", {}).get(site, {}).get("welcome_data", {})
frueher_namen = frueher.get("names", [])
frueher_urls = frueher.get("logo_urls", [])
logo_prefix = f"/media/{site}/{generate_welcome_page.LOGO_DIRNAME}/"
valid = [(n, l) for n, l in zip(filled_names, found_logos) if l]
found_logos = [None, None, None]
for i, name in enumerate(customer_names):
if not name:
continue
datei = request.files.get(f"logo_file_{i + 1}")
if datei and datei.filename:
url, hinweis = generate_welcome_page.save_uploaded_logo(
datei, site, i + 1,
freistellen=(f"logo_keep_bg_{i + 1}" not in request.form))
found_logos[i] = url
if hinweis:
logo_hinweise.append(f"{name}: {hinweis}")
elif not url:
logo_hinweise.append(f"{name}: Logo konnte nicht übernommen werden")
continue
# Kein neuer Upload: ein zuvor hochgeladenes Logo weiterverwenden,
# solange der Kundenname in diesem Feld derselbe geblieben ist
alt_url = frueher_urls[i] if i < len(frueher_urls) else None
alt_name = frueher_namen[i] if i < len(frueher_namen) else None
if alt_url and alt_name == name and str(alt_url).startswith(logo_prefix):
found_logos[i] = alt_url
continue
found_logos[i] = generate_welcome_page.search_customer_logo(name)
logo_urls = list(found_logos)
valid = [(customer_names[i], found_logos[i], contacts[i])
for i in range(len(customer_names))
if customer_names[i] and found_logos[i]]
if not valid:
error = "Kein Logo konnte gefunden werden"
error = "Kein Logo gefunden – bitte für die betroffenen Kunden ein Logo hochladen"
else:
valid_names = [n for n, l in valid]
valid_urls = [l for n, l in valid]
valid_contacts = []
for name in valid_names:
for i, cn in enumerate(customer_names):
if cn == name:
valid_contacts.append(contacts[i] if i < len(contacts) else "")
break
else:
valid_contacts.append("")
for i, name in enumerate(customer_names):
for j, (vn, vu) in enumerate(zip(valid_names, valid_urls)):
if vn == name:
logo_urls[i] = vu
break
valid_names = [n for n, u, k in valid]
valid_urls = [u for n, u, k in valid]
valid_contacts = [k for n, u, k in valid]
bg_url = get_background_url(site)
targets = generate_welcome_page.welcome_target_screens(load_config(), site)
@@ -978,7 +1008,14 @@ def add_customer():
# Letzte Kundendaten für Background-Update speichern
config = load_config()
site_cfg = config.setdefault("sites", {}).setdefault(site, {})
site_cfg["welcome_data"] = {"names": valid_names, "logo_urls": valid_urls, "contacts": valid_contacts}
# Feldweise speichern (Slot 1-3), nicht nur die erfolgreichen -
# sonst rutscht Kunde 2 beim naechsten Aufruf in Feld 1 und die
# Zuordnung von Logo und Ansprechpartner verschiebt sich mit.
site_cfg["welcome_data"] = {
"names": list(customer_names),
"logo_urls": list(found_logos),
"contacts": list(contacts)
}
save_config(config)
add_history_entry("customer_added", f"Kunden ({', '.join(valid_names)}) zur Willkommensseite an Standort '{site}' hinzugefügt (Screens: {', '.join(targets)})", current_user.email)
success = (f"✅ Willkommensseite mit {len(valid_names)} Kunden erstellt "
@@ -1019,6 +1056,7 @@ def add_customer():
customer_names=customer_names,
contacts=contacts,
welcome_screens=generate_welcome_page.welcome_target_screens(config, site) if site else [],
logo_hinweise=logo_hinweise,
site=site,
site_list=site_list,
current_site=site,

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

@@ -9,6 +9,7 @@ Zielscreens ein (Screens mit "welcome_enabled", Fallback "lobby").
import os
import json
import time
from openai import OpenAI
BASE_DIR = os.path.dirname(os.path.abspath(__file__))
@@ -32,6 +33,159 @@ def get_openai_client():
# 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 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.")
# 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)))
if not any(px[x, y][3] == 0 for x in range(0, w, max(1, w // 50)) for y in (0, h - 1)):
return img, "Es wurde kein zusammenhängender Hintergrund gefunden."
return img, None
LOGO_DIRNAME = "logos"
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")
logo_dir = os.path.join(MEDIA_DIR, site, LOGO_DIRNAME)
os.makedirs(logo_dir, exist_ok=True)
dateiname = f"logo_{slot}.png"
img.save(os.path.join(logo_dir, dateiname), "PNG")
url = f"/media/{site}/{LOGO_DIRNAME}/{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):
"""Versucht, ein Firmenlogo über Brandfetch CDN abzurufen.
Baut eine direkte CDN-URL: https://cdn.brandfetch.io/{domain}/logo"""

View File

@@ -104,6 +104,14 @@
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% 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) ─── -->
<div class="row g-3 mb-4">
@@ -122,36 +130,46 @@
</div>
<!-- ─── Eingabeformular (bis zu 3 Kunden) ─── -->
<form method="POST">
<form method="POST" enctype="multipart/form-data">
<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">
{% for i in range(3) %}
<div class="col-md-4">
<label class="form-label" for="customer_name_1">Kunde 1</label>
<input type="text" class="form-control" id="customer_name_1"
name="customer_name_1" placeholder="z. B. Beispiel GmbH"
value="{{ customer_names[0] 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"
placeholder="**Max Mustermann**&#10;+49 123 4567890&#10;*E-Mail*: max@beispiel.de">{{ contacts[0] if contacts else '' }}</textarea>
<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_{{ i + 1 }}"
name="customer_name_{{ i + 1 }}" placeholder="{{ slots[i][0] }}"
value="{{ customer_names[i] if customer_names else '' }}">
<label class="form-label mt-2" for="contact_{{ i + 1 }}">Ansprechpartner (optional)</label>
<textarea class="form-control" id="contact_{{ i + 1 }}" name="contact_{{ i + 1 }}" rows="4"
placeholder="{{ slots[i][1] }}">{{ contacts[i] if contacts else '' }}</textarea>
<label class="form-label mt-2" for="logo_file_{{ i + 1 }}">Logo hochladen (optional)</label>
<input type="file" class="form-control" id="logo_file_{{ i + 1 }}"
name="logo_file_{{ i + 1 }}" accept="image/*">
<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 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 class="col-md-4">
<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>
{% endfor %}
</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">

View File

@@ -252,6 +252,9 @@
<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>Wird ein Screen später abgewählt, verschwindet die Willkommensseite dort beim nächsten Generieren automatisch.</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 bleibt das Bild unangetastet und es erscheint ein Hinweis. Mit „Hintergrund behalten" lässt sich das Freistellen abschalten.</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 Kunde können <strong>Ansprechpartner</strong> eingegeben werden (Markdown-Format):
<ul>