Audiofy – Musikwünsche für Partys
Server (Website, Admin-Bereich, Player-API) und Player (raspotify + Controller) mit Install-Skripten für Debian-Systeme. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,450 @@
|
||||
// Audiofy – 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-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;
|
||||
$("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();
|
||||
});
|
||||
|
||||
$("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();
|
||||
Reference in New Issue
Block a user