Compare commits

..

14 Commits

Author SHA1 Message Date
Erik Thiele
7870c889f8 README: OpenAI API-Key-Doku ergänzt 2026-07-22 13:16:20 +02:00
Erik Thiele
6f82bda70c OpenAI-Key aus env var statt Hardcode: OPENAI_API_KEY in generate_welcome_page.py, docker-compose reicht Variable durch 2026-07-22 12:51:52 +02:00
Erik Thiele
bf95b3173b Doku: Link-Icon für Passthrough-Status in Playlist-Zeile beschrieben 2026-07-10 17:46:52 +02:00
Erik Thiele
22104b54cd Query-Parameter-Passthrough per URL steuerbar (Checkbox im Edit-Modal), Standard aus, sichtbar via Link-Icon in Playlist
- Neue Config-Felder: passthrough_params (true/false) pro URL-Item
- Edit-Modal in admin.html + priority.html mit Checkbox
- Player checkt item.passthrough === true vor appendParams()
- Default: false (keine Parameter-Übergabe ohne Checkbox)
- Link-Icon (ti-link / ti-link-off) in Playlist-Zeile als Status-Indikator
- Doku: help.html, README.md, AGENTS.md
2026-07-10 17:44:57 +02:00
Erik Thiele
6efce5b2ee AGENTS.md: Query-Parameter-Passthrough dokumentiert 2026-07-10 17:20:55 +02:00
Erik Thiele
5ee9e0b373 Query-Parameter der Player-URL an Playlist-URLs übergeben
- player_params als dict(request.args) an player.html übergeben
- appendParams() fügt Parameter an iframe.src + Overlay-URLs an
- Funktioniert mit & ohne bestehende Query-Parameter in der Ziel-URL
- Beispiel: /player/stuttgart/lobby?customer=acme → https://dashboard?customer=acme
2026-07-10 17:20:37 +02:00
Erik Thiele
fca129b367 Fix: Leeres Span für Edit-Spalte bei Nicht-URL-Items (Grid-Alignment) 2026-07-10 16:50:01 +02:00
Erik Thiele
1480960801 Button-Reihenfolge: Löschen vor Edit in der Playlist-Zeile 2026-07-10 16:47:41 +02:00
Erik Thiele
b8093521a0 Fix: Edit-Button-Klick-Handler vor DOMContentLoaded ziehen
Die globale Event-Delegation lag versehentlich innerhalb
des DOMContentLoaded-Callbacks. Wenn initSortable fehlschlug,
wurde der Click-Handler nie registriert.
- editState + document click handler jetzt global (vor DOMContentLoaded)
- Duplicate Code entfernt
- Debug-alerts entfernt
2026-07-10 16:40:59 +02:00
Erik Thiele
be00342109 Debug: inline onclick alert test 2026-07-10 16:37:47 +02:00
Erik Thiele
a8cbeb089f Debug: Edit-Button mit globaler Event-Delegation + alert() 2026-07-10 16:35:34 +02:00
Erik Thiele
f1c36f3c99 Switch to event delegation for edit button (robuster) + btn-outline-secondary 2026-07-10 16:34:16 +02:00
Erik Thiele
f8b7a94845 Use 'Edit' text instead of pencil emoji for URL edit button 2026-07-10 16:25:13 +02:00
Erik Thiele
ce4cd4486b URLs in Playlist inline bearbeiten (Edit-Button + Modal)
- Neue Route POST /admin/<site>/update-playlist-item/<screen>
- Bearbeiten-Button (✎) für URL-Items in admin.html + priority.html
- Modal mit URL + Zoom-Feld, Validierung und Speichern
- Grid um Edit-Spalte erweitert (28px|1fr|90px|80px|40px|50px|32px)
2026-07-10 16:24:47 +02:00
11 changed files with 491 additions and 24 deletions

View File

@@ -132,6 +132,7 @@ Standorte (sites) sind die oberste Organisationsebene und gruppieren Screens.
- Neue Standorte können über den `+`-Button im Header oder über `GET /add-site?name=<name>` angelegt werden (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.
- **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.
- 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).
- Tab-Reihenfolge in Screen-Cards: Playlist → Einstellungen → Aktionen → Digital Voice Agent → Medien → Info.

View File

@@ -25,6 +25,7 @@ Browserbasiertes Digital-Signage-System für interne Info-Screens.
- **Admin Dashboard**: `/admin/dashboard` Statistiken (Sites, Screens, User, Admins, Superuser) mit Sparkline-Charts + Trendanzeige, Aktivitätsverlauf
- **Aktivitätsverlauf**: Alle Erstell-/Lösch-/Änderungsaktionen sowie Login/Logout werden in `history.json` protokolliert und im Dashboard angezeigt
- Tab-basierte Admin-UI pro Screen: Playlist, Einstellungen, Aktionen, Digital Voice Agent, Medien, Info (Tabler Tabs)
- **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
- Dark Mode (localStorage-persistiert)
- CI-konformes Admin-UI (CANCOM-Design: `brand-surface`, `nav-surface` rot)
@@ -271,6 +272,17 @@ Beispiel:
http://localhost:5005/player/stuttgart/lobby
```
### Query-Parameter-Passthrough
Die Player-URL akzeptiert beliebige Query-Parameter, z.&thinsp;B. `?customer=acme&token=xyz`. Ob diese Parameter an eine Playlist-URL weitergegeben werden, wird pro URL im Admin-Playlist-Edit-Modal gesteuert (Checkbox "Query-Parameter an diese URL übergeben", standardmäßig aus). In der Playlist-Zeile zeigt ein Link-Icon (`ti-link`, grau) den Status an: sichtbar = an, durchgestrichen (`ti-link-off`, hellgrau) = aus.
Beispiel:
```
/player/stuttgart/lobby?customer=acme&token=xyz
```
- URL in Playlist mit **aktiviertem** Passthrough: `https://dashboard.acme.com/status?customer=acme&token=xyz`
- URL in Playlist mit **deaktiviertem** Passthrough: `https://dashboard.acme.com/status`
### Willkommensseite
```
@@ -292,6 +304,22 @@ Maximal 3 Kunden eingeben → Logos werden via OpenAI + Brandfetch gesucht → `
---
### OpenAI API-Key
Die Logo-Suche via OpenAI benötigt einen API-Key. Diesen als Umgebungsvariable setzen:
```bash
export OPENAI_API_KEY=sk-...
```
Oder via `.env`-Datei (wird von Docker Compose automatisch geladen):
```
OPENAI_API_KEY=sk-...
```
---
### Globale Konfiguration (config.json)
| Feld | Typ | Beschreibung |

61
app.py
View File

@@ -252,7 +252,8 @@ def load_priority_files():
"type": "url",
"size": "URL",
"zoom": url_data.get("zoom", 1.0),
"enabled": playlist_item_enabled(item)
"enabled": playlist_item_enabled(item),
"passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False
})
continue
@@ -980,7 +981,7 @@ def player(site, screen):
if is_url(item):
url_data = normalize_url(item)
if url_data:
normal_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": True})
normal_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": True, "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False})
continue
if item_name and os.path.exists(os.path.join(folder, item_name)) and allowed_file(item_name):
@@ -1010,7 +1011,7 @@ def player(site, screen):
if is_url(item):
url_data = normalize_url(item)
if url_data:
prio_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": True})
prio_files.append({"kind": "url", "url": url_data["url"], "zoom": url_data.get("zoom", 1.0), "enabled": True, "passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False})
continue
file_path = os.path.join(MEDIA_DIR, "priority", item_name)
@@ -1039,7 +1040,8 @@ def player(site, screen):
voice_agent_url=config.get("voice_agent_url", ""),
voice_agent_label=screen_cfg.get("voice_agent_label", "Digitaler Assistent"),
voice_agent_target=screen_cfg.get("voice_agent_target", "overlay"),
voice_agent_position=screen_cfg.get("voice_agent_position", "top-left")
voice_agent_position=screen_cfg.get("voice_agent_position", "top-left"),
player_params=dict(request.args)
)
@@ -1199,7 +1201,8 @@ def admin(site):
"type": "url",
"size": "URL",
"zoom": url_data.get("zoom", 1.0),
"enabled": playlist_item_enabled(item)
"enabled": playlist_item_enabled(item),
"passthrough": item.get("passthrough_params", False) if isinstance(item, dict) else False
})
continue
@@ -1575,6 +1578,54 @@ def save_playlist(site, screen):
return "", 204
# -------------------------------------------------
# Admin: Playlist-Item bearbeiten (URL + Zoom)
# -------------------------------------------------
@app.route("/admin/<site>/update-playlist-item/<screen>", methods=["POST"])
@site_access_required
def update_playlist_item(site, screen):
"""Aktualisiert URL und Zoom eines einzelnen Playlist-Items."""
data = request.get_json()
old_name = data.get("old_name")
new_url = data.get("new_url", "").strip()
zoom = data.get("zoom", 1.0)
if not is_url(new_url):
return jsonify({"error": "Ungültige URL"}), 400
try:
zoom = float(zoom)
if zoom <= 0:
zoom = 1.0
except (ValueError, TypeError):
zoom = 1.0
config = load_config()
if screen == "priority":
playlist = config.setdefault("priority", {}).setdefault("playlist", [])
else:
playlist = config["sites"][site]["screens"][screen].setdefault("playlist", [])
for i, item in enumerate(playlist):
name = playlist_item_name(item)
if name == old_name:
updated = {"url": new_url, "zoom": zoom}
passthrough = data.get("passthrough", None)
if passthrough is not None:
updated["passthrough_params"] = bool(passthrough)
else:
# Bestehenden Wert übernehmen falls vorhanden
existing = item.get("passthrough_params") if isinstance(item, dict) else None
if existing is not None:
updated["passthrough_params"] = existing
playlist[i] = updated
save_config(config)
return jsonify({"success": True})
return jsonify({"error": "Item nicht gefunden"}), 404
# -------------------------------------------------
# Change-Password (für First-Login / Reset)
# -------------------------------------------------

View File

@@ -19,7 +19,10 @@
"show_images": true,
"show_videos": true,
"playlist": [
"welcome.html",
{
"name": "welcome.html",
"enabled": true
},
{
"url": "https://www.meteoblue.com/en/meteotv/d7b0fd",
"zoom": 1.0,
@@ -99,11 +102,6 @@
{
"name": "welcome.html",
"enabled": true
},
{
"url": "https://dva-stage.cancom-ds.de/",
"zoom": 1.0,
"enabled": true
}
],
"interval": 10,

View File

@@ -18,4 +18,5 @@ services:
- ALL
security_opt:
- no-new-privileges:true
environment:
- OPENAI_API_KEY=${OPENAI_API_KEY:-}

View File

@@ -21,9 +21,7 @@ WELCOME_FILENAME = "welcome.html"
# -------------------------------------------------
def get_openai_client():
"""Initialisiert den OpenAI-Client mit dem API-Key.
Der Key ist aktuell direkt im Code hinterlegt sollte in env var ausgelagert werden."""
api_key = "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"
api_key = os.environ.get("OPENAI_API_KEY")
if not api_key:
raise ValueError("OPENAI_API_KEY environment variable not set")
return OpenAI(api_key=api_key)

View File

@@ -1,4 +1,178 @@
[
{
"timestamp": "2026-07-10 16:15:59",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (Trusted Device)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 16:15:56",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 16:15:51",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 16:13:50",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 16:13:46",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 16:11:43",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 16:11:39",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 16:10:18",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 16:10:14",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 16:07:35",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 16:07:27",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:45:29",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:45:20",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:44:47",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:41:11",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:40:59",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:40:56",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 7 Tage gespeichert)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:40:37",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:40:28",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet (mit MFA, Ger\u00e4t f\u00fcr 14 Tage gespeichert)",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:40:03",
"action": "user_logout",
"detail": "User 'erik.thiele@cancom.de' abgemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:39:57",
"action": "mfa_enabled",
"detail": "User 'erik.thiele@cancom.de' hat MFA aktiviert",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:39:32",
"action": "password_changed",
"detail": "Passwort f\u00fcr User 'erik.thiele@cancom.de' ge\u00e4ndert",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:39:22",
"action": "user_login",
"detail": "User 'erik.thiele@cancom.de' angemeldet",
"user": "erik.thiele@cancom.de"
},
{
"timestamp": "2026-07-10 15:39:15",
"action": "user_logout",
"detail": "User 'admin' abgemeldet",
"user": "admin"
},
{
"timestamp": "2026-07-10 15:38:55",
"action": "user_password_reset",
"detail": "Passwort f\u00fcr User 'erik.thiele@cancom.de' zur\u00fcckgesetzt (MFA deaktiviert)",
"user": "admin"
},
{
"timestamp": "2026-07-10 15:38:49",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-07-10 15:31:55",
"action": "user_logout",
"detail": "User 'admin' abgemeldet",
"user": "admin"
},
{
"timestamp": "2026-07-10 15:31:42",
"action": "user_password_reset",
"detail": "Passwort f\u00fcr User 'erik.thiele@cancom.de' zur\u00fcckgesetzt",
"user": "admin"
},
{
"timestamp": "2026-07-10 15:31:14",
"action": "user_login",
"detail": "User 'admin' angemeldet",
"user": "admin"
},
{
"timestamp": "2026-07-02 12:25:24",
"action": "user_login",

View File

@@ -43,11 +43,11 @@
background-color: #ffffff;
}
/* ─── Playlist-Zeile als Grid (Checkbox | Name | Typ | Größe | Löschen | Drag) ─── */
/* ─── Playlist-Zeile als Grid (Checkbox | Name | Typ | Größe | Löschen | Edit | Drag) ─── */
.playlist-row {
display: grid;
grid-template-columns:
28px 1fr 90px 80px 90px 32px;
28px 1fr 90px 80px 50px 40px 32px;
align-items: center;
gap: 0.75rem;
}
@@ -250,7 +250,7 @@
data-bs-toggle="tooltip"
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"
{% endif %}>
{{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% 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 %}
</span>
<!-- Typ-Badge -->
@@ -281,6 +281,23 @@
</button>
</form>
<!-- Bearbeiten (nur URLs) -->
{% if file.type == "url" %}
<button type="button"
class="btn btn-outline-secondary btn-sm edit-url-btn"
title="URL bearbeiten"
data-name="{{ file.name | e }}"
data-zoom="{{ file.zoom | default(1.0) }}"
data-site="{{ current_site }}"
data-screen="{{ screen }}"
data-oldname="{{ file.name | e }}"
data-passthrough="{{ 'true' if file.get('passthrough', False) else 'false' }}">
Edit
</button>
{% else %}
<span></span>
{% endif %}
<!-- Drag-Handle (SortableJS) -->
<span class="drag-handle" title="Reihenfolge ändern"></span>
</li>
@@ -523,9 +540,26 @@
</div> <!-- /page -->
<script>
/* ─── Playlist-Item bearbeiten (URL + Zoom) ─── */
var editState = { site: "", screen: "", oldName: "" };
/* Event-Delegation für Edit-Buttons (global, sofort aktiv) */
document.addEventListener("click", function (e) {
var btn = e.target.closest(".edit-url-btn");
if (!btn) return;
e.preventDefault();
editState.site = btn.dataset.site;
editState.screen = btn.dataset.screen;
editState.oldName = btn.dataset.oldname;
document.getElementById("edit-url-input").value = btn.dataset.oldname;
document.getElementById("edit-zoom-input").value = btn.dataset.zoom;
document.getElementById("edit-passthrough-input").checked = btn.dataset.passthrough === "true";
document.getElementById("edit-url-error").style.display = "none";
new bootstrap.Modal(document.getElementById("edit-url-modal")).show();
});
document.addEventListener("DOMContentLoaded", function () {
/* ─── SortableJS Drag & Drop initialisieren ─── */
function initSortable(name) {
const el = document.getElementById("playlist-" + name);
if (!el || typeof Sortable === "undefined") return;
@@ -577,8 +611,77 @@ document.addEventListener("DOMContentLoaded", function () {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
/* ─── Edit-URL-Modal: Speichern-Button ─── */
document.getElementById("edit-url-save").addEventListener("click", function () {
const newUrl = document.getElementById("edit-url-input").value.trim();
const zoom = parseFloat(document.getElementById("edit-zoom-input").value) || 1.0;
const errorEl = document.getElementById("edit-url-error");
if (!newUrl.startsWith("http://") && !newUrl.startsWith("https://")) {
errorEl.textContent = "Ungültige URL (muss mit http:// oder https:// beginnen)";
errorEl.style.display = "block";
return;
}
errorEl.style.display = "none";
fetch("/admin/" + editState.site + "/update-playlist-item/" + editState.screen, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
old_name: editState.oldName,
new_url: newUrl,
zoom: zoom,
passthrough: document.getElementById("edit-passthrough-input").checked
})
}).then(function (r) {
if (r.ok) {
location.reload();
} else {
return r.json().then(function (d) {
errorEl.textContent = d.error || "Fehler beim Speichern";
errorEl.style.display = "block";
});
}
}).catch(function () {
errorEl.textContent = "Netzwerkfehler";
errorEl.style.display = "block";
});
});
});
</script>
<!-- Modal: URL bearbeiten -->
<div class="modal fade" id="edit-url-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">URL bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">URL</label>
<input type="url" class="form-control" id="edit-url-input">
</div>
<div class="mb-3">
<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">
</div>
<div class="mb-3 form-check">
<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>
</div>
<div class="alert alert-danger py-2" id="edit-url-error" style="display:none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-primary" id="edit-url-save">Speichern</button>
</div>
</div>
</div>
</div>
</body>
</html>

View File

@@ -105,6 +105,7 @@
<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>Löschen:</strong> Über den roten Papierkorb-Button.</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>
<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>

View File

@@ -324,7 +324,7 @@
// ─── Custom-URL/Voice-Agent-Overlay (open/close mit Zurück-Button-Position-Tracking) ───
function openOverlay(url, pos) {
document.getElementById("overlay").style.display = "block";
document.getElementById("overlay-iframe").src = url;
document.getElementById("overlay-iframe").src = appendParams(url, playerParams);
var backBtn = document.getElementById("overlay-back");
backBtn.className = "overlay-back-" + (pos || "top-left");
}
@@ -352,6 +352,14 @@ const interval = {{ interval }} * 1000;
let stayOnFirst = {{ "true" if stay_on_first else "false" }};
const screen = "{{ screen }}";
const site = "{{ site }}";
const playerParams = {{ player_params | tojson }};
// Query-Parameter aus der Player-URL an URLs anhängen
function appendParams(url, params) {
if (!params || Object.keys(params).length === 0) return url;
const sep = url.includes('?') ? '&' : '?';
return url + sep + new URLSearchParams(params).toString();
}
// stayOnFirst: Player bleibt auf erstem Element (kein Timer/Advance)
// ─── Player-Status ───
@@ -448,7 +456,7 @@ function playNext() {
// URL im iframe anzeigen (mit Zoom-Unterstützung)
if (item.kind === "url") {
iframe.style.display = "block";
iframe.src = item.url;
iframe.src = (item.passthrough === true) ? appendParams(item.url, playerParams) : item.url;
const zoom = item.zoom || 1.0;
if (zoom !== 1.0) {
const scale = 1 / zoom;

View File

@@ -29,7 +29,7 @@
.playlist-row {
display: grid;
grid-template-columns:
28px 1fr 90px 80px 90px 32px;
28px 1fr 90px 80px 50px 40px 32px;
align-items: center;
gap: 0.75rem;
}
@@ -152,7 +152,7 @@
data-bs-toggle="tooltip"
title="URL: {{ file.name }}{% if file.zoom %} (Zoom: {{ file.zoom }}x){% endif %}"
{% endif %}>
{{ file.name }}{% if file.type == "url" and file.zoom != 1.0 %} <strong style="color: #DA002D;">[{{ file.zoom }}x]</strong>{% 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 %}
</span>
{% if file.type == "image" %}
@@ -183,6 +183,23 @@
</button>
</form>
<!-- Bearbeiten (nur URLs) -->
{% if file.type == "url" %}
<button type="button"
class="btn btn-outline-secondary btn-sm edit-url-btn"
title="URL bearbeiten"
data-name="{{ file.name | e }}"
data-zoom="{{ file.zoom | default(1.0) }}"
data-site="{{ current_site }}"
data-screen="priority"
data-oldname="{{ file.name | e }}"
data-passthrough="{{ 'true' if file.get('passthrough', False) else 'false' }}">
Edit
</button>
{% else %}
<span></span>
{% endif %}
<!-- Drag -->
<span class="drag-handle" title="Reihenfolge ändern"></span>
</li>
@@ -236,6 +253,24 @@
</div>
<script>
/* ─── Global: editState ─── */
var editState = { site: "", screen: "", oldName: "" };
/* Event-Delegation für Edit-Buttons (global, sofort aktiv) */
document.addEventListener("click", function (e) {
var btn = e.target.closest(".edit-url-btn");
if (!btn) return;
e.preventDefault();
editState.site = btn.dataset.site;
editState.screen = btn.dataset.screen;
editState.oldName = btn.dataset.oldname;
document.getElementById("edit-url-input").value = btn.dataset.oldname;
document.getElementById("edit-zoom-input").value = btn.dataset.zoom;
document.getElementById("edit-passthrough-input").checked = btn.dataset.passthrough === "true";
document.getElementById("edit-url-error").style.display = "none";
new bootstrap.Modal(document.getElementById("edit-url-modal")).show();
});
document.addEventListener("DOMContentLoaded", function () {
function initSortable(name) {
@@ -278,14 +313,83 @@ document.addEventListener("DOMContentLoaded", function () {
window.savePlaylist = savePlaylist;
// Initialize tooltips
/* ─── Bootstrap Tooltips ─── */
var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]'));
var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) {
return new bootstrap.Tooltip(tooltipTriggerEl);
});
/* ─── Edit-URL-Modal: Speichern-Button ─── */
document.getElementById("edit-url-save").addEventListener("click", function () {
const newUrl = document.getElementById("edit-url-input").value.trim();
const zoom = parseFloat(document.getElementById("edit-zoom-input").value) || 1.0;
const errorEl = document.getElementById("edit-url-error");
if (!newUrl.startsWith("http://") && !newUrl.startsWith("https://")) {
errorEl.textContent = "Ungültige URL (muss mit http:// oder https:// beginnen)";
errorEl.style.display = "block";
return;
}
errorEl.style.display = "none";
fetch("/admin/" + editState.site + "/update-playlist-item/" + editState.screen, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
old_name: editState.oldName,
new_url: newUrl,
zoom: zoom,
passthrough: document.getElementById("edit-passthrough-input").checked
})
}).then(function (r) {
if (r.ok) {
location.reload();
} else {
return r.json().then(function (d) {
errorEl.textContent = d.error || "Fehler beim Speichern";
errorEl.style.display = "block";
});
}
}).catch(function () {
errorEl.textContent = "Netzwerkfehler";
errorEl.style.display = "block";
});
});
});
</script>
<!-- Modal: URL bearbeiten -->
<div class="modal fade" id="edit-url-modal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">URL bearbeiten</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="mb-3">
<label class="form-label">URL</label>
<input type="url" class="form-control" id="edit-url-input">
</div>
<div class="mb-3">
<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">
</div>
<div class="mb-3 form-check">
<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>
</div>
<div class="alert alert-danger py-2" id="edit-url-error" style="display:none"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<button type="button" class="btn btn-primary" id="edit-url-save">Speichern</button>
</div>
</div>
</div>
</div>
</body>
</html>