Files
grotti/web/templates/einstellungen.html
T
Dominic Gundel 4ddffbbeb1 Grotti 3.2.0: Jetzt-Liste, Filament-Pause, Strom-Torte, UI-Polish.
Jetzt/Topbar und Material-Routing, Pause bei Filament-Ende, Druckbericht mit Stromanteilen, Design- und Kamera-Stream-Polish; Bedarf-Alert-Spam und Platte-belegt-Bugs behoben.
2026-10-18 14:58:32 +02:00

980 lines
40 KiB
HTML
Raw Permalink Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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.
{% extends "layout.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Einstellungen</h1>
<p class="tiny">Drucker, Benachrichtigungen und Energiekosten.</p>
</div>
<p class="settings-save-hint" id="settings-autosave-hint" aria-live="polite"></p>
</div>
<div class="settings-layout">
<nav class="settings-nav" aria-label="Abschnitte">
<a href="#sec-browser"><svg class="ico"><use href="#i-monitor"/></svg>Browser</a>
<a href="#sec-auto"><svg class="ico"><use href="#i-zap"/></svg>Automatik</a>
<a href="#sec-slice" class="admin-only"><svg class="ico"><use href="#i-folder"/></svg>Slice</a>
<a href="#sec-fqdn" class="admin-only"><svg class="ico"><use href="#i-link"/></svg>Adresse</a>
<a href="#sec-drucker" class="admin-only"><svg class="ico"><use href="#i-printer"/></svg>Drucker</a>
<a href="#sec-telegram" class="admin-only"><svg class="ico"><use href="#i-message"/></svg>Telegram</a>
<a href="#sec-ntfy" class="admin-only"><svg class="ico"><use href="#i-bell"/></svg>ntfy</a>
<a href="#sec-backup" class="admin-only"><svg class="ico"><use href="#i-hard-drive"/></svg>Backup</a>
<a href="#sec-energie" class="admin-only"><svg class="ico"><use href="#i-zap"/></svg>Energie</a>
<a href="#sec-users" class="admin-only"><svg class="ico"><use href="#i-users"/></svg>Benutzer</a>
<a href="#sec-tokens"><svg class="ico"><use href="#i-key"/></svg>API-Tokens</a>
</nav>
<div class="settings-main">
<section class="card" id="sec-browser">
<h2>Dieser Browser</h2>
<p class="hint">Nur auf diesem Gerät / in diesem Browser. Anderer PC oder Inkognito bleibt unberührt.</p>
<div class="grid-2 narrow-fields">
<div class="field">
<label for="display-mode">Anzeige</label>
<select id="display-mode">
<option value="desktop">Desktop</option>
<option value="shelf">Regal (Touch)</option>
</select>
</div>
<div class="field">
<label for="theme-mode">Darstellung</label>
<select id="theme-mode">
<option value="dark">Dunkel</option>
<option value="light">Hell</option>
<option value="system">System</option>
</select>
</div>
<div class="field">
<label for="tz-mode">Zeitzone</label>
<select id="tz-mode">
<option value="auto">Browser (automatisch)</option>
<option value="Europe/Berlin">Europe/Berlin</option>
<option value="Europe/Vienna">Europe/Vienna</option>
<option value="Europe/Zurich">Europe/Zurich</option>
<option value="UTC">UTC</option>
</select>
</div>
<div class="field">
<label for="camera-mode">Kamera</label>
<select id="camera-mode">
<option value="snapshot">Snapshot (schonend)</option>
<option value="stream">Stream (flüssig)</option>
</select>
</div>
</div>
<p class="hint">Regal: größere Bedienelemente — Shortcut <code>?shelf=1</code>. Darstellung, Zeitzone und Kamera nur dieser Browser. Stream hält eine Verbindung je sichtbarer Kamera.</p>
<p id="display-note" class="ok" hidden>Gespeichert — gilt sofort.</p>
</section>
<section class="card" id="sec-auto">
<h2>Automatik</h2>
<label class="check"><input type="checkbox" id="auto-queue"> Warteschlange „wenn frei“ und Abläufe automatisch starten</label>
<label class="check"><input type="checkbox" id="auto-retry"> Automatisch einmal nochmal versuchen bei Fehler</label>
<p class="hint">Aus = keine neuen Starts. Laufende Abläufe und gesperrte Drucker bleiben unberührt. Änderungen speichern sich selbst.</p>
<div class="actions"><button type="button" id="auto-save" hidden>Speichern</button></div>
<p id="auto-note" class="settings-save-hint"></p>
</section>
<section class="card admin-only" id="sec-slice">
<h2>Slice-Ordner</h2>
<p class="hint">Ordner, den Grotti für die Bibliothek beobachtet (Orca-Export, „Ordner neu lesen“). Leer = nur manueller Upload. Standard: Ordner <code>slice</code> im Grotti-Repo.</p>
<div class="field" style="max-width:36rem">
<label for="watch-dir">Pfad</label>
<input id="watch-dir" type="text" autocomplete="off" placeholder="D:\Projekte\Grotti\slice" spellcheck="false">
</div>
<div class="actions">
<button type="button" id="watch-save" hidden>Speichern</button>
<button type="button" class="ghost" id="watch-default">Standardordner</button>
</div>
<p id="watch-note"></p>
</section>
<section class="card admin-only" id="sec-fqdn">
<h2>Öffentliche Adresse</h2>
<p class="hint">FQDN bzw. Basis-URL für QR-Codes auf Spulen-Etiketten. Leer = Host der aktuellen Anfrage (z.&nbsp;B. localhost).</p>
<div class="field" style="max-width:28rem">
<label for="fqdn">FQDN</label>
<input id="fqdn" placeholder="https://grotti.example.de oder grotti.lan" autocomplete="off">
</div>
<p class="hint">Ohne Schema wird <code>https://</code> ergänzt. Mit Port: <code>http://10.10.1.5:8770</code>.</p>
<div class="actions"><button type="button" id="fqdn-save" hidden>Speichern</button></div>
<p id="fqdn-note"></p>
</section>
<section class="card admin-only" id="sec-drucker">
<h2>Drucker</h2>
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start. Zeitfenster begrenzt Starts (z. B. Nachtstrom). Tippen öffnet den Dialog.</p>
<div id="printer-list" class="printer-edit-cards"></div>
<form id="printer-add" class="grid-2 section-gap">
<div class="field">
<label for="add-name">Name</label>
<input id="add-name" required placeholder="z. B. Kobra 3 Werkstatt">
</div>
<div class="field">
<label for="add-host">Host / IP</label>
<input id="add-host" required placeholder="10.10.1.60">
</div>
<div class="field">
<label for="add-family">Familie</label>
<select id="add-family">
<option value="">automatisch</option>
<option value="kobra_3">Kobra 3</option>
<option value="kobra_s1">Kobra S1</option>
</select>
</div>
<div class="actions" style="align-self:end"><button type="submit">Hinzufügen</button></div>
</form>
<p id="printer-note"></p>
</section>
<section class="card admin-only" id="sec-telegram">
<h2>Telegram</h2>
<label class="check"><input type="checkbox" id="tg-enabled"> Benachrichtigungen aktiv</label>
<div class="grid-2" style="margin-top:0.75rem">
<div class="field">
<label for="tg-token">Bot-Token</label>
<input id="tg-token" type="password" autocomplete="off" placeholder="123456:ABC…">
</div>
<div class="field">
<label for="tg-chat">Chat-ID</label>
<input id="tg-chat" placeholder="z. B. -100… oder User-ID">
</div>
</div>
<div class="grid-2" style="margin-top:0.75rem">
<label class="check"><input type="checkbox" id="al-fertig"> Fertig</label>
<label class="check"><input type="checkbox" id="al-fehler"> Fehler und Abbrüche</label>
<label class="check"><input type="checkbox" id="al-offline"> Offline</label>
<label class="check"><input type="checkbox" id="al-queue"> Queue (Fehler, Ablauf gestoppt)</label>
<label class="check"><input type="checkbox" id="al-ablauf"> Ablauf-Schritte</label>
<label class="check"><input type="checkbox" id="al-start"> Druckstart und Pause</label>
</div>
<p class="hint">Ereignisse stehen immer unter <a href="/aktivitaet">Aktivität</a>. Die Häkchen steuern nur Telegram.</p>
<div class="actions">
<button type="button" id="tg-save" hidden>Speichern</button>
<button type="button" class="ghost" id="tg-test">Test senden</button>
</div>
<p id="tg-note"></p>
</section>
<section class="card admin-only" id="sec-ntfy">
<h2>ntfy</h2>
<label class="check"><input type="checkbox" id="ntfy-enabled"> Push über ntfy</label>
<div class="grid-2" style="margin-top:0.75rem">
<div class="field">
<label for="ntfy-server">Server</label>
<input id="ntfy-server" placeholder="https://ntfy.sh" autocomplete="off">
</div>
<div class="field">
<label for="ntfy-topic">Topic</label>
<input id="ntfy-topic" placeholder="grotti-werkstatt" autocomplete="off">
</div>
<div class="field" style="grid-column:1/-1">
<label for="ntfy-token">Token (optional)</label>
<input id="ntfy-token" type="password" autocomplete="off" placeholder="Bearer-Token">
</div>
</div>
<p class="hint">Gleiche Meldungen wie Telegram — unabhängig abschaltbar. Push in der ntfy-App auf dem Handy.</p>
<div class="actions">
<button type="button" id="ntfy-save" hidden>Speichern</button>
<button type="button" class="ghost" id="ntfy-test">Test senden</button>
</div>
<p id="ntfy-note"></p>
</section>
<section class="card admin-only" id="sec-backup">
<h2>Backup</h2>
<p class="hint">PostgreSQL-Dump und Datenordner (ohne toolpath/ und alte Backups).</p>
<div class="actions">
<button type="button" id="backup-now">Backup jetzt</button>
</div>
<ul id="backup-list" class="tiny"></ul>
<p id="backup-note"></p>
</section>
<section class="card admin-only" id="sec-energie">
<h2>Energie</h2>
<p class="hint">Kosten im Protokoll. Mit Düse-/Bett-Watt und ED-Proben schätzt Grotti den Verbrauch; sonst Dauer × Grundlast. Filament braucht eine Spule.</p>
<div class="grid-2">
<div class="field"><label for="en-watts">Grundlast W</label><input id="en-watts" type="number" step="1" title="Elektronik/Motion — nicht die Heizer"></div>
<div class="field"><label for="en-kwh">€ / kWh</label><input id="en-kwh" type="number" step="0.01"></div>
<div class="field"><label for="en-nozzle">Düse W</label><input id="en-nozzle" type="number" step="1" placeholder="z. B. 60"></div>
<div class="field"><label for="en-bed">Bett W</label><input id="en-bed" type="number" step="1" placeholder="z. B. 220"></div>
<div class="field"><label for="en-dryer">Trockner W</label><input id="en-dryer" type="number" step="1"></div>
<div class="field"><label for="en-dryh">Trockenstunden</label><input id="en-dryh" type="number" step="0.1"></div>
<div class="field"><label for="en-waste">Ausschuss %</label><input id="en-waste" type="number" step="0.1"></div>
<div class="field"><label for="en-util">Auslastung %</label><input id="en-util" type="number" step="1"></div>
</div>
<h3 class="section-gap">HT / NT-Tarif</h3>
<label class="check"><input type="checkbox" id="en-tariff"> Unterschiedliche Preise für Tag und Nacht</label>
<div class="grid-2" id="en-tariff-fields" hidden>
<div class="field"><label for="en-ht">HT € / kWh</label><input id="en-ht" type="number" step="0.01"></div>
<div class="field"><label for="en-nt">NT € / kWh</label><input id="en-nt" type="number" step="0.01"></div>
<div class="field"><label for="en-nt-from">NT ab</label><input id="en-nt-from" type="time" value="22:00"></div>
<div class="field"><label for="en-nt-to">NT bis</label><input id="en-nt-to" type="time" value="06:00"></div>
</div>
<p class="hint">Grundlast = Elektronik/Motion. Düse/Bett 0 = nur pauschal. Mit HT/NT gewichtet Grotti die Stromkosten nach Uhrzeit.</p>
<p id="en-ed-hint" class="hint" hidden></p>
<div class="actions"><button type="button" id="en-save" hidden>Speichern</button></div>
<p id="en-note"></p>
</section>
<section class="card admin-only" id="sec-users">
<h2>Benutzer</h2>
<p class="hint">Rollen: <strong>Admin</strong> (alles) und <strong>Operator</strong> (Drucker, Queue, Material — keine Benutzerverwaltung).</p>
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Rolle</th><th>Aktiv</th><th></th></tr></thead>
<tbody id="user-rows"></tbody>
</table>
</div>
<form id="user-add" class="grid-2" style="margin-top:1rem">
<div class="field"><label for="user-name">Benutzer</label><input id="user-name" required minlength="2" autocomplete="off"></div>
<div class="field"><label for="user-pass">Passwort</label><input id="user-pass" type="password" required minlength="8" autocomplete="new-password"></div>
<div class="field">
<label for="user-role">Rolle</label>
<select id="user-role"><option value="operator">Operator</option><option value="admin">Admin</option></select>
</div>
<div class="actions" style="align-self:end"><button type="submit">Anlegen</button></div>
</form>
<p id="user-note"></p>
</section>
<section class="card" id="sec-tokens">
<h2>API-Tokens</h2>
<p class="hint">Für App, Home-Automation oder MCP. Klartext erscheint nur einmal bei der Erstellung. Authorization: <code>Bearer grt_…</code></p>
<div class="table-wrap">
<table>
<thead><tr><th>Name</th><th>Prefix</th><th>Rolle</th><th></th></tr></thead>
<tbody id="token-rows"></tbody>
</table>
</div>
<form id="token-add" class="grid-2" style="margin-top:1rem">
<div class="field"><label for="token-name">Bezeichnung</label><input id="token-name" placeholder="Home Assistant" required></div>
<div class="field">
<label for="token-role">Rolle</label>
<select id="token-role"><option value="operator">Operator</option><option value="admin">Admin</option></select>
</div>
<div class="actions" style="align-self:end"><button type="submit">Token erzeugen</button></div>
</form>
<p id="token-note"></p>
<p class="ok" id="token-once" hidden></p>
<p class="hint" id="legacy-token-hint" hidden>In der Config steht noch ein Shared-<code>token</code> — der wird seit 3.1 nicht mehr akzeptiert. Bitte leeren und API-Tokens (<code>grt_…</code>) nutzen.</p>
</section>
</div>
</div>
<dialog id="printer-edit-dlg" class="abraeumen-dlg">
<form method="dialog" id="printer-edit-form" class="abraeumen-form">
<h2 id="printer-edit-title">Drucker</h2>
<input type="hidden" id="pe-id">
<div class="grid-2">
<div class="field"><label for="pe-name">Name</label><input id="pe-name" required></div>
<div class="field"><label for="pe-host">Host / IP</label><input id="pe-host" required></div>
<div class="field"><label for="pe-family">Familie</label>
<select id="pe-family">
<option value="kobra_3">Kobra 3</option>
<option value="kobra_s1">Kobra S1</option>
<option value="">—</option>
</select>
</div>
<div class="field"><label class="check" style="margin-top:1.4rem"><input type="checkbox" id="pe-locked"> Gesperrt</label></div>
</div>
<label class="check"><input type="checkbox" id="pe-win-on"> Nur in Zeitfenster starten</label>
<div class="grid-2" id="pe-win-fields">
<div class="field"><label for="pe-win-from">Von</label><input type="time" id="pe-win-from" value="22:00"></div>
<div class="field"><label for="pe-win-to">Bis</label><input type="time" id="pe-win-to" value="06:00"></div>
<div class="field"><label for="pe-win-max">Max. Stunden</label><input type="number" id="pe-win-max" min="0" step="0.5" placeholder="leer = egal"></div>
</div>
<div class="actions">
<button type="submit" value="ok" class="btn-primary">Speichern</button>
<button type="button" class="ghost" id="pe-cancel">Abbrechen</button>
<button type="button" class="btn-danger" id="pe-delete">Entfernen</button>
</div>
</form>
</dialog>
{% endblock %}
{% block script %}
<script>
const families = { kobra_3: "Kobra 3", kobra_s1: "Kobra S1", "": "—" };
const isAdmin = document.querySelector('meta[name="user-role"]')?.content === "admin";
document.querySelectorAll(".admin-only").forEach((el) => { el.hidden = !isAdmin; });
if (!isAdmin) {
const roleSel = document.getElementById("token-role");
if (roleSel) {
[...roleSel.options].forEach((opt) => { if (opt.value === "admin") opt.remove(); });
}
}
/* showNote aus api.js: Inline-Hinweis + Toast */
function windowFrom(row) {
const sw = row.start_window || {};
const allowed = Array.isArray(sw.allowed) ? sw.allowed : [];
const first = allowed[0] || {};
return {
enabled: allowed.length > 0,
from: first.from || "22:00",
to: first.to || "06:00",
maxHours: sw.max_hours_overnight || "",
};
}
let printerRowsCache = [];
function openPrinterDialog(row) {
const dlg = document.getElementById("printer-edit-dlg");
const win = windowFrom(row);
document.getElementById("printer-edit-title").textContent = row.name || "Drucker";
document.getElementById("pe-id").value = row.id;
document.getElementById("pe-name").value = row.name || "";
document.getElementById("pe-host").value = row.host || "";
document.getElementById("pe-family").value = row.family || "";
document.getElementById("pe-locked").checked = !!row.locked;
document.getElementById("pe-win-on").checked = win.enabled;
document.getElementById("pe-win-from").value = win.from;
document.getElementById("pe-win-to").value = win.to;
document.getElementById("pe-win-max").value = win.maxHours;
document.getElementById("pe-win-fields").hidden = !win.enabled;
dlg.showModal();
}
function renderPrinters(rows) {
printerRowsCache = rows || [];
const root = document.getElementById("printer-list");
root.innerHTML = "";
if (!rows.length) {
root.innerHTML = '<div class="empty-state"><svg class="ico"><use href="#i-printer"/></svg><p>Keine Drucker.</p></div>';
return;
}
rows.forEach((row) => {
const win = windowFrom(row);
const card = document.createElement("div");
card.className = "printer-edit-card";
card.innerHTML = `
<div class="grow">
<strong></strong>
<div class="tiny"></div>
</div>
<button type="button" class="ghost">Bearbeiten</button>`;
card.querySelector("strong").textContent = row.name || row.host;
card.querySelector(".tiny").textContent = [
row.host,
families[row.family] || row.family || "",
row.locked ? "gesperrt" : "",
win.enabled ? ("Fenster " + win.from + "–" + win.to) : "",
].filter(Boolean).join(" · ");
card.querySelector("button").addEventListener("click", () => openPrinterDialog(row));
card.addEventListener("dblclick", () => openPrinterDialog(row));
root.appendChild(card);
});
}
function renderUsers(rows) {
const body = document.getElementById("user-rows");
if (!body) return;
body.innerHTML = "";
rows.forEach((row) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${escapeHtml(row.username)}</td>
<td><select data-f="role"><option value="operator">Operator</option><option value="admin">Admin</option></select></td>
<td><label class="check"><input type="checkbox" data-f="active"></label></td><td></td>`;
tr.querySelector('[data-f="role"]').value = row.role;
tr.querySelector('[data-f="active"]').checked = !!row.active;
const save = document.createElement("button");
save.type = "button";
save.textContent = "Speichern";
save.addEventListener("click", async () => {
try {
await api("/users/" + row.id, {
method: "PATCH",
json: {
role: tr.querySelector('[data-f="role"]').value,
active: tr.querySelector('[data-f="active"]').checked,
},
});
showNote("user-note", null, "Benutzer gespeichert.");
loadUsers();
} catch (error) {
showNote("user-note", error);
}
});
const del = document.createElement("button");
del.type = "button";
del.className = "ghost warn";
del.textContent = "Löschen";
del.addEventListener("click", () => {
armButton(del, "Wirklich löschen?", async () => {
try {
await api("/users/" + row.id, { method: "DELETE" });
showNote("user-note", null, "Benutzer gelöscht.");
loadUsers();
} catch (error) {
showNote("user-note", error);
}
});
});
tr.children[3].appendChild(save);
tr.children[3].appendChild(del);
body.appendChild(tr);
});
}
function renderTokens(rows) {
const body = document.getElementById("token-rows");
body.innerHTML = "";
if (!rows.length) {
body.innerHTML = '<tr><td class="empty" colspan="4">Keine Tokens.</td></tr>';
return;
}
rows.forEach((row) => {
const tr = document.createElement("tr");
tr.innerHTML = `<td>${escapeHtml(row.name)}${row.username ? ' <span class="tiny">(' + escapeHtml(row.username) + ")</span>" : ""}</td>
<td><code>${escapeHtml(row.prefix)}…</code></td>
<td>${escapeHtml(row.role)}</td><td></td>`;
const rev = document.createElement("button");
rev.type = "button";
rev.className = "ghost warn";
rev.textContent = "Widerrufen";
rev.addEventListener("click", () => {
armButton(rev, "Widerrufen?", async () => {
try {
await api("/tokens/" + row.id, { method: "DELETE" });
showNote("token-note", null, "Token widerrufen.");
loadTokens();
} catch (error) {
showNote("token-note", error);
}
});
});
tr.children[3].appendChild(rev);
body.appendChild(tr);
});
}
async function loadUsers() {
if (!isAdmin) return;
renderUsers(await api("/users"));
}
async function loadTokens() {
renderTokens(await api("/tokens" + (isAdmin ? "?all=1" : "")));
}
async function load() {
const [settings, printers] = await Promise.all([
api("/einstellungen"),
api("/drucker?live=0"),
]);
if (isAdmin) renderPrinters(printers);
document.getElementById("auto-queue").checked = settings.auto_queue !== false;
const ar = document.getElementById("auto-retry");
if (ar) ar.checked = settings.auto_retry !== false;
const watchDir = document.getElementById("watch-dir");
if (watchDir) {
watchDir.value = settings.watch_dir || "";
watchDir.dataset.default = settings.watch_dir_default || "";
}
const fqdn = document.getElementById("fqdn");
if (fqdn) fqdn.value = settings.fqdn || "";
const tgEnabled = document.getElementById("tg-enabled");
if (tgEnabled) {
tgEnabled.checked = !!(settings.telegram && settings.telegram.enabled);
document.getElementById("tg-token").value = "";
document.getElementById("tg-token").placeholder = settings.telegram && settings.telegram.bot_token_set
? ("gesetzt: " + (settings.telegram.bot_token || "••••"))
: "Bot-Token";
document.getElementById("tg-chat").value = (settings.telegram && settings.telegram.chat_id) || "";
const alerts = settings.alerts || {};
document.getElementById("al-fertig").checked = alerts.fertig !== false;
document.getElementById("al-fehler").checked = alerts.fehler !== false;
document.getElementById("al-offline").checked = alerts.offline !== false;
document.getElementById("al-queue").checked = alerts.queue !== false;
document.getElementById("al-ablauf").checked = !!alerts.ablauf;
document.getElementById("al-start").checked = !!alerts.start;
const ntfy = settings.ntfy || {};
document.getElementById("ntfy-enabled").checked = !!ntfy.enabled;
document.getElementById("ntfy-server").value = ntfy.server || "https://ntfy.sh";
document.getElementById("ntfy-topic").value = ntfy.topic || "";
document.getElementById("ntfy-token").value = "";
document.getElementById("ntfy-token").placeholder = ntfy.token_set
? ("gesetzt: " + (ntfy.token || "••••"))
: "optional";
const en = settings.energy || {};
document.getElementById("en-watts").value = en.watts ?? 40;
document.getElementById("en-nozzle").value = en.nozzle_watts ?? 60;
document.getElementById("en-bed").value = en.bed_watts ?? 220;
document.getElementById("en-kwh").value = en.euro_per_kwh ?? 0.35;
document.getElementById("en-dryer").value = en.dryer_watts ?? 0;
document.getElementById("en-dryh").value = en.dry_hours ?? 0;
document.getElementById("en-waste").value = en.waste_percent ?? 0;
document.getElementById("en-util").value = en.utilization ?? 50;
const tariff = en.tariff || null;
document.getElementById("en-tariff").checked = !!tariff;
document.getElementById("en-tariff-fields").hidden = !tariff;
document.getElementById("en-ht").value = (tariff && tariff.ht_euro) ?? (en.euro_per_kwh ?? 0.35);
document.getElementById("en-nt").value = (tariff && tariff.nt_euro) ?? (en.euro_per_kwh ?? 0.35);
document.getElementById("en-nt-from").value = (tariff && tariff.nt_from) || "22:00";
document.getElementById("en-nt-to").value = (tariff && tariff.nt_to) || "06:00";
updateEnergyEdHint();
}
const legacy = document.getElementById("legacy-token-hint");
if (legacy) legacy.hidden = !settings.legacy_token_set;
await Promise.all([loadUsers(), loadTokens(), loadBackups()]);
}
document.getElementById("user-add")?.addEventListener("submit", async (event) => {
event.preventDefault();
try {
await api("/users", {
method: "POST",
json: {
username: document.getElementById("user-name").value,
password: document.getElementById("user-pass").value,
role: document.getElementById("user-role").value,
},
});
document.getElementById("user-add").reset();
showNote("user-note", null, "Benutzer angelegt.");
loadUsers();
} catch (error) {
showNote("user-note", error);
}
});
document.getElementById("token-add").addEventListener("submit", async (event) => {
event.preventDefault();
const once = document.getElementById("token-once");
once.hidden = true;
try {
const out = await api("/tokens", {
method: "POST",
json: {
name: document.getElementById("token-name").value,
role: document.getElementById("token-role").value,
},
});
document.getElementById("token-add").reset();
once.hidden = false;
once.textContent = "Token (nur jetzt sichtbar): " + out.token;
showNote("token-note", null, "Token erzeugt — jetzt kopieren.");
loadTokens();
} catch (error) {
showNote("token-note", error);
}
});
document.getElementById("auto-save").addEventListener("click", async () => {
try {
await api("/einstellungen", {
method: "PATCH",
json: {
auto_queue: document.getElementById("auto-queue").checked,
auto_retry: document.getElementById("auto-retry")?.checked !== false,
},
});
showNote(
"auto-note",
null,
document.getElementById("auto-queue").checked ? "Automatik an." : "Automatik aus — nichts Neues startet."
);
} catch (error) {
showNote("auto-note", error);
}
});
document.getElementById("watch-default")?.addEventListener("click", () => {
const input = document.getElementById("watch-dir");
if (input) input.value = input.dataset.default || "";
});
document.getElementById("watch-save")?.addEventListener("click", async () => {
try {
const out = await api("/einstellungen", {
method: "PATCH",
json: { watch_dir: document.getElementById("watch-dir").value.trim() },
});
document.getElementById("watch-dir").value = out.watch_dir || "";
showNote(
"watch-note",
null,
out.watch_dir
? "Slice-Ordner aktiv: " + out.watch_dir
: "Kein Slice-Ordner — nur manueller Upload."
);
} catch (error) {
showNote("watch-note", error);
}
});
document.getElementById("fqdn-save").addEventListener("click", async () => {
try {
const out = await api("/einstellungen", {
method: "PATCH",
json: { fqdn: document.getElementById("fqdn").value.trim() },
});
document.getElementById("fqdn").value = out.fqdn || "";
showNote(
"fqdn-note",
null,
out.fqdn
? "Gespeichert — Etiketten-QR: " + out.fqdn + "/material?spool=…"
: "Leer — Etiketten nutzen den Request-Host."
);
} catch (error) {
showNote("fqdn-note", error);
}
});
document.getElementById("printer-add").addEventListener("submit", async (event) => {
event.preventDefault();
try {
await api("/drucker", {
method: "POST",
json: {
name: document.getElementById("add-name").value,
host: document.getElementById("add-host").value,
family: document.getElementById("add-family").value,
},
});
document.getElementById("printer-add").reset();
showNote("printer-note", null, "Drucker angelegt.");
load();
} catch (error) {
showNote("printer-note", error);
}
});
document.getElementById("tg-save").addEventListener("click", async () => {
const token = document.getElementById("tg-token").value.trim();
const payload = {
telegram: {
enabled: document.getElementById("tg-enabled").checked,
chat_id: document.getElementById("tg-chat").value.trim(),
},
alerts: {
fertig: document.getElementById("al-fertig").checked,
fehler: document.getElementById("al-fehler").checked,
offline: document.getElementById("al-offline").checked,
queue: document.getElementById("al-queue").checked,
ablauf: document.getElementById("al-ablauf").checked,
start: document.getElementById("al-start").checked,
},
};
if (token) payload.telegram.bot_token = token;
try {
await api("/einstellungen", { method: "PATCH", json: payload });
showNote("tg-note", null, "Telegram gespeichert.");
load();
} catch (error) {
showNote("tg-note", error);
}
});
document.getElementById("ntfy-save")?.addEventListener("click", async () => {
const token = document.getElementById("ntfy-token").value.trim();
const payload = {
ntfy: {
enabled: document.getElementById("ntfy-enabled").checked,
server: document.getElementById("ntfy-server").value.trim() || "https://ntfy.sh",
topic: document.getElementById("ntfy-topic").value.trim(),
},
};
if (token) payload.ntfy.token = token;
try {
await api("/einstellungen", { method: "PATCH", json: payload });
showNote("ntfy-note", null, "ntfy gespeichert.");
load();
} catch (error) {
showNote("ntfy-note", error);
}
});
async function loadBackups() {
const list = document.getElementById("backup-list");
if (!list) return;
try {
const out = await api("/backup");
list.innerHTML = (out.items || []).slice(0, 8).map((item) => {
const mb = ((item.size || 0) / (1024 * 1024)).toLocaleString("de-DE", { maximumFractionDigits: 1 });
return `<li><a href="/backup/${encodeURIComponent(item.name)}" download>${escapeHtml(item.name)}</a> · ${mb} MB</li>`;
}).join("") || "<li>Noch kein Backup.</li>";
} catch (error) {
list.innerHTML = "<li>Liste nicht verfügbar.</li>";
}
}
document.getElementById("backup-now")?.addEventListener("click", async () => {
try {
const out = await api("/backup", { method: "POST", json: {} });
showNote("backup-note", null, "Backup erstellt: " + (out.name || ""));
loadBackups();
} catch (error) {
showNote("backup-note", error);
}
});
document.getElementById("tg-test").addEventListener("click", async () => {
try {
await api("/telegram/test", { method: "POST", json: {} });
showNote("tg-note", null, "Testnachricht gesendet.");
} catch (error) {
showNote("tg-note", error);
}
});
document.getElementById("ntfy-test")?.addEventListener("click", async () => {
try {
await api("/ntfy/test", { method: "POST", json: {} });
showNote("ntfy-note", null, "Testnachricht gesendet.");
} catch (error) {
showNote("ntfy-note", error);
}
});
document.getElementById("en-tariff")?.addEventListener("change", (event) => {
document.getElementById("en-tariff-fields").hidden = !event.target.checked;
});
function updateEnergyEdHint() {
const hint = document.getElementById("en-ed-hint");
if (!hint) return;
const nozzle = Number(document.getElementById("en-nozzle").value) || 0;
const bed = Number(document.getElementById("en-bed").value) || 0;
if (nozzle > 0 && bed > 0) {
hint.hidden = true;
hint.textContent = "";
return;
}
hint.hidden = false;
hint.textContent = "Düse oder Bett noch 0 — Bericht bleibt „pauschal“. Für ED z. B. Düse 60 W und Bett 220 W setzen.";
}
["en-nozzle", "en-bed"].forEach((id) => {
const node = document.getElementById(id);
if (node) node.addEventListener("input", updateEnergyEdHint);
});
document.getElementById("en-save").addEventListener("click", async () => {
try {
const nozzle = Number(document.getElementById("en-nozzle").value) || 0;
const bed = Number(document.getElementById("en-bed").value) || 0;
const energy = {
watts: Number(document.getElementById("en-watts").value),
nozzle_watts: nozzle,
bed_watts: bed,
euro_per_kwh: Number(document.getElementById("en-kwh").value),
dryer_watts: Number(document.getElementById("en-dryer").value),
dry_hours: Number(document.getElementById("en-dryh").value),
waste_percent: Number(document.getElementById("en-waste").value),
utilization: Number(document.getElementById("en-util").value),
};
if (document.getElementById("en-tariff").checked) {
energy.tariff = {
ht_euro: Number(document.getElementById("en-ht").value),
nt_euro: Number(document.getElementById("en-nt").value),
nt_from: document.getElementById("en-nt-from").value || "22:00",
nt_to: document.getElementById("en-nt-to").value || "06:00",
};
}
await api("/einstellungen", { method: "PATCH", json: { energy } });
updateEnergyEdHint();
if (nozzle <= 0 || bed <= 0) {
const note = document.getElementById("en-note");
if (note) {
note.textContent = "Gespeichert. Düse/Bett 0 → nur pauschal. Für ED z. B. 60 / 220 W setzen.";
note.className = "msg";
}
toast("Energie gespeichert — ED noch aus (Düse/Bett 0).", "bad");
} else {
showNote("en-note", null, "Energie gespeichert.");
}
} catch (error) {
showNote("en-note", error);
}
});
function flashDisplayNote() {
const note = document.getElementById("display-note");
if (!note) return;
note.hidden = false;
setTimeout(() => { note.hidden = true; }, 2500);
}
(function initDisplayMode() {
const sel = document.getElementById("display-mode");
if (!sel) return;
try {
sel.value = localStorage.getItem("grotti-display") === "shelf" ? "shelf" : "desktop";
} catch (error) {
sel.value = "desktop";
}
sel.addEventListener("change", () => {
if (typeof setDisplayMode === "function") setDisplayMode(sel.value);
else {
try { localStorage.setItem("grotti-display", sel.value === "shelf" ? "shelf" : "desktop"); } catch (error) {}
document.documentElement.classList.toggle("shelf", sel.value === "shelf");
document.body.classList.toggle("shelf", sel.value === "shelf");
}
flashDisplayNote();
});
})();
(function initThemeMode() {
const sel = document.getElementById("theme-mode");
if (!sel) return;
try {
const value = localStorage.getItem("grotti-theme") || "dark";
sel.value = ["dark", "light", "system"].includes(value) ? value : "dark";
} catch (error) {
sel.value = "dark";
}
sel.addEventListener("change", () => {
try { localStorage.setItem("grotti-theme", sel.value); } catch (error) {}
if (typeof applyTheme === "function") applyTheme(sel.value);
else {
const light = sel.value === "light" || (sel.value === "system" && window.matchMedia("(prefers-color-scheme: light)").matches);
document.documentElement.dataset.theme = light ? "light" : "dark";
document.documentElement.style.colorScheme = light ? "light" : "dark";
}
flashDisplayNote();
});
})();
(function initTimeZone() {
const sel = document.getElementById("tz-mode");
if (!sel) return;
const known = ["auto", "Europe/Berlin", "Europe/Vienna", "Europe/Zurich", "UTC"];
try {
const value = localStorage.getItem(TZ_KEY) || "auto";
sel.value = known.includes(value) ? value : "auto";
} catch (error) {
sel.value = "auto";
}
sel.addEventListener("change", () => {
try { localStorage.setItem(TZ_KEY, sel.value || "auto"); } catch (error) {}
const note = document.getElementById("display-note");
if (note) {
note.hidden = false;
setTimeout(() => { note.hidden = true; }, 2500);
}
});
})();
(function initCameraMode() {
const sel = document.getElementById("camera-mode");
if (!sel || typeof cameraMode !== "function") return;
sel.value = cameraMode();
sel.addEventListener("change", () => {
setCameraMode(sel.value);
flashDisplayNote();
});
window.addEventListener("grotti-camera-mode", () => {
sel.value = cameraMode();
});
})();
document.getElementById("pe-win-on")?.addEventListener("change", () => {
document.getElementById("pe-win-fields").hidden = !document.getElementById("pe-win-on").checked;
});
document.getElementById("pe-cancel")?.addEventListener("click", () => {
document.getElementById("printer-edit-dlg").close();
});
document.getElementById("printer-edit-form")?.addEventListener("submit", async (event) => {
event.preventDefault();
const id = document.getElementById("pe-id").value;
const on = document.getElementById("pe-win-on").checked;
const start_window = {};
if (on) {
start_window.allowed = [{
from: document.getElementById("pe-win-from").value || "22:00",
to: document.getElementById("pe-win-to").value || "06:00",
}];
const maxH = Number(document.getElementById("pe-win-max").value);
if (maxH > 0) start_window.max_hours_overnight = maxH;
}
try {
await api("/drucker/id/" + id, {
method: "PATCH",
json: {
name: document.getElementById("pe-name").value,
host: document.getElementById("pe-host").value,
family: document.getElementById("pe-family").value,
locked: document.getElementById("pe-locked").checked,
start_window,
},
});
document.getElementById("printer-edit-dlg").close();
toast("Drucker gespeichert.");
markAutosaved();
load();
} catch (error) {
showNote("printer-note", error);
}
});
document.getElementById("pe-delete")?.addEventListener("click", (event) => {
const id = document.getElementById("pe-id").value;
armButton(event.currentTarget, "Wirklich entfernen?", async () => {
try {
await api("/drucker/id/" + id, { method: "DELETE" });
document.getElementById("printer-edit-dlg").close();
toast("Drucker entfernt.");
load();
} catch (error) {
showNote("printer-note", error);
}
});
});
function markAutosaved() {
const el = document.getElementById("settings-autosave-hint");
if (!el) return;
el.textContent = "Gespeichert ✓";
clearTimeout(markAutosaved._t);
markAutosaved._t = setTimeout(() => { el.textContent = ""; }, 2200);
}
function wireAutosave(triggerId, noteId) {
const btn = document.getElementById(triggerId);
if (!btn) return;
const run = () => {
btn.click();
markAutosaved();
if (noteId) {
const note = document.getElementById(noteId);
if (note) note.textContent = "Gespeichert ✓";
}
};
let timer = 0;
return (event) => {
if (event && event.target && event.target.id === triggerId) return;
clearTimeout(timer);
timer = setTimeout(run, 450);
};
}
document.getElementById("auto-queue")?.addEventListener("change", wireAutosave("auto-save", "auto-note"));
document.getElementById("auto-retry")?.addEventListener("change", wireAutosave("auto-save", "auto-note"));
["watch-dir"].forEach((id) => {
document.getElementById(id)?.addEventListener("change", wireAutosave("watch-save", "watch-note"));
});
document.getElementById("fqdn")?.addEventListener("change", wireAutosave("fqdn-save", "fqdn-note"));
["tg-enabled", "tg-token", "tg-chat", "al-fertig", "al-fehler", "al-offline", "al-queue", "al-ablauf", "al-start"].forEach((id) => {
document.getElementById(id)?.addEventListener("change", wireAutosave("tg-save", "tg-note"));
});
["ntfy-enabled", "ntfy-server", "ntfy-topic", "ntfy-token"].forEach((id) => {
document.getElementById(id)?.addEventListener("change", wireAutosave("ntfy-save", "ntfy-note"));
});
["en-watts", "en-kwh", "en-nozzle", "en-bed", "en-dryer", "en-dryh", "en-waste", "en-util", "en-tariff", "en-ht", "en-nt", "en-nt-from", "en-nt-to"].forEach((id) => {
document.getElementById(id)?.addEventListener("change", wireAutosave("en-save", "en-note"));
});
document.querySelectorAll(".settings-nav a").forEach((a) => {
a.addEventListener("click", () => {
document.querySelectorAll(".settings-nav a").forEach((x) => x.classList.remove("on"));
a.classList.add("on");
});
});
load().catch((error) => showNote("printer-note", error));
</script>
{% endblock %}