Files
playwish/server/app/static/admin.js
T
99admandClaude Opus 5.5 35985fe66d Admin: eigenen Einladungslink festlegen
Statt des zufälligen Links kann der Admin einen sprechenden Link wählen
(z. B. /z/sommerfest2026, mind. 6 Zeichen: A–Z, a–z, 0–9, - und _).
Alte Zugänge werden dabei ungültig; der QR-Code passt sich an.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 20:53:37 +01:00

465 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Playwish – Admin-Bereich. Daten nur per textContent einsetzen (kein innerHTML mit fremden Inhalten).
"use strict";
const ABFRAGE_MS = 3000;
const QUELLE = { playlist: "Playlist", wish: "Wunsch", owner: "Besitzer", fremd: "Spotify-App" };
const BEFEHL = { play: "Play", pause: "Pause", skip: "Weiter", volume: "Lautstärke",
bt_scan: "Lautsprecher suchen", bt_connect: "Lautsprecher verbinden" };
const $ = (id) => document.getElementById(id);
let csrf = "";
let zustand = null;
let fortschritt = null;
let aktiverReiter = "wiedergabe";
let lautstaerkeZiel = null; // {wert, bis}: gesendeter Wert, bis der Player ihn bestätigt
// --- Hilfen ------------------------------------------------------------------
function el(tag, klasse, text) {
const e = document.createElement(tag);
if (klasse) e.className = klasse;
if (text !== undefined && text !== null) e.textContent = text;
return e;
}
function cover(url, gross) {
const img = el("img", "cover");
img.alt = "";
img.loading = "lazy";
if (url && url.startsWith("https://i.scdn.co/")) img.src = url;
if (gross) img.width = img.height = 72;
return img;
}
function textBlock(titel, interpret, ...extra) {
const t = el("div", "text");
t.append(el("span", "t", titel), el("span", "a", interpret));
extra.filter(Boolean).forEach((x) => t.append(x));
return t;
}
function knopf(text, klasse, aktion, titel) {
const b = el("button", "klein " + (klasse || ""), text);
b.type = "button";
if (titel) b.title = b.ariaLabel = titel;
b.addEventListener("click", async () => {
b.disabled = true;
try { await aktion(); } finally { b.disabled = false; }
});
return b;
}
function uhrzeit(iso) {
return new Date(iso).toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" });
}
let toastTimer = null;
function toast(text, fehler) {
const t = $("toast");
t.textContent = text;
t.className = "toast" + (fehler ? " fehler" : "");
t.hidden = false;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { t.hidden = true; }, 3500);
}
async function api(pfad, daten, methode) {
const optionen = { method: methode || (daten === undefined ? "GET" : "POST"), credentials: "same-origin", headers: {} };
if (optionen.method !== "GET") {
optionen.headers["X-CSRF-Token"] = csrf;
optionen.headers["Content-Type"] = "application/json";
optionen.body = JSON.stringify(daten || {});
}
const antwort = await fetch("/admin/api" + pfad, optionen);
if (antwort.status === 401) { location.href = "/admin/login"; throw new Error("Abgemeldet"); }
let inhalt = null;
try { inhalt = await antwort.json(); } catch (_) { /* leer */ }
if (!antwort.ok) {
const meldung = inhalt && typeof inhalt.detail === "string" ? inhalt.detail : "Fehler " + antwort.status;
toast(meldung, true);
throw new Error(meldung);
}
return inhalt;
}
async function aktion(pfad, daten, meldung) {
await api(pfad, daten);
if (meldung) toast(meldung);
await laden();
}
function setzeWert(id, wert) {
const e = $(id);
if (document.activeElement !== e) e.value = wert; // Eingaben des Besitzers nicht überschreiben
}
function setzeSchalter(id, wert) {
const e = $(id);
if (document.activeElement !== e) e.checked = !!wert;
}
// --- Reiter --------------------------------------------------------------------
function zeigeReiter(name) {
aktiverReiter = name;
document.querySelectorAll("[data-reiter]").forEach((b) => b.setAttribute("aria-selected", String(b.dataset.reiter === name)));
document.querySelectorAll("[data-seite]").forEach((s) => { s.hidden = s.dataset.seite !== name; });
if (name === "verlauf") ladeVerlauf();
if (name === "gaeste") { ladeZugang(); ladeSperrliste(); }
}
// --- Darstellung des Zustands ------------------------------------------------------
function zeigePillen(z) {
const p = $("pillen");
p.replaceChildren();
const st = z.player.state || {};
const pille = (text, gut) => p.append(el("span", "pille " + (gut ? "gut" : "schlecht"), text));
if (z.player.online) pille("Player online", true);
else pille(z.player.last_seen_s === null ? "Player noch nie gesehen" : `Player offline (${Math.round(z.player.last_seen_s / 60)} min)`, false);
if (z.player.online) {
pille(st.device_ok === false ? "Gerät fehlt" : "Gerät ok", st.device_ok !== false);
pille(st.speaker_ok === false ? "Lautsprecher getrennt" : "Lautsprecher ok", st.speaker_ok !== false);
}
if (z.settings.wishes_paused) pille("Wünsche pausiert", false);
if (!z.settings.access_enabled) pille("Gastzugang zu", false);
}
function zeigeWiedergabe(z) {
const st = z.player.state || {};
const box = $("jetzt-inhalt");
box.replaceChildren();
if (z.player.online && st.track) {
const marke = st.source ? el("span", "marke quelle", QUELLE[st.source] || st.source) : null;
box.append(cover(st.track.cover_url, true),
textBlock(st.track.title, st.track.artist, marke, st.is_playing ? null : el("span", "m", "pausiert")));
fortschritt = st.track.duration_ms ? {
basis: (st.progress_ms || 0) + (st.is_playing ? z.player.age_ms || 0 : 0),
dauer: st.track.duration_ms, zeit: performance.now(), spielt: st.is_playing,
} : null;
} else {
box.append(el("span", "hinweis", z.player.online ? "Gerade läuft nichts." : "Der Player meldet sich nicht."));
fortschritt = null;
}
zeichneFortschritt();
$("naechster").textContent = st.next ? `Als Nächstes: ${st.next.title} – ${st.next.artist}` : "";
$("pi-fehler").hidden = !st.error;
$("pi-fehler").textContent = st.error || "";
if (typeof st.volume_percent === "number") {
// Nach einer Änderung den eigenen Wert halten, bis der Player ihn zurückmeldet (max. 15 s)
const ziel = lautstaerkeZiel;
const wartet = ziel && performance.now() < ziel.bis && st.volume_percent !== ziel.wert;
if (!wartet) {
lautstaerkeZiel = null;
setzeWert("lautstaerke", st.volume_percent);
if (document.activeElement !== $("lautstaerke")) $("lautstaerke-wert").textContent = st.volume_percent;
}
}
const stopp = z.settings.emergency_stop;
$("notaus-hinweis").hidden = !stopp;
const symbol = el("span", "symbol", stopp ? "✅" : "⛔");
symbol.setAttribute("aria-hidden", "true");
$("notaus").replaceChildren(symbol, el("span", null, stopp ? "Freigeben" : "Not-Aus"));
$("notaus").className = "gross mit-symbol voll " + (stopp ? "akzent" : "gefahr");
const liste = $("befehle");
liste.replaceChildren();
z.commands.forEach((b) => {
const li = el("li");
const status = { ok: "ausgeführt", fehler: "fehlgeschlagen", wartet: "wartet …", abgelaufen: "nicht abgeholt" }[b.status];
li.append(el("span", "zeit", uhrzeit(b.created_at)), textBlock(BEFEHL[b.type] || b.type, b.message || ""),
el("span", "status " + (b.status === "ok" ? "queued" : b.status === "wartet" ? "pending" : "rejected"), status));
liste.append(li);
});
if (!z.commands.length) liste.append(el("li", "hinweis", "Noch keine Befehle."));
}
function zeichneFortschritt() {
const balken = $("fortschritt-balken");
if (!fortschritt) { balken.style.width = "0"; return; }
const pos = fortschritt.basis + (fortschritt.spielt ? performance.now() - fortschritt.zeit : 0);
balken.style.width = Math.min(100, (pos / fortschritt.dauer) * 100) + "%";
}
function wunschZeile(w, istFreigabe) {
const li = el("li");
const extra = [];
if (w.priority) extra.push(el("span", "marke besitzer", "Besitzer"));
if (w.explicit) extra.push(el("span", "marke e", "E"));
const wer = w.priority ? null : el("span", "m", (w.guest_name ? w.guest_name + " · " : "") + uhrzeit(w.created_at));
const text = textBlock(w.title, w.artist, wer);
extra.forEach((x) => text.querySelector(".t").append(x));
const knoepfe = el("div", "aktionen");
if (istFreigabe) {
knoepfe.append(knopf("✓", "akzent", () => aktion(`/wish/${w.id}/approve`, {}, "Freigegeben"), "Freigeben"),
knopf("✕", "gefahr", () => aktion(`/wish/${w.id}/reject`, {}, "Abgelehnt"), "Ablehnen"));
} else {
knoepfe.append(knopf("↑", "", () => aktion(`/wish/${w.id}/up`, {}), "Nach oben"),
knopf("↓", "", () => aktion(`/wish/${w.id}/down`, {}), "Nach unten"),
knopf("✕", "gefahr", () => aktion(`/wish/${w.id}/delete`, {}, "Entfernt"), "Löschen"));
}
if (w.has_guest) {
knoepfe.append(knopf("🚫", "gefahr", async () => {
if (confirm(`Gast „${w.guest_name || "ohne Namen"}“ sperren und seine offenen Wünsche entfernen?`)) {
await aktion(`/wish/${w.id}/block-guest`, {}, "Gast gesperrt");
}
}, "Gast sperren"));
}
li.append(cover(w.cover_url), text, knoepfe);
return li;
}
function zeigeWuensche(z) {
setzeSchalter("s-annahme", !z.settings.wishes_paused);
setzeSchalter("s-moderation", z.settings.moderation);
const fl = $("freigabe");
fl.replaceChildren(...z.pending.map((w) => wunschZeile(w, true)));
$("freigabe-leer").hidden = z.pending.length > 0;
$("freigabe-karte").hidden = !z.settings.moderation && z.pending.length === 0;
const zahl = $("zahl-freigabe");
zahl.hidden = z.pending.length === 0;
zahl.textContent = z.pending.length;
const ws = $("warteschlange");
ws.replaceChildren(...z.queue.map((w) => wunschZeile(w, false)));
$("warteschlange-leer").hidden = z.queue.length > 0;
}
function zeigeLautsprecher(z) {
const bt = z.player.bluetooth || {};
const box = $("bt-aktuell");
box.replaceChildren();
if (bt.current) {
const status = el("span", "pille " + (bt.current.connected ? "gut" : "schlecht"), bt.current.connected ? "verbunden" : "getrennt");
box.append(textBlock(bt.current.name || "Unbekannter Lautsprecher", bt.current.mac), status);
} else {
box.append(el("span", "hinweis", z.player.online ? "Kein Bluetooth-Lautsprecher eingestellt." : "Player meldet sich nicht."));
}
const suchen = $("bt-suchen");
suchen.disabled = !!bt.scanning || !z.player.online;
suchen.textContent = bt.scanning ? "Läuft …" : "Nach Lautsprechern suchen";
$("bt-status").textContent = bt.scanning ? "Suche bzw. Kopplung läuft – das dauert bis zu 45 Sekunden." :
bt.scanned_at ? `Letzte Suche: ${uhrzeit(bt.scanned_at)} – ${(bt.devices || []).length} Audiogerät(e) gefunden.` : "";
const liste = $("bt-geraete");
liste.replaceChildren(...(bt.devices || []).map((g) => {
const li = el("li");
const istAktuell = bt.current && bt.current.mac === g.mac;
const info = [g.mac, g.paired ? "gekoppelt" : "neu"].join(" · ");
const knoepfe = el("div", "aktionen");
if (!istAktuell) {
const b = knopf("Verbinden", "akzent", async () => {
if (!confirm(`Auf „${g.name || g.mac}“ umstellen? Die Musik setzt dabei kurz aus.`)) return;
await aktion("/command", { type: "bt_connect", mac: g.mac }, "Verbinde – bitte warten …");
});
b.disabled = !!bt.scanning;
knoepfe.append(b);
} else {
knoepfe.append(el("span", "pille gut", "aktuell"));
}
li.append(textBlock(g.name || "Unbekannt", info), knoepfe);
return li;
}));
}
function zeigeEinstellungen(z) {
const e = z.settings;
setzeWert("e-n", e.wish_every_n);
setzeWert("e-sperrzeit", e.repeat_block_minutes);
setzeWert("e-playlist-prozent", e.playlist_repeat_percent);
setzeWert("e-limit-anzahl", e.wish_limit_count);
setzeWert("e-limit-minuten", e.wish_limit_minutes);
setzeSchalter("s-explizit", e.block_explicit);
setzeSchalter("s-zugang", e.access_enabled);
const link = $("playlist-link");
link.textContent = e.playlist_id || "keine";
if (e.playlist_id) link.href = "https://open.spotify.com/playlist/" + encodeURIComponent(e.playlist_id);
}
async function laden() {
try {
zustand = await api("/state");
} catch (_) { return; }
csrf = zustand.csrf;
zeigePillen(zustand);
zeigeWiedergabe(zustand);
zeigeWuensche(zustand);
zeigeEinstellungen(zustand);
zeigeLautsprecher(zustand);
}
// --- Gäste: Zugang & Sperrliste ------------------------------------------------------
async function ladeZugang() {
const z = await api("/access");
setzeSchalter("s-zugang", z.enabled);
$("einladungslink").value = z.link;
$("link-basis").textContent = z.base;
setzeWert("link-eigen", z.token);
$("qr").src = "/admin/api/access/qr?t=" + Date.now();
}
async function ladeSperrliste() {
const daten = await api("/blocklist");
const art = { track: "Titel", artist: "Interpret", guest: "Gast", ip: "Gast (IP)" };
const liste = $("sperrliste");
liste.replaceChildren(...daten.items.map((s) => {
const li = el("li");
li.append(textBlock(s.label || "–", art[s.kind] || s.kind),
knopf("Entsperren", "", async () => { await api(`/blocklist/${s.id}/delete`, {}); toast("Entsperrt"); ladeSperrliste(); }));
return li;
}));
$("sperrliste-leer").hidden = daten.items.length > 0;
}
// --- Suche (Als Nächstes / Sperren) ---------------------------------------------------
function suchfeld(feldId, listeId, zeile) {
let timer = null;
let nr = 0;
$(feldId).addEventListener("input", (ereignis) => {
clearTimeout(timer);
const q = ereignis.target.value.trim();
if (q.length < 2) { nr++; $(listeId).replaceChildren(); return; }
timer = setTimeout(async () => {
const meine = ++nr;
try {
const daten = await api("/search?q=" + encodeURIComponent(q));
if (meine !== nr) return;
$(listeId).replaceChildren(...(daten.results.length ? daten.results.map(zeile) : [el("li", "hinweis", "Nichts gefunden.")]));
} catch (_) { /* Meldung kam schon */ }
}, 400);
});
}
function naechstesZeile(t) {
const li = el("li");
li.append(cover(t.cover_url), textBlock(t.title, t.artist),
knopf("Als Nächstes", "akzent", async () => {
await aktion("/play-next", { track_uri: t.track_uri }, `„${t.title}“ kommt als Nächstes`);
$("naechstes-suche").value = "";
$("naechstes-treffer").replaceChildren();
}));
return li;
}
function sperrZeile(t) {
const li = el("li");
const knoepfe = el("div", "aktionen");
knoepfe.append(knopf("Titel", "gefahr", async () => {
await api("/blocklist", { kind: "track", value: t.track_uri, label: `${t.title} – ${t.artist}` });
toast("Titel gesperrt"); ladeSperrliste();
}, "Titel sperren"));
(t.artists || []).forEach((a) => knoepfe.append(knopf(a.name, "gefahr", async () => {
await api("/blocklist", { kind: "artist", value: a.uri, label: a.name });
toast(`${a.name} gesperrt`); ladeSperrliste();
}, `Interpret ${a.name} sperren`)));
li.append(cover(t.cover_url), textBlock(t.title, t.artist), knoepfe);
return li;
}
// --- Verlauf -----------------------------------------------------------------------
async function ladeVerlauf() {
const daten = await api("/history?limit=200");
$("verlauf").replaceChildren(...daten.items.map((h) => {
const li = el("li");
const quelle = el("span", "marke quelle", QUELLE[h.source] || "?");
const wer = h.guest_name ? el("span", "m", "gewünscht von " + h.guest_name) : null;
const text = textBlock(h.title, h.artist, wer);
text.querySelector(".t").append(quelle);
li.append(el("span", "zeit", uhrzeit(h.started_at)), cover(h.cover_url), text);
return li;
}));
$("verlauf-leer").hidden = daten.items.length > 0;
}
// --- Start ---------------------------------------------------------------------------
function start() {
document.querySelectorAll("[data-reiter]").forEach((b) => b.addEventListener("click", () => zeigeReiter(b.dataset.reiter)));
document.querySelectorAll("[data-befehl]").forEach((b) => b.addEventListener("click", async () => {
b.disabled = true;
try { await aktion("/command", { type: b.dataset.befehl }, "Befehl gesendet"); } catch (_) { /* gemeldet */ }
b.disabled = false;
}));
const regler = $("lautstaerke");
regler.addEventListener("input", () => { $("lautstaerke-wert").textContent = regler.value; });
regler.addEventListener("change", () => {
lautstaerkeZiel = { wert: Number(regler.value), bis: performance.now() + 15000 };
aktion("/command", { type: "volume", percent: Number(regler.value) }, "Lautstärke gesendet")
.catch(() => { lautstaerkeZiel = null; });
});
$("notaus").addEventListener("click", async () => {
const stopp = !(zustand && zustand.settings.emergency_stop);
await aktion("/emergency", { stop: stopp }, stopp ? "Not-Aus ausgelöst" : "Freigegeben").catch(() => {});
});
const schalter = (id, key, umkehren) => $(id).addEventListener("change", (ereignis) => {
const wert = umkehren ? !ereignis.target.checked : ereignis.target.checked;
aktion("/settings", { [key]: wert }, "Gespeichert").catch(() => { ereignis.target.checked = !ereignis.target.checked; });
});
schalter("s-annahme", "wishes_paused", true);
schalter("s-moderation", "moderation");
schalter("s-explizit", "block_explicit");
document.querySelectorAll("[data-speichern]").forEach((b) => b.addEventListener("click", () => {
const werte = {};
b.dataset.speichern.split(",").forEach((paar) => {
const [feld, key] = paar.split(":");
werte[key] = Number($(feld).value);
});
aktion("/settings", werte, "Gespeichert").catch(() => {});
}));
$("s-zugang").addEventListener("change", (ereignis) => {
aktion("/access", { enabled: ereignis.target.checked }, ereignis.target.checked ? "Gastzugang offen" : "Gastzugang geschlossen")
.catch(() => { ereignis.target.checked = !ereignis.target.checked; });
});
$("link-kopieren").addEventListener("click", async () => {
try { await navigator.clipboard.writeText($("einladungslink").value); toast("Link kopiert"); }
catch (_) { $("einladungslink").select(); toast("Bitte manuell kopieren", true); }
});
$("link-erneuern").addEventListener("click", async () => {
if (!confirm("Neuen Link erzeugen? Der alte Link und alle bisherigen Gastzugänge werden ungültig.")) return;
await api("/access/renew", {});
toast("Neuer Link erzeugt");
ladeZugang();
});
$("link-festlegen").addEventListener("click", async () => {
const token = $("link-eigen").value.trim();
if (!/^[A-Za-z0-9_-]{6,64}$/.test(token)) {
toast("Mindestens 6 Zeichen: nur Buchstaben, Ziffern, - und _", true);
return;
}
if (!confirm(`Link auf „${token}“ ändern? Der alte Link und alle bisherigen Gastzugänge werden ungültig.`)) return;
await api("/access/token", { token });
toast("Eigener Link übernommen");
ladeZugang();
});
$("playlist-wechseln").addEventListener("click", async () => {
await aktion("/playlist", { link: $("playlist-neu").value }, "Playlist gewechselt").catch(() => {});
$("playlist-neu").value = "";
});
$("bt-suchen").addEventListener("click", () => aktion("/command", { type: "bt_scan" }, "Suche gestartet").catch(() => {}));
$("neu-mischen").addEventListener("click", () => aktion("/reshuffle", {}, "Wird neu gemischt").catch(() => {}));
$("abmelden").addEventListener("click", async () => {
await api("/logout", {}).catch(() => {});
location.href = "/admin/login";
});
suchfeld("naechstes-suche", "naechstes-treffer", naechstesZeile);
suchfeld("sperr-suche", "sperr-treffer", sperrZeile);
laden();
setInterval(() => { if (!document.hidden) laden(); }, ABFRAGE_MS);
setInterval(zeichneFortschritt, 1000);
document.addEventListener("visibilitychange", () => { if (!document.hidden) laden(); });
}
start();