Files
grotti/web/templates/einstellungen.html
T
Dominic Gundel 6e59df22e5 Grotti 3.1.0: Bedarf-UI, Zeitfenster, HT/NT, Theme, Chrome-Fixes.
Bedarf in Sidebar/Material, Drucker-Zeitfenster und Tarif in Einstellungen, helles Theme; Legacy-Token und /api/drucker entfernt; SSE/SW-Fixes gegen hängende Navigation in Chrome.
2026-10-18 02:20:12 +02:00

847 lines
34 KiB
HTML
Raw 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>
</div>
<section class="card">
<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>
<p class="hint">Regal: größere Bedienelemente — Shortcut <code>?shelf=1</code>. Darstellung und Zeitzone nur dieser Browser.</p>
<p id="display-note" class="ok" hidden>Gespeichert — gilt sofort.</p>
</section>
<section class="card">
<h2>Automatik</h2>
<label class="check"><input type="checkbox" id="auto-queue"> Warteschlange „wenn frei“ und Abläufe automatisch starten</label>
<p class="hint">Aus = keine neuen Starts. Laufende Abläufe und gesperrte Drucker bleiben unberührt.</p>
<div class="actions"><button type="button" id="auto-save">Speichern</button></div>
<p id="auto-note"></p>
</section>
<section class="card admin-only">
<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">Speichern</button>
<button type="button" class="ghost" id="watch-default">Standardordner</button>
</div>
<p id="watch-note"></p>
</section>
<section class="card admin-only">
<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">Speichern</button></div>
<p id="fqdn-note"></p>
</section>
<section class="card admin-only">
<h2>Drucker</h2>
<p class="hint">Gesperrt = kein neuer „wenn frei“-Start. Zeitfenster begrenzt Starts (z. B. Nachtstrom).</p>
<div id="printer-list" class="printer-edit-list"></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">
<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">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">
<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">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">
<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">
<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">Speichern</button></div>
<p id="en-note"></p>
</section>
<section class="card admin-only">
<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">
<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>
{% 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 || "",
};
}
function renderPrinters(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.innerHTML = `
<div class="grid-2">
<div class="field"><label>Name</label><input data-f="name"></div>
<div class="field"><label>Host / IP</label><input data-f="host"></div>
<div class="field"><label>Familie</label><select data-f="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" data-f="locked"> Gesperrt</label></div>
</div>
<details class="mat-more" ${win.enabled ? "open" : ""}>
<summary><svg class="ico caret"><use href="#i-down"/></svg><span class="grow">Start-Zeitfenster</span>
<span class="tiny">${win.enabled ? escapeHtml(win.from) + "–" + escapeHtml(win.to) : "aus"}</span>
</summary>
<div class="grid-2" style="margin-top:0.6rem">
<label class="check" style="grid-column:1/-1"><input type="checkbox" data-f="win-on"> Nur in Zeitfenster starten</label>
<div class="field"><label>Von</label><input type="time" data-f="win-from"></div>
<div class="field"><label>Bis</label><input type="time" data-f="win-to"></div>
<div class="field"><label>Max. Stunden (Nachtjob)</label><input type="number" min="0" step="0.5" data-f="win-max" placeholder="leer = egal"></div>
</div>
</details>
<div class="actions"></div>`;
card.querySelector('[data-f="name"]').value = row.name || "";
card.querySelector('[data-f="host"]').value = row.host || "";
card.querySelector('[data-f="family"]').value = row.family || "";
card.querySelector('[data-f="locked"]').checked = !!row.locked;
card.querySelector('[data-f="win-on"]').checked = win.enabled;
card.querySelector('[data-f="win-from"]').value = win.from;
card.querySelector('[data-f="win-to"]').value = win.to;
card.querySelector('[data-f="win-max"]').value = win.maxHours;
const save = document.createElement("button");
save.type = "button";
save.textContent = "Speichern";
save.addEventListener("click", async () => {
const on = card.querySelector('[data-f="win-on"]').checked;
const start_window = {};
if (on) {
start_window.allowed = [{
from: card.querySelector('[data-f="win-from"]').value || "22:00",
to: card.querySelector('[data-f="win-to"]').value || "06:00",
}];
const maxH = Number(card.querySelector('[data-f="win-max"]').value);
if (maxH > 0) start_window.max_hours_overnight = maxH;
}
try {
await api("/drucker/id/" + row.id, {
method: "PATCH",
json: {
name: card.querySelector('[data-f="name"]').value,
host: card.querySelector('[data-f="host"]').value,
family: card.querySelector('[data-f="family"]').value,
locked: card.querySelector('[data-f="locked"]').checked,
start_window,
},
});
showNote("printer-note", null, "Gespeichert: " + card.querySelector('[data-f="name"]').value);
toast("Drucker gespeichert.");
load();
} catch (error) {
showNote("printer-note", error);
}
});
const del = document.createElement("button");
del.type = "button";
del.className = "ghost warn";
del.textContent = "Entfernen";
del.addEventListener("click", () => {
armButton(del, "Wirklich entfernen?", async () => {
try {
await api("/drucker/id/" + row.id, { method: "DELETE" });
showNote("printer-note", null, "Drucker entfernt.");
load();
} catch (error) {
showNote("printer-note", error);
}
});
});
card.querySelector(".actions").append(save, del);
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 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 },
});
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);
}
});
})();
load().catch((error) => showNote("printer-note", error));
</script>
{% endblock %}