Senden und Abbruch laufen über den Treiber, Orca-Doppelzeilen und tote Ideen-API fallen weg, Drucker folgen der Datei, Etikettenformat liegt in den Einstellungen.
1218 lines
49 KiB
HTML
1218 lines
49 KiB
HTML
{% 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-etikett" class="admin-only"><svg class="ico"><use href="#i-tag"/></svg>Etiketten</a>
|
||
<a href="#sec-drucker" class="admin-only"><svg class="ico"><use href="#i-printer"/></svg>Drucker</a>
|
||
<a href="#sec-meldungen" class="admin-only"><svg class="ico"><use href="#i-alert"/></svg>Meldungen</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. 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-etikett">
|
||
<h2>Etiketten</h2>
|
||
<p class="hint">Papierformat für Spulen- und Drucketiketten (Browser-Druck). Standard 70×40 mm. Der QR schrumpft auf flacheren Rollen mit.</p>
|
||
<div class="field" style="max-width:16rem">
|
||
<label for="etikett-preset">Voreinstellung</label>
|
||
<select id="etikett-preset">
|
||
<option value="">Eigene Maße</option>
|
||
</select>
|
||
</div>
|
||
<div class="grid-2" style="max-width:20rem">
|
||
<div class="field">
|
||
<label for="etikett-w">Breite (mm)</label>
|
||
<input id="etikett-w" type="number" min="20" max="200" step="0.1" value="70">
|
||
</div>
|
||
<div class="field">
|
||
<label for="etikett-h">Höhe (mm)</label>
|
||
<input id="etikett-h" type="number" min="15" max="200" step="0.1" value="40">
|
||
</div>
|
||
</div>
|
||
<div class="actions"><button type="button" id="etikett-save" hidden>Speichern</button></div>
|
||
<p id="etikett-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>
|
||
<p class="hint">USB (Anycubic i3, Tenlog): Schnittstelle statt IP, zum Beispiel <code>COM5</code> oder <code>COM5@250000</code>. Grotti startet den USB-Dienst dazu selbst und lässt ihn bei einem Neustart weiterlaufen.</p>
|
||
<p class="hint" id="marlin-line" hidden></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" id="add-host-label">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>
|
||
<option value="kobra_4">Kobra 4</option>
|
||
<option value="anycubic_i3">Anycubic i3</option>
|
||
<option value="tenlog">Tenlog</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-meldungen">
|
||
<h2>Problem-Meldungen</h2>
|
||
<p class="hint">Kopien von „Problem melden“. Offene Einträge hier erledigen. Issues landen beim Entwickler über den Empfänger auf git.gundel.dev.</p>
|
||
<div id="meldung-list" class="printer-edit-cards"></div>
|
||
<p id="meldung-admin-note" class="tiny"></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>
|
||
<div id="token-qr-wrap" hidden style="margin-top:0.75rem">
|
||
<p class="tiny">App-Kopplung — QR mit Server-URL und Token (nur jetzt):</p>
|
||
<canvas id="token-qr" width="180" height="180" aria-label="QR-Code"></canvas>
|
||
</div>
|
||
<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" id="pe-host-label">Host / IP</label><input id="pe-host" required placeholder="10.10.1.60"></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="kobra_4">Kobra 4</option>
|
||
<option value="anycubic_i3">Anycubic i3</option>
|
||
<option value="tenlog">Tenlog</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 src="{{ url_for('static', filename='qrcode.min.js') }}?v=1"></script>
|
||
<script>
|
||
const families = {
|
||
kobra_3: "Kobra 3", kobra_s1: "Kobra S1", kobra_4: "Kobra 4",
|
||
anycubic_i3: "Anycubic i3", tenlog: "Tenlog", "": "—",
|
||
};
|
||
const USB_FAMILIES = new Set(["anycubic_i3", "tenlog"]);
|
||
|
||
function syncHostField(family, labelEl, inputEl, nameHint) {
|
||
const usb = USB_FAMILIES.has(family)
|
||
|| (!family && /tenlog|i3/i.test(nameHint || ""));
|
||
if (labelEl) labelEl.textContent = usb ? "Schnittstelle" : "Host / IP";
|
||
if (inputEl) inputEl.placeholder = usb ? "COM5 oder COM5@250000" : "10.10.1.60";
|
||
}
|
||
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 || "";
|
||
syncHostField(row.family || "", document.getElementById("pe-host-label"), document.getElementById("pe-host"), row.name || "");
|
||
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 loadMeldungen() {
|
||
const root = document.getElementById("meldung-list");
|
||
const note = document.getElementById("meldung-admin-note");
|
||
if (!root || !isAdmin) return;
|
||
try {
|
||
const rows = await api("/meldungen?status=offen");
|
||
if (!rows.length) {
|
||
root.innerHTML = '<p class="tiny">Keine offenen Meldungen.</p>';
|
||
return;
|
||
}
|
||
root.innerHTML = rows.map((row) => `
|
||
<article class="printer-edit-card">
|
||
<div class="grow">
|
||
<strong>#${row.id}</strong>
|
||
<span class="tiny">${escapeHtml((row.created_at || "").replace("T", " ").slice(0, 16))} · ${escapeHtml(row.username || "—")} · ${escapeHtml(row.page || "")}</span>
|
||
<p>${escapeHtml(row.text || "")}</p>
|
||
<p class="tiny">${row.relay_ok ? "Beim Entwickler angekommen" : ("Lokal: " + escapeHtml(row.relay_message || "Empfänger fehlte"))}</p>
|
||
</div>
|
||
<button type="button" class="ghost" data-meldung-done="${row.id}">Erledigt</button>
|
||
</article>`).join("");
|
||
root.querySelectorAll("[data-meldung-done]").forEach((button) => {
|
||
button.addEventListener("click", async () => {
|
||
button.disabled = true;
|
||
try {
|
||
await api("/meldungen/" + button.dataset.meldungDone + "/erledigt", { method: "POST", json: {} });
|
||
loadMeldungen();
|
||
} catch (error) {
|
||
if (note) note.textContent = error.message || String(error);
|
||
}
|
||
});
|
||
});
|
||
} catch (error) {
|
||
if (note) note.textContent = error.message || String(error);
|
||
}
|
||
}
|
||
|
||
async function loadMarlin(printers) {
|
||
const line = document.getElementById("marlin-line");
|
||
if (!line) return;
|
||
const usb = (printers || []).some((row) => row.family === "tenlog" || row.family === "anycubic_i3");
|
||
if (!usb) {
|
||
line.hidden = true;
|
||
return;
|
||
}
|
||
try {
|
||
const status = await api("/marlin-dienst");
|
||
line.hidden = false;
|
||
line.textContent = status.running
|
||
? "USB-Dienst läuft."
|
||
: (status.message || "USB-Dienst ist aus.");
|
||
} catch (err) {
|
||
line.hidden = false;
|
||
line.textContent = "USB-Dienst: " + (err.message || "keine Antwort");
|
||
}
|
||
}
|
||
|
||
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);
|
||
loadMarlin(printers);
|
||
loadMeldungen();
|
||
}
|
||
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 || "";
|
||
fillEtikett(settings.etikett || {});
|
||
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");
|
||
const qrWrap = document.getElementById("token-qr-wrap");
|
||
once.hidden = true;
|
||
if (qrWrap) qrWrap.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 oder QR scannen.");
|
||
loadTokens();
|
||
if (out.token && window.QRCode && qrWrap) {
|
||
const canvas = document.getElementById("token-qr");
|
||
const payload = JSON.stringify({
|
||
url: location.origin,
|
||
token: out.token,
|
||
name: "Grotti",
|
||
});
|
||
try {
|
||
QRCode.toCanvas(canvas, payload, { width: 180, margin: 1, errorCorrectionLevel: "M" });
|
||
qrWrap.hidden = false;
|
||
} catch (error) { /* QR optional */ }
|
||
}
|
||
} 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);
|
||
}
|
||
});
|
||
|
||
let etikettPresets = [];
|
||
|
||
function fillEtikett(etikett) {
|
||
const w = document.getElementById("etikett-w");
|
||
const h = document.getElementById("etikett-h");
|
||
const preset = document.getElementById("etikett-preset");
|
||
if (!w || !h || !preset) return;
|
||
etikettPresets = Array.isArray(etikett.presets) ? etikett.presets : etikettPresets;
|
||
const width = Number(etikett.width_mm) || 70;
|
||
const height = Number(etikett.height_mm) || 40;
|
||
w.value = String(width);
|
||
h.value = String(height);
|
||
const current = preset.value;
|
||
preset.innerHTML = '<option value="">Eigene Maße</option>';
|
||
etikettPresets.forEach((row) => {
|
||
const option = document.createElement("option");
|
||
option.value = row.id;
|
||
option.textContent = row.label;
|
||
option.dataset.w = String(row.width_mm);
|
||
option.dataset.h = String(row.height_mm);
|
||
preset.appendChild(option);
|
||
});
|
||
const hit = etikettPresets.find(
|
||
(row) => Number(row.width_mm) === width && Number(row.height_mm) === height
|
||
);
|
||
preset.value = hit ? hit.id : (current && [...preset.options].some((o) => o.value === current) ? current : "");
|
||
}
|
||
|
||
function syncEtikettPresetFromFields() {
|
||
const preset = document.getElementById("etikett-preset");
|
||
const width = Number(document.getElementById("etikett-w").value);
|
||
const height = Number(document.getElementById("etikett-h").value);
|
||
const hit = etikettPresets.find(
|
||
(row) => Number(row.width_mm) === width && Number(row.height_mm) === height
|
||
);
|
||
if (preset) preset.value = hit ? hit.id : "";
|
||
}
|
||
|
||
document.getElementById("etikett-preset")?.addEventListener("change", () => {
|
||
const preset = document.getElementById("etikett-preset");
|
||
const option = preset.selectedOptions[0];
|
||
if (!option || !option.value) return;
|
||
document.getElementById("etikett-w").value = option.dataset.w || "70";
|
||
document.getElementById("etikett-h").value = option.dataset.h || "40";
|
||
document.getElementById("etikett-save")?.click();
|
||
});
|
||
|
||
["etikett-w", "etikett-h"].forEach((id) => {
|
||
document.getElementById(id)?.addEventListener("change", () => {
|
||
syncEtikettPresetFromFields();
|
||
});
|
||
});
|
||
|
||
document.getElementById("etikett-save").addEventListener("click", async () => {
|
||
try {
|
||
const out = await api("/einstellungen", {
|
||
method: "PATCH",
|
||
json: {
|
||
etikett: {
|
||
width_mm: Number(document.getElementById("etikett-w").value) || 70,
|
||
height_mm: Number(document.getElementById("etikett-h").value) || 40,
|
||
},
|
||
},
|
||
});
|
||
fillEtikett(out.etikett || {});
|
||
const e = out.etikett || {};
|
||
showNote(
|
||
"etikett-note",
|
||
null,
|
||
"Gespeichert — " + (e.width_mm || 70) + " × " + (e.height_mm || 40) + " mm (QR " + (e.qr_mm || 20) + " mm)."
|
||
);
|
||
} catch (error) {
|
||
showNote("etikett-note", error);
|
||
}
|
||
});
|
||
|
||
function syncAddHost() {
|
||
syncHostField(
|
||
document.getElementById("add-family").value,
|
||
document.getElementById("add-host-label"),
|
||
document.getElementById("add-host"),
|
||
document.getElementById("add-name").value,
|
||
);
|
||
}
|
||
document.getElementById("add-family")?.addEventListener("change", syncAddHost);
|
||
document.getElementById("add-name")?.addEventListener("input", syncAddHost);
|
||
document.getElementById("pe-family")?.addEventListener("change", () => {
|
||
syncHostField(
|
||
document.getElementById("pe-family").value,
|
||
document.getElementById("pe-host-label"),
|
||
document.getElementById("pe-host"),
|
||
document.getElementById("pe-name").value,
|
||
);
|
||
});
|
||
document.getElementById("pe-name")?.addEventListener("input", () => {
|
||
syncHostField(
|
||
document.getElementById("pe-family").value,
|
||
document.getElementById("pe-host-label"),
|
||
document.getElementById("pe-host"),
|
||
document.getElementById("pe-name").value,
|
||
);
|
||
});
|
||
|
||
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"));
|
||
["etikett-w", "etikett-h"].forEach((id) => {
|
||
document.getElementById(id)?.addEventListener("change", wireAutosave("etikett-save", "etikett-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 %}
|